From 2ce44bc0149268f38256498f121137365a5b5925 Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Thu, 15 Oct 2026 12:10:02 -0500 Subject: [PATCH] fix(vibe): Allow compile-only repairs to v1.0 stories - Design ruling, 2026-10-15: a frozen v1.0 story file may take a compile-only repair. The freeze protects what a story shows and its link, not the code wording, so a rename elsewhere may repair one with only prop renames, imports and types: no story added or removed, no title, story ID or tag change and no visual change. Sessions no longer avoid a rename to protect a v1.0 story file. - handoff-inventory lists an in-place edit of a v1.0 story file whose meta title, meta tags and exported story names match the base under a new non-blocking compileOnlyV1Repairs. A change to any of those, or a move, stays in v1Stories with the reason frozen and blocks. - The ticket worker puts each repair on the live ticket's Engineering checklist as "Check compile-only repair to v1.0: ; confirm only prop, import or type changes and no visual change". - The placement owner states the exception; guardrails, design pass, handoff and the README carry it; the writer repairs and logs it; the guardrails reviewer's check 10 confirms each repair is mechanical with no visual change and blocks anything else. - Bump vibe to 1.0.34. Testing: new node test (prop rename passes; added, removed or renamed story export, title change, tag change and move each block) and a new contract test file for the prose and ticket placement, all failing on 1.0.33. Updated pinned wording in three existing contract tests. vibe node suites (284), vibe pytest (174), ruff and pyright pass. Risks: a visual change inside a compile-only repair is not caught mechanically; the reviewer and the Engineering checklist item cover it. --- CHANGELOG.md | 8 + plugins/vibe/.claude-plugin/plugin.json | 2 +- plugins/vibe/.codex-plugin/plugin.json | 2 +- plugins/vibe/README.md | 7 +- plugins/vibe/agents/vibe-change-worker.md | 10 +- .../vibe/agents/vibe-guardrails-reviewer.md | 5 +- .../vibe/agents/vibe-storybook-decomposer.md | 8 +- plugins/vibe/agents/vibe-ticket-worker.md | 4 + plugins/vibe/skills/handoff/SKILL.md | 7 +- .../handoff/scripts/handoff-inventory.mjs | 96 +++++++++--- .../scripts/handoff-inventory.test.mjs | 64 ++++++++ .../skills/vibe/references/design-pass.md | 3 +- .../vibe/skills/vibe/references/guardrails.md | 11 +- .../skills/vibe/references/ticket-template.md | 6 +- ...st_vibe_forbidden_path_routing_contract.py | 2 +- ...e_storybook_existing_component_contract.py | 26 ++-- .../test_vibe_storybook_placement_contract.py | 2 +- ...st_vibe_v1_compile_only_repair_contract.py | 144 ++++++++++++++++++ 18 files changed, 352 insertions(+), 55 deletions(-) create mode 100644 plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b3de8d0..bcd57d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to the claude-plugins project will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`. +### vibe v1.0.34 + +#### Changed +- Compile-only repairs to frozen v1.0 story files are allowed (design ruling, 2026-10-15). The freeze protects what a story shows and its link, not the code wording, so a rename elsewhere may repair a v1.0 story file with only mechanical changes (prop renames, imports, types): no story added or removed, no title, story ID or tag change and no visual change. Sessions no longer avoid a rename to protect a v1.0 story file. +- `handoff-inventory.mjs` lists an in-place edit of a v1.0 story file whose meta title, meta tags and set of exported story names (and so its story IDs) match the base under a new non-blocking `compileOnlyV1Repairs`. Any change to the title, tags or exported stories, or a move, stays in `v1Stories` with the reason `frozen` and blocks. A visual change cannot be proven mechanically and is left to the reviewer and the engineer. +- The ticket worker puts each `compileOnlyV1Repairs` file on the live ticket's Engineering checklist as `- [ ] Check compile-only repair to v1.0: ; confirm only prop, import or type changes and no visual change` (`ticket-template.md`, "Compile-only v1.0 repairs"). `vibe-storybook-decomposer` states the exception; `guardrails.md`, `design-pass.md`, handoff and the README carry it; the writer repairs and logs it; `vibe-guardrails-reviewer` check 10 confirms each repair is purely mechanical with no visual change and blocks anything else. +- Coverage: a node test for a v1.0 file where a prop rename passes into `compileOnlyV1Repairs` while an added, removed or renamed story export, a title change, a tag change and a move each block; a new contract test file for the prose and the ticket placement. Both fail on 1.0.33. + ### vibe v1.0.33 #### Fixed diff --git a/plugins/vibe/.claude-plugin/plugin.json b/plugins/vibe/.claude-plugin/plugin.json index c7bec0a7..f6940de3 100644 --- a/plugins/vibe/.claude-plugin/plugin.json +++ b/plugins/vibe/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vibe", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", - "version": "1.0.33", + "version": "1.0.34", "dependencies": ["closedloop-core"], "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"} } diff --git a/plugins/vibe/.codex-plugin/plugin.json b/plugins/vibe/.codex-plugin/plugin.json index 79bf9163..942dd354 100644 --- a/plugins/vibe/.codex-plugin/plugin.json +++ b/plugins/vibe/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe", - "version": "1.0.33", + "version": "1.0.34", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}, "homepage": "https://github.com/closedloop-ai/claude-plugins", diff --git a/plugins/vibe/README.md b/plugins/vibe/README.md index 0d9e49d8..e6dad4ca 100644 --- a/plugins/vibe/README.md +++ b/plugins/vibe/README.md @@ -146,11 +146,12 @@ and Storybook skill (`.claude/skills/storybook/`, whose placement workflows and index scripts it uses) where they say how and mirroring that precedent's structure, never its root or version, where they do not; the story files the writer matches are the same capability's, not the same folder's. Stories cover the components consumers -render, not internal render pieces. A v1.0 story file is never edited, so new -states of its component go in a new v2.0 entry holding only them. New states +render, not internal render pieces. A v1.0 story file is never edited beyond +a compile-only repair, so new states of its component go in a new v2.0 entry +holding only them. New states of any other existing component go in its existing story file under the same title and tag, since a retitle breaks every link to its stories; handoff -blocks an edited v1.0 file, and a retitle unless it is a sibling replacement's +blocks any other edit of a v1.0 file, and a retitle unless it is a sibling replacement's entry. The writer records it in its local plan; a conflict goes on the live ticket by audience, design conflicts under Design Review and everything else on the Engineering checklist, never to the diff --git a/plugins/vibe/agents/vibe-change-worker.md b/plugins/vibe/agents/vibe-change-worker.md index 6fa0f5df..2894fc5c 100644 --- a/plugins/vibe/agents/vibe-change-worker.md +++ b/plugins/vibe/agents/vibe-change-worker.md @@ -126,9 +126,13 @@ Read the repo's `apps/storybook/TAXONOMY.md` and follow it: every new story goes in a v2.0 root, in its page tree, tagged `v2.0`, with the Storybook release bump it requires, never in a frozen v1.0 root; new states of an existing component go in a new v2.0 entry holding only them when its story -file is v1.0, which you never edit, and otherwise in its existing story file, -title and tag unchanged -(`../skills/vibe/references/guardrails.md`, "Storybook taxonomy"). The story +file is v1.0, which you never edit beyond a compile-only repair, and otherwise +in its existing story file, title and tag unchanged +(`../skills/vibe/references/guardrails.md`, "Storybook taxonomy"). Never avoid +a rename to protect a v1.0 story file; when a rename breaks one, repair it with +only prop renames, imports and types, no story added or removed and no title, +story ID, tag or visual change, and log `Compile-only repair to v1.0: ` +in the change log (design ruling, 2026-10-15). The story files you match are the same capability's, not the same folder's (same section). diff --git a/plugins/vibe/agents/vibe-guardrails-reviewer.md b/plugins/vibe/agents/vibe-guardrails-reviewer.md index 6a6e6b8f..847eded0 100644 --- a/plugins/vibe/agents/vibe-guardrails-reviewer.md +++ b/plugins/vibe/agents/vibe-guardrails-reviewer.md @@ -112,7 +112,10 @@ closedloop-graph is required, per `../skills/vibe/references/closedloop-graph.md under or tagged for a frozen v1.0 root, or any edit or move of a story file that is v1.0 on main (the inventory's `v1Stories`, reason `frozen`), is blocking; that component's new states belong in a new v2.0 entry - holding only them, with no version number in its name. So is a v2.0 story outside its page tree, without the `v2.0` + holding only them, with no version number in its name. Confirm each of + the inventory's `compileOnlyV1Repairs` is purely mechanical (prop + renames, imports, types) with no visual change; anything else in one is + blocking. So is a v2.0 story outside its page tree, without the `v2.0` tag, or added, moved or renamed without the release bump the doc names. An existing non-v1.0 story file (on main) retitled, re-tagged or moved breaks every link to its stories and is blocking (the inventory's `retitledStories` diff --git a/plugins/vibe/agents/vibe-storybook-decomposer.md b/plugins/vibe/agents/vibe-storybook-decomposer.md index bfb1d356..fd338949 100644 --- a/plugins/vibe/agents/vibe-storybook-decomposer.md +++ b/plugins/vibe/agents/vibe-storybook-decomposer.md @@ -87,7 +87,11 @@ Do this first, in both modes. even to add a story: the new states go in a new v2.0 entry for that component holding only the new states, its name descriptive, permanent, per `apps/storybook/TAXONOMY.md` and without a version number, and the - v1.0 stories stay where they are. Any other existing story file takes the + v1.0 stories stay where they are. The one exception is a compile-only + repair after a rename elsewhere: only mechanical changes (prop renames, + imports, types), no story added or removed, no title, story ID or tag + change and no visual change, since the freeze protects what its stories + show and their links, not the code wording. Any other existing story file takes the new states as extra stories, its title, tag and existing story names unchanged. Otherwise only a new component, or a sibling replacement such as a V3, gets a new @@ -126,7 +130,7 @@ Placement: - Capability: - Precedents: , or none found - Owner: , per or mirroring -- Stories: , root and tag per , structure per or mirroring ; an existing file's title and tag unchanged, a v1.0 file not edited at all (step 4) +- Stories: , root and tag per , structure per or mirroring ; an existing file's title and tag unchanged, a v1.0 file not edited beyond a compile-only repair (step 4) - Conflict: , or none ``` diff --git a/plugins/vibe/agents/vibe-ticket-worker.md b/plugins/vibe/agents/vibe-ticket-worker.md index 4c62fd4d..e468c8c1 100644 --- a/plugins/vibe/agents/vibe-ticket-worker.md +++ b/plugins/vibe/agents/vibe-ticket-worker.md @@ -215,6 +215,10 @@ historical inspections accurately. Preserve the existing grading block. entry in the change log once, as an unchecked item at the end of the Engineering checklist in its logged shape (`../skills/vibe/references/ticket-template.md`, "Engineering needed"). + Place each file in the inventory's `compileOnlyV1Repairs` once, as an + unchecked item at the end of the Engineering checklist in the template's + shape (`../skills/vibe/references/ticket-template.md`, "Compile-only v1.0 + repairs"). Fill Grading with the template's Grading section copied unchanged, adding the section after Handoff when the ticket has none. diff --git a/plugins/vibe/skills/handoff/SKILL.md b/plugins/vibe/skills/handoff/SKILL.md index 7ad9eb86..ee00230f 100644 --- a/plugins/vibe/skills/handoff/SKILL.md +++ b/plugins/vibe/skills/handoff/SKILL.md @@ -276,10 +276,11 @@ Storybook placement line; the SAME writer logs each Conflict with its audience, and the ticket worker places it by that audience (`../vibe/references/ticket-template.md`, "Conflicts"), never to the person. Every new story follows the repo's `apps/storybook/TAXONOMY.md`, a v1.0 -story file is never edited and any other existing story file keeps its title -and tag +story file is never edited beyond a compile-only repair and any other existing +story file keeps its title and tag (`../vibe/references/guardrails.md`, "Storybook taxonomy"); the inventory's -`v1Stories` and `retitledStories` block handoff. Resume the SAME writer to perform every extraction, story and catalog +`v1Stories` and `retitledStories` block handoff. Its `compileOnlyV1Repairs` +do not; the ticket worker puts each on the Engineering checklist. Resume the SAME writer to perform every extraction, story and catalog source change. Read-only verify footprint mode measures the sidebar and reports remaining problems; they return to the writer, never another author. Collect Design Review evidence from the verified final diff and existing diff --git a/plugins/vibe/skills/handoff/scripts/handoff-inventory.mjs b/plugins/vibe/skills/handoff/scripts/handoff-inventory.mjs index 0ff693e8..b5ace15f 100755 --- a/plugins/vibe/skills/handoff/scripts/handoff-inventory.mjs +++ b/plugins/vibe/skills/handoff/scripts/handoff-inventory.mjs @@ -35,7 +35,11 @@ // root, or an existing one newly given either, is listed under `v1Stories` and // blocks. A story file that was v1.0 at the base is frozen: any edit or move // of it is listed under `v1Stories` with the reason `frozen` and blocks, so new -// states of its component go in a new v2.0 entry instead. Any other existing +// states of its component go in a new v2.0 entry instead. The one exception is +// a compile-only repair (design ruling, 2026-10-15): an in-place edit that +// keeps the meta title, the meta tags and the set of exported story names, and +// so every story ID, is listed under `compileOnlyV1Repairs` and does not block; +// the ticket asks engineering to confirm it changed no visuals. Any other existing // story file keeps its meta title, because Storybook builds story IDs from it: // a title changed from the base, moves included, is listed under // `retitledStories` and blocks unless the session record lists the file as a @@ -96,12 +100,18 @@ const STORY_PATTERN = /\.stories\.tsx?$/; const COMPONENT_PATTERN = /\/components\/.+\.tsx$/; // v1.0 is the frozen baseline; new work goes under ` v2.0/` // (apps/storybook/TAXONOMY.md; Daniel Ochoa's ruling, 2026-10-14). -// A v1.0 story file is never edited (design ruling, 2026-10-15). +// A v1.0 story file is never edited except for a compile-only repair +// (design ruling, 2026-10-15). const V1_STORY_TITLE = /\btitle:\s*["'`](?:Foundations|Primitives|Composites|Surfaces)\//; const V1_STORY_TAG = /\btags:\s*\[[^\]]*["'`]v1\.0["'`]/; // The meta object's title, not a `title:` arg or prop elsewhere in the file. const STORY_META_START = /\b(?:const\s+meta\b[^=]*=|export\s+default)\s*\{/; const STORY_META_TITLE = /\btitle:\s*(["'`])((?:(?!\1).)+)\1/; +const STORY_META_TAGS = /\btags:\s*\[([^\]]*)\]/; +const STRING_LITERAL = /(["'`])((?:(?!\1).)*)\1/g; +const NAMED_EXPORT = /\bexport\s+(?:const|let|var|function\*?|class)\s+([\w$]+)/g; +const EXPORT_LIST = /\bexport\s*\{([^}]*)\}/g; +const EXPORT_ALIAS = /\s+as\s+/; const STORY_PATHSPECS = ["*.stories.ts", "*.stories.tsx"]; // Tests are written only when the person explicitly asks for a pull request // (Daniel Ochoa's ruling, 2026-10-13); earlier phases may only repair existing @@ -165,7 +175,7 @@ const outsideAllowed = changedFiles.filter( const backendFiles = changedFiles.filter((file) => isBackend(file.path)); const componentsWithoutStories = findComponentsWithoutStories(changedFiles); const movedStories = findMovedStories(); -const v1Stories = findV1Stories(changedFiles); +const { v1Stories, compileOnlyV1Repairs } = findV1Stories(changedFiles); const retitledStories = findRetitledStories(changedFiles); const blocking = { @@ -204,6 +214,7 @@ const result = { backendFiles, componentsWithoutStories, v1Stories, + compileOnlyV1Repairs, retitledStories, }; process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); @@ -358,30 +369,48 @@ function isBackend(filePath) { } /** - * Story files whose meta title sits in a v1.0 root or whose tags include v1.0 - * where the base had neither, and every modified or moved story file that was - * v1.0 at the base (`frozen`). + * `v1Stories`: story files whose meta title sits in a v1.0 root or whose tags + * include v1.0 where the base had neither, and every modified or moved story + * file that was v1.0 at the base (`frozen`). `compileOnlyV1Repairs`: the + * in-place edits of a v1.0 file that keep its story IDs and tags. */ function findV1Stories(files) { const movedFrom = new Map(movedStories.map((story) => [story.path, story.from])); - return files - .filter((file) => file.status !== "D" && STORY_PATTERN.test(file.path)) - .flatMap((file) => { - const source = readWorktreeFile(file.path); - if (source === undefined) { - return []; - } - const from = movedFrom.get(file.path); - const basePath = file.status === "M" ? file.path : from; - const base = basePath === undefined ? undefined : readBaseFile(basePath); - const gained = (pattern) => pattern.test(source) && !(base !== undefined && pattern.test(base)); - const reasons = [ - ...(gained(V1_STORY_TITLE) ? ["title"] : []), - ...(gained(V1_STORY_TAG) ? ["tag"] : []), - ...(base !== undefined && isV1Story(base) ? ["frozen"] : []), - ]; - return reasons.length ? [{ ...file, ...(from === undefined ? {} : { from }), reasons }] : []; - }); + const v1 = []; + const compileOnly = []; + for (const file of files) { + if (file.status === "D" || !STORY_PATTERN.test(file.path)) { + continue; + } + const source = readWorktreeFile(file.path); + if (source === undefined) { + continue; + } + const from = movedFrom.get(file.path); + const basePath = file.status === "M" ? file.path : from; + const base = basePath === undefined ? undefined : readBaseFile(basePath); + const gained = (pattern) => pattern.test(source) && !(base !== undefined && pattern.test(base)); + const reasons = [ + ...(gained(V1_STORY_TITLE) ? ["title"] : []), + ...(gained(V1_STORY_TAG) ? ["tag"] : []), + ...(base !== undefined && isV1Story(base) ? ["frozen"] : []), + ]; + if (reasons.length === 1 && reasons[0] === "frozen" && file.status === "M" && keepsStoryIdentity(base, source)) { + compileOnly.push(file); + } else if (reasons.length) { + v1.push({ ...file, ...(from === undefined ? {} : { from }), reasons }); + } + } + return { v1Stories: v1, compileOnlyV1Repairs: compileOnly }; +} + +/** Same meta title, same meta tags and same exported story names, so the same story IDs. */ +function keepsStoryIdentity(base, source) { + return ( + storyMetaTitle(base) === storyMetaTitle(source) && + JSON.stringify(storyMetaTags(base)) === JSON.stringify(storyMetaTags(source)) && + JSON.stringify(storyExportNames(base)) === JSON.stringify(storyExportNames(source)) + ); } function isV1Story(source) { @@ -436,6 +465,25 @@ function storyMetaTitle(source) { return STORY_META_TITLE.exec(start === -1 ? source : source.slice(start))?.[2] ?? null; } +function storyMetaTags(source) { + const start = source.search(STORY_META_START); + const tags = STORY_META_TAGS.exec(start === -1 ? source : source.slice(start))?.[1]; + return tags === undefined ? null : [...tags.matchAll(STRING_LITERAL)].map((match) => match[2]); +} + +function storyExportNames(source) { + const names = new Set([...source.matchAll(NAMED_EXPORT)].map((match) => match[1])); + for (const [, list] of source.matchAll(EXPORT_LIST)) { + for (const entry of list.split(",")) { + const name = entry.trim().split(EXPORT_ALIAS).at(-1); + if (name && name !== "default") { + names.add(name); + } + } + } + return [...names].sort(); +} + function readWorktreeFile(filePath) { const absolute = path.join(worktree, filePath); return existsSync(absolute) ? readFileSync(absolute, "utf8") : undefined; diff --git a/plugins/vibe/skills/handoff/scripts/handoff-inventory.test.mjs b/plugins/vibe/skills/handoff/scripts/handoff-inventory.test.mjs index fe2a802e..0ee4f97f 100644 --- a/plugins/vibe/skills/handoff/scripts/handoff-inventory.test.mjs +++ b/plugins/vibe/skills/handoff/scripts/handoff-inventory.test.mjs @@ -418,6 +418,70 @@ test("a v1.0 story file is frozen: an edit or move blocks, and new states go in assert.equal(clean.status, 0, JSON.stringify(clean.json)); }); +test("a compile-only repair of a v1.0 story file passes for engineering review; a story, title, tag or move change still blocks", (t) => { + const dir = "apps/storybook/stories/composites"; + const v1File = (name, { title = `Composites/Data Display/${name}`, tags = ["v1.0", "autodocs"], prop = "label", from = "@repo/design-system/tree-row", stories = ["Default", "Selected"] } = {}) => + [ + `import type { Meta } from "@storybook/react";`, + `import { TreeRow } from "${from}";`, + "const meta = {", + ` title: "${title}",`, + " component: TreeRow,", + ` tags: [${tags.map((tag) => `"${tag}"`).join(", ")}],`, + "} satisfies Meta;", + "export default meta;", + ...stories.map((story) => `export const ${story} = { args: { ${prop}: "${story}" } };`), + "", + ].join("\n"); + const cases = { + propRename: "Prop Rename", + addedStory: "Added Story", + removedStory: "Removed Story", + renamedStory: "Renamed Story", + retitled: "Retitled", + retagged: "Retagged", + moved: "Moved", + }; + const file = (key) => `${dir}/${key}.stories.tsx`; + const movedTo = "apps/storybook/stories/tree/moved.stories.tsx"; + const { home, worktree } = setup( + t, + "story-compile-only", + Object.fromEntries(Object.entries(cases).map(([key, name]) => [file(key), v1File(name)])) + ); + const repaired = { prop: "text", from: "@repo/design-system/tree/tree-row" }; + write(worktree, file("propRename"), v1File(cases.propRename, repaired)); + write(worktree, file("addedStory"), v1File(cases.addedStory, { ...repaired, stories: ["Default", "Selected", "Disabled"] })); + write(worktree, file("removedStory"), v1File(cases.removedStory, { ...repaired, stories: ["Default"] })); + write(worktree, file("renamedStory"), v1File(cases.renamedStory, { ...repaired, stories: ["Default", "Active"] })); + write(worktree, file("retitled"), v1File(cases.retitled, { ...repaired, title: "Composites/Data Display/Retitled Row" })); + write(worktree, file("retagged"), v1File(cases.retagged, { ...repaired, tags: ["v1.0"] })); + mkdirSync(path.join(worktree, "apps/storybook/stories/tree"), { recursive: true }); + git(worktree, ["mv", file("moved"), movedTo], home); + write(worktree, movedTo, v1File(cases.moved, repaired)); + + const result = runNode(INVENTORY, ["--worktree", worktree, "--phase", "handoff"], home); + assert.equal(result.status, 1); + assert.equal(result.json.blocking.storiesInV2Taxonomy, false); + assert.deepEqual(result.json.compileOnlyV1Repairs, [{ path: file("propRename"), status: "M" }]); + assert.deepEqual(result.json.v1Stories, [ + { path: file("addedStory"), status: "M", reasons: ["frozen"] }, + { path: file("removedStory"), status: "M", reasons: ["frozen"] }, + { path: file("renamedStory"), status: "M", reasons: ["frozen"] }, + { path: file("retagged"), status: "M", reasons: ["frozen"] }, + { path: file("retitled"), status: "M", reasons: ["frozen"] }, + { path: movedTo, status: "A", from: file("moved"), reasons: ["frozen"] }, + ]); + + git(worktree, ["mv", movedTo, file("moved")], home); + git(worktree, ["checkout", "-q", "--", ...["addedStory", "removedStory", "renamedStory", "retitled", "retagged", "moved"].map(file)], home); + const repairOnly = runNode(INVENTORY, ["--worktree", worktree, "--phase", "handoff"], home); + assert.equal(repairOnly.status, 0, JSON.stringify(repairOnly.json)); + assert.equal(repairOnly.json.blocking.storiesInV2Taxonomy, true); + assert.deepEqual(repairOnly.json.v1Stories, []); + assert.deepEqual(repairOnly.json.compileOnlyV1Repairs, [{ path: file("propRename"), status: "M" }]); +}); + test("an existing non-v1.0 story file whose meta title changed blocks unless recorded as a replacement's entry", (t) => { const meta = (title, tags) => `const meta = {\n title: "${title}",\n tags: [${tags.map((tag) => `"${tag}"`).join(", ")}],\n};\nexport default meta;\n`; const table = "apps/storybook/stories/grid-table-v2.stories.tsx"; diff --git a/plugins/vibe/skills/vibe/references/design-pass.md b/plugins/vibe/skills/vibe/references/design-pass.md index 17124d51..d55eeb10 100644 --- a/plugins/vibe/skills/vibe/references/design-pass.md +++ b/plugins/vibe/skills/vibe/references/design-pass.md @@ -78,7 +78,8 @@ not a design session: no second design, no questions, no approval. component is added, the writer returns `NEEDS_STORYBOOK` and `vibe-storybook-decomposer` answers with its Placement block (`SKILL.md`, section 5). New states of an existing component go in a new v2.0 entry - holding only them when its story file is v1.0, which is never edited, and + holding only them when its story file is v1.0, which is never edited + beyond a compile-only repair, and otherwise in its existing story file, title and tag unchanged; only a new component or a sibling replacement otherwise gets a new entry (design ruling, 2026-10-15). diff --git a/plugins/vibe/skills/vibe/references/guardrails.md b/plugins/vibe/skills/vibe/references/guardrails.md index ddd92163..233ce594 100644 --- a/plugins/vibe/skills/vibe/references/guardrails.md +++ b/plugins/vibe/skills/vibe/references/guardrails.md @@ -228,8 +228,9 @@ PRD-720 session titled its new stories under v1.0 roots). The bare minimum: `Surfaces/`). - New states or variants of an existing component never retitle, re-tag or move its existing story file, since story IDs come from the title. A v1.0 - story file is never edited: its component's new states go in a new v2.0 - entry holding only them. Any other existing file takes them as extra + story file is never edited beyond a compile-only repair (prop renames, + imports, types; no story, title, tag or visual change): its component's new + states go in a new v2.0 entry holding only them. Any other existing file takes them as extra stories. Otherwise only a new component or a sibling replacement (a V3) gets a new entry (design ruling, 2026-10-15; `vibe-storybook-decomposer.md`, "Precedent search" step 4). @@ -240,11 +241,13 @@ PRD-720 session titled its new stories under v1.0 roots). The bare minimum: and Version History entry the doc names, in the same change. The handoff inventory blocks a new story file titled under or tagged for a -v1.0 root and any edit or move of a v1.0 story file (`v1Stories`, +v1.0 root and any other edit or move of a v1.0 story file (`v1Stories`, `storiesInV2Taxonomy`), and an existing non-v1.0 one whose title changed (`retitledStories`, `existingStoryTitlesKept`) unless the writer recorded it as a replacement's entry with `vibe-sessions.mjs story-replacement --worktree --path --replacement `, and -`vibe-guardrails-reviewer` checks the placement. Where in that tree a story +`vibe-guardrails-reviewer` checks the placement. A compile-only repair of a +v1.0 file does not block; it is listed under `compileOnlyV1Repairs` for the +Engineering checklist. Where in that tree a story goes, and which component owns it, is `vibe-storybook-decomposer`'s Placement block: the repo docs where they say how, otherwise the structure a sibling entity's precedent set, never its root or version. The "existing story files diff --git a/plugins/vibe/skills/vibe/references/ticket-template.md b/plugins/vibe/skills/vibe/references/ticket-template.md index 5680fd08..9e4f9cdf 100644 --- a/plugins/vibe/skills/vibe/references/ticket-template.md +++ b/plugins/vibe/skills/vibe/references/ticket-template.md @@ -58,6 +58,10 @@ publication, and the same ticket worker verifies completeness and assignment. `- [ ] Engineering needed (forbidden path): ; why: `; the same path or area logged again is the same item. Never in the Handoff section, and never routed to the person running the session. +- Compile-only v1.0 repairs: each file in the handoff inventory's + `compileOnlyV1Repairs` goes once at the end of the Engineering checklist as + `- [ ] Check compile-only repair to v1.0: ; confirm only prop, import or type changes and no visual change` + (design ruling, 2026-10-15). | Section | Written by | When | |---|---|---| @@ -71,7 +75,7 @@ publication, and the same ticket worker verifies completeness and assignment. | Handoff | `vibe-ticket-worker` | handoff | | Design Review | `vibe-ticket-worker` | handoff, from verified final code/publication and actual design evidence | | Grading | `vibe-ticket-worker` | handoff, copied unchanged from this template | -| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts and forbidden-path needs | +| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs and compile-only v1.0 repairs | ## Body diff --git a/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py b/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py index dfd6d89e..4d0c5e23 100644 --- a/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py +++ b/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py @@ -106,7 +106,7 @@ def test_the_template_places_each_need_once_as_an_unchecked_engineering_item() - "Never in the Handoff section, and never routed to the person running the session.", ): assert required in rule, required - assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts and forbidden-path needs |" in template + assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs and compile-only v1.0 repairs |" in template assert "Engineering needed" not in section(template, "## Handoff") diff --git a/plugins/vibe/tools/python/test_vibe_storybook_existing_component_contract.py b/plugins/vibe/tools/python/test_vibe_storybook_existing_component_contract.py index 7e1ad2b2..4f8e3414 100644 --- a/plugins/vibe/tools/python/test_vibe_storybook_existing_component_contract.py +++ b/plugins/vibe/tools/python/test_vibe_storybook_existing_component_contract.py @@ -3,7 +3,8 @@ 1. In the placement owner's decision order, a change to an existing component (new states or variants, a column action, a footer) comes first: it never retitles, re-tags or moves the existing story file. A v1.0 story file is - frozen and never edited, so the new states go in a new v2.0 entry holding + frozen and never edited beyond a compile-only repair + (test_vibe_v1_compile_only_repair_contract.py), so the new states go in a new v2.0 entry holding only them, named descriptively per the taxonomy doc without a version number; any other existing file takes them as extra stories, its title, tag and story names unchanged. Otherwise only a new component or a sibling @@ -73,7 +74,7 @@ def test_existing_components_come_first_in_the_placement_owners_decision_order() assert "even in a v1.0 root" not in order assert order.index(f"First ({RULING})") < order.index("Then (") placement = flat(agent("vibe-storybook-decomposer")) - assert "an existing file's title and tag unchanged, a v1.0 file not edited at all (step 4)" in placement + assert "an existing file's title and tag unchanged, a v1.0 file not edited beyond a compile-only repair (step 4)" in placement assert "which places every new story:" in placement assert "An existing component's story file keeps its title and tag (\"Precedent search\", step 4)." in placement @@ -88,7 +89,8 @@ def test_guardrails_and_design_pass_carry_the_one_liner() -> None: guardrails = paragraph(GUARDRAILS.read_text(), "- New states or variants of an existing component") for required in ( "never retitle, re-tag or move its existing story file, since story IDs come from the title", - "A v1.0 story file is never edited: its component's new states go in a new v2.0 entry holding only them.", + "A v1.0 story file is never edited beyond a compile-only repair (prop renames, imports, types; no story, " + "title, tag or visual change): its component's new states go in a new v2.0 entry holding only them.", "Any other existing file takes them as extra stories.", "Otherwise only a new component or a sibling replacement (a V3) gets a new entry", f"({RULING}; `vibe-storybook-decomposer.md`, \"Precedent search\" step 4)", @@ -97,7 +99,8 @@ def test_guardrails_and_design_pass_carry_the_one_liner() -> None: design = paragraph(DESIGN_PASS.read_text(), "6. Placement:") assert ( "New states of an existing component go in a new v2.0 entry holding only them when its story file is " - "v1.0, which is never edited, and otherwise in its existing story file, title and tag unchanged; only a " + "v1.0, which is never edited beyond a compile-only repair, and otherwise in its existing story file, title " + "and tag unchanged; only a " f"new component or a sibling replacement otherwise gets a new entry ({RULING})." ) in design @@ -110,10 +113,14 @@ def test_no_role_still_sends_touched_stories_to_v2() -> None: writer = flat(agent("vibe-change-worker")) assert ( "new states of an existing component go in a new v2.0 entry holding only them when its story file is " - "v1.0, which you never edit, and otherwise in its existing story file, title and tag unchanged" + "v1.0, which you never edit beyond a compile-only repair, and otherwise in its existing story file, title " + "and tag unchanged" ) in writer handoff = flat(HANDOFF_SKILL.read_text()) - assert "a v1.0 story file is never edited and any other existing story file keeps its title and tag" in handoff + assert ( + "a v1.0 story file is never edited beyond a compile-only repair and any other existing story file keeps " + "its title and tag" + ) in handoff for path in sorted(PLUGIN_ROOT.rglob("*.md")): text = flat(path.read_text()) for retired in ("even in a v1.0 root", "an existing v1.0 file keeps"): @@ -145,16 +152,17 @@ def test_the_inventory_freezes_v1_files_and_admits_only_recorded_replacements_fo ): assert required in inventory, required guardrails = flat(GUARDRAILS.read_text()) - assert "and any edit or move of a v1.0 story file (`v1Stories`, `storiesInV2Taxonomy`)" in guardrails + assert "and any other edit or move of a v1.0 story file (`v1Stories`, `storiesInV2Taxonomy`)" in guardrails assert "an existing non-v1.0 one whose title changed (`retitledStories`, `existingStoryTitlesKept`)" in guardrails assert "`vibe-sessions.mjs story-replacement --worktree --path --replacement `" in guardrails sessions = SESSIONS.read_text() assert 'case "story-replacement":' in sessions readme = flat(README.read_text()) for required in ( - "A v1.0 story file is never edited, so new states of its component go in a new v2.0 entry holding only them.", + "A v1.0 story file is never edited beyond a compile-only repair, so new states of its component go in a " + "new v2.0 entry holding only them.", "New states of any other existing component go in its existing story file under the same title and tag", - "handoff blocks an edited v1.0 file, and a retitle unless it is a sibling replacement's entry", + "handoff blocks any other edit of a v1.0 file, and a retitle unless it is a sibling replacement's entry", ): assert required in readme, required diff --git a/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py b/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py index aff7e82c..afd95587 100644 --- a/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py +++ b/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py @@ -342,7 +342,7 @@ def test_the_template_routes_design_conflicts_to_design_review_and_the_rest_to_e assert required in rule, required review = section(template, "## Design Review") assert "- Design conflicts: " in review - assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts and forbidden-path needs |" in template + assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs and compile-only v1.0 repairs |" in template def test_the_handoff_section_no_longer_collects_conflicts() -> None: diff --git a/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py b/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py new file mode 100644 index 00000000..35c81170 --- /dev/null +++ b/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py @@ -0,0 +1,144 @@ +"""Contract checks for compile-only repairs to frozen v1.0 story files (design ruling, 2026-10-15). + +1. A v1.0 story file stays frozen except for a compile-only repair: only + mechanical changes (prop renames, imports, types), no story added or + removed, no title, story ID or tag change and no visual change. The freeze + protects what its stories show and their links, not the code wording. The + placement owner states it in full; guardrails, design pass, handoff and the + README carry a one-liner. +2. The writer never avoids a rename to protect a v1.0 story file; it repairs + the file within those limits and logs the repair. +3. The guardrails reviewer confirms each repair is purely mechanical with no + visual change and blocks anything else. +4. The handoff inventory lists the repairs under `compileOnlyV1Repairs` + without blocking (its node tests prove the check), and the ticket worker + places each one once as an unchecked Engineering checklist item. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +PLUGIN_ROOT = Path(__file__).resolve().parents[2] +AGENTS = PLUGIN_ROOT / "agents" +VIBE = PLUGIN_ROOT / "skills" / "vibe" +GUARDRAILS = VIBE / "references" / "guardrails.md" +DESIGN_PASS = VIBE / "references" / "design-pass.md" +TICKET_TEMPLATE = VIBE / "references" / "ticket-template.md" +HANDOFF_SKILL = PLUGIN_ROOT / "skills" / "handoff" / "SKILL.md" +INVENTORY = PLUGIN_ROOT / "skills" / "handoff" / "scripts" / "handoff-inventory.mjs" +README = PLUGIN_ROOT / "README.md" +RULING = "design ruling, 2026-10-15" +EXCEPTION = ( + "The one exception is a compile-only repair after a rename elsewhere: only mechanical changes (prop renames, " + "imports, types), no story added or removed, no title, story ID or tag change and no visual change, since the " + "freeze protects what its stories show and their links, not the code wording." +) +CHECKLIST_SHAPE = ( + "`- [ ] Check compile-only repair to v1.0: ; confirm only prop, import or type changes and no visual change`" +) + + +def flat(text: str) -> str: + return re.sub(r"\s+", " ", text) + + +def section(text: str, heading: str) -> str: + """The section under `heading`, up to the next heading of the same or a higher level, flattened.""" + level = len(heading.split(" ", 1)[0]) + start = text.index(heading) + rest = text[start + len(heading) :] + ends = [match.start() for match in re.finditer(r"\n(#{1,%d}) " % level, rest)] + return flat(rest[: ends[0]] if ends else rest) + + +def paragraph(text: str, marker: str) -> str: + """The blank-line-delimited paragraph or list item block containing `marker`, flattened.""" + for block in re.split(r"\n\s*\n|\n(?=\d+\. )|\n(?=- )", text): + if marker in flat(block): + return flat(block) + raise AssertionError(marker) + + +def agent(name: str) -> str: + return (AGENTS / f"{name}.md").read_text() + + +def test_the_placement_owner_states_the_exception_in_full_once() -> None: + order = paragraph(agent("vibe-storybook-decomposer"), "4. Decide in this order") + assert EXCEPTION in order + assert order.index("A v1.0 story file is frozen and never edited") < order.index(EXCEPTION) + assert f"First ({RULING})" in order + holders = [path.name for path in sorted(PLUGIN_ROOT.rglob("*.md")) if EXCEPTION in flat(path.read_text())] + assert holders == ["vibe-storybook-decomposer.md"] + + +def test_guardrails_design_pass_handoff_and_readme_carry_the_one_liner() -> None: + guardrails = flat(GUARDRAILS.read_text()) + assert ( + "A v1.0 story file is never edited beyond a compile-only repair (prop renames, imports, types; no story, " + "title, tag or visual change)" + ) in guardrails + assert ( + "A compile-only repair of a v1.0 file does not block; it is listed under `compileOnlyV1Repairs` for the " + "Engineering checklist." + ) in guardrails + assert "which is never edited beyond a compile-only repair" in paragraph(DESIGN_PASS.read_text(), "6. Placement:") + storybook = section(HANDOFF_SKILL.read_text(), "## 4. Storybook") + assert "a v1.0 story file is never edited beyond a compile-only repair" in storybook + assert ( + "Its `compileOnlyV1Repairs` do not; the ticket worker puts each on the Engineering checklist." + ) in storybook + readme = flat(README.read_text()) + assert "A v1.0 story file is never edited beyond a compile-only repair" in readme + assert "handoff blocks any other edit of a v1.0 file" in readme + + +def test_the_writer_never_avoids_a_rename_and_logs_the_repair() -> None: + writer = flat(agent("vibe-change-worker")) + assert ( + "Never avoid a rename to protect a v1.0 story file; when a rename breaks one, repair it with only prop " + "renames, imports and types, no story added or removed and no title, story ID, tag or visual change, and " + f"log `Compile-only repair to v1.0: ` in the change log ({RULING})." + ) in writer + + +def test_the_reviewer_confirms_each_repair_is_mechanical() -> None: + check = paragraph(agent("vibe-guardrails-reviewer"), "10. Storybook taxonomy") + assert ( + "Confirm each of the inventory's `compileOnlyV1Repairs` is purely mechanical (prop renames, imports, " + "types) with no visual change; anything else in one is blocking." + ) in check + assert "any edit or move of a story file that is v1.0 on main (the inventory's `v1Stories`, reason `frozen`)" in check + + +def test_the_inventory_reports_repairs_without_blocking_on_them() -> None: + inventory = INVENTORY.read_text() + blocking = inventory[inventory.index("const blocking = {") : inventory.index("};", inventory.index("const blocking = {"))] + assert "compileOnlyV1Repairs" not in blocking + assert "storiesInV2Taxonomy: v1Stories.length === 0," in blocking + result = inventory[inventory.index("const result = {") :] + assert " compileOnlyV1Repairs,\n" in result + assert "function keepsStoryIdentity(base, source)" in inventory + + +def test_the_ticket_worker_places_each_repair_once_on_the_engineering_checklist() -> None: + template = TICKET_TEMPLATE.read_text() + rule = paragraph(template, "- Compile-only v1.0 repairs:") + assert ( + "each file in the handoff inventory's `compileOnlyV1Repairs` goes once at the end of the Engineering " + f"checklist as {CHECKLIST_SHAPE} ({RULING})." + ) in rule + assert ( + "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, " + "forbidden-path needs and compile-only v1.0 repairs |" + ) in template + assert "compile-only" not in section(template, "## Handoff") + handoff_mode = section(agent("vibe-ticket-worker"), "## Handoff mode") + assert ( + "Place each file in the inventory's `compileOnlyV1Repairs` once, as an unchecked item at the end of the " + "Engineering checklist in the template's shape (`../skills/vibe/references/ticket-template.md`, " + "\"Compile-only v1.0 repairs\")." + ) in handoff_mode