v1.91.230.0 feat(design-shotgun,design-html): archetype handoff and persisted craft exceptions (GSTA-92 phase 2) (#3422)

* feat(design-html): craft section and free craft check, adapted from Elaya design skills (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(GSTA-92): restore the full CHANGELOG under the Unreleased entry; drop a $-digit literal from craft.md; count only craft-check executions in design-html-slop-gate

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-shotgun,design-html): carry the chosen archetype into approved.json; persist accepted craft exceptions in finalized.json (GSTA-92 phase 2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(touchfiles): design-html-slop-gate selects on the craft check it runs (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (GSTA-92)

A malformed or missing --accepted exceptions file now gets the full craft
check with nothing accepted, after a warning, still exiting 2. Before, it
printed nothing, and the craft section treats a missing CRAFT_RESULT as
'check unavailable', so one bad finalized.json switched the gate off
(found by both adversarial passes). The header comment and the craft
section now document the accepted-count suffix on CRAFT_RESULT. Tests
cover duplicate carried-forward exception ids and the slop-gate touchfiles
selection.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: scope --accepted to the same page; record current counts (GSTA-92)

The craft section passed --accepted whenever a finalized.json sat beside
the page, so a same-day Start fresh build inherited the old page's
exceptions. It now passes them only when evolving or rerunning a page it
finalized, records the count the check reports now, and writes [] when
nothing was accepted instead of the template's placeholder entry. A test
pins exit 2 for a clean page with a broken exceptions file.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: note the archetype handoff in the shotgun and design-html loops (GSTA-92)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-26 06:07:07 +00:00
1 parent 5128ec1469
commit 4814b3d8a4
15 files changed
+219 -39

No files matched your search

+13
View File
@@ -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 <finalized.json>`**: a finding whose id an exception covers, at no more than the accepted count, prints `CRAFT_ACCEPTED: <id> <count> <reason>` 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.
+1 -1
View File
@@ -1 +1 @@
1.91.229.0
1.91.230.0
+1 -1
View File
@@ -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,
+28 -11
View File
@@ -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 <file.html>
// Usage: gstack-design-craft <file.html> [--accepted <finalized.json>]
//
// Prints one "CRAFT_CHECK: <id> <count> <fix>" line per tell, then
// "CRAFT_RESULT: clean" (exit 0) or "CRAFT_RESULT: <n> 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: <id> <count> <reason>" and does not fail the check; the
// result line then adds " (<k> accepted)" or ", <k> 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 <file.html>');
const USAGE = 'Usage: gstack-design-craft <file.html> [--accepted <finalized.json>]';
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);
+4 -2
View File
@@ -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 `<!-- impeccable-disable <rule>: <reason> -->` 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 <path-to-finalized.html>`, one fix pass and one rerun, as above.
Then, always: the craft section's check, `~/.claude/skills/gstack/bin/gstack-design-craft <path-to-finalized.html>` (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": "<selected tier>",
"framework": "<vanilla|react|svelte|vue>",
"iterations": <number of refinement iterations>,
"craft_exceptions": [{"id": "<craft id>", "count": <n>, "reason": "<why accepted>"}],
"date": "<ISO 8601>",
"screen": "<screen name>",
"branch": "<current branch>"
+4 -2
View File
@@ -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 <PID> 2>/dev/null || true
{{DESIGN_DETECTOR:gate}}
Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft <path-to-finalized.html>`, one fix pass and one rerun, as above.
Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft <path-to-finalized.html>` (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": "<selected tier>",
"framework": "<vanilla|react|svelte|vue>",
"iterations": <number of refinement iterations>,
"craft_exceptions": [{"id": "<craft id>", "count": <n>, "reason": "<why accepted>"}],
"date": "<ISO 8601>",
"screen": "<screen name>",
"branch": "<current branch>"
+4 -2
View File
@@ -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 <path-to-finalized.html>
```
It prints one `CRAFT_CHECK: <id> <count> <fix>` 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: <n> 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: <id> <count> <fix>` 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: <n> 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 <that finalized.json>` 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: <id> <count> <reason>` 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.
+4 -2
View File
@@ -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 <path-to-finalized.html>
```
It prints one `CRAFT_CHECK: <id> <count> <fix>` 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: <n> 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: <id> <count> <fix>` 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: <n> 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 <that finalized.json>` 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: <id> <count> <reason>` 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.
+6 -4
View File
@@ -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
+6 -4
View File
@@ -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
+2 -2
View File
@@ -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
+37 -3
View File
@@ -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');
}
+1 -1
View File
@@ -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",
+107 -3
View File
@@ -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 = `<!doctype html><html lang="en"><head><title>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>');
});
});
+1 -1
View File
@@ -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).