From f37896765a3a250e8f0c80b47f94daf4a6af8250 Mon Sep 17 00:00:00 2001 From: interacsean Date: Thu, 29 Oct 2026 15:36:13 +1100 Subject: [PATCH 1/2] feat(docs-kit): stamp app-shell version into the generated skill (#2227) docs:sync substitutes the core package version into SKILL.md's metadata.app-shell-version and a Version section that tells agents to compare it with the installed package. The manifest hashes the unstamped content, so a changesets version bump doesn't register as skill drift; changeset:publish already re-syncs after the bump, so the tarball carries the released version. Co-Authored-By: Claude Opus 5.5 --- .changeset/skill-version-stamp.md | 5 +++ docs-kit.config.json | 3 +- docs-manifest.json | 2 +- docs-src/skill/SKILL.template.md | 6 ++++ packages/docs-kit/src/skill.test.ts | 48 +++++++++++++++++++++++++++++ packages/docs-kit/src/skill.ts | 20 ++++++++++++ packages/docs-kit/src/sync.ts | 5 +-- packages/docs-kit/src/types.ts | 3 ++ 8 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 .changeset/skill-version-stamp.md create mode 100644 packages/docs-kit/src/skill.test.ts diff --git a/.changeset/skill-version-stamp.md b/.changeset/skill-version-stamp.md new file mode 100644 index 000000000..56a84e842 --- /dev/null +++ b/.changeset/skill-version-stamp.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +The bundled `app-shell-patterns` skill now records the `@tailor-platform/app-shell` version it was generated from, in its `metadata.app-shell-version` frontmatter and in a new "Version" section. The skill tells the agent to compare it with the installed package and to suggest re-syncing a vendored copy that has fallen behind. diff --git a/docs-kit.config.json b/docs-kit.config.json index a072f11d0..8118fdaf8 100644 --- a/docs-kit.config.json +++ b/docs-kit.config.json @@ -50,7 +50,8 @@ "outDir": "packages/core/skills/app-shell-patterns", "templatePath": "docs-src/skill/SKILL.template.md", "migrationsSource": "docs/migrations.md", - "repoBlobUrl": "https://github.com/tailor-platform/app-shell/blob/main" + "repoBlobUrl": "https://github.com/tailor-platform/app-shell/blob/main", + "versionSource": "packages/core/package.json" } }, "policy": { diff --git a/docs-manifest.json b/docs-manifest.json index 8ed7709e7..a33138270 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -1856,6 +1856,6 @@ "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "59c93617dc135033", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "5a6612349cb50ef1", "packages/core/skills/app-shell-patterns/references/migrations.md": "e0286979718e2769", - "packages/core/skills/app-shell-patterns/SKILL.md": "1aa14970eee9bce0" + "packages/core/skills/app-shell-patterns/SKILL.md": "1d05baafe99d0cce" } } diff --git a/docs-src/skill/SKILL.template.md b/docs-src/skill/SKILL.template.md index 2e7a327b1..6cbb82b76 100644 --- a/docs-src/skill/SKILL.template.md +++ b/docs-src/skill/SKILL.template.md @@ -1,6 +1,8 @@ --- name: app-shell-patterns description: "Best-practice UI patterns and correct component usage for building pages in apps that use @tailor-platform/app-shell. Use when: building or editing any screen, page, list, table, detail view, form, modal, dialog, wizard, or bulk/confirm/toast interaction in an app with @tailor-platform/app-shell installed — when choosing the right AppShell component, layout, or design token for a UI — or when upgrading @tailor-platform/app-shell, or diagnosing styling, theming, or dark-mode breakage that appeared after a version bump." +metadata: + app-shell-version: "{{APP_SHELL_VERSION}}" --- # App-Shell Patterns @@ -9,6 +11,10 @@ description: "Best-practice UI patterns and correct component usage for building Select and implement the correct UI pattern using @tailor-platform/app-shell components. +## Version + +This skill was generated from `@tailor-platform/app-shell` **{{APP_SHELL_VERSION}}**. Before relying on it, compare that with the installed version (`node_modules/@tailor-platform/app-shell/package.json`). If they differ, this copy is stale: tell the user and suggest re-syncing the skill from the installed package (`node_modules/@tailor-platform/app-shell/skills/app-shell-patterns/`). + ## Concepts Cross-cutting guides that underpin every screen — styling & theming and the token system, GraphQL data access, building custom components, plus auth and routing. Patterns and components build on these. diff --git a/packages/docs-kit/src/skill.test.ts b/packages/docs-kit/src/skill.test.ts new file mode 100644 index 000000000..c28c62706 --- /dev/null +++ b/packages/docs-kit/src/skill.test.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { readSkillVersion, stampSkill } from "./skill"; +import type { DocsConfig } from "./types"; + +const configWith = (versionSource?: string): DocsConfig => ({ + roots: [], + tsconfig: "", + indexFile: "", + manifestFile: "", + categories: [], + enforceCoverage: false, + exclusions: [], + skill: { outDir: "", templatePath: "", migrationsSource: "", repoBlobUrl: "", versionSource }, +}); + +describe("stampSkill", () => { + it("replaces every version placeholder", () => { + const content = 'app-shell-version: "{{APP_SHELL_VERSION}}"\n**{{APP_SHELL_VERSION}}**'; + expect(stampSkill(content, "1.17.0")).toBe('app-shell-version: "1.17.0"\n**1.17.0**'); + }); + + it("leaves content untouched without a version", () => { + expect(stampSkill("{{APP_SHELL_VERSION}}", null)).toBe("{{APP_SHELL_VERSION}}"); + }); +}); + +describe("readSkillVersion", () => { + it("reads the version from the configured package.json", () => { + const root = mkdtempSync(join(tmpdir(), "docs-kit-")); + writeFileSync(join(root, "package.json"), JSON.stringify({ version: "2.0.0" })); + expect(readSkillVersion(root, configWith("package.json"))).toBe("2.0.0"); + }); + + it("returns null when no source is configured", () => { + expect(readSkillVersion("/nonexistent", configWith())).toBeNull(); + }); + + it("throws when the package.json has no version", () => { + const root = mkdtempSync(join(tmpdir(), "docs-kit-")); + writeFileSync(join(root, "package.json"), "{}"); + expect(() => readSkillVersion(root, configWith("package.json"))).toThrow(/no "version"/); + }); +}); diff --git a/packages/docs-kit/src/skill.ts b/packages/docs-kit/src/skill.ts index 159cf8388..2d5d170fb 100644 --- a/packages/docs-kit/src/skill.ts +++ b/packages/docs-kit/src/skill.ts @@ -71,6 +71,26 @@ function entryTable(list: Outline[], dir: string): string { return blocks.join("\n\n"); } +const VERSION_PLACEHOLDER = /\{\{APP_SHELL_VERSION\}\}/g; + +/** Read the version stamped into the skill, or null when no source is configured. */ +export function readSkillVersion(repoRoot: string, config: DocsConfig): string | null { + const source = config.skill?.versionSource; + if (!source) return null; + const { version } = JSON.parse(readFileSync(join(repoRoot, source), "utf8")) as { + version?: string; + }; + if (!version) throw new Error(`${source} has no "version" field`); + return version; +} + +/** Substitute the package version into a computed skill file. Applied only when + * writing — the manifest hashes the unstamped content, so a changesets version + * bump never registers as skill drift. */ +export function stampSkill(content: string, version: string | null): string { + return version === null ? content : content.replace(VERSION_PLACEHOLDER, version); +} + /** Compute the whole consumer skill as a map of repo-relative path → content. * Pure: reads only committed inputs (the generated docs incl. concepts, the SKILL template, and the migrations doc) and writes nothing — so `sync` * can emit it and `check` can recompute and validate it without the (gitignored) diff --git a/packages/docs-kit/src/sync.ts b/packages/docs-kit/src/sync.ts index 5af03b40e..a8a385868 100644 --- a/packages/docs-kit/src/sync.ts +++ b/packages/docs-kit/src/sync.ts @@ -8,7 +8,7 @@ import { type Finding, reconcile } from "./coverage"; import { hash, normalizeText } from "./hash"; import { writeManifest } from "./manifest"; import { discoverOutlines } from "./outline"; -import { computeSkill } from "./skill"; +import { computeSkill, readSkillVersion, stampSkill } from "./skill"; import { loadSurface, snapshotHashForSlug } from "./project"; import type { Manifest, ManifestEntry } from "./types"; @@ -106,10 +106,11 @@ export function sync(repoRoot: string): SyncResult { if (config.skill) { rmSync(join(repoRoot, config.skill.outDir), { recursive: true, force: true }); skill = {}; + const version = readSkillVersion(repoRoot, config); for (const [skillRel, content] of computeSkill(repoRoot, config, outlines)) { const abs = join(repoRoot, skillRel); mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, content, "utf8"); + writeFileSync(abs, stampSkill(content, version), "utf8"); skill[skillRel] = hash(normalizeText(content)); written.push(skillRel); } diff --git a/packages/docs-kit/src/types.ts b/packages/docs-kit/src/types.ts index ece898032..5d223133f 100644 --- a/packages/docs-kit/src/types.ts +++ b/packages/docs-kit/src/types.ts @@ -104,6 +104,9 @@ export interface SkillConfig { migrationsSource: string; /** Base blob URL for rewriting the migrations doc's relative links. */ repoBlobUrl: string; + /** Repo-relative package.json whose `version` is stamped into SKILL.md's + * `{{APP_SHELL_VERSION}}` at write time. */ + versionSource?: string; } export interface DocsConfig { From d0e81e7b4d3dbcc0fb00dc38d63875d291bb14c2 Mon Sep 17 00:00:00 2001 From: interacsean Date: Thu, 29 Oct 2026 15:43:26 +1100 Subject: [PATCH 2/2] fix(docs-kit): fail check and sync when the skill version stamp can't be filled - stampSkill throws instead of shipping a literal {{APP_SHELL_VERSION}} - docs:check validates the stamp, since the manifest hashes unstamped content - sync resolves the version before deleting the skill dir - Version section resolves the installed package from the app, not the repo root Co-Authored-By: Claude Opus 5.5 --- docs-manifest.json | 2 +- docs-src/skill/SKILL.template.md | 2 +- packages/docs-kit/src/check.ts | 9 ++++++++- packages/docs-kit/src/skill.test.ts | 8 ++++++-- packages/docs-kit/src/skill.ts | 23 +++++++++++++++++++++-- packages/docs-kit/src/sync.ts | 8 ++++---- 6 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs-manifest.json b/docs-manifest.json index a33138270..ac5a6abef 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -1856,6 +1856,6 @@ "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "59c93617dc135033", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "5a6612349cb50ef1", "packages/core/skills/app-shell-patterns/references/migrations.md": "e0286979718e2769", - "packages/core/skills/app-shell-patterns/SKILL.md": "1d05baafe99d0cce" + "packages/core/skills/app-shell-patterns/SKILL.md": "de250e21921fc3e8" } } diff --git a/docs-src/skill/SKILL.template.md b/docs-src/skill/SKILL.template.md index 6cbb82b76..f0bc48ffd 100644 --- a/docs-src/skill/SKILL.template.md +++ b/docs-src/skill/SKILL.template.md @@ -13,7 +13,7 @@ Select and implement the correct UI pattern using @tailor-platform/app-shell com ## Version -This skill was generated from `@tailor-platform/app-shell` **{{APP_SHELL_VERSION}}**. Before relying on it, compare that with the installed version (`node_modules/@tailor-platform/app-shell/package.json`). If they differ, this copy is stale: tell the user and suggest re-syncing the skill from the installed package (`node_modules/@tailor-platform/app-shell/skills/app-shell-patterns/`). +This skill was generated from `@tailor-platform/app-shell` **{{APP_SHELL_VERSION}}**. Before relying on it, compare that with the version installed in the app you're working on (resolve `@tailor-platform/app-shell/package.json` from that app, not the monorepo root). If they differ, this copy is stale: tell the user and suggest re-syncing it from the installed package's `skills/app-shell-patterns/` directory. ## Concepts diff --git a/packages/docs-kit/src/check.ts b/packages/docs-kit/src/check.ts index d43341b28..da3c1ec5b 100644 --- a/packages/docs-kit/src/check.ts +++ b/packages/docs-kit/src/check.ts @@ -7,7 +7,7 @@ import { hash, normalizeText } from "./hash"; import { readManifest } from "./manifest"; import { discoverOutlines } from "./outline"; import { loadSurface, snapshotHashForSlug } from "./project"; -import { computeSkill } from "./skill"; +import { computeSkill, computeStampedSkill } from "./skill"; import { unmanagedOutputs, walkMarkdown } from "./tree"; export interface CheckResult { @@ -140,6 +140,13 @@ export function check(repoRoot: string): CheckResult { }); } } + // The manifest hashes unstamped content, so validate the stamp separately — + // otherwise a broken version source would only surface at publish time. + try { + computeStampedSkill(repoRoot, config, outlines); + } catch (e) { + findings.push({ level: "block", slug: "skill", message: (e as Error).message }); + } } // Nothing unmanaged may live in the generated tree. Every .md under an output diff --git a/packages/docs-kit/src/skill.test.ts b/packages/docs-kit/src/skill.test.ts index c28c62706..0f51a6cea 100644 --- a/packages/docs-kit/src/skill.test.ts +++ b/packages/docs-kit/src/skill.test.ts @@ -24,8 +24,12 @@ describe("stampSkill", () => { expect(stampSkill(content, "1.17.0")).toBe('app-shell-version: "1.17.0"\n**1.17.0**'); }); - it("leaves content untouched without a version", () => { - expect(stampSkill("{{APP_SHELL_VERSION}}", null)).toBe("{{APP_SHELL_VERSION}}"); + it("leaves placeholder-free content untouched without a version", () => { + expect(stampSkill("no stamp here", null)).toBe("no stamp here"); + }); + + it("throws rather than ship an unfilled placeholder", () => { + expect(() => stampSkill("{{APP_SHELL_VERSION}}", null)).toThrow(/versionSource/); }); }); diff --git a/packages/docs-kit/src/skill.ts b/packages/docs-kit/src/skill.ts index 2d5d170fb..0da6e5310 100644 --- a/packages/docs-kit/src/skill.ts +++ b/packages/docs-kit/src/skill.ts @@ -86,9 +86,28 @@ export function readSkillVersion(repoRoot: string, config: DocsConfig): string | /** Substitute the package version into a computed skill file. Applied only when * writing — the manifest hashes the unstamped content, so a changesets version - * bump never registers as skill drift. */ + * bump never registers as skill drift. Throws rather than ship a literal + * placeholder, which would tell every consumer their copy is stale. */ export function stampSkill(content: string, version: string | null): string { - return version === null ? content : content.replace(VERSION_PLACEHOLDER, version); + if (version !== null) return content.replace(VERSION_PLACEHOLDER, version); + if (content.includes("{{APP_SHELL_VERSION}}")) { + throw new Error("skill template uses {{APP_SHELL_VERSION}} but skill.versionSource is not set"); + } + return content; +} + +/** Compute the skill and stamp it, so a bad version source fails before any write. */ +export function computeStampedSkill( + repoRoot: string, + config: DocsConfig, + outlines: Outline[], +): Map { + const version = readSkillVersion(repoRoot, config); + const files = new Map(); + for (const [p, content] of computeSkill(repoRoot, config, outlines)) { + files.set(p, { content, stamped: stampSkill(content, version) }); + } + return files; } /** Compute the whole consumer skill as a map of repo-relative path → content. diff --git a/packages/docs-kit/src/sync.ts b/packages/docs-kit/src/sync.ts index a8a385868..7945cc806 100644 --- a/packages/docs-kit/src/sync.ts +++ b/packages/docs-kit/src/sync.ts @@ -8,7 +8,7 @@ import { type Finding, reconcile } from "./coverage"; import { hash, normalizeText } from "./hash"; import { writeManifest } from "./manifest"; import { discoverOutlines } from "./outline"; -import { computeSkill, readSkillVersion, stampSkill } from "./skill"; +import { computeStampedSkill } from "./skill"; import { loadSurface, snapshotHashForSlug } from "./project"; import type { Manifest, ManifestEntry } from "./types"; @@ -104,13 +104,13 @@ export function sync(repoRoot: string): SyncResult { // docs + authored guidance, and hash each file so `check` can validate it. let skill: Record | undefined; if (config.skill) { + const files = computeStampedSkill(repoRoot, config, outlines); rmSync(join(repoRoot, config.skill.outDir), { recursive: true, force: true }); skill = {}; - const version = readSkillVersion(repoRoot, config); - for (const [skillRel, content] of computeSkill(repoRoot, config, outlines)) { + for (const [skillRel, { content, stamped }] of files) { const abs = join(repoRoot, skillRel); mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, stampSkill(content, version), "utf8"); + writeFileSync(abs, stamped, "utf8"); skill[skillRel] = hash(normalizeText(content)); written.push(skillRel); }