From fe6d1ae62a42e67bfb12b7f8e6143c705f375f38 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 3 Oct 2026 16:13:45 -0700 Subject: [PATCH] v1.91.17.0 fix: mvanhorn wave (checkpoint provenance, never-overwrite design images) (#3022) * fix: mark Remaining Work provenance so restore verifies guessed steps first (#3004) /context-save now starts every Remaining Work item with `Open.` and ends it with how this session knows it: `(path run)` with the observed outcome, `(path read)` or `(path assumed)` for concrete paths, and `(target state checked)`, `(code read)` or `(path assumed)` for writing steps. /context-restore keeps saved order and original text but splits the list into Next steps and Verify first (assumed, code-read, failed runs and unmarked concrete or writing items), prints a banner for pre-marker checkpoints, and option A walks saved order so an unverified first item is verified before any later step runs. Adds a prompt-byte contract test for the marker wire format and a periodic behavior E2E (context-restore-provenance-order) that seeds an assumed item ahead of a run item plus a code-read write. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(design): never overwrite a paid design image (#1529) Every image the API returns now goes through one persistence path (design/src/persist.ts): mkdir, exclusive `wx` claim that bumps x.png -> x-2.png ... x-999.png, write, fsync, close. A failure after the bytes arrived removes the claimed file and keeps a 0600 recovery copy in the temp dir; empty image data is a failure, never a 0-byte PNG. The API call is the only thing inside each retry loop, so a local save failure never buys a second image (variants, iterate fallback, generate --retry). generate, iterate, evolve and variants (both branches) print the same run fields (requested, saved, selected, failures, recovered) on every exit, with exit 0 / 2 (nothing saved) / 3 (stopped after saving). Each generate --retry attempt is its own saved image listed in `attempts`. compare accepts --images-file and JSON-array --images. The gallery lists bumped names, orders history by mtime then board-images.json, and marks approvals by approved_path. New bin/gstack-design-claim publishes staged images without clobbering; bin/gstack-design-approved resolves an approval record (legacy letter-only records still resolve; a missing approved_path is an error, never a substitute). Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(design skills): read the paths $D printed, never fixed variant names (#1529) Every design flow now captures each generating $D command's JSON and exit code without set -e, does round accounting before the first board, check or inline view (stopping when nothing was saved), and carries the printed paths forward. Boards are built from board-images.json via compare --images-file after archiving any earlier feedback.json, and approvals map the confirmed letter through that file and record approved_path. design-shotgun subagents generate into a per-run mktemp staging directory and publish with bin/gstack-design-claim instead of a shared /tmp/variant-X.png plus cp. design-html and taste memory resolve approvals with gstack-design-approved; design-review verifies against the printed target path. test/design-printed-paths.test.ts bans fixed variant names, variant globs, variant-{letter} reads, cp/mv of $D output and literal check paths in the generated skills and resolvers (one content-anchored allowlist entry: the design-html discovery probe), and pins the accounting/board marker order. test/design-approval-flow.test.ts runs the generated board and approval blocks through a stubbed two-round flow. * chore: release v1.91.17.0 (mvanhorn fix wave: checkpoint provenance, never-overwrite design images) * fix(design): keep iterate sessions in the OS temp dir and normalize tripwire paths on Windows * fix(setup-deploy): report failing status commands, specify merge-method, health and workflow checks --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- CHANGELOG.md | 40 ++ TODOS.md | 25 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-design-approved | 24 ++ bin/gstack-design-claim | 55 +++ context-restore/SKILL.md | 29 +- context-restore/SKILL.md.tmpl | 29 +- context-save/SKILL.md | 24 +- context-save/SKILL.md.tmpl | 24 +- design-consultation/SKILL.md | 10 +- .../sections/proposal-and-preview.md | 28 +- .../sections/proposal-and-preview.md.tmpl | 14 +- design-html/SKILL.md | 21 +- design-html/SKILL.md.tmpl | 13 +- design-review/SKILL.md | 15 +- design-review/SKILL.md.tmpl | 7 +- design-shotgun/SKILL.md | 111 +++--- design-shotgun/SKILL.md.tmpl | 80 ++-- design/src/approval.ts | 56 +++ design/src/auth.ts | 19 +- design/src/cli.ts | 65 ++-- design/src/commands.ts | 41 +- design/src/evolve.ts | 99 +++-- design/src/gallery.ts | 43 ++- design/src/generate.ts | 134 ++++--- design/src/image-args.ts | 53 +++ design/src/iterate.ts | 103 ++--- design/src/persist.ts | 196 ++++++++++ design/src/session.ts | 5 +- design/src/variants.ts | 357 ++++++++++-------- design/test/approval.test.ts | 61 +++ design/test/cli-persistence.test.ts | 223 +++++++++++ design/test/gallery.test.ts | 36 +- design/test/image-args.test.ts | 52 +++ design/test/models.test.ts | 18 +- design/test/persist.test.ts | 165 ++++++++ office-hours/SKILL.md | 30 +- package.json | 2 +- plan-design-review/SKILL.md | 46 ++- plan-design-review/SKILL.md.tmpl | 17 +- .../sections/review-sections.md | 4 +- .../sections/review-sections.md.tmpl | 4 +- scripts/resolvers/design.ts | 87 +++-- setup-deploy/SKILL.md | 13 +- setup-deploy/SKILL.md.tmpl | 13 +- .../context-remaining-work-provenance.test.ts | 63 ++++ test/design-approval-flow.test.ts | 97 +++++ test/design-printed-paths.test.ts | 165 ++++++++ test/gstack-design-claim.test.ts | 118 ++++++ test/helpers/touchfiles-data.ts | 5 + test/skill-e2e-context-skills.test.ts | 69 ++++ 52 files changed, 2423 insertions(+), 589 deletions(-) create mode 100755 bin/gstack-design-approved create mode 100755 bin/gstack-design-claim create mode 100644 design/src/approval.ts create mode 100644 design/src/image-args.ts create mode 100644 design/src/persist.ts create mode 100644 design/test/approval.test.ts create mode 100644 design/test/cli-persistence.test.ts create mode 100644 design/test/image-args.test.ts create mode 100644 design/test/persist.test.ts create mode 100644 test/context-remaining-work-provenance.test.ts create mode 100644 test/design-approval-flow.test.ts create mode 100644 test/design-printed-paths.test.ts create mode 100644 test/gstack-design-claim.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e70b8e951..759c9b9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## [1.91.17.0] - 2026-10-03 + +**Checkpoints say whether each next step was run, read or guessed, and `/context-restore` verifies the guesses first.** +**Paid design images are never overwritten, and design skills always show the round you just paid for.** + +This wave rebuilds three community PRs from @mvanhorn on current main. A checkpoint's Remaining Work used to read the same whether a step had been run or only inferred, so a resumed session could act on a guess and hit the wrong system (#3004, reported by @tomg65). Running `$D variants` twice into one folder replaced `variant-A.png`, `$D generate --retry` kept only the last image, and skills built boards from fixed `variant-A/B/C` names, so you could pay for images you never saw (#1529, reported by @SakenW). + +### What changes for you + +- **Remaining Work carries provenance.** Every item starts with `Open.` and ends with how the saving session knows it: `(path run)` with the outcome, `(path read)` or `(path assumed)` for commands, flags, config values and files; `(target state checked)`, `(code read)` or `(path assumed)` for writes. For example: + + 1. Open. Run the migration check with SWITCH_B=1 against staging. (path assumed) + 2. Open. Run bun test test/billing.test.ts. (path run) exit 0 + + `/context-restore` keeps the saved order but shows item 2 under **Next steps** and item 1 under **Verify first**, and "continue" starts by verifying item 1 read-only instead of jumping to item 2. Checkpoints saved before this release have no markers, so their command, path and write items land under Verify first, with a one-line banner saying so. +- **Nothing paid is lost.** A taken image name gets `-2`, `-3` and so on (`variant-A-2.png`); stderr says `note: exists; saved to (existing file kept)` and the JSON reports the real path. Every `--retry` attempt is its own saved image. If saving fails after the API returned the image, the bytes go to a private recovery copy in your temp directory and the message says where; a local write failure never buys a second image. +- **Every image command prints JSON, even on failure**: `requested`, `saved`, `selected`, `failures`, `recovered`. Exit 0 means the result is ready, 2 means nothing was saved, 3 means the run stopped after saving some images. +- **Boards show this round only.** Skills tell you how many of the requested images were saved, write the round's printed paths to `board-images.json`, build the board with `$D compare --images-file`, archive an old `feedback.json` first, and record `approved_path` in `approved.json`, so a later session opens exactly the image you approved. Old letter-only approvals still work. /design-shotgun subagents stage in a fresh temp directory and publish with `gstack-design-claim`, which never overwrites. +- **If you script `$D`,** read paths from the JSON instead of assuming the file name: + + out=$($D generate --brief "pricing page" --output designs/pricing.png); rc=$? + img=$(printf '%s' "$out" | jq -r .outputPath) + [ "$rc" -eq 0 ] && $D check --image "$img" --brief "pricing page" + + `variants` prints its list in `.paths`; `compare` takes it losslessly with `--images-file paths.json`. +- **Windows console flashes (#1784)** were confirmed fixed on main (v1.91.15.0) by @kaiwulff on a real Windows 11 desktop; this release ships no code for them. + +### Itemized changes + +#### Fixed +- `/context-save` Remaining Work did not record whether a step was run, read or assumed, and `/context-restore` offered the first item as the next action (#3004). Marker format from #3019 by @mvanhorn, field-tested by @tomg65. +- Design images were silently overwritten by later rounds and `--retry` attempts (#1529). Exclusive-create claim idea from #1737 by @mvanhorn. +- Comparison boards and quality checks read fixed `variant-A/B/C` names or a `variant-*.png` glob and mixed rounds; a stale `feedback.json` could approve a new round. +- `$D` wrote iterate session files to `/tmp`, which does not exist on Windows, so `generate` could not record its session and `iterate` could not find it there; sessions now live in the OS temp directory. +- /setup-deploy reported a failing status command as success (the pipe hid its exit code), matched `cd` anywhere in a workflow as a deploy workflow, and left the merge-method check, Fly `/health` detection and GitHub-Actions-only status command unspecified. + +#### Added +- `$D compare --images-file` and JSON-array `--images`, `bin/gstack-design-claim`, `bin/gstack-design-approved`, and `approved_path` in approval records. +- Periodic behavior eval `context-restore-provenance-order` (3-trial panel) and free contract tests for the provenance markers, `$D` persistence and the design-skill path rules. + ## [1.91.16.0] - 2026-10-03 **Installing or upgrading gstack never quietly changes another copy, and `./setup --status` shows every install.** diff --git a/TODOS.md b/TODOS.md index 7fbbaf3dd..83b18db0a 100644 --- a/TODOS.md +++ b/TODOS.md @@ -33,6 +33,31 @@ trials per case, apply the CASE_QUARANTINE entry rule instead of chasing one run at a time. Effort S. +### P2/P3: mvanhorn fix-wave deferrals (filed 2026-10-03, from the autoplan review of the wave) + +- **E6 (P2): behavior E2E for design round accounting** — stub `$D` so a + generating step reports `saved` shorter than `requested` (and exit 2 for zero + saved), run /plan-design-review or /design-shotgun against it, and assert the + skill tells the user how many paid images were saved, names the failures and + builds no board on zero saved. `test/design-printed-paths.test.ts` only pins + the marker order; whether the model reports the count is behavior. Context: + #1529. Effort M. +- **E7 (P3): prune or archive unapproved design rounds** — never-overwrite plus + `--retry` attempts and iterate outputs grow + `$GSTACK_STATE_ROOT/projects/$SLUG/designs/` without bound. Add a prune or + archive command for unapproved rounds and attempts (keep anything an + `approved.json` names) and show the design directory size in `$D gallery`. + Context: #1529, eng review. Effort S. +- **E5 (P3): restore verifies assumed items read-only** — `/context-restore` + could check `(path assumed)` and `(code read)` items itself with read-only + commands (file exists, flag is defined, table has a unique key) and report + what it found before offering option A. Context: #3004. Effort M. +- **E4 (P3): real-desktop Windows console smoke for spawn changes** — #1784 was + confirmed fixed on a real Windows 11 desktop by its reporter (v1.91.15.0); CI + can only check that spawns pass `windowsHide`. A smoke that starts browse from + a console-less parent and watches for new visible windows would catch a + regression the static sweep can't. Effort M. + ### P2/P3: parallel fix-wave follow-ups (filed 2026-10-02, from the approved fix-wave plan) - **Agent Skills distribution (#113)** — publish gstack in the Agent Skills diff --git a/VERSION b/VERSION index 448b3dfa7..456d26b01 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.16.0 +1.91.17.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index b25d702b2..7afe1f5d7 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.16.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.17.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-approved b/bin/gstack-design-approved new file mode 100755 index 000000000..7c3f360a2 --- /dev/null +++ b/bin/gstack-design-approved @@ -0,0 +1,24 @@ +#!/usr/bin/env bun +// gstack-design-approved — print the absolute path of the image an +// approved.json names. +// +// Usage: gstack-design-approved +// +// Prefers approved_path (relative to approved.json's directory). Legacy +// records with only approved_variant resolve to variant-.png. When the +// named image is missing, exits 1 with "approved image is missing; +// reselect from the board" and never substitutes another image. + +import { resolveApprovedImage } from '../design/src/approval'; + +const file = process.argv[2]; +if (!file) { + console.error('Usage: gstack-design-approved '); + process.exit(1); +} +const resolved = resolveApprovedImage(file); +if (!resolved.ok) { + console.error(resolved.error); + process.exit(1); +} +console.log(resolved.path); diff --git a/bin/gstack-design-claim b/bin/gstack-design-claim new file mode 100755 index 000000000..88a8c9f1f --- /dev/null +++ b/bin/gstack-design-claim @@ -0,0 +1,55 @@ +#!/bin/sh +# gstack-design-claim — publish a staged design image without overwriting. +# +# Usage: gstack-design-claim +# +# Creates with an exclusive, non-overwriting create (set -C +# noclobber, which opens with O_EXCL). When the name is taken it tries +# -2, -3 ... -999, the same rule as +# $D's claimOutputPath (x-2.png bumps to x-2-2.png). Prints the final path. +# On any failure it exits non-zero with problem/cause/fix and leaves the staged +# image in place, so the paid bytes survive. On success the staged copy is +# removed. + +set -u + +fail() { + printf 'gstack-design-claim: %s\ncause: %s\nfix: %s\n' "$1" "$2" "$3" >&2 + exit 1 +} + +[ "$#" -eq 2 ] || fail "expected 2 arguments, got $#" "usage: gstack-design-claim " "pass the staged image path, then the destination path" +src=$1 +dest=$2 + +[ -f "$src" ] && [ -s "$src" ] || fail "staged image $src is missing or empty" "the generating command did not save an image there" "use the outputPath the \$D command printed" + +dir=$(dirname "$dest") +base=$(basename "$dest") +case "$base" in + ?*.*) stem=${base%.*}; ext=".${base##*.}" ;; + *) stem=$base; ext="" ;; +esac + +err=$(mkdir -p "$dir" 2>&1) || fail "cannot create $dir; staged image kept at $src" "$err" "make $dir writable or choose another destination" + +n=1 +candidate=$dest +while :; do + if err=$( (set -C; : > "$candidate") 2>&1 ); then + if err=$(cat "$src" 2>&1 > "$candidate"); then + rm -f "$src" + printf '%s\n' "$candidate" + exit 0 + fi + rm -f "$candidate" + fail "cannot write $candidate; staged image kept at $src" "$err" "free disk space or make $dir writable, then rerun" + fi + if [ -e "$candidate" ] || [ -L "$candidate" ]; then + n=$((n + 1)) + [ "$n" -le 999 ] || fail "$dest and every name through $stem-999$ext already exist; staged image kept at $src" "999 images already use this name" "choose a new destination name or move older images out of $dir" + candidate="$dir/$stem-$n$ext" + continue + fi + fail "cannot create $candidate; staged image kept at $src" "$err" "make $dir writable or choose another destination" +done diff --git a/context-restore/SKILL.md b/context-restore/SKILL.md index f552fe9e1..d0345843b 100644 --- a/context-restore/SKILL.md +++ b/context-restore/SKILL.md @@ -506,6 +506,22 @@ checkpoint(s) from another project; only this project's are listed." If it print `LEGACY_BUCKET`, relay that line. If the chosen file is `ROOT_DIFFERS`, add "Saved from another checkout of this repository at `{project_root}`." as info. +**Sort Remaining Work by provenance.** Keep every item's original text and saved +order, and drop nothing. Put an item under **Verify first** when it: +- ends in `(path assumed)` or `(code read)`; +- ends in `(path run)` but its text reports a failure; +- has no marker and is a writing step (migration, sync, insert, import, a dialog that + writes) or names a concrete path (a runnable command, CLI flag or switch, config key + or value, or file or directory path). + +Every other item goes under **Next steps**: `(path run)` with a successful outcome, +`(path read)`, `(target state checked)`, and unmarked items that neither write nor +name a concrete path. Verifying means read-only inspection: read the file or the +target, or run a command that changes nothing. Checkpoints saved before provenance +markers existed have none, so their concrete-path items land under Verify first. When +the file has no provenance markers at all, print this line above the groups: +`This checkpoint predates provenance markers; items naming commands, paths or writes are listed under Verify first.` + Read the chosen file and present a summary: ``` @@ -522,7 +538,13 @@ Status: {status} {summary from saved file} ### Remaining Work -{remaining work items} +{legacy banner line, if it applies} + +Next steps +{Next steps items, in saved order, original text} + +Verify first (inspect read-only before executing anything) +{Verify first items, in saved order, original text} ### Notes {notes} @@ -540,7 +562,10 @@ After presenting, ask via AskUserQuestion: - B) Show the full saved file - C) Just needed the context, thanks -If A, summarize the first remaining work item and suggest starting there. +If A, take the first Remaining Work item in saved order. If it is under Next steps, +suggest starting there. If it is under Verify first, suggest verifying it (read-only) +before doing it or any later item, so a later runnable step never jumps ahead of an +unverified earlier one. --- diff --git a/context-restore/SKILL.md.tmpl b/context-restore/SKILL.md.tmpl index 4ba1571c9..55db95b21 100644 --- a/context-restore/SKILL.md.tmpl +++ b/context-restore/SKILL.md.tmpl @@ -180,6 +180,22 @@ checkpoint(s) from another project; only this project's are listed." If it print `LEGACY_BUCKET`, relay that line. If the chosen file is `ROOT_DIFFERS`, add "Saved from another checkout of this repository at `{project_root}`." as info. +**Sort Remaining Work by provenance.** Keep every item's original text and saved +order, and drop nothing. Put an item under **Verify first** when it: +- ends in `(path assumed)` or `(code read)`; +- ends in `(path run)` but its text reports a failure; +- has no marker and is a writing step (migration, sync, insert, import, a dialog that + writes) or names a concrete path (a runnable command, CLI flag or switch, config key + or value, or file or directory path). + +Every other item goes under **Next steps**: `(path run)` with a successful outcome, +`(path read)`, `(target state checked)`, and unmarked items that neither write nor +name a concrete path. Verifying means read-only inspection: read the file or the +target, or run a command that changes nothing. Checkpoints saved before provenance +markers existed have none, so their concrete-path items land under Verify first. When +the file has no provenance markers at all, print this line above the groups: +`This checkpoint predates provenance markers; items naming commands, paths or writes are listed under Verify first.` + Read the chosen file and present a summary: ``` @@ -196,7 +212,13 @@ Status: {status} {summary from saved file} ### Remaining Work -{remaining work items} +{legacy banner line, if it applies} + +Next steps +{Next steps items, in saved order, original text} + +Verify first (inspect read-only before executing anything) +{Verify first items, in saved order, original text} ### Notes {notes} @@ -214,7 +236,10 @@ After presenting, ask via AskUserQuestion: - B) Show the full saved file - C) Just needed the context, thanks -If A, summarize the first remaining work item and suggest starting there. +If A, take the first Remaining Work item in saved order. If it is under Next steps, +suggest starting there. If it is under Verify first, suggest verifying it (read-only) +before doing it or any later item, so a later runnable step never jumps ahead of an +unverified earlier one. --- diff --git a/context-save/SKILL.md b/context-save/SKILL.md index 803e7db3a..bdc3997b2 100644 --- a/context-save/SKILL.md +++ b/context-save/SKILL.md @@ -404,10 +404,30 @@ Using the gathered state plus your conversation history, produce a summary cover 1. **What's being worked on** — the high-level goal or feature 2. **Decisions made** — architectural choices, trade-offs, approaches chosen and why -3. **Remaining work** — concrete next steps, in priority order +3. **Remaining work** — concrete next steps, in priority order, each marked with + how this session knows it (see the provenance table below) 4. **Notes** — anything a future session needs to know (gotchas, blocked items, open questions, things that were tried and didn't work) +**Remaining Work provenance.** Every item starts with the status `Open.` and never +with a completion token such as `[done]` or `[executed]`. Then pick its trailing +marker by the first rule that applies: + +| # | The item is... | Marker | Use when this session... | +|---|----------------|--------|--------------------------| +| 1 | a writing step (migration, sync, insert, import, a dialog that writes), with or without a concrete path | `(target state checked)` | inspected the target's schema or state (for example the unique key) | +| | | `(code read)` | read the writer but not the target | +| | | `(path assumed)` | inferred the write without reading either | +| 2 | otherwise, names a concrete path: a runnable command, CLI flag or switch, config key or value, or file or directory path | `(path run)` | executed it and observed the outcome; state the outcome in the item (`exit 0`, `failed: `) | +| | | `(path read)` | read the code or file but did not execute it | +| | | `(path assumed)` | inferred it without running or reading it, however confident | +| 3 | anything else | none | (`Open.` alone) | + +Writing steps have their own markers because whether a write is safe to repeat +depends on the target's state, not on the code that writes. Examples: +`Open. Run the suite with CONFIG=staging. (path run) exit 0` and +`Open. Switch B reads config Y. (path assumed)`. + If the user provided a title, use it. Otherwise, infer a concise title (3-6 words) from the work being done. @@ -496,7 +516,7 @@ files_modified: ### Remaining Work -{Numbered list of concrete next steps, in priority order} +{Numbered list of concrete next steps, in priority order. Each item starts with `Open.` and ends with its provenance marker from Step 2, or no marker under rule 3.} ### Notes diff --git a/context-save/SKILL.md.tmpl b/context-save/SKILL.md.tmpl index d9bfef977..26d38cb6a 100644 --- a/context-save/SKILL.md.tmpl +++ b/context-save/SKILL.md.tmpl @@ -79,10 +79,30 @@ Using the gathered state plus your conversation history, produce a summary cover 1. **What's being worked on** — the high-level goal or feature 2. **Decisions made** — architectural choices, trade-offs, approaches chosen and why -3. **Remaining work** — concrete next steps, in priority order +3. **Remaining work** — concrete next steps, in priority order, each marked with + how this session knows it (see the provenance table below) 4. **Notes** — anything a future session needs to know (gotchas, blocked items, open questions, things that were tried and didn't work) +**Remaining Work provenance.** Every item starts with the status `Open.` and never +with a completion token such as `[done]` or `[executed]`. Then pick its trailing +marker by the first rule that applies: + +| # | The item is... | Marker | Use when this session... | +|---|----------------|--------|--------------------------| +| 1 | a writing step (migration, sync, insert, import, a dialog that writes), with or without a concrete path | `(target state checked)` | inspected the target's schema or state (for example the unique key) | +| | | `(code read)` | read the writer but not the target | +| | | `(path assumed)` | inferred the write without reading either | +| 2 | otherwise, names a concrete path: a runnable command, CLI flag or switch, config key or value, or file or directory path | `(path run)` | executed it and observed the outcome; state the outcome in the item (`exit 0`, `failed: `) | +| | | `(path read)` | read the code or file but did not execute it | +| | | `(path assumed)` | inferred it without running or reading it, however confident | +| 3 | anything else | none | (`Open.` alone) | + +Writing steps have their own markers because whether a write is safe to repeat +depends on the target's state, not on the code that writes. Examples: +`Open. Run the suite with CONFIG=staging. (path run) exit 0` and +`Open. Switch B reads config Y. (path assumed)`. + If the user provided a title, use it. Otherwise, infer a concise title (3-6 words) from the work being done. @@ -170,7 +190,7 @@ files_modified: ### Remaining Work -{Numbered list of concrete next steps, in priority order} +{Numbered list of concrete next steps, in priority order. Each item starts with `Open.` and ends with its provenance marker from Step 2, or no marker under rule 3.} ### Notes diff --git a/design-consultation/SKILL.md b/design-consultation/SKILL.md index 6922787e6..ea3d6829f 100644 --- a/design-consultation/SKILL.md +++ b/design-consultation/SKILL.md @@ -550,12 +550,14 @@ For interactive feedback, use `compare --serve` and its printed HTTP URL; openin If `DESIGN_READY`: the design binary is available for visual mockup generation. Commands: -- `$D generate --brief "..." --output /path.png` — generate a single mockup -- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants -- `$D compare --images "a.png,b.png,c.png" --output /path/board.html --serve` — comparison board + HTTP server +- `$D generate --brief "..." --output /path.png` — generate a single mockup (prints `outputPath`) +- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants (prints `paths`) +- `$D compare --images-file /path/board-images.json --output /path/board.html --serve` — comparison board + HTTP server - `$D serve --html /path/board.html` — serve comparison board and collect feedback via HTTP - `$D check --image /path.png --brief "..."` — vision quality gate - `$D iterate --session /path/session.json --feedback "..." --output /path.png` — iterate + +Image commands never overwrite (a taken name gets `-2`) and always print JSON (`requested`, `saved`, `failures`); exit 0 ready, 2 nothing saved, 3 stopped after saving some. Capture without `set -e`: `_OUT=$($D ...); _RC=$?`. - `$D extract --image /absolute/path.png` — print tokens and automatically update DESIGN.md in the current Git repository; no read-only flag `generate` returns `sessionFile`; `iterate` requires that existing session. `variants` returns `paths` but creates no session: regenerate with an updated brief instead. @@ -662,7 +664,7 @@ fonts [top-3], colors [top-3], layouts [top-3], aesthetics [top-3]. Bias generation toward these unless the user explicitly requests a different direction. Also avoid their strong rejections: [top-3 rejected per dimension]." -**Legacy fallback:** Glob `$GSTACK_STATE_ROOT/projects/$SLUG/designs/**/approved.json` (resolve the root with gstack-paths); Read the five newest. Use explicit feedback only, never infer fonts/colors from variant letters. No usable files: continue without a taste profile. +**Legacy fallback:** Glob `$GSTACK_STATE_ROOT/projects/$SLUG/designs/**/approved.json` (resolve the root with gstack-paths); Read the five newest. To view an approved image, resolve it with `~/.claude/skills/gstack/bin/gstack-design-approved `. Use explicit feedback only, never infer fonts/colors from variant letters. No usable files: continue without a taste profile. **Conflict handling:** If the current user request contradicts a strong persistent signal (e.g., "make it playful" when taste profile strongly prefers minimal), flag diff --git a/design-consultation/sections/proposal-and-preview.md b/design-consultation/sections/proposal-and-preview.md index 090737c77..b4cb37eaa 100644 --- a/design-consultation/sections/proposal-and-preview.md +++ b/design-consultation/sections/proposal-and-preview.md @@ -304,13 +304,17 @@ echo "DESIGN_DIR: $_DESIGN_DIR" Brief: Phase 3 aesthetic/colors/type/spacing/layout plus Phase 1 product context: ```bash -$D variants --brief "" --count 3 --output-dir "$_DESIGN_DIR/" +_OUT=$($D variants --brief "" --count 3 --output-dir "$_DESIGN_DIR/"); _RC=$? +printf '%s\n' "$_OUT"; echo "EXIT: $_RC" ``` -Run quality check on each successful path returned by `variants`; never include failed variants: + +**Round accounting:** names are never overwritten (a taken one is bumped), so use only the printed `saved` paths. Tell the user how many of `requested` paid images were saved and name each `failures` entry. Exit 2 means nothing was saved: report `failures` and stop here (offer Path B or skip); run no `$D check` or board. + +Run quality check on each saved path, starting with the first; never include failed variants: ```bash -$D check --image "$_DESIGN_DIR/variant-A.png" --brief "" +$D check --image "" --brief "" ``` Read JSON, not exit code: `pass: false` means regenerate addressing `issues`, then recheck. `pass: true` with an unavailable/skipped warning is missing automated coverage; disclose it and inspect visually. @@ -321,10 +325,12 @@ Read each accepted PNG inline, then open the board with those paths before invit ### Comparison Board + Feedback Loop -Use the successful, quality-checked paths in this example: + +Write this round's board images (printed paths that passed checks, in order) as a JSON array to `$_DESIGN_DIR/board-images.json` with the Write tool; board letters A, B, C follow that order. Then archive any earlier Submit so it cannot approve these images, and build the board: ```bash -$D compare --images "$_DESIGN_DIR/variant-A.png,$_DESIGN_DIR/variant-B.png,$_DESIGN_DIR/variant-C.png" --output "$_DESIGN_DIR/design-board.html" --serve +[ -f "$_DESIGN_DIR/feedback.json" ] && mv "$_DESIGN_DIR/feedback.json" "$_DESIGN_DIR/feedback-$(date -u +%Y%m%dT%H%M%SZ).json" +$D compare --images-file "$_DESIGN_DIR/board-images.json" --output "$_DESIGN_DIR/design-board.html" --serve ``` This publishes to a persistent daemon, opens the board and exits. Read captured stderr for the startup marker; a PID is not readiness. Exit 0 with `BOARD_URL` means the daemon is serving. Save its full `http://127.0.0.1:N/boards//` URL. Only legacy `--no-daemon` needs a host background task; `SERVE_STARTED: port=N` gives root URL `http://127.0.0.1:N/`. @@ -346,18 +352,18 @@ After the response, read current feedback next to the board HTML: **Board or chat:** revisions regenerate; a final choice needs summary confirmation; skip goes to Phase 6 without a mockup. Ask if no choice/detail; never infer approval from a missing file. Submit with revision notes is a revision. **Regenerate:** -1. Revise the brief, preserving unrelated constraints. Archive this round's feedback files so old Submit cannot approve new images. -2. Run `$D variants` with the new brief (no session). Re-run the quality check and visual self-gate on every new image. -3. Rebuild: `$D compare --images "" --output "$_DESIGN_DIR/design-board.html"`, without `--serve`. +1. Revise the brief, preserving unrelated constraints. Archive this round's feedback files so old Submit cannot approve new images (the board block does this on rebuild). +2. Run `$D variants` with the new brief (no session), with the same capture and round accounting. Re-run the quality check and visual self-gate on every new image (its printed path). +3. Rebuild with the board block above (it rewrites board-images.json), without `--serve`. 4. Reload at the saved URL (keep its per-board path; legacy uses root): `jq -nc --arg html "$_DESIGN_DIR/design-board.html" '{html: $html}' | curl -sS -X POST "${BOARD_URL}api/reload" -H 'Content-Type: application/json' --data-binary @-` 5. Check reload succeeded, then AskUserQuestion at the same URL until a final choice, skip or stop. Failed generation/reload uses the fallback, not another wait. **SERVER FALLBACK:** Nonzero exit or no readiness marker: show each variant inline with Read, then AskUserQuestion: "The comparison board server failed to start. Which variant? Any changes?" Route chat feedback as above. -**After receiving feedback (any path):** summarize PREFERRED, RATINGS, YOUR NOTES, DIRECTION; AskUserQuestion "Is this right?" A confirmed final choice permits Write of `$_DESIGN_DIR/approved.json` with `approved_variant`, `feedback`, `date` (UTC), `screen` (the product page depicted by the chosen mockup), and `branch` (the current `git branch --show-current` result, empty if detached). Use valid JSON, never shell interpolation. This approves the image only; Q-final gates project writes. +**After receiving feedback (any path):** summarize PREFERRED, RATINGS, YOUR NOTES, DIRECTION; AskUserQuestion "Is this right?" A confirmed final choice permits Write of `$_DESIGN_DIR/approved.json` with `approved_variant`, `approved_path` (file name of that letter's entry in this board's `board-images.json`, never the directory listing), `feedback`, `date` (UTC), `screen` (the product page depicted by the chosen mockup), and `branch` (the current `git branch --show-current` result, empty if detached). Use valid JSON, never shell interpolation. This approves the image only; Q-final gates project writes. -After final image confirmation, `$D extract` would write DESIGN.md in a Git repo: run it only in a fresh non-repository scratch directory. Bind `$D` and `APPROVED_IMAGE` to absolute paths: +After final image confirmation, `$D extract` would write DESIGN.md in a Git repo: run it only in a fresh non-repository scratch directory. Bind `$D` and `APPROVED_IMAGE` (the confirmed letter's `board-images.json` entry) to absolute paths: ```bash _EXTRACT_DIR=$(mktemp -d /tmp/gstack-design-extract-XXXXXXXX) || exit 1 @@ -373,7 +379,7 @@ _EXTRACT_DIR=$(mktemp -d /tmp/gstack-design-extract-XXXXXXXX) || exit 1 Compare extracted tokens with the approved image and verified fonts; show discrepancies at Q-final. Empty arrays, an "Unable to extract" mood or command failure → disclose fallback to Phase 3 values, never invent measured tokens. -Late visual changes return to the feedback loop: regenerate, recheck, reconfirm, then extract again. Only `generate` supplies `sessionFile` for `$D iterate --session "" --feedback "" --output "$_DESIGN_DIR/refined.png"`; variants must regenerate. +Late visual changes return to the feedback loop: regenerate, recheck, reconfirm, then extract again. Only `generate` supplies `sessionFile` for `$D iterate --session "" --feedback "" --output "$_DESIGN_DIR/refined.png"`; use its printed `outputPath`. Variants must regenerate. **Plan mode:** Carry the approved mockup paths/tokens into Phase 6's "## Proposed DESIGN.md" plan section. Its Q-final approval governs saving that content; defer the actual DESIGN.md to implementation. diff --git a/design-consultation/sections/proposal-and-preview.md.tmpl b/design-consultation/sections/proposal-and-preview.md.tmpl index f76f9fcb0..42ffefefd 100644 --- a/design-consultation/sections/proposal-and-preview.md.tmpl +++ b/design-consultation/sections/proposal-and-preview.md.tmpl @@ -111,13 +111,17 @@ echo "DESIGN_DIR: $_DESIGN_DIR" Brief: Phase 3 aesthetic/colors/type/spacing/layout plus Phase 1 product context: ```bash -$D variants --brief "" --count 3 --output-dir "$_DESIGN_DIR/" +_OUT=$($D variants --brief "" --count 3 --output-dir "$_DESIGN_DIR/"); _RC=$? +printf '%s\n' "$_OUT"; echo "EXIT: $_RC" ``` -Run quality check on each successful path returned by `variants`; never include failed variants: + +**Round accounting:** names are never overwritten (a taken one is bumped), so use only the printed `saved` paths. Tell the user how many of `requested` paid images were saved and name each `failures` entry. Exit 2 means nothing was saved: report `failures` and stop here (offer Path B or skip); run no `$D check` or board. + +Run quality check on each saved path, starting with the first; never include failed variants: ```bash -$D check --image "$_DESIGN_DIR/variant-A.png" --brief "" +$D check --image "" --brief "" ``` Read JSON, not exit code: `pass: false` means regenerate addressing `issues`, then recheck. `pass: true` with an unavailable/skipped warning is missing automated coverage; disclose it and inspect visually. @@ -128,7 +132,7 @@ Read each accepted PNG inline, then open the board with those paths before invit {{DESIGN_SHOTGUN_LOOP}} -After final image confirmation, `$D extract` would write DESIGN.md in a Git repo: run it only in a fresh non-repository scratch directory. Bind `$D` and `APPROVED_IMAGE` to absolute paths: +After final image confirmation, `$D extract` would write DESIGN.md in a Git repo: run it only in a fresh non-repository scratch directory. Bind `$D` and `APPROVED_IMAGE` (the confirmed letter's `board-images.json` entry) to absolute paths: ```bash _EXTRACT_DIR=$(mktemp -d /tmp/gstack-design-extract-XXXXXXXX) || exit 1 @@ -144,7 +148,7 @@ _EXTRACT_DIR=$(mktemp -d /tmp/gstack-design-extract-XXXXXXXX) || exit 1 Compare extracted tokens with the approved image and verified fonts; show discrepancies at Q-final. Empty arrays, an "Unable to extract" mood or command failure → disclose fallback to Phase 3 values, never invent measured tokens. -Late visual changes return to the feedback loop: regenerate, recheck, reconfirm, then extract again. Only `generate` supplies `sessionFile` for `$D iterate --session "" --feedback "" --output "$_DESIGN_DIR/refined.png"`; variants must regenerate. +Late visual changes return to the feedback loop: regenerate, recheck, reconfirm, then extract again. Only `generate` supplies `sessionFile` for `$D iterate --session "" --feedback "" --output "$_DESIGN_DIR/refined.png"`; use its printed `outputPath`. Variants must regenerate. **Plan mode:** Carry the approved mockup paths/tokens into Phase 6's "## Proposed DESIGN.md" plan section. Its Q-final approval governs saving that content; defer the actual DESIGN.md to implementation. diff --git a/design-html/SKILL.md b/design-html/SKILL.md index 0409f2cfa..0b9a13915 100644 --- a/design-html/SKILL.md +++ b/design-html/SKILL.md @@ -399,13 +399,15 @@ Comparison boards are local HTML files: open them with `open file://...` on macO If `DESIGN_READY`: the design binary is available for visual mockup generation. Commands: -- `$D generate --brief "..." --output /path.png` — generate a single mockup -- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants -- `$D compare --images "a.png,b.png,c.png" --output /path/board.html --serve` — comparison board + HTTP server +- `$D generate --brief "..." --output /path.png` — generate a single mockup (prints `outputPath`) +- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants (prints `paths`) +- `$D compare --images-file /path/board-images.json --output /path/board.html --serve` — comparison board + HTTP server - `$D serve --html /path/board.html` — serve comparison board and collect feedback via HTTP - `$D check --image /path.png --brief "..."` — vision quality gate - `$D iterate --session /path/session.json --feedback "..." --output /path.png` — iterate +Image commands never overwrite (a taken name gets `-2`) and always print JSON (`requested`, `saved`, `failures`); exit 0 ready, 2 nothing saved, 3 stopped after saving some. Capture without `set -e`: `_OUT=$($D ...); _RC=$?`. + **Path rule:** Design artifacts belong in `$GSTACK_STATE_ROOT/projects/$SLUG/designs/`. Use `bin/gstack-paths` (docs/state-root.md). Keep it even if temporary; never substitute .context/, docs/designs/ or another directory. @@ -455,8 +457,17 @@ 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. Extract: approved variant PNG path, user feedback, -screen name. Also read the CEO plan if one exists (it adds strategic context). +If `APPROVED` was found, read it for user feedback and screen name, and resolve the +approved PNG (never guess it from a letter or the newest variant): + +```bash +~/.claude/skills/gstack/bin/gstack-design-approved "" +``` + +It prints the approved image's absolute path. If it prints "approved image ... is +missing; reselect from the board" instead, tell the user exactly that, offer +/design-shotgun to reselect, and do not substitute another image. Also read the CEO +plan if one exists (it adds strategic context). Read `DESIGN.md` if it exists in the repo root. These tokens take priority for system-level values (fonts, brand colors, spacing scale). diff --git a/design-html/SKILL.md.tmpl b/design-html/SKILL.md.tmpl index 6d97772c6..9fea4b7d0 100644 --- a/design-html/SKILL.md.tmpl +++ b/design-html/SKILL.md.tmpl @@ -82,8 +82,17 @@ 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. Extract: approved variant PNG path, user feedback, -screen name. Also read the CEO plan if one exists (it adds strategic context). +If `APPROVED` was found, read it for user feedback and screen name, and resolve the +approved PNG (never guess it from a letter or the newest variant): + +```bash +{{BIN_DIR}}/gstack-design-approved "" +``` + +It prints the approved image's absolute path. If it prints "approved image ... is +missing; reselect from the board" instead, tell the user exactly that, offer +/design-shotgun to reselect, and do not substitute another image. Also read the CEO +plan if one exists (it adds strategic context). Read `DESIGN.md` if it exists in the repo root. These tokens take priority for system-level values (fonts, brand colors, spacing scale). diff --git a/design-review/SKILL.md b/design-review/SKILL.md index 06072d2b8..c2aa3b7ef 100644 --- a/design-review/SKILL.md +++ b/design-review/SKILL.md @@ -736,13 +736,15 @@ Comparison boards are local HTML files: open them with `open file://...` on macO If `DESIGN_READY`: the design binary is available for visual mockup generation. Commands: -- `$D generate --brief "..." --output /path.png` — generate a single mockup -- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants -- `$D compare --images "a.png,b.png,c.png" --output /path/board.html --serve` — comparison board + HTTP server +- `$D generate --brief "..." --output /path.png` — generate a single mockup (prints `outputPath`) +- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants (prints `paths`) +- `$D compare --images-file /path/board-images.json --output /path/board.html --serve` — comparison board + HTTP server - `$D serve --html /path/board.html` — serve comparison board and collect feedback via HTTP - `$D check --image /path.png --brief "..."` — vision quality gate - `$D iterate --session /path/session.json --feedback "..." --output /path.png` — iterate +Image commands never overwrite (a taken name gets `-2`) and always print JSON (`requested`, `saved`, `failures`); exit 0 ready, 2 nothing saved, 3 stopped after saving some. Capture without `set -e`: `_OUT=$($D ...); _RC=$?`. + **Path rule:** Design artifacts belong in `$GSTACK_STATE_ROOT/projects/$SLUG/designs/`. Use `bin/gstack-paths` (docs/state-root.md). Keep it even if temporary; never substitute .context/, docs/designs/ or another directory. @@ -1736,9 +1738,12 @@ For each fixable finding, in impact order: If the gstack designer is available and the finding involves visual layout, hierarchy, or spacing (not just a CSS value fix like wrong color or font-size), generate a target mockup showing what the corrected version should look like: ```bash -$D generate --brief "" --output "$REPORT_DIR/screenshots/finding-NNN-target.png" +_OUT=$($D generate --brief "" --output "$REPORT_DIR/screenshots/finding-NNN-target.png"); _RC=$? +printf '%s\n' "$_OUT"; echo "EXIT: $_RC" ``` +Record the printed `outputPath` for this finding (images are never overwritten, so it may be bumped); later steps use that path. Exit 2: no mockup was saved; skip it for this finding. + Show the user: "Here's the current state (screenshot) and here's what it should look like (mockup). Now I'll fix the source to match." This step is optional — skip for trivial CSS fixes (wrong hex color, missing padding value). Use it for findings where the intended design isn't obvious from the description alone. @@ -1828,7 +1833,7 @@ DESIGN-FIX RISK: After all fixes are applied: 1. Re-run the design audit on all affected pages -2. If target mockups were generated during the fix loop AND `DESIGN_READY`: run `$D verify --mockup "$REPORT_DIR/screenshots/finding-NNN-target.png" --screenshot "$REPORT_DIR/screenshots/finding-NNN-after.png"` to compare the fix result against the target. Include pass/fail in the report. +2. If target mockups were generated during the fix loop AND `DESIGN_READY`: run `$D verify --mockup "" --screenshot "$REPORT_DIR/screenshots/finding-NNN-after.png"` to compare the fix result against the target. Include pass/fail in the report. 3. Compute final design score and AI slop score 4. Recompute the detector count the same way Phase 0 scanned: DOM mode re-dumps the affected pages after reload into `$REPORT_DIR/dom/$RUN_ID/` and rescans; source mode rescans the files you touched. Then delete `$REPORT_DIR/dom/$RUN_ID/` unless the user passed `--keep-dom`. 5. **If final scores are WORSE than baseline:** WARN prominently — something regressed diff --git a/design-review/SKILL.md.tmpl b/design-review/SKILL.md.tmpl index 6d7d70f0e..5b01fd0f0 100644 --- a/design-review/SKILL.md.tmpl +++ b/design-review/SKILL.md.tmpl @@ -179,9 +179,12 @@ For each fixable finding, in impact order: If the gstack designer is available and the finding involves visual layout, hierarchy, or spacing (not just a CSS value fix like wrong color or font-size), generate a target mockup showing what the corrected version should look like: ```bash -$D generate --brief "" --output "$REPORT_DIR/screenshots/finding-NNN-target.png" +_OUT=$($D generate --brief "" --output "$REPORT_DIR/screenshots/finding-NNN-target.png"); _RC=$? +printf '%s\n' "$_OUT"; echo "EXIT: $_RC" ``` +Record the printed `outputPath` for this finding (images are never overwritten, so it may be bumped); later steps use that path. Exit 2: no mockup was saved; skip it for this finding. + Show the user: "Here's the current state (screenshot) and here's what it should look like (mockup). Now I'll fix the source to match." This step is optional — skip for trivial CSS fixes (wrong hex color, missing padding value). Use it for findings where the intended design isn't obvious from the description alone. @@ -271,7 +274,7 @@ DESIGN-FIX RISK: After all fixes are applied: 1. Re-run the design audit on all affected pages -2. If target mockups were generated during the fix loop AND `DESIGN_READY`: run `$D verify --mockup "$REPORT_DIR/screenshots/finding-NNN-target.png" --screenshot "$REPORT_DIR/screenshots/finding-NNN-after.png"` to compare the fix result against the target. Include pass/fail in the report. +2. If target mockups were generated during the fix loop AND `DESIGN_READY`: run `$D verify --mockup "" --screenshot "$REPORT_DIR/screenshots/finding-NNN-after.png"` to compare the fix result against the target. Include pass/fail in the report. 3. Compute final design score and AI slop score 4. Recompute the detector count the same way Phase 0 scanned: DOM mode re-dumps the affected pages after reload into `$REPORT_DIR/dom/$RUN_ID/` and rescans; source mode rescans the files you touched. Then delete `$REPORT_DIR/dom/$RUN_ID/` unless the user passed `--keep-dom`. 5. **If final scores are WORSE than baseline:** WARN prominently — something regressed diff --git a/design-shotgun/SKILL.md b/design-shotgun/SKILL.md index d343c20ca..e3c05fd39 100644 --- a/design-shotgun/SKILL.md +++ b/design-shotgun/SKILL.md @@ -410,13 +410,15 @@ Comparison boards are local HTML files: open them with `open file://...` on macO If `DESIGN_READY`: the design binary is available for visual mockup generation. Commands: -- `$D generate --brief "..." --output /path.png` — generate a single mockup -- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants -- `$D compare --images "a.png,b.png,c.png" --output /path/board.html --serve` — comparison board + HTTP server +- `$D generate --brief "..." --output /path.png` — generate a single mockup (prints `outputPath`) +- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants (prints `paths`) +- `$D compare --images-file /path/board-images.json --output /path/board.html --serve` — comparison board + HTTP server - `$D serve --html /path/board.html` — serve comparison board and collect feedback via HTTP - `$D check --image /path.png --brief "..."` — vision quality gate - `$D iterate --session /path/session.json --feedback "..." --output /path.png` — iterate +Image commands never overwrite (a taken name gets `-2`) and always print JSON (`requested`, `saved`, `failures`); exit 0 ready, 2 nothing saved, 3 stopped after saving some. Capture without `set -e`: `_OUT=$($D ...); _RC=$?`. + **Path rule:** Design artifacts belong in `$GSTACK_STATE_ROOT/projects/$SLUG/designs/`. Use `bin/gstack-paths` (docs/state-root.md). Keep it even if temporary; never substitute .context/, docs/designs/ or another directory. @@ -552,7 +554,7 @@ fonts [top-3], colors [top-3], layouts [top-3], aesthetics [top-3]. Bias generation toward these unless the user explicitly requests a different direction. Also avoid their strong rejections: [top-3 rejected per dimension]." -**Legacy fallback:** Glob `$GSTACK_STATE_ROOT/projects/$SLUG/designs/**/approved.json` (resolve the root with gstack-paths); Read the five newest. Use explicit feedback only, never infer fonts/colors from variant letters. No usable files: continue without a taste profile. +**Legacy fallback:** Glob `$GSTACK_STATE_ROOT/projects/$SLUG/designs/**/approved.json` (resolve the root with gstack-paths); Read the five newest. To view an approved image, resolve it with `~/.claude/skills/gstack/bin/gstack-design-approved `. Use explicit feedback only, never infer fonts/colors from variant letters. No usable files: continue without a taste profile. **Conflict handling:** If the current user request contradicts a strong persistent signal (e.g., "make it playful" when taste profile strongly prefers minimal), flag @@ -576,14 +578,16 @@ _TASTE=$(find "$GSTACK_STATE_ROOT/projects/$SLUG/designs/" -name "approved.json" ``` If prior sessions exist, read each `approved.json` and extract patterns from the -approved variants. Merge these into the taste-profile.json-derived signal — if the +approved variants; resolve each approved image with `~/.claude/skills/gstack/bin/gstack-design-approved ` +(an error means that image is gone: skip it, never substitute another). Merge these into the taste-profile.json-derived signal — if the profile already says "user prefers Geist font" (from aggregated history), the approved.json files add the specific recent approval context. Limit to last 10 sessions. Try/catch JSON parse on each (skip corrupted files). **Updating taste profile after a design-shotgun session:** When the user picks a -variant, call `~/.claude/skills/gstack/bin/gstack-taste-update approved `. When they +variant, call `~/.claude/skills/gstack/bin/gstack-taste-update approved ` (the +`APPROVED_IMAGE` printed when approved.json is saved). When they explicitly reject a variant, call `~/.claude/skills/gstack/bin/gstack-taste-update rejected `. The CLI handles schema migration from approved.json, decay, and conflict flagging. @@ -674,58 +678,55 @@ variable that agents do NOT inherit. Substitute the resolved absolute path (from **Agent prompt template** (one per variant, substitute all `{...}` values): ``` -Generate a design variant and save it. +Generate a design variant and publish it without overwriting any image. Design binary: {absolute path to $D binary} +Claim helper: ~/.claude/skills/gstack/bin/gstack-design-claim Brief: {the full variant-specific brief for this direction} -Output: /tmp/variant-{letter}.png -Final location: {_DESIGN_DIR absolute path}/variant-{letter}.png +Design dir: {_DESIGN_DIR absolute path} -Steps: -1. Run: {$D path} generate --brief "{brief}" --output /tmp/variant-{letter}.png -2. If the command fails with a rate limit error (429 or "rate limit"), wait 5 seconds - and retry. Up to 3 retries. -3. If the output file is missing or empty after the command succeeds, retry once. -4. Copy: cp /tmp/variant-{letter}.png {_DESIGN_DIR}/variant-{letter}.png -5. Quality check: {$D path} check --image {_DESIGN_DIR}/variant-{letter}.png --brief "{brief}" - If quality check fails, retry generation once. -6. Verify: ls -lh {_DESIGN_DIR}/variant-{letter}.png -7. Report exactly one of: - VARIANT_{letter}_DONE: {file size} - VARIANT_{letter}_FAILED: {error description} +Steps (no set -e; never cp or mv an image): +1. Stage in a fresh per-run directory: STAGE=$(mktemp -d /tmp/gstack-variant-XXXXXXXX) +2. Generate: OUT=$({$D path} generate --brief "{brief}" --output "$STAGE/variant.png"); RC=$?; echo "$OUT"; echo "EXIT: $RC" + Read outputPath, saved and failures from the JSON. Exit 2 = nothing saved: on a rate + limit (429 or "rate limit") wait 5 seconds and retry, up to 3 retries. +3. Publish: FINAL=$(~/.claude/skills/gstack/bin/gstack-design-claim "" "{Design dir}/variant-{letter}.png") + It never overwrites and prints the final (possibly bumped) path. Use FINAL from here on. + If it fails, report the error; the staged image stays at . +4. Quality check: {$D path} check --image "" --brief "{brief}" + If it fails, regenerate once (steps 2-3; the first image is kept). +5. Verify: ls -lh "" +6. Report exactly one of: + VARIANT_{letter}_DONE: {every published path, comma-separated, last one is the pick} {file size} + VARIANT_{letter}_FAILED: {error description, plus any published paths} VARIANT_{letter}_RATE_LIMITED: exhausted retries ``` -For the evolve path, replace step 1 with: +For the evolve path, replace the step 2 command with: ``` -{$D path} evolve --screenshot {_DESIGN_DIR}/current.png --brief "{brief}" --output /tmp/variant-{letter}.png +OUT=$({$D path} evolve --screenshot {_DESIGN_DIR}/current.png --brief "{brief}" --output "$STAGE/variant.png"); RC=$? ``` -**Generate to `/tmp/`, then `cp`:** in sandboxed sessions `$D generate --output` under -`~/.gstack/` can abort ("The operation was aborted"), while `/tmp/` works. +**Why staging:** in sandboxed sessions `$D generate --output` under `~/.gstack/` can +abort ("The operation was aborted"), while `/tmp/` works. A fresh `mktemp -d` per run +means no other session's file can be published, and the claim helper never clobbers. ### Step 3d: Results After all agents complete: -1. Read each generated PNG inline (Read tool) so the user sees all variants at once. -2. Report status: "All {N} variants generated in ~{actual time}. {successes} succeeded, - {failures} failed." -3. For any failures: report explicitly with the error. Do NOT silently skip. -4. If zero variants succeeded: fall back to sequential generation (one at a time with - `$D generate`, showing each as it lands). Tell the user: "Parallel generation failed - (likely rate limiting). Falling back to sequential..." -5. Proceed to Step 4 (comparison board). - -**Dynamic image list for comparison board:** When proceeding to Step 4, construct the -image list from whatever variant files actually exist, not a hardcoded A/B/C list: - -```bash -setopt +o nomatch 2>/dev/null || true # zsh compat -_IMAGES=$(ls "$_DESIGN_DIR"/variant-*.png 2>/dev/null | tr '\n' ',' | sed 's/,$//') -``` - -Use `$_IMAGES` in the `$D compare --images` command. + +1. Round accounting first: this round's images are exactly the paths the DONE and FAILED + lines report (never a directory listing; older rounds stay on disk). Tell the user: + "{saved} of {N} paid images saved in ~{actual time}", listing every published path. +2. For any failures: report explicitly with the error. Do NOT silently skip. +3. If zero variants succeeded: fall back to sequential generation, running the agent + steps yourself one variant at a time and showing each as it lands. Tell the user: + "Parallel generation failed (likely rate limiting). Falling back to sequential..." + If that also saves nothing, report the failures and stop: no board. +4. Read each saved image inline (Read tool, the reported paths) so the user sees all variants at once. +5. Proceed to Step 4 with each variant's pick (the last reported path), in letter order, + as this round's board images. ## Step 4: Comparison Board + Feedback Loop @@ -733,8 +734,12 @@ Use `$_IMAGES` in the `$D compare --images` command. Create the comparison board and serve it over HTTP: + +Write this round's board images (printed paths that passed checks, in order) as a JSON array to `$_DESIGN_DIR/board-images.json` with the Write tool; board letters A, B, C follow that order. Then archive any earlier Submit so it cannot approve these images, and build the board: + ```bash -$D compare --images "$_DESIGN_DIR/variant-A.png,$_DESIGN_DIR/variant-B.png,$_DESIGN_DIR/variant-C.png" --output "$_DESIGN_DIR/design-board.html" --serve +[ -f "$_DESIGN_DIR/feedback.json" ] && mv "$_DESIGN_DIR/feedback.json" "$_DESIGN_DIR/feedback-$(date -u +%Y%m%dT%H%M%SZ).json" +$D compare --images-file "$_DESIGN_DIR/board-images.json" --output "$_DESIGN_DIR/design-board.html" --serve ``` Creates HTML and opens the board. **Run it in the background** (host task, or `&` redirecting stdout/stderr to private files in `$_DESIGN_DIR`). Read captured stderr for the startup marker; a PID is not readiness. Missing marker: use the failure fallback below. @@ -793,8 +798,8 @@ the approved variant. 1. Read `regenerateAction` from the JSON (`"different"`, `"match"`, `"more_like_B"`, `"remix"`, or custom text) 2. If `regenerateAction` is `"remix"`, read `remixSpec` (e.g. `{"layout":"A","colors":"B"}`) -3. Generate new variants with `$D iterate` or `$D variants` using updated brief -4. Create new board: `$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"` +3. Generate new variants with `$D iterate` or `$D variants` using updated brief (capture the JSON and do round accounting as for the first round) +4. Rebuild with the board block above (it archives feedback.json and rewrites board-images.json), without `--serve` 5. Reload the board in the user's browser (same tab) — the URL is per-board under daemon mode, so use `` (from the `BOARD_URL:` stderr line) as the base: @@ -828,9 +833,16 @@ Is this right?" Use AskUserQuestion to verify before proceeding. -**Save the approved choice:** +**Save the approved choice.** Map the confirmed letter through this board's `board-images.json` (never the directory listing) and save it: + ```bash -echo '{"approved_variant":"","feedback":"","date":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","screen":"","branch":"'$(git branch --show-current 2>/dev/null)'"}' > "$_DESIGN_DIR/approved.json" +_IMG=$(jq -r --arg v "" '.[($v | explode[0]) - 65] // empty' "$_DESIGN_DIR/board-images.json") +if [ -n "$_IMG" ]; then + echo '{"approved_variant":"","approved_path":"'"$(basename "$_IMG")"'","feedback":"","date":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","screen":"","branch":"'$(git branch --show-current 2>/dev/null)'"}' > "$_DESIGN_DIR/approved.json" + echo "APPROVED_IMAGE: $_IMG" +else + echo "NO_BOARD_IMAGE: is not on this board; reselect from the board" +fi ``` ## Step 5: Feedback Confirmation @@ -854,7 +866,8 @@ Use AskUserQuestion to confirm before saving. Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). If invoked from another skill: return the structured feedback for that skill to consume. -The calling skill reads `approved.json` and the approved variant PNG. +The calling skill reads `approved.json` and resolves the approved image with +`~/.claude/skills/gstack/bin/gstack-design-approved "$_DESIGN_DIR/approved.json"`. If standalone, offer next steps via AskUserQuestion: diff --git a/design-shotgun/SKILL.md.tmpl b/design-shotgun/SKILL.md.tmpl index ac3d1ecdd..1845aa8c7 100644 --- a/design-shotgun/SKILL.md.tmpl +++ b/design-shotgun/SKILL.md.tmpl @@ -173,14 +173,16 @@ _TASTE=$(find "$GSTACK_STATE_ROOT/projects/$SLUG/designs/" -name "approved.json" ``` If prior sessions exist, read each `approved.json` and extract patterns from the -approved variants. Merge these into the taste-profile.json-derived signal — if the +approved variants; resolve each approved image with `{{BIN_DIR}}/gstack-design-approved ` +(an error means that image is gone: skip it, never substitute another). Merge these into the taste-profile.json-derived signal — if the profile already says "user prefers Geist font" (from aggregated history), the approved.json files add the specific recent approval context. Limit to last 10 sessions. Try/catch JSON parse on each (skip corrupted files). **Updating taste profile after a design-shotgun session:** When the user picks a -variant, call `{{BIN_DIR}}/gstack-taste-update approved `. When they +variant, call `{{BIN_DIR}}/gstack-taste-update approved ` (the +`APPROVED_IMAGE` printed when approved.json is saved). When they explicitly reject a variant, call `{{BIN_DIR}}/gstack-taste-update rejected `. The CLI handles schema migration from approved.json, decay, and conflict flagging. @@ -271,58 +273,55 @@ variable that agents do NOT inherit. Substitute the resolved absolute path (from **Agent prompt template** (one per variant, substitute all `{...}` values): ``` -Generate a design variant and save it. +Generate a design variant and publish it without overwriting any image. Design binary: {absolute path to $D binary} +Claim helper: {{BIN_DIR}}/gstack-design-claim Brief: {the full variant-specific brief for this direction} -Output: /tmp/variant-{letter}.png -Final location: {_DESIGN_DIR absolute path}/variant-{letter}.png +Design dir: {_DESIGN_DIR absolute path} -Steps: -1. Run: {$D path} generate --brief "{brief}" --output /tmp/variant-{letter}.png -2. If the command fails with a rate limit error (429 or "rate limit"), wait 5 seconds - and retry. Up to 3 retries. -3. If the output file is missing or empty after the command succeeds, retry once. -4. Copy: cp /tmp/variant-{letter}.png {_DESIGN_DIR}/variant-{letter}.png -5. Quality check: {$D path} check --image {_DESIGN_DIR}/variant-{letter}.png --brief "{brief}" - If quality check fails, retry generation once. -6. Verify: ls -lh {_DESIGN_DIR}/variant-{letter}.png -7. Report exactly one of: - VARIANT_{letter}_DONE: {file size} - VARIANT_{letter}_FAILED: {error description} +Steps (no set -e; never cp or mv an image): +1. Stage in a fresh per-run directory: STAGE=$(mktemp -d /tmp/gstack-variant-XXXXXXXX) +2. Generate: OUT=$({$D path} generate --brief "{brief}" --output "$STAGE/variant.png"); RC=$?; echo "$OUT"; echo "EXIT: $RC" + Read outputPath, saved and failures from the JSON. Exit 2 = nothing saved: on a rate + limit (429 or "rate limit") wait 5 seconds and retry, up to 3 retries. +3. Publish: FINAL=$({{BIN_DIR}}/gstack-design-claim "" "{Design dir}/variant-{letter}.png") + It never overwrites and prints the final (possibly bumped) path. Use FINAL from here on. + If it fails, report the error; the staged image stays at . +4. Quality check: {$D path} check --image "" --brief "{brief}" + If it fails, regenerate once (steps 2-3; the first image is kept). +5. Verify: ls -lh "" +6. Report exactly one of: + VARIANT_{letter}_DONE: {every published path, comma-separated, last one is the pick} {file size} + VARIANT_{letter}_FAILED: {error description, plus any published paths} VARIANT_{letter}_RATE_LIMITED: exhausted retries ``` -For the evolve path, replace step 1 with: +For the evolve path, replace the step 2 command with: ``` -{$D path} evolve --screenshot {_DESIGN_DIR}/current.png --brief "{brief}" --output /tmp/variant-{letter}.png +OUT=$({$D path} evolve --screenshot {_DESIGN_DIR}/current.png --brief "{brief}" --output "$STAGE/variant.png"); RC=$? ``` -**Generate to `/tmp/`, then `cp`:** in sandboxed sessions `$D generate --output` under -`~/.gstack/` can abort ("The operation was aborted"), while `/tmp/` works. +**Why staging:** in sandboxed sessions `$D generate --output` under `~/.gstack/` can +abort ("The operation was aborted"), while `/tmp/` works. A fresh `mktemp -d` per run +means no other session's file can be published, and the claim helper never clobbers. ### Step 3d: Results After all agents complete: -1. Read each generated PNG inline (Read tool) so the user sees all variants at once. -2. Report status: "All {N} variants generated in ~{actual time}. {successes} succeeded, - {failures} failed." -3. For any failures: report explicitly with the error. Do NOT silently skip. -4. If zero variants succeeded: fall back to sequential generation (one at a time with - `$D generate`, showing each as it lands). Tell the user: "Parallel generation failed - (likely rate limiting). Falling back to sequential..." -5. Proceed to Step 4 (comparison board). - -**Dynamic image list for comparison board:** When proceeding to Step 4, construct the -image list from whatever variant files actually exist, not a hardcoded A/B/C list: - -```bash -setopt +o nomatch 2>/dev/null || true # zsh compat -_IMAGES=$(ls "$_DESIGN_DIR"/variant-*.png 2>/dev/null | tr '\n' ',' | sed 's/,$//') -``` - -Use `$_IMAGES` in the `$D compare --images` command. + +1. Round accounting first: this round's images are exactly the paths the DONE and FAILED + lines report (never a directory listing; older rounds stay on disk). Tell the user: + "{saved} of {N} paid images saved in ~{actual time}", listing every published path. +2. For any failures: report explicitly with the error. Do NOT silently skip. +3. If zero variants succeeded: fall back to sequential generation, running the agent + steps yourself one variant at a time and showing each as it lands. Tell the user: + "Parallel generation failed (likely rate limiting). Falling back to sequential..." + If that also saves nothing, report the failures and stop: no board. +4. Read each saved image inline (Read tool, the reported paths) so the user sees all variants at once. +5. Proceed to Step 4 with each variant's pick (the last reported path), in letter order, + as this round's board images. ## Step 4: Comparison Board + Feedback Loop @@ -349,7 +348,8 @@ Use AskUserQuestion to confirm before saving. Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). If invoked from another skill: return the structured feedback for that skill to consume. -The calling skill reads `approved.json` and the approved variant PNG. +The calling skill reads `approved.json` and resolves the approved image with +`{{BIN_DIR}}/gstack-design-approved "$_DESIGN_DIR/approved.json"`. If standalone, offer next steps via AskUserQuestion: diff --git a/design/src/approval.ts b/design/src/approval.ts new file mode 100644 index 000000000..400015439 --- /dev/null +++ b/design/src/approval.ts @@ -0,0 +1,56 @@ +/** + * Resolve the image an approval record names. `approved_path` is stored + * relative to the directory holding approved.json, so a copied or relocated + * designs directory still resolves. Records without `approved_path` (written + * before never-overwrite) fall back to `variant-.png`. A + * present `approved_path` whose file is missing is an error: another image is + * never substituted. + */ + +import fs from "fs"; +import path from "path"; + +export type ApprovedImage = { ok: true; path: string; legacy: boolean } | { ok: false; error: string }; + +export interface ApprovalRecord { + approved_variant?: unknown; + approved_path?: unknown; +} + +function missing(imagePath: string): ApprovedImage { + return { ok: false, error: `approved image ${imagePath} is missing; reselect from the board` }; +} + +function readable(imagePath: string): boolean { + try { + fs.accessSync(imagePath, fs.constants.R_OK); + return fs.statSync(imagePath).isFile(); + } catch { + return false; + } +} + +/** The image path a record names, without checking that it exists. */ +export function approvedImagePath(record: ApprovalRecord, recordDir: string): { path: string; legacy: boolean } | null { + if (typeof record.approved_path === "string" && record.approved_path !== "") { + return { path: path.resolve(recordDir, record.approved_path), legacy: false }; + } + if (typeof record.approved_variant === "string" && record.approved_variant !== "") { + return { path: path.join(recordDir, `variant-${record.approved_variant}.png`), legacy: true }; + } + return null; +} + +export function resolveApprovedImage(approvedJsonPath: string): ApprovedImage { + let record: ApprovalRecord; + try { + record = JSON.parse(fs.readFileSync(approvedJsonPath, "utf-8")); + } catch (err: any) { + return { ok: false, error: `cannot read ${approvedJsonPath}: ${err.code || err.message}` }; + } + const named = approvedImagePath(record ?? {}, path.dirname(path.resolve(approvedJsonPath))); + if (!named) { + return { ok: false, error: `${approvedJsonPath} names no approved image; reselect from the board` }; + } + return readable(named.path) ? { ok: true, ...named } : missing(named.path); +} diff --git a/design/src/auth.ts b/design/src/auth.ts index 1202500a9..fc25cbcf4 100644 --- a/design/src/auth.ts +++ b/design/src/auth.ts @@ -119,19 +119,20 @@ export function saveApiKey(key: string): void { } /** - * Get API key or exit with setup instructions. + * Get API key or throw with setup instructions. */ export function requireApiKey(): string { const resolution = resolveApiKeyInfo(); if (!resolution) { - console.error("No OpenAI API key found."); - console.error(""); - console.error("Run: $D setup"); - console.error(" or save to ~/.gstack/openai.json: { \"api_key\": \"sk-...\" }"); - console.error(" or set OPENAI_API_KEY environment variable"); - console.error(""); - console.error("Get a key at: https://platform.openai.com/api-keys"); - process.exit(1); + throw new Error([ + "No OpenAI API key found.", + "", + "Run: $D setup", + " or save to ~/.gstack/openai.json: { \"api_key\": \"sk-...\" }", + " or set OPENAI_API_KEY environment variable", + "", + "Get a key at: https://platform.openai.com/api-keys", + ].join("\n")); } console.error(`Using OpenAI key from ${describeApiKeySource(resolution)}.`); if (resolution.warning) console.error(resolution.warning); diff --git a/design/src/cli.ts b/design/src/cli.ts index b266c6f38..2278b9a65 100644 --- a/design/src/cli.ts +++ b/design/src/cli.ts @@ -12,7 +12,7 @@ * 4. Print result JSON to stdout */ -import { COMMANDS } from "./commands"; +import { COMMANDS, OUTPUT_CONTRACT } from "./commands"; import { generate } from "./generate"; import { checkCommand } from "./check"; import { compare } from "./compare"; @@ -26,6 +26,7 @@ import { generateDesignToCodePrompt } from "./design-to-code"; import { serve } from "./serve"; import { gallery } from "./gallery"; import { normalizeIntFlag } from "./flag-utils"; +import { resolveImagePaths } from "./image-args"; import { daemonStatus as daemonStatusClient, ensureDaemon, @@ -33,6 +34,9 @@ import { shutdownDaemon, } from "./daemon-client"; import { spawn as nodeSpawn } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; function parseArgs(argv: string[]): { command: string; @@ -75,6 +79,7 @@ function printUsage(): void { console.log(` ${name.padEnd(12)} ${info.description}`); console.log(` ${"".padEnd(12)} ${info.usage}`); } + console.log(`\n${OUTPUT_CONTRACT}`); console.log("\nAuth: ~/.gstack/openai.json, then OPENAI_API_KEY env var"); console.log("If OPENAI_API_KEY matches a current-directory .env file, the source is reported before billing."); console.log("Setup: $D setup"); @@ -107,19 +112,18 @@ async function runSetup(): Promise { // Smoke test console.log("\nRunning smoke test (generating a simple image)..."); - try { - await generate({ - brief: "A simple blue square centered on a white background. Minimal, geometric, clean.", - output: "/tmp/gstack-design-smoke-test.png", - size: "1024x1024", - quality: "low", - }); - console.log("\nSmoke test PASSED. Design generation is working."); - } catch (err: any) { - console.error(`\nSmoke test FAILED: ${err.message}`); + const smoke = await generate({ + brief: "A simple blue square centered on a white background. Minimal, geometric, clean.", + output: path.join(fs.mkdtempSync(path.join(os.tmpdir(), "gstack-design-smoke-")), "smoke-test.png"), + size: "1024x1024", + quality: "low", + }); + if (smoke.exitCode !== 0) { + console.error(`\nSmoke test FAILED: ${smoke.failures.map(f => f.reason).join("; ")}`); console.error("Check your API key and organization verification status."); process.exit(1); } + console.log("\nSmoke test PASSED. Design generation is working."); } async function main(): Promise { @@ -133,7 +137,7 @@ async function main(): Promise { switch (command) { case "generate": - await generate({ + process.exitCode = (await generate({ brief: flags.brief as string, briefFile: flags["brief-file"] as string, output: (flags.output as string) || "/tmp/gstack-mockup.png", @@ -141,7 +145,7 @@ async function main(): Promise { retry: normalizeIntFlag(flags.retry, { name: "retry", def: 0, min: 0 }), size: flags.size as string, quality: flags.quality as string, - }); + })).exitCode; break; case "check": @@ -149,9 +153,7 @@ async function main(): Promise { break; case "compare": { - // Parse --images as glob or multiple files - const imagesArg = flags.images as string; - const images = await resolveImagePaths(imagesArg); + const images = await resolveImagePaths(flags.images as string, flags["images-file"] as string | undefined); const outputPath = (flags.output as string) || "/tmp/gstack-design-board.html"; compare({ images, output: outputPath }); // If --serve flag is set, publish the board. @@ -195,7 +197,7 @@ async function main(): Promise { break; case "variants": - await variants({ + process.exitCode = await variants({ brief: flags.brief as string, briefFile: flags["brief-file"] as string, // #2032: pass the RAW flag through — variants() normalizes at its @@ -209,7 +211,7 @@ async function main(): Promise { break; case "iterate": - await iterate({ + process.exitCode = await iterate({ session: flags.session as string, feedback: flags.feedback as string, output: (flags.output as string) || "/tmp/gstack-iterate.png", @@ -261,7 +263,7 @@ async function main(): Promise { } case "evolve": - await evolve({ + process.exitCode = await evolve({ screenshot: flags.screenshot as string, brief: flags.brief as string, output: (flags.output as string) || "/tmp/gstack-evolved.png", @@ -381,31 +383,6 @@ function openBrowser(url: string): void { } } -/** - * Resolve image paths from a glob pattern or comma-separated list. - */ -async function resolveImagePaths(input: string): Promise { - if (!input) { - console.error("--images is required. Provide glob pattern or comma-separated paths."); - process.exit(1); - } - - // Check if it's a glob pattern - if (input.includes("*")) { - const glob = new Bun.Glob(input); - const paths: string[] = []; - for await (const match of glob.scan({ absolute: true })) { - if (match.endsWith(".png") || match.endsWith(".jpg") || match.endsWith(".jpeg")) { - paths.push(match); - } - } - return paths.sort(); - } - - // Comma-separated or single path - return input.split(",").map(p => p.trim()); -} - // Self-execution shortcut: when invoked with --daemon-mode, this same // binary runs as the persistent design daemon instead of the CLI. Keeps // the production install to a single executable; daemon-client.ts spawns diff --git a/design/src/commands.ts b/design/src/commands.ts index 17d5811a7..76ff9ded7 100644 --- a/design/src/commands.ts +++ b/design/src/commands.ts @@ -16,17 +16,17 @@ export const COMMANDS = new Map([ ["generate", { description: "Generate a UI mockup from a design brief", - usage: "generate --brief \"...\" --output /path.png", + usage: "generate --brief \"...\" --output /path.png [--check --retry N] (never overwrites: bumps to /path-2.png; each --retry attempt is its own saved image; prints outputPath, attempts + run fields)", flags: ["--brief", "--brief-file", "--output", "--check", "--retry", "--size", "--quality"], }], ["variants", { description: "Generate N design variants from a brief", - usage: "variants --brief \"...\" --count 3 --output-dir /path/", + usage: "variants --brief \"...\" --count 3 --output-dir /path/ [--viewports desktop,tablet,mobile] (never overwrites: variant-A.png bumps to variant-A-2.png; prints paths, errors + run fields)", flags: ["--brief", "--brief-file", "--count", "--output-dir", "--size", "--quality", "--viewports"], }], ["iterate", { description: "Iterate on an existing mockup with feedback", - usage: "iterate --session /path/session.json --feedback \"...\" --output /path.png", + usage: "iterate --session /path/session.json --feedback \"...\" --output /path.png (never overwrites; prints outputPath + run fields)", flags: ["--session", "--feedback", "--output"], }], ["check", { @@ -36,8 +36,8 @@ export const COMMANDS = new Map x-2.png, x-2.png -> x-2-2.png), stderr prints +"note: exists; saved to (existing file kept)", and the JSON +reports the actual path. Read paths from the JSON, never from a fixed file name. + +Run fields printed by generate, iterate, evolve and variants (on every exit): + requested images requested saved every saved path, in order + selected outputPath or paths failures [{file, reason}] not saved + recovered [{path, reason}] private recovery copies in the temp dir + + command extra fields readers + generate outputPath sessionFile responseId checkResult design-review, design-shotgun, + attempts[{path, check}] (one per --retry try) plan-design-review + iterate outputPath sessionFile responseId iteration plan-design-review, design-consultation + evolve outputPath sourceScreenshot brief attempts design-shotgun + variants outputDir count succeeded failed paths errors office-hours, plan-design-review, + viewports (--viewports only) design-consultation + compare board HTML; --serve prints {id, url, sourceDir} all board flows (--images-file) + +Exit codes: 0 a selected result exists; 2 nothing was saved; 3 stopped after +saving at least one image but before its selected result (interrupted retry); +1 invalid flags rejected before any API call (no JSON).`; diff --git a/design/src/evolve.ts b/design/src/evolve.ts index a656054b0..69b486849 100644 --- a/design/src/evolve.ts +++ b/design/src/evolve.ts @@ -6,10 +6,10 @@ */ import fs from "fs"; -import path from "path"; import { requireApiKey } from "./auth"; import { receiptedFetch } from "./receipted-fetch"; import { imageRequestBody, modelRejectionHint, visionRequestBody } from "./models"; +import { emitResult, exitCodeFor, newAccounting, persistImage, recordOutcome, type ExitCode } from "./persist"; export interface EvolveOptions { screenshot: string; // Path to current site screenshot @@ -22,37 +22,68 @@ export interface EvolveOptions { * Sends the screenshot as context to the image model with image generation, * asking it to produce a new version incorporating the brief's changes. */ -export async function evolve(options: EvolveOptions): Promise { - const apiKey = requireApiKey(); - const screenshotData = fs.readFileSync(options.screenshot).toString("base64"); +export async function evolve(options: EvolveOptions): Promise { + const acct = newAccounting(1); + let outputPath: string | null = null; - console.error(`Evolving ${options.screenshot} with: "${options.brief}"`); - const startTime = Date.now(); + try { + const apiKey = requireApiKey(); + const screenshotData = fs.readFileSync(options.screenshot).toString("base64"); - // Use the Responses API with both a text prompt referencing the screenshot - // and the image_generation tool to produce the evolved version. - // Since we can't send reference images directly to image_generation, - // we describe the current state in detail first via vision, then generate. + console.error(`Evolving ${options.screenshot} with: "${options.brief}"`); + const startTime = Date.now(); - // Step 1: Analyze current screenshot - const analysis = await analyzeScreenshot(apiKey, screenshotData); - console.error(` Analyzed current design: ${analysis.slice(0, 100)}...`); + // Use the Responses API with both a text prompt referencing the screenshot + // and the image_generation tool to produce the evolved version. + // Since we can't send reference images directly to image_generation, + // we describe the current state in detail first via vision, then generate. - // Step 2: Generate evolved version using analysis + brief - const evolvedPrompt = [ - "Generate a pixel-perfect UI mockup that is an improved version of an existing design.", - "", - "CURRENT DESIGN (what exists now):", - analysis, - "", - "REQUESTED CHANGES:", - options.brief, - "", - "Generate a new mockup that keeps the existing layout structure but applies the requested changes.", - "The result should look like a real production UI. All text must be readable.", - "1536x1024 pixels.", - ].join("\n"); + // Step 1: Analyze current screenshot + const analysis = await analyzeScreenshot(apiKey, screenshotData); + console.error(` Analyzed current design: ${analysis.slice(0, 100)}...`); + // Step 2: Generate evolved version using analysis + brief + const evolvedPrompt = [ + "Generate a pixel-perfect UI mockup that is an improved version of an existing design.", + "", + "CURRENT DESIGN (what exists now):", + analysis, + "", + "REQUESTED CHANGES:", + options.brief, + "", + "Generate a new mockup that keeps the existing layout structure but applies the requested changes.", + "The result should look like a real production UI. All text must be readable.", + "1536x1024 pixels.", + ].join("\n"); + + const imageData = await requestEvolvedImage(apiKey, evolvedPrompt); + const outcome = persistImage(imageData, options.output); + outputPath = recordOutcome(acct, outcome); + if (outcome.ok) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.error(`Generated (${elapsed}s, ${(outcome.bytes / 1024).toFixed(0)}KB) → ${outcome.path}`); + } + } catch (err: any) { + const reason = err?.message || String(err); + console.error(reason); + acct.failures.push({ file: options.output, reason }); + } + + return emitResult({ + outputPath, + sourceScreenshot: options.screenshot, + brief: options.brief, + attempts: acct.saved.map(p => ({ path: p })), + requested: acct.requested, + saved: acct.saved, + selected: outputPath, + failures: acct.failures, + recovered: acct.recovered, + }, exitCodeFor(outputPath !== null, acct.saved.length)); +} + +async function requestEvolvedImage(apiKey: string, evolvedPrompt: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 240_000); @@ -85,19 +116,7 @@ export async function evolve(options: EvolveOptions): Promise { if (!imageItem?.result) { throw new Error("No image data in response"); } - - fs.mkdirSync(path.dirname(options.output), { recursive: true }); - const imageBuffer = Buffer.from(imageItem.result, "base64"); - fs.writeFileSync(options.output, imageBuffer); - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.error(`Generated (${elapsed}s, ${(imageBuffer.length / 1024).toFixed(0)}KB) → ${options.output}`); - - console.log(JSON.stringify({ - outputPath: options.output, - sourceScreenshot: options.screenshot, - brief: options.brief, - }, null, 2)); + return imageItem.result; } finally { clearTimeout(timeout); } diff --git a/design/src/gallery.ts b/design/src/gallery.ts index 95675559d..5ab08a486 100644 --- a/design/src/gallery.ts +++ b/design/src/gallery.ts @@ -6,6 +6,7 @@ import fs from "fs"; import path from "path"; +import { approvedImagePath } from "./approval"; export interface GalleryOptions { designsDir: string; // ~/.gstack/projects/$SLUG/designs/ @@ -20,6 +21,32 @@ interface SessionData { variants: string[]; // paths to variant PNGs } +const VARIANT_IMAGE = [/^variant-[A-Z](-\d+)?\.png$/i, /^variant-\d+(-\d+)?\.png$/i]; + +/** + * Variant images in a session directory as chronological history, not rounds: + * names not on the current board sort by mtime then name, followed by the + * board's images in board-images.json order. Never-overwrite means one + * letter can appear in several rounds (variant-A.png, variant-A-2.png). + */ +export function listVariantImages(sessionDir: string): string[] { + const found = fs.readdirSync(sessionDir) + .filter(f => VARIANT_IMAGE.some(re => re.test(f))) + .map(f => path.join(sessionDir, f)); + + let board: string[] = []; + try { + const parsed = JSON.parse(fs.readFileSync(path.join(sessionDir, "board-images.json"), "utf-8")); + if (Array.isArray(parsed)) board = parsed.filter((p): p is string => typeof p === "string").map(p => path.resolve(sessionDir, p)); + } catch {} + + const mtime = new Map(found.map(f => [f, fs.statSync(f).mtimeMs])); + const history = found + .filter(f => !board.includes(f)) + .sort((a, b) => (mtime.get(a)! - mtime.get(b)!) || a.localeCompare(b)); + return [...history, ...board.filter(f => found.includes(f))]; +} + export function generateGalleryHtml(designsDir: string): string { const sessions: SessionData[] = []; @@ -44,16 +71,9 @@ export function generateGalleryHtml(designsDir: string): string { } } - // Find variant PNGs - const variants: string[] = []; + let variants: string[] = []; try { - const files = fs.readdirSync(sessionDir); - for (const f of files) { - if (f.match(/variant-[A-Z]\.png$/i) || f.match(/variant-\d+\.png$/i)) { - variants.push(path.join(sessionDir, f)); - } - } - variants.sort(); + variants = listVariantImages(sessionDir); } catch { // Can't read directory, skip } @@ -81,12 +101,13 @@ export function generateGalleryHtml(designsDir: string): string { sessions.sort((a, b) => b.date.localeCompare(a.date)); const sessionCards = sessions.map(session => { + const approvedTarget = session.approved ? approvedImagePath(session.approved, session.dir) : null; const variantImgs = session.variants.map((vPath, i) => { try { const imgData = fs.readFileSync(vPath).toString("base64"); const ext = path.extname(vPath).slice(1) || "png"; const label = path.basename(vPath, `.${ext}`).replace("variant-", ""); - const isApproved = session.approved?.approved_variant === label; + const isApproved = approvedTarget?.path === vPath; return `