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