diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2778549..16e635d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.230.0] - 2026-10-26 + +### Changed +- **The archetype the user picks on the /design-shotgun board now reaches /design-html.** Captions name the archetype id (`classic-hero`, `long-form-story`, `minimal-conversion`, `comparison`). On approval, approved.json also gets the chosen `archetype` and its `sections` outline, added with the Edit tool so no free text reaches a shell command. /design-html reads both in Case A, and the craft section uses them as given instead of choosing again or asking. +- **Accepted craft exceptions persist.** /design-html's finalized.json records each accepted `CRAFT_CHECK` finding in `craft_exceptions` (`id`, `count`, `reason`) and carries forward earlier ones. A later run passes that file with `--accepted`, so a finding the user already accepted is reported as accepted instead of flagged again. A higher count is still flagged as a new issue. + +### Added +- **`gstack-design-craft --accepted `**: a finding whose id an exception covers, at no more than the accepted count, prints `CRAFT_ACCEPTED: ` and doesn't fail the check. `CRAFT_RESULT` adds the accepted count. Exit codes: 0 when only accepted findings remain, 1 for unaccepted ones, and 2 for an unknown argument or a missing, unreadable or malformed exceptions file. A bad exceptions file still gets the full check with nothing accepted, after a warning, so a broken record can't switch the craft check off. A finalized.json with no `craft_exceptions` field, written before this change, means no exceptions. + +### Tests +- `design-html-slop-gate` is now selected by changes to `lib/design-craft.ts` and `bin/gstack-design-craft` (`test/helpers/touchfiles-data.ts`), which it already exercised but did not list. +- `test/design-craft.test.ts` also covers accepted exceptions: count coverage and overflow, duplicate ids carried forward (the highest count wins), a bad exceptions file still checked in full, the finalized.json block in design-html/SKILL.md parsed by the same reader the CLI uses, rejected malformed lists, CLI exit codes with `--accepted`, and the archetype handoff contract (the same four ids in /design-shotgun and the craft section, the approved.json step, Case A and the craft section's no-re-ask rule). + ## [1.91.229.0] - 2026-10-26 **The OpenSSF Scorecard workflow uploads its results with `github/codeql-action/upload-sarif` 4.39.0 (from 4.38.3), recreated from Dependabot's #3307.** Dependabot's branch can't take a version stamp at merge time, so the same one-line change ships from a crew branch. The new pin `6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee` is the commit that the annotated tag `v4.39.0` points to in github/codeql-action, and GitHub shows that commit as verified. 4.39.0's one breaking change raises the minimum CodeQL bundle to 2.20.7, which applies to `init` and `analyze`. This workflow uses only `upload-sarif` with Scorecard's own SARIF file. diff --git a/VERSION b/VERSION index a62380468..2b6fc7540 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.229.0 +1.91.230.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 4cf8065f5..e920a894c 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.229.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.230.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-design-craft b/bin/gstack-design-craft index 50c01cb9f..11569f4ed 100755 --- a/bin/gstack-design-craft +++ b/bin/gstack-design-craft @@ -1,20 +1,27 @@ #!/usr/bin/env -S bun --no-env-file --config=/dev/null // gstack-design-craft — free craft check for a generated page (/design-html Step 4). // -// Usage: gstack-design-craft +// Usage: gstack-design-craft [--accepted ] // // Prints one "CRAFT_CHECK: " line per tell, then // "CRAFT_RESULT: clean" (exit 0) or "CRAFT_RESULT: issue(s)" (exit 1). -// Exit 2: usage error, a non-HTML file, an unreadable or empty file. Head and -// navigation rules run only on a full HTML document; a fragment gets the copy -// and code rules alone. +// --accepted reads the craft_exceptions a finalized.json recorded: a tell an +// exception covers (same id, count at most the accepted count) prints as +// "CRAFT_ACCEPTED: " and does not fail the check; the +// result line then adds " ( accepted)" or ", accepted". +// Exit 2: usage error, a non-HTML file, or an unreadable or empty file, with +// nothing checked; or an unreadable or malformed exceptions file, after a +// warning and a full check with no exceptions accepted, so a bad record never +// hides the page's tells. Head and navigation rules run only on a full HTML +// document; a fragment gets the copy and code rules alone. import { readFileSync } from 'fs'; -import { checkCraft, formatCraft } from '../lib/design-craft'; +import { acceptFindings, checkCraft, formatCraft, parseCraftExceptions, type CraftException } from '../lib/design-craft'; -const file = process.argv[2]; -if (!file) { - console.error('Usage: gstack-design-craft '); +const USAGE = 'Usage: gstack-design-craft [--accepted ]'; +const [file, flag, acceptedFile, ...rest] = process.argv.slice(2); +if (!file || (flag !== undefined && (flag !== '--accepted' || !acceptedFile)) || rest.length > 0) { + console.error(USAGE); process.exit(2); } if (!/\.html?$/i.test(file)) { @@ -32,6 +39,16 @@ if (source.trim() === '') { console.error(`gstack-design-craft: ${file} is empty; nothing was checked`); process.exit(2); } -const findings = checkCraft(source); -console.log(formatCraft(findings)); -process.exit(findings.length === 0 ? 0 : 1); +let exceptions: CraftException[] = []; +let badExceptions = false; +if (acceptedFile) { + try { + exceptions = parseCraftExceptions(readFileSync(acceptedFile, 'utf-8')); + } catch (err) { + console.error(`gstack-design-craft: cannot use exceptions from ${acceptedFile}: ${(err as Error).message}; checked with none accepted`); + badExceptions = true; + } +} +const { issues, accepted } = acceptFindings(checkCraft(source), exceptions); +console.log(formatCraft(issues, accepted)); +process.exit(badExceptions ? 2 : issues.length === 0 ? 0 : 1); diff --git a/design-html/SKILL.md b/design-html/SKILL.md index 3f33d4f00..fa2c85ac8 100644 --- a/design-html/SKILL.md +++ b/design-html/SKILL.md @@ -462,7 +462,8 @@ Now route based on what was found. Check these cases in order: ### Case A: approved.json exists (design-shotgun ran) -If `APPROVED` was found, read it for user feedback and screen name, and resolve the +If `APPROVED` was found, read it for user feedback, screen name, and any `archetype` +and `sections` (the craft section takes them as given), and resolve the approved PNG (never guess it from a letter or the newest variant): ```bash @@ -731,7 +732,7 @@ _DJ=$(mktemp "${TMPDIR:-/tmp}/gstack-detect.XXXXXX"); bun --no-env-file --config Exit 2 → one surgical fix pass over the non-advisory rules in the `DETECT_TOP` block, then scan once more. Whatever remains, present the page with those findings listed as accepted-with-reason: a pattern the approved mockup contains, a value DESIGN.md's tokens bless or a pattern its Decisions Log or Do's and Don'ts records as intentional, or an inline `` the user agreed to. One pass, not a loop. Any other first line from the probe: skip, no ceremony. -Then, always: the craft section's check, `~/.claude/skills/gstack/bin/gstack-design-craft `, one fix pass and one rerun, as above. +Then, always: the craft section's check, `~/.claude/skills/gstack/bin/gstack-design-craft ` (plus `--accepted` per the section), one fix pass and one rerun, as above. ### Verification Screenshots @@ -832,6 +833,7 @@ Write `finalized.json` alongside the HTML: "pretext_tier": "", "framework": "", "iterations": , + "craft_exceptions": [{"id": "", "count": , "reason": ""}], "date": "", "screen": "", "branch": "" diff --git a/design-html/SKILL.md.tmpl b/design-html/SKILL.md.tmpl index 998782370..80d425437 100644 --- a/design-html/SKILL.md.tmpl +++ b/design-html/SKILL.md.tmpl @@ -82,7 +82,8 @@ Now route based on what was found. Check these cases in order: ### Case A: approved.json exists (design-shotgun ran) -If `APPROVED` was found, read it for user feedback and screen name, and resolve the +If `APPROVED` was found, read it for user feedback, screen name, and any `archetype` +and `sections` (the craft section takes them as given), and resolve the approved PNG (never guess it from a letter or the newest variant): ```bash @@ -341,7 +342,7 @@ kill 2>/dev/null || true {{DESIGN_DETECTOR:gate}} -Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft `, one fix pass and one rerun, as above. +Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft ` (plus `--accepted` per the section), one fix pass and one rerun, as above. ### Verification Screenshots @@ -442,6 +443,7 @@ Write `finalized.json` alongside the HTML: "pretext_tier": "", "framework": "", "iterations": , + "craft_exceptions": [{"id": "", "count": , "reason": ""}], "date": "", "screen": "", "branch": "" diff --git a/design-html/sections/craft.md b/design-html/sections/craft.md index 1630f7fb9..e17d7ee12 100644 --- a/design-html/sections/craft.md +++ b/design-html/sections/craft.md @@ -13,7 +13,7 @@ - who it is for, the problem they came with, their top three objections, what they already know - the proof the user can supply (named customers, attributed quotes, real numbers) and the risk terms the business offers -**Archetype.** Pick one and say why in the spec's layout type: **classic hero + sections** (default; the product reads from one screenshot), **long-form story** (the visitor needs educating or is skeptical), **minimal conversion** (high-intent traffic or a small offer), **comparison** (search intent is "X vs Y"). +**Archetype.** When approved.json carries an `archetype`, the user already chose it on the /design-shotgun board: take it and its `sections` outline as given, name both in the spec's layout type, and do not re-pick or re-ask. Otherwise pick one and say why there: **classic hero + sections** (`classic-hero`, default; the product reads from one screenshot), **long-form story** (`long-form-story`, the visitor needs educating or is skeptical), **minimal conversion** (`minimal-conversion`, high-intent traffic or a small offer), **comparison** (`comparison`, search intent is "X vs Y"). **Structure.** Above the fold at 375×667 and on desktop, inside the hero budget: a headline naming outcome and audience, one sentence on how, one primary CTA, one proof signal only when real proof exists (named logos or one attributed quote; a metric row stays banned), one product visual. Below it, choose modules and order from the archetype and the objections. The long-form menu: problem → solution, three to five benefits, how it works in three steps, proof, an FAQ of six to twelve real objections, the risk reversal, a final CTA identical to the top. A minimal page stays minimal; a comparison page leads with the comparison. @@ -65,7 +65,9 @@ At Step 4, after the Slop Gate and before the screenshots, run the free checker ~/.claude/skills/gstack/bin/gstack-design-craft ``` -It prints one `CRAFT_CHECK: ` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: issue(s)`. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines. +It prints one `CRAFT_CHECK: ` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: issue(s)`, plus the accepted count when `--accepted` covers any. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines. + +**Accepted exceptions persist.** Step 5's finalized.json records each one in `craft_exceptions` as `{ id, count, reason }`, carrying forward earlier ones the check still reports at the count it reports now (`[]` when none, never the template's placeholder entry). When evolving a finalized page, or rerunning on the page this run finalized, append `--accepted ` to the command; never after Start fresh or for a new page, whose old exceptions covered different content: a tell it covers prints `CRAFT_ACCEPTED: ` and does not fail the check, so name those as accepted earlier instead of fixing or re-flagging them. A count above the accepted one stays a `CRAFT_CHECK` line; it is new, so fix it or accept the new count with a reason. A `cannot use exceptions` warning with exit 2 means that record is malformed: the page was still checked with none accepted, so treat every line as new and write `craft_exceptions` correctly at Step 5. **Before this can ship.** Open the Step 4 hand-off, and repeat before Step 5's "Copy to project": every `[Needs proof: …]` marker, the dependencies the page cannot own (legal URLs, form endpoint, og:image, indexing, analytics and cookie consent where required), the accepted exceptions, and every claim the check made you change. diff --git a/design-html/sections/craft.md.tmpl b/design-html/sections/craft.md.tmpl index e6119d84d..b249a50ee 100644 --- a/design-html/sections/craft.md.tmpl +++ b/design-html/sections/craft.md.tmpl @@ -11,7 +11,7 @@ - who it is for, the problem they came with, their top three objections, what they already know - the proof the user can supply (named customers, attributed quotes, real numbers) and the risk terms the business offers -**Archetype.** Pick one and say why in the spec's layout type: **classic hero + sections** (default; the product reads from one screenshot), **long-form story** (the visitor needs educating or is skeptical), **minimal conversion** (high-intent traffic or a small offer), **comparison** (search intent is "X vs Y"). +**Archetype.** When approved.json carries an `archetype`, the user already chose it on the /design-shotgun board: take it and its `sections` outline as given, name both in the spec's layout type, and do not re-pick or re-ask. Otherwise pick one and say why there: **classic hero + sections** (`classic-hero`, default; the product reads from one screenshot), **long-form story** (`long-form-story`, the visitor needs educating or is skeptical), **minimal conversion** (`minimal-conversion`, high-intent traffic or a small offer), **comparison** (`comparison`, search intent is "X vs Y"). **Structure.** Above the fold at 375×667 and on desktop, inside the hero budget: a headline naming outcome and audience, one sentence on how, one primary CTA, one proof signal only when real proof exists (named logos or one attributed quote; a metric row stays banned), one product visual. Below it, choose modules and order from the archetype and the objections. The long-form menu: problem → solution, three to five benefits, how it works in three steps, proof, an FAQ of six to twelve real objections, the risk reversal, a final CTA identical to the top. A minimal page stays minimal; a comparison page leads with the comparison. @@ -63,7 +63,9 @@ At Step 4, after the Slop Gate and before the screenshots, run the free checker {{BIN_DIR}}/gstack-design-craft ``` -It prints one `CRAFT_CHECK: ` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: issue(s)`. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines. +It prints one `CRAFT_CHECK: ` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: issue(s)`, plus the accepted count when `--accepted` covers any. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines. + +**Accepted exceptions persist.** Step 5's finalized.json records each one in `craft_exceptions` as `{ id, count, reason }`, carrying forward earlier ones the check still reports at the count it reports now (`[]` when none, never the template's placeholder entry). When evolving a finalized page, or rerunning on the page this run finalized, append `--accepted ` to the command; never after Start fresh or for a new page, whose old exceptions covered different content: a tell it covers prints `CRAFT_ACCEPTED: ` and does not fail the check, so name those as accepted earlier instead of fixing or re-flagging them. A count above the accepted one stays a `CRAFT_CHECK` line; it is new, so fix it or accept the new count with a reason. A `cannot use exceptions` warning with exit 2 means that record is malformed: the page was still checked with none accepted, so treat every line as new and write `craft_exceptions` correctly at Step 5. **Before this can ship.** Open the Step 4 hand-off, and repeat before Step 5's "Copy to project": every `[Needs proof: …]` marker, the dependencies the page cannot own (legal URLs, form endpoint, og:image, indexing, analytics and cookie consent where required), the accepted exceptions, and every claim the check made you change. diff --git a/design-shotgun/SKILL.md b/design-shotgun/SKILL.md index cc9703d6b..2e7608b4c 100644 --- a/design-shotgun/SKILL.md +++ b/design-shotgun/SKILL.md @@ -634,9 +634,9 @@ Draw on DESIGN.md, taste memory, and the user's request to make each concept dis exists, it decides what varies: keep its fonts and palette and vary layout and composition. Without one, vary font family, color palette, and layout approach. If two variants look like siblings, rework the weaker one with a deliberately different direction. -For a landing, pricing or campaign screen, also vary the page archetype (classic hero, -long-form story, minimal conversion, comparison), keep the one primary action identical, -and caption each variant with its section outline. +For a landing, pricing or campaign screen, also vary the page archetype (`classic-hero`, +`long-form-story`, `minimal-conversion`, `comparison`), keep the one primary action +identical, and caption each variant with its archetype id and section outline. Concrete test: if someone could swap the headline text between two variants without noticing, they're too similar. Variants should feel like separate design teams made @@ -880,7 +880,9 @@ Use AskUserQuestion to confirm before saving. ## Step 6: Save & Next Steps -Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). +Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). When the variants +varied archetype, then add the chosen variant's `archetype` id and `sections` (its outline +as an array of section names) to it with the Edit tool; /design-html takes them as given. If invoked from another skill: return the structured feedback for that skill to consume. The calling skill reads `approved.json` and resolves the approved image with diff --git a/design-shotgun/SKILL.md.tmpl b/design-shotgun/SKILL.md.tmpl index a037184ad..ac85e05c4 100644 --- a/design-shotgun/SKILL.md.tmpl +++ b/design-shotgun/SKILL.md.tmpl @@ -219,9 +219,9 @@ Draw on DESIGN.md, taste memory, and the user's request to make each concept dis exists, it decides what varies: keep its fonts and palette and vary layout and composition. Without one, vary font family, color palette, and layout approach. If two variants look like siblings, rework the weaker one with a deliberately different direction. -For a landing, pricing or campaign screen, also vary the page archetype (classic hero, -long-form story, minimal conversion, comparison), keep the one primary action identical, -and caption each variant with its section outline. +For a landing, pricing or campaign screen, also vary the page archetype (`classic-hero`, +`long-form-story`, `minimal-conversion`, `comparison`), keep the one primary action +identical, and caption each variant with its archetype id and section outline. Concrete test: if someone could swap the headline text between two variants without noticing, they're too similar. Variants should feel like separate design teams made @@ -286,7 +286,9 @@ Use AskUserQuestion to confirm before saving. ## Step 6: Save & Next Steps -Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). +Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). When the variants +varied archetype, then add the chosen variant's `archetype` id and `sections` (its outline +as an array of section names) to it with the Edit tool; /design-html takes them as given. If invoked from another skill: return the structured feedback for that skill to consume. The calling skill reads `approved.json` and resolves the approved image with diff --git a/docs/skills.md b/docs/skills.md index e6cb489bf..718376b7f 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -462,7 +462,7 @@ You know the feeling. You have a feature, a page, a landing screen... and you're 3. It generates 3 distinct design variants as PNGs 4. A comparison board opens in your browser with all 3 side-by-side 5. You click "Approve" on the one you like, or give feedback for another round -6. The approved variant saves to `~/.gstack/projects/$SLUG/designs/` with an `approved.json` +6. The approved variant saves to `~/.gstack/projects/$SLUG/designs/` with an `approved.json`; for a landing, pricing or campaign screen, it also records the page archetype you picked (`classic-hero`, `long-form-story`, `minimal-conversion` or `comparison`) and its section outline That `approved.json` is one way to feed `/design-html`. The design pipeline chains: shotgun picks the direction, design-html renders it as working code. But `/design-html` also works with CEO plans, design reviews, or just a description. @@ -512,7 +512,7 @@ Not every page needs the full Pretext engine. The skill reads the design and pic ### The refinement loop -1. Reads the approved mockup from `approved.json` +1. Reads the approved mockup from `approved.json`, plus any page archetype and section outline you picked on the board, which it builds as given instead of asking again 2. Uses GPT-4o vision to extract implementation spec (colors, typography, layout) 3. Generates self-contained HTML with Pretext inlined (15KB, zero network dependency) 4. Spins up a live-reload server so you see changes instantly diff --git a/lib/design-craft.ts b/lib/design-craft.ts index 94198739f..faa5a12dc 100644 --- a/lib/design-craft.ts +++ b/lib/design-craft.ts @@ -103,8 +103,42 @@ export function checkCraft(html: string): CraftFinding[] { return findings; } -export function formatCraft(findings: CraftFinding[]): string { - const lines = findings.map(f => `CRAFT_CHECK: ${f.id} ${f.count} ${f.fix}`); - lines.push(findings.length === 0 ? 'CRAFT_RESULT: clean' : `CRAFT_RESULT: ${findings.length} issue(s)`); +/** An accepted finding recorded in finalized.json's `craft_exceptions`. */ +export interface CraftException { + id: string; + count: number; + reason: string; +} + +/** Reads `craft_exceptions` from a finalized.json; a record without one has none. Throws on a malformed record. */ +export function parseCraftExceptions(json: string): CraftException[] { + const record = JSON.parse(json); + if (typeof record !== 'object' || record === null || Array.isArray(record)) throw new Error('not a finalized.json object'); + const list = record.craft_exceptions ?? []; + const valid = (e: any) => typeof e?.id === 'string' && Number.isInteger(e.count) && e.count > 0 && typeof e.reason === 'string' && e.reason.trim() !== ''; + if (!Array.isArray(list) || !list.every(valid)) throw new Error('craft_exceptions must be a list of { id, count, reason } with a positive count and a reason'); + return list.map(({ id, count, reason }) => ({ id, count, reason: reason.replace(/\s+/g, ' ').trim() })); +} + +/** A finding is accepted when an exception names its id with at least its count; a higher count stays an issue and says how many were accepted. */ +export function acceptFindings(findings: CraftFinding[], exceptions: CraftException[]): { issues: CraftFinding[]; accepted: CraftException[] } { + const issues: CraftFinding[] = []; + const accepted: CraftException[] = []; + for (const finding of findings) { + const exception = exceptions.filter(e => e.id === finding.id).sort((a, b) => b.count - a.count)[0]; + if (!exception) issues.push(finding); + else if (finding.count <= exception.count) accepted.push({ id: finding.id, count: finding.count, reason: exception.reason }); + else issues.push({ ...finding, fix: `${finding.fix} (${exception.count} accepted earlier: ${exception.reason})` }); + } + return { issues, accepted }; +} + +export function formatCraft(findings: CraftFinding[], accepted: CraftException[] = []): string { + const lines = [ + ...findings.map(f => `CRAFT_CHECK: ${f.id} ${f.count} ${f.fix}`), + ...accepted.map(a => `CRAFT_ACCEPTED: ${a.id} ${a.count} ${a.reason}`), + ]; + const note = accepted.length === 0 ? '' : findings.length === 0 ? ` (${accepted.length} accepted)` : `, ${accepted.length} accepted`; + lines.push(`${findings.length === 0 ? 'CRAFT_RESULT: clean' : `CRAFT_RESULT: ${findings.length} issue(s)`}${note}`); return lines.join('\n'); } diff --git a/package.json b/package.json index 8846de526..7c5eb415d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.229", + "version": "1.91.230", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/design-craft.test.ts b/test/design-craft.test.ts index d6d832f3b..f1c2af57b 100644 --- a/test/design-craft.test.ts +++ b/test/design-craft.test.ts @@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { BUZZWORDS, checkCraft, formatCraft, visibleText } from '../lib/design-craft'; +import { acceptFindings, BUZZWORDS, checkCraft, formatCraft, parseCraftExceptions, visibleText } from '../lib/design-craft'; import { catalogEntry } from '../lib/design-catalog'; +import { selectTests } from './helpers/test-selection'; +import { E2E_TOUCHFILES } from './helpers/touchfiles-data'; const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin', 'gstack-design-craft'); @@ -21,8 +23,8 @@ const CLEAN = `Ledgerline: close the <main id="main"><h1>Close the books in 2 days, not 9</h1><p>Teams at Halvorsen Freight cut month-end by 47.2%. Save 20% yearly. 100% open source. 30-day refund.</p><img src="x.png" alt=""></main> <script>const io = new IntersectionObserver(() => {}); window.showAlert = () => {};</script></body></html>`; -function run(file?: string) { - const r = Bun.spawnSync([BIN, ...(file ? [file] : [])], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); +function run(file?: string, ...extra: string[]) { + const r = Bun.spawnSync([BIN, ...(file ? [file] : []), ...extra], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }; } @@ -116,3 +118,105 @@ describe('design-craft check', () => { } }); }); + +describe('accepted craft exceptions (finalized.json craft_exceptions)', () => { + test('an exception covers its id up to the accepted count; a higher count stays an issue and names the accepted count', () => { + const findings = checkCraft(DIRTY); + const { issues, accepted } = acceptFindings(findings, [ + { id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' }, + { id: 'round-number', count: 2, reason: 'supplied figures' }, + { id: 'scroll-listener', count: 5, reason: 'unused' }, + ]); + expect(accepted).toEqual([{ id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' }, { id: 'scroll-listener', count: 1, reason: 'unused' }]); + expect(issues.find(f => f.id === 'round-number')?.fix).toEndWith('(2 accepted earlier: supplied figures)'); + expect(issues).toHaveLength(findings.length - 2); + expect(formatCraft([], accepted)).toBe('CRAFT_ACCEPTED: placeholder-brand 1 Acme is the real customer\nCRAFT_ACCEPTED: scroll-listener 1 unused\nCRAFT_RESULT: clean (2 accepted)'); + const carried = [{ id: 'round-number', count: 1, reason: 'first run' }, { id: 'round-number', count: 3, reason: 'supplied figures' }]; + for (const list of [carried, [...carried].reverse()]) { + expect(acceptFindings([{ id: 'round-number', count: 3, fix: 'use the supplied figure' }], list)).toEqual({ issues: [], accepted: [{ id: 'round-number', count: 3, reason: 'supplied figures' }] }); + } + }); + + test('parseCraftExceptions reads the finalized.json design-html writes and rejects a malformed list', () => { + const skill = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md'), 'utf-8'); + const block = skill.slice(skill.indexOf('Write `finalized.json` alongside the HTML')).match(/```json\n([\s\S]*?)```/)![1]; + const record = JSON.parse(block.replace(/<number of refinement iterations>|<n>/g, '1')); + expect(parseCraftExceptions(JSON.stringify(record))).toEqual([{ id: '<craft id>', count: 1, reason: '<why accepted>' }]); + expect(parseCraftExceptions('{"mode":"freeform"}')).toEqual([]); + expect(parseCraftExceptions('{"craft_exceptions":[{"id":"oops","count":1,"reason":"quoted\\nfrom the user"}]}')).toEqual([{ id: 'oops', count: 1, reason: 'quoted from the user' }]); + for (const bad of ['[]', 'null', '{"craft_exceptions":{}}', '{"craft_exceptions":[{"id":"oops","count":0,"reason":"x"}]}', '{"craft_exceptions":[{"id":"oops","count":1,"reason":" "}]}', '{"craft_exceptions":[{"id":"oops","count":1.5,"reason":"x"}]}']) { + expect(() => parseCraftExceptions(bad)).toThrow(); + } + }); + + test('the CLI reports accepted tells as CRAFT_ACCEPTED lines: exit 0 when only accepted ones remain, 1 for the rest, 2 on a bad exceptions file after a full check with none accepted', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-accepted-')); + try { + const page = path.join(dir, 'finalized.html'); + const record = path.join(dir, 'finalized.json'); + fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme')); + fs.writeFileSync(record, JSON.stringify({ mode: 'evolve', craft_exceptions: [{ id: 'placeholder-brand', count: 1, reason: 'Acme Freight is the customer' }] })); + expect(run(page, '--accepted', record)).toMatchObject({ code: 0, out: 'CRAFT_ACCEPTED: placeholder-brand 1 Acme Freight is the customer\nCRAFT_RESULT: clean (1 accepted)\n' }); + expect(run(page).code).toBe(1); + fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme and Nexus')); + const twice = run(page, '--accepted', record); + expect(twice.code).toBe(1); + expect(twice.out).toBe('CRAFT_CHECK: placeholder-brand 2 use a supplied customer, or a fitting name in sample data (1 accepted earlier: Acme Freight is the customer)\nCRAFT_RESULT: 1 issue(s)\n'); + fs.writeFileSync(page, DIRTY); + const dirty = run(page, '--accepted', record); + expect(dirty.code).toBe(1); + expect(dirty.out).toContain('CRAFT_ACCEPTED: placeholder-brand 1 '); + expect(dirty.out.trim().split('\n').at(-1)).toBe('CRAFT_RESULT: 17 issue(s), 1 accepted'); + fs.writeFileSync(path.join(dir, 'old.json'), JSON.stringify({ mode: 'freeform' })); + expect(run(page, '--accepted', path.join(dir, 'old.json')).out).toBe(run(page).out); + fs.writeFileSync(path.join(dir, 'bad.json'), '{"craft_exceptions":[{"id":"oops"}]}'); + fs.writeFileSync(path.join(dir, 'broken.json'), '{'); + for (const name of ['bad.json', 'broken.json', 'missing.json']) { + const bad = run(page, '--accepted', path.join(dir, name)); + expect(bad).toMatchObject({ code: 2, out: run(page).out }); + expect(bad.err).toContain('checked with none accepted'); + } + fs.writeFileSync(page, CLEAN); + expect(run(page, '--accepted', path.join(dir, 'broken.json'))).toMatchObject({ code: 2, out: 'CRAFT_RESULT: clean\n' }); + expect(run(page, '--accepted').code).toBe(2); + expect(run(page, '--strict').code).toBe(2); + expect(run(page, '--accepted', record, 'extra').code).toBe(2); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('design-shotgun to design-html handoff', () => { + const ARCHETYPES = ['classic-hero', 'long-form-story', 'minimal-conversion', 'comparison']; + const read = (...p: string[]) => fs.readFileSync(path.join(ROOT, ...p), 'utf-8'); + + test('design-shotgun records the chosen archetype and outline in approved.json, and design-html takes them as given', () => { + const shotgun = read('design-shotgun', 'SKILL.md'); + const craft = read('design-html', 'sections', 'craft.md'); + const html = read('design-html', 'SKILL.md'); + for (const id of ARCHETYPES) { + expect(shotgun).toContain(`\`${id}\``); + expect(craft).toContain(`\`${id}\``); + } + const save = shotgun.slice(shotgun.indexOf('## Step 6: Save & Next Steps')); + expect(save).toMatch(/add the chosen variant's `archetype` id and `sections`[^.]*to it with the Edit tool/); + expect(html.slice(html.indexOf('### Case A'), html.indexOf('### Case B'))).toContain('any `archetype`\nand `sections`'); + expect(craft).toMatch(/When approved\.json carries an `archetype`[^.]*: take it and its `sections` outline as given[^.]*do not re-pick or re-ask/); + }); + + test('a change to the craft checker selects the design-html-slop-gate E2E that runs it', () => { + for (const file of ['lib/design-craft.ts', 'bin/gstack-design-craft']) { + expect(selectTests([file], E2E_TOUCHFILES).selected).toContain('design-html-slop-gate'); + } + }); + + test('design-html persists accepted exceptions and passes them back with --accepted', () => { + const html = read('design-html', 'SKILL.md'); + const craft = read('design-html', 'sections', 'craft.md'); + expect(html).toMatch(/gstack-design-craft <path-to-finalized\.html>` \(plus `--accepted` per the section\)/); + expect(craft).toContain('records each one in `craft_exceptions`'); + expect(craft).toContain('append `--accepted <that finalized.json>` to the command'); + expect(craft).toContain('CRAFT_ACCEPTED: <id> <count> <reason>'); + }); +}); diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 8c8856d66..0f0376441 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -856,7 +856,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = { 'design-review-detector-shim-dom': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'browse/src/**', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts', 'scripts/resolvers/testing.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'], 'design-review-plugin-handoff': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/testing.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/skill-e2e-design.test.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'], - 'design-html-slop-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'], + 'design-html-slop-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'lib/design-craft.ts', 'bin/gstack-design-craft', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'], // /diagram (diagram-render bundle consumers). Triplet = deterministic // functional (gate); authoring quality = LLM-judged benchmark (periodic).