chore: merge main and stamp v1.91.227.0 (#3431)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 04:20:16 +00:00
commit c863f28c31
29 files changed
+989 -91

No files matched your search

+55 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## [Unreleased]
## [1.91.227.0] - 2026-10-26
**Windows: gstack's record bins stop losing review, question, learning and timeline records when Git Bash path conversion is off (#3415).** Since GSTA-56 every shell bin started Bun with `--config=/dev/null`. Native `bun.exe` cannot open `/dev/null`: it exits 1 with `EINVAL` on Bun 1.3.10, 1.4.2 and 1.4.4 (measured on windows-latest). Git Bash hides this by rewriting the argument to `nul`, but only while MSYS path conversion is on. With it off (`MSYS_NO_PATHCONV` set to any value, even empty; `MSYS2_ARG_CONV_EXCL=*`; or Cygwin), `gstack-review-log`, `gstack-question-log` and `gstack-learnings-log` exited 1 with nothing written, and `gstack-timeline-log` exited 0 with nothing written.
@@ -28,6 +28,60 @@
- Negative controls, each of which fails the check: the old spelling, a variable without the default, an empty `--config=`, a missing, late or altered definition, a `local` definition used from another function, and a reverted bin.
- No threshold, budget, timeout, trial count or case kind changed.
## [1.91.226.0] - 2026-10-26
**/review can re-review just its own fixes after a fixing pass, under the same rules as /ship (#3339).** Before, every /review fix cycle reran the whole review over the whole branch, so a large branch could hit the three-cycle stop on findings in code the earlier passes had already read. Now the pass after an edited pass starts with `gstack-review-log --start review --fix-range` and its checklist, specialists and QA read only what changed since that pass. The Step 4.8 adversarial review still reads the whole branch on every pass, because its rows carry their own freshness. A narrowed pass is never the final review: when it makes no edits, /review runs one full pass before writing the final record, and a fix-range record is never CURRENT. Garry's "do it" on 2026-10-24 for a fix-range re-review with a final full pass covers it.
### Changed
- **`review/SKILL.md.tmpl`** Step 3: the first pass always starts full. The pass right after an edited pass starts with `--start review --fix-range`, and wherever that pass sets DIFF_BASE outside Step 4.8 it uses the printed `FIX_BASE=<sha>` instead. Step 4.8 and every other `<base>` use (state-matrix detection, the gate, banned-terms and slop scans, the queue check) keep the base branch. State-matrix detection builds `origin/<base>` from its argument, and the slop scan diffs `<base>...HEAD`, which is empty while /review's fixes are uncommitted. If the logger refuses (for example, the first pass read uncommitted work, so no commit marks what it read), the pass starts full.
- **Step 5.8**: an edited pass now saves its own record with `converged:false` before the repeat, because the fix-range start takes its base from that record. Before, only the final pass was logged. Step 5.0 never treats this invocation's own records as prior reviews, so a defect the user skipped this invocation is not suppressed on the next pass and still counts as unresolved. "No edits" fills the final record only after a full pass; after a fix-range pass it repeats Steps 3–5 with a full start. Each logged pass finishes its own REVIEW_START.
- Nearby prose was tightened to pay for the growth (the slop-scan note, the Step 4.7 probe-reuse rule, the search-before-recommending list, the version-compare note, the final-report and Greptile lines). Every host's review SKILL.md shrank by 36 characters, and its size pin was lowered to match. The Claude skeleton is 80,581 bytes against its 80,900 cap.
### Tests
- `test/review-fix-range.test.ts` adds five cases. Two check the rendered rules: the first pass's start block has no `--fix-range`, only the pass after an edited pass narrows, FIX_BASE replaces only DIFF_BASE outside Step 4.8, a refusal falls back to a full start, the edited pass is logged before the repeat and kept out of Step 5.0, and a fix-range pass is never final. Three run /review's real order on every pass against the real logger: core start, then the Step 4.8 attempt's start and finish, then fixes in the working tree, then the core finish with the pass's own token, and never a commit. A committed first pass narrows the next repeat to the uncommitted fixes, and the following repeat is refused back to a full pass that is CURRENT. A zero-edit fix-range row is UNVERIFIED even if it were logged. A first pass over uncommitted work can't be narrowed.
- `test/review-workflow-clarity.test.ts`: three expectations follow the new contract: the "No edits" marker, the edited pass saving its record with `converged:false`, and each pass finishing its own token instead of only the final one. The test formerly named "finish only the final core token without log-time capture" is now "each logged pass finishes its own core token without log-time capture". It still requires one `--finish REVIEW_START`, no `--start review` in Step 5.8 and no log-time token capture.
### Gate integrity
- One gate change to review: /review may narrow a repeat pass. Verdict: this does not weaken what the final review covers, because a narrowed pass can't be the final record. The logger never marks a fix-range row CURRENT, Step 5.8 requires a full pass after a zero-edit fix-range pass, Step 4.8 stays full, and Step 5.0 can't suppress this invocation's skipped defects through its own intermediate records. Mutation checks on the rendered skill, each caught by the suite:
- letting a fix-range pass be final;
- dropping the full-pass condition on "No edits";
- narrowing every repeat;
- dropping the refusal fallback;
- narrowing the first pass;
- not logging the edited pass;
- logging after the repeat;
- narrowing Step 4.8 and other base uses;
- letting Step 5.0 read this invocation's records.
- No threshold, budget, timeout, trial count or case kind changed. Size pins only went down.
## [1.91.225.0] - 2026-10-26
**/design-html builds landing pages around one visitor action and pages that look finished, without inventing proof; adapted from Elaya's design skills (GSTA-92).** Freeform /design-html asked for purpose, audience and feel, then built. It never asked for the one action the page exists for or where the traffic comes from, and nothing caught a page that shipped with invented customers and numbers, a dead `href="#"`, a scroll-listener reveal, `100vh`, or no description, favicon or working skip link. Now every run reads a craft section at Step 1, and a free checker counts the tells at Step 4. Adapted from Elaya's [ai-design-skills](https://github.com/elayadesign/ai-design-skills) (landing-page-design, design-to-motion) and [redesign-skill](https://github.com/elayadesign/redesign-skill), MIT, © 2026 Elaya. Thank you, Elaya.
### Changed
- **/design-html reads `sections/craft.md` on every run (Step 1).** The spec opens with `Surface: Persuade` or `Surface: other`. A Persuade page (landing, pricing, waitlist, campaign, comparison) asks at most three multiple-choice questions (primary action, traffic source, published or reference) and states the rest in one `Assumed:` line; picks one of four archetypes with a reason; keeps the hero budget (one proof signal only when real proof exists, never a metric row) and chooses the rest of the page from the archetype and objections; gives the primary action real invalid, pending, failure and success states. Every page: claims (testimonials, logos, results, prices, guarantees) come only from the user, and missing proof is left out or marked `[Needs proof: …]`; illustrative data looks real; a ship floor scoped to full documents (title, description, favicon, share tags on shareable pages, `og:image` only from a supplied URL, a skip link that reaches `<main>`, landmarks, alt text, inline validation, no dead links, `aria-current` on route navigation); scale snapping with readability floors and the nested-radius rule; balanced line breaks; motion rules for the one authored moment (content visible without script, `IntersectionObserver`, cheap properties only, no `scale(0)`, named curves, `100dvh`).
- **Step 4** runs `bin/gstack-design-craft` after the Slop Gate, whether or not the detector is installed, with the gate's contract: one fix pass, one rerun, the rest accepted with a reason, once more when the refinement loop ends. The hand-off opens with a "Before this can ship" block: proof markers, production dependencies, exceptions, and every claim the check changed.
- **/design-shotgun**: a landing, pricing or campaign screen's variants also differ in page archetype, keep the one primary action identical, and carry their section outline as a caption.
### Added
- **`bin/gstack-design-craft`** (`lib/design-craft.ts`): prints `CRAFT_CHECK: <id> <count> <fix>` per tell and `CRAFT_RESULT: clean` (exit 0) or `<n> issue(s)` (exit 1); exit 2 on a non-HTML, empty or unreadable file. Copy rules read decoded visible text; `alert()` counts only in scripts; head and navigation rules run only on a full document. Buzzwords report under the catalog's `marketing-buzzword` id.
- **NOTICE.md** and `licenses/MIT-elaya.txt`. Not carried over, because they conflict with gstack's design rules: Elaya's fixed fonts (Geist and Poppins are on gstack's overused-display list), palette, spacing values and icon sets, gradient hero text, mandatory scroll reveals and tagline section, and the island nav.
### Tests
- `test/design-craft.test.ts`: every tell with its count; a finished page, inlined Pretext, a `100vh` line with its `100dvh` fallback, discount copy and in-page anchors stay clean; look-alike attributes, encoded markers, `!important` and reversed fallbacks; skip links judged by position and target in any language; the craft section's banned words and names all flagged; CLI exit codes.
- `design-html-slop-gate` also requires the craft check to run once or twice; the design-html carve guard requires the craft section read.
## [1.91.224.0] - 2026-10-25
**`/context-restore` now says when commits landed after a checkpoint was saved, instead of offering finished steps as the plan.** `/context-save` recorded the branch, a timestamp and the modified files, but not the commit, so a checkpoint saved before a step was committed came back with that step still listed under Remaining Work. Ported from #3316 by @soyames, with their commit kept.
### Fixed
- **`bin/gstack-slug --stamp-checkpoint`** also writes `head:`, the commit the save describes, next to `remote:`, `project_root:` and `worktree:`. A re-stamp replaces it rather than adding a second line.
- **`/context-restore`** compares `head:` with `HEAD` before presenting. When commits landed after the save it prints how many and lists up to ten, and puts every Remaining Work item under Verify first. A saved commit that is not in this checkout's history (saved on another branch, rebased, reset, or not fetched) is reported the same way. A malformed `head:` is checked as 40 or 64 hex digits before any git command sees it, `head:` is read from the frontmatter only, and outside git the comparison uses the saved `worktree:` before `project_root:`. Checkpoints saved before this keep their usual sorting with a one-line note.
### Tests
- `test/context-restore-head.test.ts` runs the real save-path, stamp and head-check blocks from the generated SKILL.md files in fixture repos: unchanged, two commits landed, diverged after a reset, resumed from another branch, a nested linked worktree restored from outside git, a re-stamp after a new commit, pre-tracking, a `head:` that appears only in the body, and a `head: $(touch pwned)` that must never execute.
## [1.91.223.0] - 2026-10-25
**/plan-tune's inferred profile finally moves: a clicked answer is logged as the question's registered option key, not its label.** #2623 showed every inferred dimension stuck at 0.5 on a 1,286-event log: the AskUserQuestion hook recorded labels like "A) Fix it now (recommended)", while the signal map that turns answers into dimension nudges is keyed by registered option keys like `fix-now`, so 0 of 1,286 answers matched. Now five hook-captured label answers to the registered architecture-finding question move `architecture_care` from 0.5 to 0.632, and `--trace` lists all five.
+25 -4
View File
@@ -1,9 +1,9 @@
# Third-Party Notices
gstack is MIT. The files below contain material derived from Apache-2.0 works,
rewritten in gstack's voice and modified. Rewriting is not an exemption: the
license text is in `licenses/Apache-2.0.txt`, and each derived file carries a
notice that it was changed. Unmodified copies (the rule-registry fixture, the
gstack is MIT. The files below contain material derived from Apache-2.0 and MIT
works, rewritten in gstack's voice and modified. Rewriting is not an exemption:
the license texts are in `licenses/`, and each file derived from an Apache-2.0
work carries a notice that it was changed. Unmodified copies (the rule-registry fixture, the
license text) carry no header and are listed here instead.
## impeccable — Copyright Paul Bakaus — Apache License 2.0
@@ -29,6 +29,27 @@ against the checksum pinned in `lib/design-detect-contract.ts`, and recorded in
the egress ledger first. gstack does not audit the engine's network behavior; the
wrapper refuses URL targets so gstack never asks it to touch the network.
## ai-design-skills and redesign-skill — Copyright (c) 2026 Elaya — MIT License
https://github.com/elayadesign/ai-design-skills (`skills/landing-page-design`,
`skills/design-to-motion`, at 38bbe576) and
https://github.com/elayadesign/redesign-skill (`skills/redesign-existing-projects`,
at 64627f6e). License text: `licenses/MIT-elaya.txt`.
Derived, modified (rules rewritten in gstack's voice and reconciled with its
design rules; Elaya's fixed fonts, colors, spacing values, icon sets and
signature components are not carried over):
- `design-html/sections/craft.md.tmpl`: the Persuade intake, page archetypes,
page structure, conversion and copy rules, indexing and build order from
landing-page-design Part A; the content-realism, ship-floor, scale-snapping,
line-break and motion rules from landing-page-design Part B, the website
context of design-to-motion and the redesign-skill audit; and the generated
`design-html/SKILL.md` / `sections/craft.md`.
- `lib/design-craft.ts` and `bin/gstack-design-craft`: the content-realism and
ship-floor tells, turned into a free check.
- `design-shotgun/SKILL.md.tmpl`: page archetypes as a variant axis.
## DESIGN.md specification — Copyright Google LLC — Apache License 2.0
https://github.com/google-labs-code/design.md
+1 -1
View File
@@ -1 +1 @@
1.91.223.0
1.91.227.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.223.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.227.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,
+37
View File
@@ -0,0 +1,37 @@
#!/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>
//
// 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.
import { readFileSync } from 'fs';
import { checkCraft, formatCraft } from '../lib/design-craft';
const file = process.argv[2];
if (!file) {
console.error('Usage: gstack-design-craft <file.html>');
process.exit(2);
}
if (!/\.html?$/i.test(file)) {
console.error(`gstack-design-craft: ${file} is not an HTML file; check the rendered HTML preview instead`);
process.exit(2);
}
let source: string;
try {
source = readFileSync(file, 'utf-8');
} catch (err) {
console.error(`gstack-design-craft: cannot read ${file}: ${(err as Error).message}`);
process.exit(2);
}
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);
+7 -4
View File
@@ -8,7 +8,7 @@
# Code sessions refuse eval/source)
# Modes (not on the hot path; see `gstack-slug --help`):
# --identity also print PROJECT_REMOTE, PROJECT_ROOT, LEGACY_SLUG
# --stamp-checkpoint <file> write remote:/project_root: into a checkpoint's frontmatter
# --stamp-checkpoint <file> write remote:/project_root:/head: into a checkpoint's frontmatter
# --classify-checkpoints read checkpoint paths on stdin, print "<status>\t<path>"
# --checkpoints-below list other buckets' checkpoints saved below the cwd, plus pointers
# --point-from <dir> <file> leave a pointer to <file> in <dir>'s bucket (nested-repo saves)
@@ -138,7 +138,7 @@ gstack-slug — print SLUG=<project slug> and BRANCH=<branch> for the current di
field); no eval needed
gstack-slug --identity also print PROJECT_REMOTE (canonical, credential-free),
PROJECT_ROOT and LEGACY_SLUG (pre-#3003 bucket, if different)
gstack-slug --stamp-checkpoint FILE write remote:/project_root: into FILE's frontmatter
gstack-slug --stamp-checkpoint FILE write remote:/project_root:/head: into FILE's frontmatter
gstack-slug --classify-checkpoints read checkpoint paths on stdin; print "<status><TAB><path>",
status = match | match-root | foreign | unstamped
gstack-slug --checkpoints-below outside git: other buckets' checkpoints whose project_root
@@ -682,15 +682,18 @@ case "$MODE" in
STAMP_TMP=$(mktemp "${MODE_ARG}.stamp-XXXXXX") || exit 1
STAMP_WORKTREE=$(_git_toplevel "$PROJECT_DIR")
[[ "$STAMP_WORKTREE" != "$ID_ROOT" ]] || STAMP_WORKTREE=""
if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" GRI_WORKTREE="$STAMP_WORKTREE" awk '
# The commit the save describes, so /context-restore can tell when work landed after it.
STAMP_HEAD=$(git -C "$PROJECT_DIR" rev-parse --verify -q HEAD 2>/dev/null) || STAMP_HEAD=""
if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" GRI_WORKTREE="$STAMP_WORKTREE" GRI_HEAD="$STAMP_HEAD" awk '
function ids() {
if (ENVIRON["GRI_REMOTE"] != "") print "remote: " ENVIRON["GRI_REMOTE"]
print "project_root: " ENVIRON["GRI_ROOT"]
if (ENVIRON["GRI_WORKTREE"] != "") print "worktree: " ENVIRON["GRI_WORKTREE"]
if (ENVIRON["GRI_HEAD"] != "") print "head: " ENVIRON["GRI_HEAD"]
}
NR == 1 { if ($0 == "---") { infm = 1; print; next } print "---"; ids(); print "---" }
infm && $0 == "---" { ids(); print; infm = 0; next }
infm && (/^remote:/ || /^project_root:/ || /^worktree:/) { next }
infm && (/^remote:/ || /^project_root:/ || /^worktree:/ || /^head:/) { next }
{ print }
END { if (NR == 0) { print "---"; ids(); print "---" } }' "$MODE_ARG" > "$STAMP_TMP"; then
mv "$STAMP_TMP" "$MODE_ARG"
+46 -1
View File
@@ -527,8 +527,50 @@ 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.
**Check whether the branch moved since the save.** The stamp records the commit
the save was made at in `head:`. Before presenting, compare it with this
checkout. FILE is the checkpoint path chosen in Step 2, one of the paths Step 1
printed:
```bash
FILE="<the FILE path printed above>"
SAVED_HEAD=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^head:/{s/^head:[[:space:]]*//p;q;}')
REPO_DIR=.
git rev-parse --git-dir >/dev/null 2>&1 || REPO_DIR=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^worktree:/{s/^worktree:[[:space:]]*//p;q;}')
[ -n "$REPO_DIR" ] || REPO_DIR=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^project_root:/{s/^project_root:[[:space:]]*//p;q;}')
CURRENT_HEAD=$(git -C "${REPO_DIR:-.}" rev-parse --verify -q HEAD 2>/dev/null)
if [ -z "$SAVED_HEAD" ]; then
echo "HEAD_UNTRACKED"
elif [ -z "$CURRENT_HEAD" ]; then
echo "HEAD_NOT_CHECKED"
elif [ "$SAVED_HEAD" = "$CURRENT_HEAD" ]; then
echo "HEAD_UNCHANGED"
elif ! printf '%s\n' "$SAVED_HEAD" | grep -Eqx '[0-9a-f]{40}([0-9a-f]{24})?' \
|| ! git -C "$REPO_DIR" cat-file -e "$SAVED_HEAD^{commit}" 2>/dev/null; then
echo "HEAD_UNKNOWN $SAVED_HEAD"
elif git -C "$REPO_DIR" merge-base --is-ancestor "$SAVED_HEAD" "$CURRENT_HEAD"; then
echo "HEAD_MOVED $(git -C "$REPO_DIR" rev-list --count "$SAVED_HEAD..$CURRENT_HEAD")"
git -C "$REPO_DIR" log --oneline -10 "$SAVED_HEAD..$CURRENT_HEAD"
else
echo "HEAD_DIVERGED $SAVED_HEAD"
fi
```
- `HEAD_UNCHANGED` or `HEAD_NOT_CHECKED`: nothing to add.
- `HEAD_MOVED <n>`: print `<n> commit(s) landed since this save:` and the commit
lines above the Remaining Work groups, and list **every** Remaining Work item
under **Verify first**: the saving session could not know about those commits,
so any step may already be done.
- `HEAD_DIVERGED` or `HEAD_UNKNOWN`: print `This checkpoint was saved at {first 7
characters of head}, which is not in this checkout's history (saved on another
branch, rebased, reset, or not fetched).` and list every item under Verify first.
- `HEAD_UNTRACKED`: print `This checkpoint predates commit tracking; restore cannot tell whether work landed after it.`
and sort as usual.
**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:
order, and drop nothing. When the head check printed `HEAD_MOVED`, `HEAD_DIVERGED`
or `HEAD_UNKNOWN`, skip the rules below: every item goes under Verify first,
whatever its marker. 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;
- ends in a parenthesised trailing marker that is **not** one of the five English
@@ -563,6 +605,7 @@ Status: {status}
{summary from saved file}
### Remaining Work
{commit-tracking lines, if any}
{legacy banner line, if it applies}
Next steps
@@ -616,6 +659,8 @@ in the old bucket until the user copies them with `gstack-slug --adopt-legacy`.
- **Never present another project's checkpoint as "latest".** Identity is the
canonical remote (the root only when there is no remote); a mismatch is
reported before any summary.
- **A checkpoint describes the commit it was saved at.** When commits landed
after the save, every Remaining Work item is verified before it is run.
- **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not
`ls -1t` (filesystem mtime). Filenames are stable across file-system
operations; mtime is not.
+46 -1
View File
@@ -203,8 +203,50 @@ 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.
**Check whether the branch moved since the save.** The stamp records the commit
the save was made at in `head:`. Before presenting, compare it with this
checkout. FILE is the checkpoint path chosen in Step 2, one of the paths Step 1
printed:
```bash
FILE="<the FILE path printed above>"
SAVED_HEAD=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^head:/{s/^head:[[:space:]]*//p;q;}')
REPO_DIR=.
git rev-parse --git-dir >/dev/null 2>&1 || REPO_DIR=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^worktree:/{s/^worktree:[[:space:]]*//p;q;}')
[ -n "$REPO_DIR" ] || REPO_DIR=$(tr -d '\r' < "$FILE" 2>/dev/null | sed -n '1{/^---$/!q;d;}; /^---$/q; /^project_root:/{s/^project_root:[[:space:]]*//p;q;}')
CURRENT_HEAD=$(git -C "${REPO_DIR:-.}" rev-parse --verify -q HEAD 2>/dev/null)
if [ -z "$SAVED_HEAD" ]; then
echo "HEAD_UNTRACKED"
elif [ -z "$CURRENT_HEAD" ]; then
echo "HEAD_NOT_CHECKED"
elif [ "$SAVED_HEAD" = "$CURRENT_HEAD" ]; then
echo "HEAD_UNCHANGED"
elif ! printf '%s\n' "$SAVED_HEAD" | grep -Eqx '[0-9a-f]{40}([0-9a-f]{24})?' \
|| ! git -C "$REPO_DIR" cat-file -e "$SAVED_HEAD^{commit}" 2>/dev/null; then
echo "HEAD_UNKNOWN $SAVED_HEAD"
elif git -C "$REPO_DIR" merge-base --is-ancestor "$SAVED_HEAD" "$CURRENT_HEAD"; then
echo "HEAD_MOVED $(git -C "$REPO_DIR" rev-list --count "$SAVED_HEAD..$CURRENT_HEAD")"
git -C "$REPO_DIR" log --oneline -10 "$SAVED_HEAD..$CURRENT_HEAD"
else
echo "HEAD_DIVERGED $SAVED_HEAD"
fi
```
- `HEAD_UNCHANGED` or `HEAD_NOT_CHECKED`: nothing to add.
- `HEAD_MOVED <n>`: print `<n> commit(s) landed since this save:` and the commit
lines above the Remaining Work groups, and list **every** Remaining Work item
under **Verify first**: the saving session could not know about those commits,
so any step may already be done.
- `HEAD_DIVERGED` or `HEAD_UNKNOWN`: print `This checkpoint was saved at {first 7
characters of head}, which is not in this checkout's history (saved on another
branch, rebased, reset, or not fetched).` and list every item under Verify first.
- `HEAD_UNTRACKED`: print `This checkpoint predates commit tracking; restore cannot tell whether work landed after it.`
and sort as usual.
**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:
order, and drop nothing. When the head check printed `HEAD_MOVED`, `HEAD_DIVERGED`
or `HEAD_UNKNOWN`, skip the rules below: every item goes under Verify first,
whatever its marker. 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;
- ends in a parenthesised trailing marker that is **not** one of the five English
@@ -239,6 +281,7 @@ Status: {status}
{summary from saved file}
### Remaining Work
{commit-tracking lines, if any}
{legacy banner line, if it applies}
Next steps
@@ -292,6 +335,8 @@ in the old bucket until the user copies them with `gstack-slug --adopt-legacy`.
- **Never present another project's checkpoint as "latest".** Identity is the
canonical remote (the root only when there is no remote); a mismatch is
reported before any summary.
- **A checkpoint describes the commit it was saved at.** When commits landed
after the save, every Remaining Work item is verified before it is run.
- **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not
`ls -1t` (filesystem mtime). Filenames are stable across file-system
operations; mtime is not.
+4 -3
View File
@@ -542,12 +542,13 @@ files_modified:
The `files_modified` list comes from `git status --short` (both staged and unstaged
modified files). Use relative paths from the repo root. Do not write `remote:`,
`project_root:` or `worktree:` yourself; the stamp step below adds them.
`project_root:`, `worktree:` or `head:` yourself; the stamp step below adds them.
Then stamp the project identity. Several projects can share one checkpoints
directory, so `/context-restore` needs to know which project wrote each file. The helper adds `remote:` (canonical origin:
lowercased host, no credentials, port or `.git`), `project_root:` and, in a
linked worktree, `worktree:` to the frontmatter. When the session started in
lowercased host, no credentials, port or `.git`), `project_root:`, in a linked
worktree `worktree:`, and `head:` (the commit this save describes, so
`/context-restore` can tell when work landed after it) to the frontmatter. When the session started in
another directory that files elsewhere (a non-git parent of this repo), it also
leaves a pointer there so `/context-restore` in that directory finds this save:
+4 -3
View File
@@ -217,12 +217,13 @@ files_modified:
The `files_modified` list comes from `git status --short` (both staged and unstaged
modified files). Use relative paths from the repo root. Do not write `remote:`,
`project_root:` or `worktree:` yourself; the stamp step below adds them.
`project_root:`, `worktree:` or `head:` yourself; the stamp step below adds them.
Then stamp the project identity. Several projects can share one checkpoints
directory, so `/context-restore` needs to know which project wrote each file. The helper adds `remote:` (canonical origin:
lowercased host, no credentials, port or `.git`), `project_root:` and, in a
linked worktree, `worktree:` to the frontmatter. When the session started in
lowercased host, no credentials, port or `.git`), `project_root:`, in a linked
worktree `worktree:`, and `head:` (the commit this save describes, so
`/context-restore` can tell when work landed after it) to the frontmatter. When the session started in
another directory that files elsewhere (a non-git parent of this repo), it also
leaves a pointer there so `/context-restore` in that directory finds this save:
+7
View File
@@ -366,6 +366,7 @@ sections. Read a section in full before doing its step; do not work from memory.
|------|-------------------|
| analyzing the design or making any layout/visual decision (Step 1 onward) — the UX-principles doctrine governs every design choice | `sections/doctrine.md` |
| writing the finalized HTML in Step 3 — the Pretext wiring patterns and API cheatsheet are the required reference for all text-layout code | `sections/pretext-patterns.md` |
| writing the Step 1 implementation spec — the Persuade intake and archetype, and the craft floor every page ships with | `sections/craft.md` |
| the Setup probe printed DESIGN_DETECTOR_INSTALL_OFFER — ask the user once whether gstack may download impeccable's engine (checksum-pinned, receipted) before any other step | `sections/detector-install-offer.md` |
---
@@ -535,6 +536,9 @@ After routing, output a brief context summary:
## Step 1: Design Analysis
> **STOP.** Before writing the Step 1 implementation spec — the Persuade intake and archetype, and the craft floor every page ships with, Read `~/.claude/skills/gstack/design-html/sections/craft.md` and execute it
> in full. Do not work from memory — that section is the source of truth for this step.
1. If `$D` is available (`DESIGN_READY`), extract a structured implementation spec:
```bash
$D prompt --image <approved-variant.png> --output json
@@ -658,6 +662,7 @@ For framework output, save to:
- `prefers-color-scheme` media query for dark mode
- `prefers-reduced-motion` for animation respect
- Real content extracted from the mockup (never lorem ipsum)
- The craft section's ship floor, scale-snapped values and motion rules
**Never include by default (AI slop blacklist):** an approved mockup that carries one, a DESIGN.md blessing, or an explicit user ask overrides it; say the tradeoff once.
- Purple/blue gradients as default <!-- ai-color-palette -->
@@ -726,6 +731,8 @@ _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.
### Verification Screenshots
Take verification screenshots at 3 viewports. One `gstack-render` call serves
+5
View File
@@ -156,6 +156,8 @@ After routing, output a brief context summary:
## Step 1: Design Analysis
{{SECTION:craft}}
1. If `$D` is available (`DESIGN_READY`), extract a structured implementation spec:
```bash
$D prompt --image <approved-variant.png> --output json
@@ -278,6 +280,7 @@ For framework output, save to:
- `prefers-color-scheme` media query for dark mode
- `prefers-reduced-motion` for animation respect
- Real content extracted from the mockup (never lorem ipsum)
- The craft section's ship floor, scale-snapped values and motion rules
**Never include by default (AI slop blacklist):** an approved mockup that carries one, a DESIGN.md blessing, or an explicit user ask overrides it; say the tradeoff once.
- Purple/blue gradients as default <!-- ai-color-palette -->
@@ -338,6 +341,8 @@ 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.
### Verification Screenshots
Take verification screenshots at 3 viewports. One `gstack-render` call serves
+72
View File
@@ -0,0 +1,72 @@
<!-- AUTO-GENERATED from craft.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
## Craft: Persuade pages and the craft floor
**Persuade pages** applies when the page exists to get one action from a visitor: a landing, marketing, pricing, waitlist, campaign or comparison page. Write `Surface: Persuade` or `Surface: other` as the spec's first line; on `other`, skip to Craft floor, which applies to every page. An approved mockup wins on structure and visuals; this section fills only what it leaves open.
### Persuade pages
**One offer → one audience → one primary action.**
**Intake.** When the context does not settle them, ask one AskUserQuestion with at most three multiple-choice questions: the primary action, the traffic source, and published or design reference. Assume the rest in one `Assumed:` line the user corrects in the refinement loop:
- the conversion and the offer: exactly what they get
- 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").
**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.
**Conversion rules.**
- A page behind an ad repeats the ad's promise and tone in the hero.
- One primary CTA; nothing competes with it above the fold.
- Benefit first and specific: "Cut weekly reporting from 4 hours to 15 minutes", never "save time".
- Show the risk reversal the business offers (free plan, trial, no card, cancel anytime). Never invent one.
- Proof sits beside the claim it supports. A high-friction offer moves the FAQ up.
**The primary action works.** A real destination (supplied URL or form endpoint) and every state: invalid (inline error, input kept), pending (control disabled so it cannot submit twice, announced through `aria-live`), failure (what happened, input kept, retry) and success (what happens next). With no backend, simulate the states and say so in a code comment and your summary.
**Copy.** A CTA is a verb plus what they get ("Start free trial"). Draft two or three headlines in the product's voice and keep the most specific one that names outcome and audience; "{outcome} without {pain}" is a test, not a template.
**Indexing.** Record the publication decision; keep the site's policy when known. A page the user keeps out of search gets `<meta name="robots" content="noindex">`; an indexed page gets a real title and description, and its FAQ ships as FAQPage JSON-LD.
**Build order.** One section at a time, top down; a refinement edits one section.
### Craft floor (every page)
**Claims come from the user, never from you.** Testimonials, customer names and logos, results, uptime, prices and guarantees appear only when supplied. Missing proof: leave the slot out, or mark it `[Needs proof: …]` in the warning color so it cannot ship by accident. A believable invented claim is worse than an obvious placeholder.
**Illustrative content looks real.** Sample data in product shots and UI (table rows, chart values, people in a demo inbox):
- No placeholder people or brands (John Doe, Jane Doe, Acme, Nexus, SmartFlow); distinct avatars; varied dates.
- No round sample numbers (99.99%, 50%, 1,000+ customers, 10x). In a claim, a round number signals it was made up: use the supplied figure or drop it.
- No buzzwords: elevate, seamless, effortless, supercharge, streamline, revolutionize, unlock, empower, unleash, next-gen, game-changer, delve, tapestry, "in the world of".
- Sentence-case headings. Errors say what happened and what to do, never "Oops!". No exclamation marks in success messages.
**Ship floor.** Head tags apply to a full HTML document, not a component or fragment. Every full document has `<title>`, a meta description, a favicon (an inline SVG data URI from the brand mark when no file is supplied), and on a page meant for sharing `og:title` and `og:description`; `og:image` only from a supplied URL. A "Skip to content" link is the first focusable element, hidden until focused, targeting the `id` of `<main>`. Landmarks (`<header>`, `<nav>`, `<main>`, `<footer>`); alt text on meaningful images, `alt=""` on decorative ones. A marketing footer links privacy and terms at supplied URLs. Required fields and email format validate inline, wired with `aria-invalid` and `aria-describedby`. Every link goes somewhere real: no `href="#"`, no made-up path; a control with no target renders disabled. Navigation to other pages marks the current one `aria-current="page"`; in-page section links do not.
**Values come from the scale.** Every font size, space and radius is a step of the system's scale (DESIGN.md, else the spec), chosen by role. An off-scale value snaps to the nearest step that keeps its role, usually the one below, with that step's line height; never below 16px body text or a 44px touch target, and an approved mockup's measured values stand. A shape nested in another with a gap under 32px gets inner radius = outer − gap when that exceeds 2px.
**Line breaks.** Browser-wrapped headings get `text-wrap: balance`, body `text-wrap: pretty`. Break display lines where the thought breaks; never one word alone on the last line.
**Motion.** These govern the one authored motion moment and state transitions; they license no more motion.
- Content is visible by default; script adds the hidden start state only after the observer is set up (`.motion-ready` on `<html>`), so a script failure or a crawler sees everything and the CTA never waits on an animation.
- Reveal with `IntersectionObserver` (`threshold: 0`, bottom `rootMargin` about −20%, once). Never an unthrottled `window.addEventListener('scroll')`; scroll-linked effects read scroll inside `requestAnimationFrame`.
- Animate `transform`, `opacity`, `filter`, `clip-path` only; blur 14px or less.
- No object grows from `scale(0)` (start at 0.85 or more); only lines and bars grow from zero. A data bar fills as a left-to-right `clip-path` wipe, 0.7–1.1s.
- Stagger in reading order inside one section (30–80ms), never across the page.
- Curves are named tokens: DESIGN.md's, else `cubic-bezier(0.16, 1, 0.3, 1)` for entrances and `cubic-bezier(0.65, 0, 0.35, 1)` for wipes; `linear` only for constant motion.
- Loops pause off-screen. `prefers-reduced-motion: reduce` shows the final state. Full-height sections use `min-height: 100dvh`, not `100vh`.
### Craft check
At Step 4, after the Slop Gate and before the screenshots, run the free checker on a vanilla HTML output (for framework output, say it is unavailable):
```bash
~/.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.
**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.
Adapted from Elaya's [ai-design-skills](https://github.com/elayadesign/ai-design-skills) and [redesign-skill](https://github.com/elayadesign/redesign-skill) (MIT, © 2026 Elaya), rewritten for gstack. See NOTICE.md.
+70
View File
@@ -0,0 +1,70 @@
## Craft: Persuade pages and the craft floor
**Persuade pages** applies when the page exists to get one action from a visitor: a landing, marketing, pricing, waitlist, campaign or comparison page. Write `Surface: Persuade` or `Surface: other` as the spec's first line; on `other`, skip to Craft floor, which applies to every page. An approved mockup wins on structure and visuals; this section fills only what it leaves open.
### Persuade pages
**One offer → one audience → one primary action.**
**Intake.** When the context does not settle them, ask one AskUserQuestion with at most three multiple-choice questions: the primary action, the traffic source, and published or design reference. Assume the rest in one `Assumed:` line the user corrects in the refinement loop:
- the conversion and the offer: exactly what they get
- 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").
**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.
**Conversion rules.**
- A page behind an ad repeats the ad's promise and tone in the hero.
- One primary CTA; nothing competes with it above the fold.
- Benefit first and specific: "Cut weekly reporting from 4 hours to 15 minutes", never "save time".
- Show the risk reversal the business offers (free plan, trial, no card, cancel anytime). Never invent one.
- Proof sits beside the claim it supports. A high-friction offer moves the FAQ up.
**The primary action works.** A real destination (supplied URL or form endpoint) and every state: invalid (inline error, input kept), pending (control disabled so it cannot submit twice, announced through `aria-live`), failure (what happened, input kept, retry) and success (what happens next). With no backend, simulate the states and say so in a code comment and your summary.
**Copy.** A CTA is a verb plus what they get ("Start free trial"). Draft two or three headlines in the product's voice and keep the most specific one that names outcome and audience; "{outcome} without {pain}" is a test, not a template.
**Indexing.** Record the publication decision; keep the site's policy when known. A page the user keeps out of search gets `<meta name="robots" content="noindex">`; an indexed page gets a real title and description, and its FAQ ships as FAQPage JSON-LD.
**Build order.** One section at a time, top down; a refinement edits one section.
### Craft floor (every page)
**Claims come from the user, never from you.** Testimonials, customer names and logos, results, uptime, prices and guarantees appear only when supplied. Missing proof: leave the slot out, or mark it `[Needs proof: …]` in the warning color so it cannot ship by accident. A believable invented claim is worse than an obvious placeholder.
**Illustrative content looks real.** Sample data in product shots and UI (table rows, chart values, people in a demo inbox):
- No placeholder people or brands (John Doe, Jane Doe, Acme, Nexus, SmartFlow); distinct avatars; varied dates.
- No round sample numbers (99.99%, 50%, 1,000+ customers, 10x). In a claim, a round number signals it was made up: use the supplied figure or drop it.
- No buzzwords: elevate, seamless, effortless, supercharge, streamline, revolutionize, unlock, empower, unleash, next-gen, game-changer, delve, tapestry, "in the world of".
- Sentence-case headings. Errors say what happened and what to do, never "Oops!". No exclamation marks in success messages.
**Ship floor.** Head tags apply to a full HTML document, not a component or fragment. Every full document has `<title>`, a meta description, a favicon (an inline SVG data URI from the brand mark when no file is supplied), and on a page meant for sharing `og:title` and `og:description`; `og:image` only from a supplied URL. A "Skip to content" link is the first focusable element, hidden until focused, targeting the `id` of `<main>`. Landmarks (`<header>`, `<nav>`, `<main>`, `<footer>`); alt text on meaningful images, `alt=""` on decorative ones. A marketing footer links privacy and terms at supplied URLs. Required fields and email format validate inline, wired with `aria-invalid` and `aria-describedby`. Every link goes somewhere real: no `href="#"`, no made-up path; a control with no target renders disabled. Navigation to other pages marks the current one `aria-current="page"`; in-page section links do not.
**Values come from the scale.** Every font size, space and radius is a step of the system's scale (DESIGN.md, else the spec), chosen by role. An off-scale value snaps to the nearest step that keeps its role, usually the one below, with that step's line height; never below 16px body text or a 44px touch target, and an approved mockup's measured values stand. A shape nested in another with a gap under 32px gets inner radius = outer − gap when that exceeds 2px.
**Line breaks.** Browser-wrapped headings get `text-wrap: balance`, body `text-wrap: pretty`. Break display lines where the thought breaks; never one word alone on the last line.
**Motion.** These govern the one authored motion moment and state transitions; they license no more motion.
- Content is visible by default; script adds the hidden start state only after the observer is set up (`.motion-ready` on `<html>`), so a script failure or a crawler sees everything and the CTA never waits on an animation.
- Reveal with `IntersectionObserver` (`threshold: 0`, bottom `rootMargin` about −20%, once). Never an unthrottled `window.addEventListener('scroll')`; scroll-linked effects read scroll inside `requestAnimationFrame`.
- Animate `transform`, `opacity`, `filter`, `clip-path` only; blur 14px or less.
- No object grows from `scale(0)` (start at 0.85 or more); only lines and bars grow from zero. A data bar fills as a left-to-right `clip-path` wipe, 0.7–1.1s.
- Stagger in reading order inside one section (30–80ms), never across the page.
- Curves are named tokens: DESIGN.md's, else `cubic-bezier(0.16, 1, 0.3, 1)` for entrances and `cubic-bezier(0.65, 0, 0.35, 1)` for wipes; `linear` only for constant motion.
- Loops pause off-screen. `prefers-reduced-motion: reduce` shows the final state. Full-height sections use `min-height: 100dvh`, not `100vh`.
### Craft check
At Step 4, after the Slop Gate and before the screenshots, run the free checker on a vanilla HTML output (for framework output, say it is unavailable):
```bash
{{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.
**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.
Adapted from Elaya's [ai-design-skills](https://github.com/elayadesign/ai-design-skills) and [redesign-skill](https://github.com/elayadesign/redesign-skill) (MIT, © 2026 Elaya), rewritten for gstack. See NOTICE.md.
+6
View File
@@ -16,6 +16,12 @@
"title": "Pretext wiring patterns + API reference",
"trigger": "writing the finalized HTML in Step 3 — the Pretext wiring patterns and API cheatsheet are the required reference for all text-layout code"
},
{
"id": "craft",
"file": "craft.md",
"title": "Craft: Persuade pages and the craft floor",
"trigger": "writing the Step 1 implementation spec — the Persuade intake and archetype, and the craft floor every page ships with"
},
{
"id": "detector-install-offer",
"file": "detector-install-offer.md",
+3
View File
@@ -634,6 +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.
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
+3
View File
@@ -219,6 +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.
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
+110
View File
@@ -0,0 +1,110 @@
/**
* Free craft check for a generated page (design-html Step 4). Counts the tells
* that make a page read as generated or unfinished: placeholder content, AI
* cliché copy, scroll listeners, 100vh, dead links, and the missing ship-floor
* tags. Copy checks read the visible text only (script, style and tags
* removed); code checks read the source. Head checks run only on a full
* document. Adapted from Elaya's landing-page-design and redesign-skill
* (MIT); see NOTICE.md. Buzzwords report under the catalog's
* `marketing-buzzword` id, and BUZZWORDS covers every word its heuristic
* names (test-enforced), so the two lists cannot drift.
*/
/** The catalog's marketing-buzzword words plus Elaya's AI cliché words. */
export const BUZZWORDS: readonly string[] = [
'seamless', 'effortless', 'supercharge', 'streamline', 'revolutionize', 'unlock', 'empower', 'elevate',
'unleash', 'next-gen', 'game-changer', 'delve', 'tapestry', 'in the world of',
];
function buzzwordPattern(): RegExp {
const stem = (w: string) => w.replace(/-/g, '[- ]').replace(/e$/, '');
return new RegExp(`\\b(?:${BUZZWORDS.map(stem).join('|')})(?:e|es|ed|ing|ly|er|ers|s|d|eration)?\\b`, 'gi');
}
export interface CraftFinding {
id: string;
count: number;
fix: string;
}
interface CountRule {
id: string;
on: 'source' | 'text' | 'script';
pattern: RegExp;
fix: string;
}
interface PresenceRule {
id: string;
pattern: RegExp;
fix: string;
}
const COUNT_RULES: CountRule[] = [
{ id: 'placeholder-name', on: 'text', pattern: /\b(?:John|Jane) Doe\b/gi, fix: 'use a supplied name, or one that fits the product in sample data' },
{ id: 'placeholder-brand', on: 'text', pattern: /\b(?:Acme|Nexus|SmartFlow)\b/g, fix: 'use a supplied customer, or a fitting name in sample data' },
{ id: 'lorem', on: 'text', pattern: /\blorem ipsum\b/gi, fix: 'write real copy' },
{ id: 'marketing-buzzword', on: 'text', pattern: buzzwordPattern(), fix: 'say the specific thing' },
{ id: 'round-number', on: 'text', pattern: /(?<![\d.])(?:99\.9+|[1-9]0|100)%\s+(?:uptime|faster|more|less|fewer|better|growth|increase|reduction|of (?:teams|users|customers|companies))\b|\b(?:\d+0|\d+(?:,\d{3})+)\+?\s+(?:happy |active )?(?:users|customers|teams|companies)\b|(?<![\d.])10x\s+(?:faster|more|better)\b/gi, fix: 'use the supplied figure, or drop the claim' },
{ id: 'needs-proof', on: 'text', pattern: /\[Needs proof:/g, fix: 'supply the proof or remove the claim before publishing' },
{ id: 'oops', on: 'text', pattern: /\boops\b/gi, fix: 'say what happened and what to do' },
{ id: 'scroll-listener', on: 'source', pattern: /addEventListener\(\s*['"`]scroll['"`]/g, fix: 'use IntersectionObserver; read scroll inside requestAnimationFrame for a scroll-linked effect' },
{ id: 'transition-all', on: 'source', pattern: /\btransition\s*:\s*all\b|\btransition-all\b/g, fix: 'list the transitioned properties' },
{ id: 'dead-link', on: 'source', pattern: /\bhref\s*=\s*["']#["']/g, fix: 'link it, or render the control disabled' },
{ id: 'alert', on: 'script', pattern: /(?<![\w.$])(?:window\.)?alert\s*\(/g, fix: 'show the message inline' },
{ id: 'img-no-alt', on: 'source', pattern: /<img\b(?![^>]*(?<![\w-])alt\s*=)[^>]*>/gi, fix: 'add alt text (alt="" when decorative)' },
];
const HEAD_RULES: PresenceRule[] = [
{ id: 'missing-title', pattern: /<title>\s*[^<\s][^<]*<\/title>/i, fix: 'add a <title>' },
{ id: 'missing-meta-description', pattern: /<meta\b[^>]*\bname\s*=\s*["']description["']/i, fix: 'add <meta name="description">' },
{ id: 'missing-favicon', pattern: /<link\b[^>]*\brel\s*=\s*["'][^"']*\bicon\b/i, fix: 'add a favicon <link rel="icon">' },
];
export function visibleText(html: string): string {
return html
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<(script|style|template)\b[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&(nbsp|lt|gt|quot|apos|amp);/g, (_, e) => ({ nbsp: ' ', lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[e as string] as string)
.replace(/\s+/g, ' ');
}
export function checkCraft(html: string): CraftFinding[] {
const text = visibleText(html);
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi), ...html.matchAll(/\bon[a-z]+\s*=\s*"([^"]*)"/gi)].map(m => m[1]).join('\n');
const findings: CraftFinding[] = [];
for (const rule of COUNT_RULES) {
const count = (rule.on === 'text' ? text : rule.on === 'script' ? scripts : html).match(rule.pattern)?.length ?? 0;
if (count > 0) findings.push({ id: rule.id, count, fix: rule.fix });
}
const vh = [...html.matchAll(/\b((?:min-|max-)?height)\s*:\s*100vh\b(\s*!important)?([^}]*)/g)]
.filter(m => m[2] || !new RegExp(`(?<![\\w-])${m[1]}\\s*:\\s*100[dsl]vh\\b`).test(m[3])).length
+ (html.match(/\b(?:min-)?h-screen\b/g)?.length ?? 0);
if (vh > 0) findings.push({ id: 'vh-height', count: vh, fix: 'use min-height: 100dvh (a 100vh line followed by a 100dvh fallback is fine)' });
if (!/<head\b/i.test(html)) return findings;
for (const rule of HEAD_RULES) {
if (!rule.pattern.test(html)) findings.push({ id: rule.id, count: 1, fix: rule.fix });
}
const body = html.slice(html.search(/<body\b/i) + 1);
const firstFocusable = body.match(/<(?:a\b[^>]*\bhref|button|input|select|textarea)\b[^>]*>/i)?.[0] ?? '';
const skipTarget = firstFocusable.match(/^<a\b[^>]*\bhref\s*=\s*["']#([^"']+)["']/i)?.[1];
const main = html.match(/<main\b[^>]*(?<![\w-])id\s*=\s*["']([^"']+)["']/i)?.[1]
?? html.match(/<[a-z]+\b[^>]*\brole\s*=\s*["']main["'][^>]*(?<![\w-])id\s*=\s*["']([^"']+)["']/i)?.[1];
if (!skipTarget || skipTarget !== main) {
findings.push({ id: 'missing-skip-link', count: 1, fix: 'add a "Skip to content" link first in <body> whose #id exists on <main>' });
}
const routeLinks = html.match(/<nav\b[\s\S]*?<\/nav>/gi)?.join('').match(/<a\b[^>]*\bhref\s*=\s*["'](?!#)[^"']+["']/gi)?.length ?? 0;
if (routeLinks >= 2 && !/\baria-current\s*=\s*["']page["']/i.test(html)) {
findings.push({ id: 'missing-aria-current', count: 1, fix: 'mark the current nav link aria-current="page"' });
}
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)`);
return lines.join('\n');
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Elaya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.223",
"version": "1.91.227",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+25 -26
View File
@@ -505,8 +505,11 @@ git diff "$DIFF_BASE"
```
1. Save the printed REVIEW_START for this core candidate before reading its diff.
2. Each re-review captures a new token before reading, never at log time. Earlier
core tokens remain unused; Step 5.8 finishes only the final core token.
2. Each re-review captures a new token before reading, never at log time. After an
edited pass, start the next with `--start review --fix-range`: it prints
REVIEW_START, then `FIX_BASE=<sha>`. Wherever this pass sets DIFF_BASE outside
Step 4.8, use FIX_BASE instead; Step 4.8 and every other `<base>` use stay full.
On refusal, start a full pass.
3. Native/outside reviewer attempts own separate PASS_START tokens, not REVIEW_START.
4. Read non-ignored untracked source too (`git ls-files --others --exclude-standard`);
the captured candidate includes it.
@@ -540,13 +543,13 @@ OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
- `OFFLINE=true`: skip this section.
- Otherwise, include ONE line in the review output: `Version claimed: v<BRANCH_VERSION>. Queue: <CLAIMED_COUNT> PR(s) ahead. <VERDICT>` where VERDICT is either `Slot free` (if `BRANCH_VERSION >= NEXT_SLOT`) or `⚠ queue moved — rerun /ship to reconcile v<BRANCH_VERSION> → v<NEXT_SLOT>`.
Compare dotted version components as integers from left to right; missing trailing components count as zero.
Compare versions numerically per dotted component; missing ones count as zero.
---
## Step 3.5: Diff scans
Run the gate scan first (local, seconds; every diff size), then the slop scan.
Run the gate scan first (local; every diff size), then the slop scan.
### Gate scan
@@ -589,14 +592,11 @@ category, Step 5.8's record and the final report.
### Slop scan (advisory)
Scan changed files for empty catches, redundant `return await` and needless abstractions:
```bash
bun run slop:diff origin/<base> 2>/dev/null || true
```
Include findings as non-blocking informational diagnostics. If slop:diff is
unavailable, skip silently.
Findings are non-blocking informational diagnostics; skip if unavailable.
---
@@ -719,10 +719,8 @@ a clean result. Gate findings are ASK in Step 5 regardless of how mechanical the
revert looks. A test-only flake fix (timeout, retry, looser assertion, skip) states `P(fail | regression)` before and after, from a forced probe; without both numbers it is RH-15.
**Search-before-recommending:** Research proposed fixes through Aside, especially
concurrency, caching, auth and framework behavior:
- Check current best practice for the installed framework version.
- Look for a newer built-in before proposing a workaround.
- Verify API signatures against current docs.
concurrency, caching, auth and framework behavior: current best practice for the
installed version, newer built-ins that replace a workaround, and current API signatures.
Prompt file text (create, write and send it with the Web research runs in Aside blocks above): `{framework} {version} {pattern} current best practice and whether a built-in replaces it. Reply with up to 5 bullets, each with its source URL.`
@@ -1052,7 +1050,7 @@ Verify claims or flag them as unknown; "this looks fine" is not evidence.
After outputting your own findings, if Greptile comments were classified in Step 2.5:
**Include a Greptile summary in your output header:** `+ N Greptile comments (X valid, Y fixed, Z FP)`
**Greptile summary in your output header:** `+ N Greptile comments (X valid, Y fixed, Z FP)`
Before replying to any comment, run greptile-triage.md's **Escalation Detection** to choose Tier 1 (friendly) or Tier 2 (firm) reply templates.
@@ -1067,7 +1065,7 @@ Before replying to any comment, run greptile-triage.md's **Escalation Detection*
3. **VALID BUT ALREADY FIXED comments:** Reply with greptile-triage.md's **Already Fixed reply template**, no AskUserQuestion: what was done and the fixing commit SHA; save to both per-project and global greptile-history.
4. **SUPPRESSED comments:** Skip silently (known false positives from previous triage).
4. **SUPPRESSED comments:** Skip silently.
---
@@ -1076,16 +1074,17 @@ Before replying to any comment, run greptile-triage.md's **Escalation Detection*
### 1. Re-review after edits
1. A pass covers Steps 3–5, including all reviewers before fixes. Allow at most 3 fix cycles:
- Edited: increment CYCLES once. Below 3, repeat Steps 3–5 with a new
REVIEW_START. At 3, persist `converged:false` and remaining findings by filling
and saving the record below. Report nonconvergence and coverage gaps, then STOP
this invocation, without a clean summary or a fourth pass.
- No edits: fill the record below.
- Edited: increment CYCLES once, then fill and save the record below with
`converged:false`. Step 5.0 never treats this invocation's records as prior
reviews. Below 3, repeat Steps 3–5 with a new REVIEW_START. At 3, report
nonconvergence and coverage gaps, then STOP this invocation, without a clean
summary or a fourth pass.
- No edits: after a full pass, fill the record below. A fix-range pass is never
final: repeat Steps 3–5 with a full start.
2. On a repeat, execute Steps 3–5 in order. At Step 4.7, reuse only this invocation's
unchanged-input QA evidence; rerun affected probes after source, test, contract,
command or fixture changes. Reusing a probe never skips a review step.
A probe is affected when its entrypoint, dependencies, contract or replay inputs
change; if uncertain, rerun it.
unchanged-input QA evidence; rerun a probe if its source, tests, entrypoint,
dependencies, contract, command, fixture or replay inputs changed, or if uncertain.
Reusing a probe never skips a review step.
3. **Verify completed actions.** On the final zero-edit pass, reconcile this
invocation's actions with current findings. Deduplicate by structural identity
and advisory/defect kind. For a completed extraction, retain `fixed` and the
@@ -1121,7 +1120,7 @@ pass: report it with its reason.
- Use Step 4.6's `specialists` object unchanged, including its empty small-diff map.
If this host omits Review Army, use `specialists: {}` without claiming specialist coverage.
- Build `findings` from Step 5's combined final-pass findings (core, specialist,
- Build `findings` from Step 5's combined findings (core, specialist,
adversarial, actionable Greptile, verified exploratory QA findings) and invocation actions. Retain `fingerprint`, `severity`
(`CRITICAL|INFORMATIONAL`), `action`, and any `advisory`, `evidence_paths`,
`helper_target`. Recheck source after fixes. The logger uses `sharedLibsFingerprint`,
@@ -1149,11 +1148,11 @@ were skipped or this host omits Review Army). This default is not completion evi
unresolved non-advisory core defects still count in `issues_found`,
`critical`, `informational`. The logger builds trusted `review_binding` from the
validated captured branch digest, discarding caller bindings. Never invent a binding
or replace REVIEW_START at log time; finish only the final core token.
or replace REVIEW_START at log time; finish only this pass's own token.
### Report the final review
Emit one final report, merging all reviewers rather than concatenating them:
Emit one final report merging all reviewers:
1. `Pre-Landing Review: N issues (X critical, Y informational)` counts final unresolved
non-advisory defects. State INCOMPLETE if `COMPLETED` is false, even when N=0.
2. Use the checklist's action groups with confidence-tagged finding lines. Keep fixed,
+25 -26
View File
@@ -87,8 +87,11 @@ git diff "$DIFF_BASE"
```
1. Save the printed REVIEW_START for this core candidate before reading its diff.
2. Each re-review captures a new token before reading, never at log time. Earlier
core tokens remain unused; Step 5.8 finishes only the final core token.
2. Each re-review captures a new token before reading, never at log time. After an
edited pass, start the next with `--start review --fix-range`: it prints
REVIEW_START, then `FIX_BASE=<sha>`. Wherever this pass sets DIFF_BASE outside
Step 4.8, use FIX_BASE instead; Step 4.8 and every other `<base>` use stay full.
On refusal, start a full pass.
3. Native/outside reviewer attempts own separate PASS_START tokens, not REVIEW_START.
4. Read non-ignored untracked source too (`git ls-files --others --exclude-standard`);
the captured candidate includes it.
@@ -122,13 +125,13 @@ OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
- `OFFLINE=true`: skip this section.
- Otherwise, include ONE line in the review output: `Version claimed: v<BRANCH_VERSION>. Queue: <CLAIMED_COUNT> PR(s) ahead. <VERDICT>` where VERDICT is either `Slot free` (if `BRANCH_VERSION >= NEXT_SLOT`) or `⚠ queue moved — rerun /ship to reconcile v<BRANCH_VERSION> → v<NEXT_SLOT>`.
Compare dotted version components as integers from left to right; missing trailing components count as zero.
Compare versions numerically per dotted component; missing ones count as zero.
---
## Step 3.5: Diff scans
Run the gate scan first (local, seconds; every diff size), then the slop scan.
Run the gate scan first (local; every diff size), then the slop scan.
### Gate scan
@@ -143,14 +146,11 @@ category, Step 5.8's record and the final report.
### Slop scan (advisory)
Scan changed files for empty catches, redundant `return await` and needless abstractions:
```bash
bun run slop:diff origin/<base> 2>/dev/null || true
```
Include findings as non-blocking informational diagnostics. If slop:diff is
unavailable, skip silently.
Findings are non-blocking informational diagnostics; skip if unavailable.
---
@@ -180,10 +180,8 @@ a clean result. Gate findings are ASK in Step 5 regardless of how mechanical the
revert looks. {{FLAKE_FIX_NUMBER:review}}
**Search-before-recommending:** Research proposed fixes through Aside, especially
concurrency, caching, auth and framework behavior:
- Check current best practice for the installed framework version.
- Look for a newer built-in before proposing a workaround.
- Verify API signatures against current docs.
concurrency, caching, auth and framework behavior: current best practice for the
installed version, newer built-ins that replace a workaround, and current API signatures.
Prompt file text (create, write and send it with the Web research runs in Aside blocks above): `{framework} {version} {pattern} current best practice and whether a built-in replaces it. Reply with up to 5 bullets, each with its source URL.`
@@ -352,7 +350,7 @@ Verify claims or flag them as unknown; "this looks fine" is not evidence.
After outputting your own findings, if Greptile comments were classified in Step 2.5:
**Include a Greptile summary in your output header:** `+ N Greptile comments (X valid, Y fixed, Z FP)`
**Greptile summary in your output header:** `+ N Greptile comments (X valid, Y fixed, Z FP)`
Before replying to any comment, run greptile-triage.md's **Escalation Detection** to choose Tier 1 (friendly) or Tier 2 (firm) reply templates.
@@ -367,7 +365,7 @@ Before replying to any comment, run greptile-triage.md's **Escalation Detection*
3. **VALID BUT ALREADY FIXED comments:** Reply with greptile-triage.md's **Already Fixed reply template**, no AskUserQuestion: what was done and the fixing commit SHA; save to both per-project and global greptile-history.
4. **SUPPRESSED comments:** Skip silently (known false positives from previous triage).
4. **SUPPRESSED comments:** Skip silently.
---
@@ -376,16 +374,17 @@ Before replying to any comment, run greptile-triage.md's **Escalation Detection*
### 1. Re-review after edits
1. A pass covers Steps 3–5, including all reviewers before fixes. Allow at most 3 fix cycles:
- Edited: increment CYCLES once. Below 3, repeat Steps 3–5 with a new
REVIEW_START. At 3, persist `converged:false` and remaining findings by filling
and saving the record below. Report nonconvergence and coverage gaps, then STOP
this invocation, without a clean summary or a fourth pass.
- No edits: fill the record below.
- Edited: increment CYCLES once, then fill and save the record below with
`converged:false`. Step 5.0 never treats this invocation's records as prior
reviews. Below 3, repeat Steps 3–5 with a new REVIEW_START. At 3, report
nonconvergence and coverage gaps, then STOP this invocation, without a clean
summary or a fourth pass.
- No edits: after a full pass, fill the record below. A fix-range pass is never
final: repeat Steps 3–5 with a full start.
2. On a repeat, execute Steps 3–5 in order. At Step 4.7, reuse only this invocation's
unchanged-input QA evidence; rerun affected probes after source, test, contract,
command or fixture changes. Reusing a probe never skips a review step.
A probe is affected when its entrypoint, dependencies, contract or replay inputs
change; if uncertain, rerun it.
unchanged-input QA evidence; rerun a probe if its source, tests, entrypoint,
dependencies, contract, command, fixture or replay inputs changed, or if uncertain.
Reusing a probe never skips a review step.
3. **Verify completed actions.** On the final zero-edit pass, reconcile this
invocation's actions with current findings. Deduplicate by structural identity
and advisory/defect kind. For a completed extraction, retain `fixed` and the
@@ -421,7 +420,7 @@ pass: report it with its reason.
- Use Step 4.6's `specialists` object unchanged, including its empty small-diff map.
If this host omits Review Army, use `specialists: {}` without claiming specialist coverage.
- Build `findings` from Step 5's combined final-pass findings (core, specialist,
- Build `findings` from Step 5's combined findings (core, specialist,
adversarial, actionable Greptile, verified exploratory QA findings) and invocation actions. Retain `fingerprint`, `severity`
(`CRITICAL|INFORMATIONAL`), `action`, and any `advisory`, `evidence_paths`,
`helper_target`. Recheck source after fixes. The logger uses `sharedLibsFingerprint`,
@@ -449,11 +448,11 @@ were skipped or this host omits Review Army). This default is not completion evi
unresolved non-advisory core defects still count in `issues_found`,
`critical`, `informational`. The logger builds trusted `review_binding` from the
validated captured branch digest, discarding caller bindings. Never invent a binding
or replace REVIEW_START at log time; finish only the final core token.
or replace REVIEW_START at log time; finish only this pass's own token.
### Report the final review
Emit one final report, merging all reviewers rather than concatenating them:
Emit one final report merging all reviewers:
1. `Pre-Landing Review: N issues (X critical, Y informational)` counts final unresolved
non-advisory defects. State INCOMPLETE if `COMPLETED` is false, even when N=0.
2. Use the checklist's action groups with confidence-tagged finding lines. Keep fixed,
+202
View File
@@ -0,0 +1,202 @@
/**
* /context-save records the commit a checkpoint describes, and /context-restore
* notices when commits landed after it, end to end.
*
* Runs the REAL save-path, stamp and head-check bash blocks extracted from the
* generated context-save and context-restore SKILL.md files (with
* ~/.claude/skills/gstack pointed at this checkout) inside fixture repos. The bug
* these pin: a checkpoint saved before work landed came back as the current plan,
* with nothing saying HEAD had moved, so restore offered to redo finished steps.
*
* Value: protects=save stamps head: and restore reports unchanged, moved, diverged, untracked and unknown commits; fails_when=the stamp drops head: or restore stops comparing it with HEAD; why_new=no test compares a checkpoint with commits made after it; seam=none; no_claim=fixture repos and a hand-written checkpoint body stand in for a live /context-save, so green does not prove the agent follows the prompt
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
// Forward slashes keep the path intact inside bash on Windows (Git Bash); a no-op elsewhere.
const BASH_ROOT = ROOT.split(path.sep).join('/');
function bashBlockAfter(skillFile: string, heading: string, contains?: string): string {
const md = fs.readFileSync(path.join(ROOT, skillFile), 'utf-8');
const start = md.indexOf(heading);
if (start < 0) throw new Error(`${skillFile}: heading not found: ${heading}`);
const re = /```bash\n([\s\S]*?)```/g;
re.lastIndex = start;
for (let m = re.exec(md); m; m = re.exec(md)) {
if (!contains || m[1].includes(contains)) return m[1].replaceAll('~/.claude/skills/gstack', BASH_ROOT);
}
throw new Error(`${skillFile}: no bash block after ${heading}`);
}
const SAVE_PATH_BLOCK = bashBlockAfter('context-save/SKILL.md', '### Step 4: Write saved-context file').replace(
'TIMESTAMP=$(date +%Y%m%d-%H%M%S)',
'TIMESTAMP="$TEST_TIMESTAMP"',
);
const STAMP_BLOCK = bashBlockAfter('context-save/SKILL.md', '### Step 4: Write saved-context file', '--stamp-checkpoint');
const HEAD_BLOCK = bashBlockAfter('context-restore/SKILL.md', '**Check whether the branch moved since the save.**', 'SAVED_HEAD');
let tmp: string;
let home: string;
function env(extra: Record<string, string> = {}): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) out[k] = v;
delete out.GSTACK_PROJECT_SLUG;
delete out.GSTACK_STATE_ROOT;
delete out.GSTACK_STATE_DIR;
delete out.CURRENT_BRANCH;
delete out.CLAUDE_PROJECT_DIR;
return { ...out, HOME: tmp, GSTACK_HOME: home, ...extra };
}
function sh(script: string, cwd: string, extra: Record<string, string> = {}) {
const r = spawnSync('bash', ['-c', script], { cwd, env: env(extra), encoding: 'utf-8', timeout: 30_000 });
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
}
function git(cwd: string, ...args: string[]): string {
const r = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 30_000 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
function commit(cwd: string, subject: string) {
fs.appendFileSync(path.join(cwd, 'app.txt'), `${subject}\n`);
git(cwd, 'add', 'app.txt');
git(cwd, 'commit', '-q', '-m', subject);
}
function repo(): string {
const dir = path.join(tmp, 'app');
fs.mkdirSync(dir, { recursive: true });
git(dir, 'init', '-q', '-b', 'main');
git(dir, 'remote', 'add', 'origin', 'https://github.com/example/head-check.git');
git(dir, 'config', 'user.name', 'gstack test');
git(dir, 'config', 'user.email', 'gstack-test@example.invalid');
commit(dir, 'baseline');
return dir;
}
/** The context-save flow: path block, the agent's Write, then the stamp block. */
function save(cwd: string): string {
const paths = sh(SAVE_PATH_BLOCK, cwd, { TITLE_RAW: 'login flow', TEST_TIMESTAMP: '20261020-090000' });
expect(paths.status).toBe(0);
const file = paths.stdout.match(/^FILE=(.+)$/m)![1];
fs.writeFileSync(file, '---\nstatus: in-progress\nbranch: main\n---\n\n## Working on: login flow\n');
const stamp = sh(STAMP_BLOCK.replace('FILE="<the FILE path printed above>"', `FILE=${JSON.stringify(file)}`), cwd);
expect(stamp.stdout).toContain('STAMPED');
return file;
}
function headCheck(cwd: string, file: string) {
const r = sh(HEAD_BLOCK.replace('FILE="<the FILE path printed above>"', `FILE=${JSON.stringify(file)}`), cwd);
expect(r.status).toBe(0);
return r.stdout;
}
beforeEach(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ctx-head-')));
home = path.join(tmp, '.gstack');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
describe('context-save records the commit a checkpoint describes', () => {
test('the stamp writes head: once, and re-stamping does not duplicate it', () => {
const app = repo();
const file = save(app);
const fm = fs.readFileSync(file, 'utf-8').split('---')[1];
expect(fm).toContain(`\nhead: ${git(app, 'rev-parse', 'HEAD')}\n`);
commit(app, 'later work');
const restamp = spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-slug'), '--stamp-checkpoint', file], { cwd: app, env: env(), timeout: 30_000 });
expect(restamp.status).toBe(0);
expect(fs.readFileSync(file, 'utf-8').split('---')[1].match(/^head:.*$/gm)).toEqual([`head: ${git(app, 'rev-parse', 'HEAD')}`]);
});
});
describe('context-restore compares the checkpoint with HEAD', () => {
test('nothing to add when no commit landed after the save', () => {
const app = repo();
expect(headCheck(app, save(app)).trim()).toBe('HEAD_UNCHANGED');
});
test('commits that landed after the save are counted and listed', () => {
const app = repo();
const file = save(app);
commit(app, 'add the login route');
commit(app, 'add login tests');
const out = headCheck(app, file);
expect(out).toStartWith('HEAD_MOVED 2\n');
expect(out).toContain('add the login route');
expect(out).toContain('add login tests');
});
test('a checkpoint whose commit left the branch history is reported, not trusted', () => {
const app = repo();
commit(app, 'work in progress');
const file = save(app);
git(app, 'reset', '-q', '--hard', 'HEAD~1');
commit(app, 'rewritten work');
expect(headCheck(app, file)).toStartWith('HEAD_DIVERGED ');
});
test('a checkpoint resumed from another branch is reported, and the prompt names that cause', () => {
const app = repo();
git(app, 'checkout', '-q', '-b', 'feature');
commit(app, 'feature work');
const file = save(app);
git(app, 'checkout', '-q', 'main');
commit(app, 'unrelated main work');
expect(headCheck(app, file)).toStartWith('HEAD_DIVERGED ');
const restore = fs.readFileSync(path.join(ROOT, 'context-restore/SKILL.md'), 'utf-8');
expect(restore).toContain('(saved on another\n branch, rebased, reset, or not fetched)');
});
test('checkpoints saved before head tracking, or with a malformed head, are never treated as current', () => {
const app = repo();
const legacy = path.join(tmp, 'legacy.md');
fs.writeFileSync(legacy, '---\nstatus: in-progress\nbranch: main\n---\n');
expect(headCheck(app, legacy).trim()).toBe('HEAD_UNTRACKED');
const bogus = path.join(tmp, 'bogus.md');
fs.writeFileSync(bogus, '---\nbranch: main\nhead: $(touch pwned)\n---\n');
expect(headCheck(app, bogus)).toStartWith('HEAD_UNKNOWN ');
expect(fs.existsSync(path.join(app, 'pwned'))).toBe(false);
const bodyOnly = path.join(tmp, 'body-only.md');
fs.writeFileSync(bodyOnly, `---\nstatus: in-progress\nbranch: main\n---\n\nhead: ${git(app, 'rev-parse', 'HEAD~0')}\n`);
expect(headCheck(app, bodyOnly).trim()).toBe('HEAD_UNTRACKED');
});
test('outside git, a save from a nested linked worktree is compared with that worktree, not the main checkout', () => {
const app = repo();
const wt = path.join(app, '.worktrees', 'side');
git(app, 'worktree', 'add', '-q', '-b', 'side', wt);
const file = save(wt);
expect(fs.readFileSync(file, 'utf-8')).toContain(`\nworktree: ${wt}\n`);
commit(app, 'main moves on');
commit(app, 'and again');
const outside = path.join(tmp, 'outside');
fs.mkdirSync(outside);
expect(headCheck(outside, file).trim()).toBe('HEAD_UNCHANGED');
commit(wt, 'worktree work');
expect(headCheck(outside, file)).toStartWith('HEAD_MOVED 1\n');
});
test('the prompt maps every status to what the user sees', () => {
const restore = fs.readFileSync(path.join(ROOT, 'context-restore/SKILL.md'), 'utf-8');
for (const status of ['HEAD_UNCHANGED', 'HEAD_NOT_CHECKED', 'HEAD_MOVED <n>', 'HEAD_DIVERGED', 'HEAD_UNKNOWN', 'HEAD_UNTRACKED']) {
expect(restore).toContain(`\`${status}\``);
}
expect(restore).toContain('`<n> commit(s) landed since this save:`');
expect(restore).toContain('under **Verify first**');
expect(restore).toContain('{commit-tracking lines, if any}');
expect(restore).toContain('When the head check printed `HEAD_MOVED`, `HEAD_DIVERGED`\nor `HEAD_UNKNOWN`, skip the rules below: every item goes under Verify first,\nwhatever its marker.');
const save = fs.readFileSync(path.join(ROOT, 'context-save/SKILL.md'), 'utf-8');
expect(save).toContain('`project_root:`, `worktree:` or `head:` yourself');
});
});
+118
View File
@@ -0,0 +1,118 @@
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 { catalogEntry } from '../lib/design-catalog';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-design-craft');
const DIRTY = `<!doctype html><html><head><style>.hero{height:100vh;transition: all .3s}</style></head><body>
<nav><a href="#">Docs</a><a href="/">Home</a><a href="/pricing">Pricing</a></nav>
<h1>Elevate your workflow seamlessly</h1><p>Trusted by Acme and John Doe. 99.99% uptime, 50% faster, 10,000+ happy users. [Needs proof: logo]</p>
<img src="a.png"><p>Oops! Something broke. Lorem ipsum.</p>
<script>window.addEventListener('scroll', () => {}); alert('hi'); showAlert(1);</script></body></html>`;
const CLEAN = `<!doctype html><html lang="en"><head><title>Ledgerline: close the books in 2 days</title>
<meta name="description" content="Close faster."><meta property="og:image" content="/og.png"><link rel="icon" href="/favicon.svg">
<style>.hero{min-height:100vh;min-height:100dvh;transition: transform .4s var(--ease-out)} .w{width:100%}</style></head>
<body><a href="#main">Skip to content</a><a href="#pricing">Pricing</a><nav><a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a></nav>
<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 });
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
}
describe('design-craft check', () => {
test('a page with every tell reports each one with its count', () => {
const counts = Object.fromEntries(checkCraft(DIRTY).map(f => [f.id, f.count]));
expect(counts).toEqual({
'placeholder-name': 1, 'placeholder-brand': 1, lorem: 1, 'marketing-buzzword': 2, 'round-number': 3, 'needs-proof': 1, oops: 1,
'scroll-listener': 1, 'transition-all': 1, 'dead-link': 1, alert: 1, 'img-no-alt': 1, 'vh-height': 1,
'missing-title': 1, 'missing-meta-description': 1, 'missing-favicon': 1,
'missing-skip-link': 1, 'missing-aria-current': 1,
});
});
test('a finished page is clean: CSS 100%, a 100vh line with its 100dvh fallback, in-page anchors, a showAlert helper and inlined Pretext are not flagged', () => {
expect(checkCraft(CLEAN)).toEqual([]);
const pretext = fs.readFileSync(path.join(ROOT, 'design-html', 'vendor', 'pretext.js'), 'utf-8');
expect(checkCraft(CLEAN.replace('</body>', `<script type="module">${pretext}</script></body>`))).toEqual([]);
expect(formatCraft([])).toBe('CRAFT_RESULT: clean');
});
test('BUZZWORDS covers every word the catalog marketing-buzzword heuristic names', () => {
const heuristic = catalogEntry('marketing-buzzword')?.heuristic ?? '';
const words = heuristic.replace(/^.*?for /, '').replace(/\.$/, '').split(/,\s*/);
expect(words.length).toBeGreaterThan(5);
for (const word of words) expect(BUZZWORDS).toContain(word);
expect(checkCraft('<p>Unlocking seamless, next gen workflows that elevate teams</p>').map(f => [f.id, f.count])).toEqual([['marketing-buzzword', 4]]);
expect(checkCraft('<p>Take the elevator to the elevation map; streamers sold here</p>')).toEqual([]);
});
test('the skip link must reach an existing id, and aria-current is required only for route navigation', () => {
const ids = (html: string) => checkCraft(html).map(f => f.id);
expect(ids(CLEAN.replace('id="main"', 'id="content"'))).toEqual(['missing-skip-link']);
const sectionNav = CLEAN.replace('<a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a>', '<a href="#features">Features</a><a href="#faq">FAQ</a>');
expect(ids(sectionNav)).toEqual([]);
expect(ids(CLEAN.replace(' aria-current="page"', ''))).toEqual(['missing-aria-current']);
});
test('attribute look-alikes, encoded markers and 100vh ordering are read correctly', () => {
const ids = (html: string) => checkCraft(html).map(f => f.id);
expect(ids('<img src="a.png" data-alt="x">')).toEqual(['img-no-alt']);
expect(ids('<p>&#91;Needs proof: logo]</p>')).toEqual(['needs-proof']);
expect(ids(CLEAN.replace('id="main"', 'data-id="main"'))).toEqual(['missing-skip-link']);
expect(ids('<style>.a{min-height:100dvh;min-height:100vh}</style>')).toEqual(['vh-height']);
expect(ids('<style>.a{min-height:100vh !important;min-height:100dvh}</style>')).toEqual(['vh-height']);
});
test('every word and name the craft section bans is one the checker flags', () => {
const section = fs.readFileSync(path.join(ROOT, 'design-html', 'sections', 'craft.md.tmpl'), 'utf-8');
const buzz = section.match(/No buzzwords: ([^\n]+)\.\n/)?.[1].replace(/"/g, '').split(/,\s*/) ?? [];
expect(buzz.length).toBeGreaterThan(10);
for (const word of buzz) expect(BUZZWORDS).toContain(word);
const names = section.match(/No placeholder people or brands \(([^)]+)\)/)?.[1].split(/,\s*/) ?? [];
expect(names.length).toBe(5);
for (const name of names) expect(checkCraft(`<p>${name}</p>`).map(f => f.id)).toEqual([/Doe/.test(name) ? 'placeholder-name' : 'placeholder-brand']);
});
test('skip link is judged by position and target; alert() counts only in script; svg text is copy', () => {
const ids = (html: string) => checkCraft(html).map(f => f.id);
expect(ids(CLEAN.replace('>Skip to content<', '>Zum Inhalt springen<'))).toEqual([]);
expect(ids(CLEAN.replace('<a href="#main">Skip to content</a>', ''))).toEqual(['missing-skip-link']);
expect(ids('<p>Set a price alert (free)</p>')).toEqual([]);
expect(ids('<button onclick="alert(1)">Go</button>')).toEqual(['alert']);
expect(ids('<svg><text>Acme</text></svg>')).toEqual(['placeholder-brand']);
});
test('copy rules read visible text only; head rules skip a fragment', () => {
expect(visibleText('<style>.a{content:"Acme"}</style><script>// John Doe</script><p>Halvorsen</p>')).toBe(' Halvorsen ');
expect(checkCraft('<section><h2>Pricing</h2><a href="/buy">Start free trial</a></section>')).toEqual([]);
});
test('the CLI prints CRAFT_CHECK lines and exits 1 on tells, 0 when clean, 2 on usage', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-'));
try {
fs.writeFileSync(path.join(dir, 'dirty.html'), DIRTY);
fs.writeFileSync(path.join(dir, 'clean.html'), CLEAN);
const dirty = run(path.join(dir, 'dirty.html'));
expect(dirty.code).toBe(1);
expect(dirty.out).toContain('CRAFT_CHECK: scroll-listener 1 ');
expect(dirty.out.trim().split('\n').at(-1)).toBe('CRAFT_RESULT: 18 issue(s)');
const clean = run(path.join(dir, 'clean.html'));
expect(clean).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n' });
expect(run().code).toBe(2);
expect(run(path.join(dir, 'missing.html')).code).toBe(2);
fs.writeFileSync(path.join(dir, 'empty.html'), ' \n');
expect(run(path.join(dir, 'empty.html')).code).toBe(2);
fs.writeFileSync(path.join(dir, 'page.tsx'), 'export const P = () => <p>Acme</p>;');
expect(run(path.join(dir, 'page.tsx'))).toMatchObject({ code: 2, out: '' });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
+8 -8
View File
@@ -9,7 +9,7 @@
"agy/gstack-plan-design-review": 140013,
"agy/gstack-plan-devex-review": 135760,
"agy/gstack-plan-eng-review": 152582,
"agy/gstack-review": 124218,
"agy/gstack-review": 124182,
"claude/design-review": 136531,
"codex/gstack-autoplan": 130628,
"codex/gstack-design-consultation": 109452,
@@ -19,7 +19,7 @@
"codex/gstack-plan-design-review": 142466,
"codex/gstack-plan-devex-review": 138649,
"codex/gstack-plan-eng-review": 155128,
"codex/gstack-review": 125122,
"codex/gstack-review": 125086,
"copilot/gstack-autoplan": 131637,
"copilot/gstack-codex": 100235,
"copilot/gstack-design-consultation": 109536,
@@ -29,7 +29,7 @@
"copilot/gstack-plan-design-review": 140884,
"copilot/gstack-plan-devex-review": 136912,
"copilot/gstack-plan-eng-review": 153307,
"copilot/gstack-review": 125489,
"copilot/gstack-review": 125453,
"cursor/gstack-autoplan": 129278,
"cursor/gstack-design-consultation": 107222,
"cursor/gstack-design-review": 142507,
@@ -37,7 +37,7 @@
"cursor/gstack-plan-design-review": 138968,
"cursor/gstack-plan-devex-review": 134591,
"cursor/gstack-plan-eng-review": 151436,
"cursor/gstack-review": 140750,
"cursor/gstack-review": 140714,
"factory/gstack-autoplan": 131562,
"factory/gstack-codex": 100655,
"factory/gstack-design-consultation": 108670,
@@ -47,7 +47,7 @@
"factory/gstack-plan-design-review": 141749,
"factory/gstack-plan-devex-review": 137960,
"factory/gstack-plan-eng-review": 154550,
"factory/gstack-review": 144529,
"factory/gstack-review": 144493,
"gbrain/gstack-autoplan": 129815,
"gbrain/gstack-design-review": 132842,
"gbrain/gstack-land-and-deploy": 117167,
@@ -67,7 +67,7 @@
"kiro/gstack-plan-design-review": 138808,
"kiro/gstack-plan-devex-review": 134431,
"kiro/gstack-plan-eng-review": 151286,
"kiro/gstack-review": 140562,
"kiro/gstack-review": 140526,
"openclaw/gstack-autoplan": 129520,
"openclaw/gstack-design-review": 131647,
"openclaw/gstack-land-and-deploy": 116757,
@@ -82,7 +82,7 @@
"opencode/gstack-plan-design-review": 140738,
"opencode/gstack-plan-devex-review": 136812,
"opencode/gstack-plan-eng-review": 153188,
"opencode/gstack-review": 142985,
"opencode/gstack-review": 142949,
"slate/gstack-autoplan": 129159,
"slate/gstack-design-consultation": 107143,
"slate/gstack-design-review": 142414,
@@ -90,6 +90,6 @@
"slate/gstack-plan-design-review": 138888,
"slate/gstack-plan-devex-review": 134511,
"slate/gstack-plan-eng-review": 151361,
"slate/gstack-review": 140656
"slate/gstack-review": 140620
}
}
+3 -3
View File
@@ -736,8 +736,8 @@ do not launch the downstream skill or open a browser.`,
// (D3A: read-on-demand doctrine, requiredReads-guarded + loading eval)
'design-html': {
skill: 'design-html',
expectedSections: ['doctrine.md', 'pretext-patterns.md', 'detector-install-offer.md'],
requiredReads: ['doctrine.md', 'pretext-patterns.md'],
expectedSections: ['doctrine.md', 'pretext-patterns.md', 'craft.md', 'detector-install-offer.md'],
requiredReads: ['doctrine.md', 'pretext-patterns.md', 'craft.md'],
scenario:
'Walk /design-html in SIMULATION — do not run bash, start servers, launch a browser, or take screenshots. Treat Step 0 as already resolved: no CEO plan, no approved mockup, no variants, no DESIGN.md, no prior finalized.html — freeform mode (Case C option D), screen name "pricing", the user wants a pricing page for a developer-tools SaaS (dark, dense, three tiers, monospace-leaning). Do NOT use AskUserQuestion — proceed with the stated assumptions. Read each pointed section before doing its step, then execute Steps 1-3: produce the implementation spec, state the chosen Pretext tier and why, and generate the complete Pretext-native HTML — include the HTML in your report instead of writing files. Stop there: skip Step 3.5, Step 4, and Step 5.',
staticInvariants: {
@@ -759,7 +759,7 @@ do not launch the downstream skill or open a browser.`,
gateAfterStop: undefined, // operational skill, no plan-mode gate
},
behavioral: 'prompt',
maxSkeletonBytes: 56_200, // measured 55,262 (2026-09-09): the detector install offer pointer + its sections-table row (the brief itself lives in sections/detector-install-offer.md); before that 54,545 for the review-cycle trust prose, the Slop Gate's Decisions-Log clause, and the blacklist header's override sentence + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10).
maxSkeletonBytes: 56_200, // measured 55,786 (2026-10-25): the craft section pointer, its index row and the Step 4 craft-check line (GSTA-92); before that 55,262 (2026-09-09): the detector install offer pointer + its sections-table row (the brief itself lives in sections/detector-install-offer.md); before that 54,545 for the review-cycle trust prose, the Slop Gate's Decisions-Log clause, and the blacklist header's override sentence + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10).
minUnionBytes: 57_500, // Phase 4 wave 4; measured union 58,682
mustContain: ["Don't make me think", "Users scan, they don't read", 'The Goodwill Reservoir', 'PRETEXT API CHEATSHEET', 'Pattern 3: Text around obstacles'],
},
+73 -1
View File
@@ -1,5 +1,5 @@
/**
* #3339: /ship Step 9 may re-review only the fix range, but a fix-range pass
* #3339: /ship Step 9 and /review may re-review only the fix range, but a fix-range pass
* can never be the last review. The fix base comes from the review log (never
* the working tree or the caller) and is the commit whose tree the fixing pass
* started from, a narrowed row is never CURRENT and carries no
@@ -214,3 +214,75 @@ describe('ship Step 9 instructions', () => {
expect(army).toContain('continue to Step 10, and only from a full pass: after a fix-range pass, run one full Step 9 first.');
});
});
describe('review Step 3 and Step 5.8 instructions', () => {
const skill = fs.readFileSync(path.join(ROOT, 'review/SKILL.md'), 'utf8');
const step3 = skill.slice(skill.indexOf('## Step 3: Get the diff'), skill.indexOf('## Step 3.4:'));
const step58 = skill.slice(skill.indexOf('## Step 5.8: Persist Eng Review result'), skill.indexOf('### Report the final review'));
const flat3 = step3.replace(/\s+/g, ' '), flat58 = step58.replace(/\s+/g, ' ');
test('the first pass starts full and only the pass after an edited pass narrows its DIFF_BASE, never Step 4.8 or other base uses', () => {
const block = step3.slice(step3.indexOf('```bash'), step3.indexOf('```', step3.indexOf('```bash') + 7));
expect(block).toContain('gstack-review-log --start review\ngit diff "$DIFF_BASE"');
expect(block).not.toContain('--fix-range');
expect(flat3).toContain('After an edited pass, start the next with `--start review --fix-range`: it prints REVIEW_START, then `FIX_BASE=<sha>`. Wherever this pass sets DIFF_BASE outside Step 4.8, use FIX_BASE instead; Step 4.8 and every other `<base>` use stay full. On refusal, start a full pass.');
expect(skill.match(/--fix-range/g)).toHaveLength(1);
});
test('an edited pass is logged before the repeat, kept out of Step 5.0, and a fix-range pass is never the final review', () => {
const edited = flat58.indexOf("Edited: increment CYCLES once, then fill and save the record below with `converged:false`. Step 5.0 never treats this invocation's records as prior reviews.");
const repeat = flat58.indexOf('Below 3, repeat Steps 3–5 with a new REVIEW_START.');
const none = flat58.indexOf('No edits: after a full pass, fill the record below. A fix-range pass is never final: repeat Steps 3–5 with a full start.');
expect(edited).toBeGreaterThan(-1);
expect(repeat).toBeGreaterThan(edited);
expect(none).toBeGreaterThan(repeat);
expect(flat58).toContain("finish only this pass's own token");
});
});
describe('review Step 3/5.8 order against the real logger', () => {
// /review's order on every pass: core start, Step 4.8 attempt start and finish, fixes in the working tree, core finish; never a commit.
const reviewRow = (fields: Record<string, unknown>) => JSON.stringify({ skill: 'review', status: 'clean', issues_found: 0, critical: 0, informational: 0, ...fields });
function reviewPass(fields: Record<string, unknown>, start: string[], fixes?: string) {
const started = bin('gstack-review-log', start);
expect(started.code).toBe(0);
const [token, base] = started.out.split('\n');
// Step 4.8: each pass's adversarial attempt saves its row before any fix and before the core row.
const attempt = bin('gstack-review-log', ['--start', 'adversarial-review']);
expect(attempt.code).toBe(0);
expect(bin('gstack-review-log', [JSON.stringify({ skill: 'adversarial-review', status: 'clean', source: 'claude', ...clean }), '--finish', attempt.out]).code).toBe(0);
if (fixes) edit(fixes);
expect(bin('gstack-review-log', [reviewRow(fields), '--finish', token!]).code).toBe(0);
return base;
}
test('a committed first pass narrows the next repeat to the uncommitted fixes, then the chain returns to a full pass', () => {
const reviewed = head();
reviewPass(fixing, ['--start', 'review'], 'export const a = 2;\n');
expect(reviewPass(fixing, ['--start', 'review', '--fix-range'], 'export const a = 3;\n')).toBe(`FIX_BASE=${reviewed}`);
expect(gitIn(repo, `diff --name-only ${reviewed}`).trim()).toBe('a.ts');
const third = narrowStart();
expect(third.code).toBe(1);
expect(third.err).toContain('started from uncommitted content');
reviewPass(clean, ['--start', 'review']);
const last = rows().at(-1)!;
expect(last.scope).toBeUndefined();
expect(reviewFreshness(last, last.wtree)?.status).toBe('CURRENT');
}, 30_000);
test('a zero-edit fix-range pass is never CURRENT even if it were logged, so the final record needs a full pass', () => {
reviewPass(fixing, ['--start', 'review'], 'export const a = 2;\n');
reviewPass(clean, ['--start', 'review', '--fix-range']);
const narrowed = rows().at(-1)!;
expect(narrowed.scope).toBe('fix-range');
expect(reviewFreshness(narrowed, narrowed.review_binding.range_end_wtree)?.status).toBe('UNVERIFIED');
}, 30_000);
test('a first pass over uncommitted work cannot be narrowed', () => {
edit('export const a = 1; // uncommitted\n');
reviewPass(fixing, ['--start', 'review'], 'export const a = 2;\n');
const r = narrowStart();
expect(r.code).toBe(1);
expect(r.err).toContain('started from uncommitted content');
}, 30_000);
});
+6 -5
View File
@@ -229,7 +229,8 @@ test('review finalization ownership: initialize invocation state and capture the
expect(flat).toMatch(/REVIEW_START for this core candidate before reading its diff/i);
expectMentions(flat, [['never', 'reading', 'before']], 'flat');
expect(flat).toMatch(/separate PASS_START tokens, not REVIEW_START/);
expectMentions(flat, [['only', 'finishes', 'final']], 'flat');
expect(flat).toContain('After an edited pass, start the next with `--start review --fix-range`');
expect(flat).toContain('On refusal, start a full pass.');
});
test('review finalization ownership: late findings use Fix-First before the bounded parent transition', () => {
@@ -240,7 +241,7 @@ test('review finalization ownership: late findings use Fix-First before the boun
'Structured approval does not waive advisory/test_stub ASK gates',
'## Step 5.8: Persist Eng Review result',
'Edited: increment CYCLES once',
'No edits: fill the record below',
'No edits: after a full pass, fill the record below',
'### 2. Fill the record',
'--finish REVIEW_START',
];
@@ -248,7 +249,7 @@ test('review finalization ownership: late findings use Fix-First before the boun
expect(positions.every(position => position >= 0)).toBe(true);
expect(positions).toEqual([...positions].sort((a, b) => a - b));
expect(flat).toContain('at most 3 fix cycles');
expect(flat).toContain('At 3, persist `converged:false`');
expect(flat).toContain('Edited: increment CYCLES once, then fill and save the record below with `converged:false`.');
const limit = flat.slice(flat.indexOf('At 3,'), flat.indexOf('No edits:'));
expect(limit).toMatch(/then stop this invocation/i);
expectMentions(limit, [['without', 'summary', 'fourth']], 'limit');
@@ -276,14 +277,14 @@ test('review finalization ownership: required native completion and optional out
expect(flat).toContain('`CONVERGED`: true only for a completed zero-edit pass');
});
test('review finalization ownership: finish only the final core token without log-time capture', () => {
test('review finalization ownership: each logged pass finishes its own core token without log-time capture', () => {
const step = skill.slice(skill.indexOf('## Step 5.8: Persist Eng Review result'));
const flat = step.replace(/\s+/g, ' ');
expect(step.match(/--finish REVIEW_START/g)).toHaveLength(1);
expect(step).not.toContain('--start review');
expect(step).not.toContain('--finish PASS_START');
expect(flat).toMatch(/never invent a binding or replace REVIEW_START at log time/i);
expectMentions(flat, [['only', 'finish', 'final']], 'flat');
expect(flat).toContain("finish only this pass's own token");
expect(step).toContain('"completed":COMPLETED,"converged":CONVERGED,"cycles":CYCLES');
});
+5 -2
View File
@@ -1316,8 +1316,8 @@ describeIfSelected('Design HTML slop gate E2E', ['design-html-slop-gate'], () =>
testConcurrentIfSelected('design-html-slop-gate', async () => {
const result = await runSkillTest({
prompt: `Read design-html-gate.md: the /design-html detector probe block and its "Slop Gate (bounded, never a loop)" step.
finalized.html in this directory is the finished page. Run the probe (--host claude), then the slop gate on finalized.html exactly as written: one surgical fix pass over the non-advisory findings, one rescan, then stop.
Write ${workDir}/gate-output.md listing what you fixed and every remaining finding as accepted-with-reason, each tagged with its [rule-id]. Do not take screenshots, do not run npx, do not scan more than twice.`,
finalized.html in this directory is the finished page. Run the probe (--host claude), then the slop gate on finalized.html exactly as written: one surgical fix pass over the non-advisory findings, one rescan. Then the craft check line that follows it, the same way, then stop.
Write ${workDir}/gate-output.md listing what you fixed and every remaining finding as accepted-with-reason, each tagged with its [rule-id] or craft id. Do not take screenshots, do not run npx, do not run either check more than twice.`,
workingDirectory: workDir,
maxTurns: 20,
timeout: CAPTURE_MS,
@@ -1332,6 +1332,9 @@ Write ${workDir}/gate-output.md listing what you fixed and every remaining findi
const scans = result.toolCalls.filter(c => c.tool === 'Bash' && /gstack-design-detect\.ts scan /.test(String(c.input?.command ?? '')));
expect(scans.length).toBeGreaterThanOrEqual(1);
expect(scans.length).toBeLessThanOrEqual(2);
const craftRuns = result.toolCalls.filter(c => c.tool === 'Bash' && /gstack-design-craft['"]?\s+[^\s;|&]*\.html\b/.test(String(c.input?.command ?? '')));
expect(craftRuns.length).toBeGreaterThanOrEqual(1);
expect(craftRuns.length).toBeLessThanOrEqual(2);
const outPath = path.join(workDir, 'gate-output.md');
expect(fs.existsSync(outPath)).toBe(true);
const out = fs.readFileSync(outPath, 'utf-8').toLowerCase();