Compare commits

..
Author SHA1 Message Date
Abdul WahabandClaude Opus 5 b291f4217e Fit the questionnaire seed into the measured comp pipeline, fix CI
The live-reference test still asserted SKILL.src.md's pre-rewrite setup
phrasing, which failed every pipeline on main and here and blocked the
generated-output sync; the assertions now match the current text. The
sync list gains .veto, added in #675 but never listed. Two seed-world
seams in the comp-led phases close: DESIGN.md's named faces ride into
font-match as candidates instead of losing silently to a metric
lookalike, and a sampled spec color within render noise of a recorded
hex builds at the recorded value. The cue also stops claiming type
character it cannot carry; comp prompts name the recorded faces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:55:54 +05:00
Abdul WahabandClaude Opus 5 6efe4a43c8 Restore generated provider output to main's state
This PR stays source-first per 187e589e: the mid-rebase regeneration
swept in provider dirs the sync workflow owns, .veto included, which
main's CI produces after merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:21:53 +05:00
Abdul WahabandClaude Opus 5 66632b050a Map main's two new detector rules into the hook discipline groups
organic-clip-path and buried-raster landed on main after this branch's
hook-rule-presentation map; both are produced-imagery rules, grouped
under Imagery beside shape-assembled-illustration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:18:14 +05:00
Abdul WahabandClaude Opus 5 93218c543a Regenerate provider output and hook rules after rebase onto main
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:13:02 +05:00
Abdul Wahab 5b9974a50a update 2026-09-01 10:09:45 +05:00
Abdul Wahab 07ebdfd5b1 update 2026-09-01 10:09:45 +05:00
Abdul Wahab 35de8595b6 update 2026-09-01 10:09:45 +05:00
Abdul Wahab 82b03772e4 update 2026-09-01 10:09:03 +05:00
Abdul Wahab f775f9e585 Fix the three design-context findings from the command test pass
- edit_request replies now apply attached --answers/--context through the
  session, as visual-cues.md's edit-loop contract already promised
- the bundle's per-file cap rises to 8 MiB so the chosen cue image survives
  export/import (the 20 MiB bundle ceiling stays)
- the questionnaire refuses to start without visual-cues/cues.json instead
  of serving a broken palette screen

AI-assisted: implemented by Cursor Composer executing plan-27.
2026-09-01 10:09:03 +05:00
Abdul Wahab 618dcae7bf udpate 2026-09-01 10:09:03 +05:00
Abdul WahabandClaude Fable 5 065bd4f3ca Trim dead code and tighten the branch to surgical scope
- palette-picker.js: drop the superseded APP/DOCS/INDEX copy tables, the
  disabled font-rail hoist machinery, LOREM.caption, and an orphan marker
- design-context.css + dcx-hierarchy.css: drop the demo-era specimen,
  radius, and spacing-bar rule groups nothing renders
- index.astro: drop an unreferenced boards const
- de-export module-internal names (store, bindings, portability,
  visual-cues); remove picker-server's unused doc-session kill switch and
  document --doc in its help
- palette.mjs: realpath the entry guard so symlinked installs still run
  the CLI (same guard as visual-cues.mjs)
- revert serve-question.mjs copy ride-along; drop the plan/ gitignore
  entry; dedupe a build-picker comment; file design-context with the
  system category entries; alphabetize IMPECCABLE_SUB_COMMANDS
- new-work.md: drop a same-screen restatement of the comp/world ranking
- CLAUDE.md: correct the picker gate baseline to the measured 606 and
  explain the per-element multiplication; the trims removed the two
  dead-block font findings
- DESIGN-CONTEXT-ARCHITECTURE.md: list portability.mjs in the code layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:08:22 +05:00
Abdul WahabandClaude Fable 5 6c2a79891c Remove stale design-context-categorization docs
PROVENANCE.md specs a design.json questionnaire/producedPaths schema that
shipped nowhere and contradicts the store DESIGN-CONTEXT-ARCHITECTURE.md
documents; design-context.html is an 89KB frozen prototype snapshot with
no inbound references (the port to picker/ is complete, and git history
keeps it); the README only indexed the two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:08:03 +05:00
Abdul WahabandClaude Fable 5 187e589ef7 Drop generated provider output from the feature branch
The 17 harness dirs and plugin/ revert to main's state: feature PRs are
source-first per CLAUDE.md, the post-merge sync workflow regenerates them,
and the committed snapshot was stale by one commit (its context.mjs
predated the DESIGN_CONTEXT directive). Also add .agent and .github/agents
to the sync workflow's GENERATED_PATHS so those two outputs stop relying
on manual release syncs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:08:03 +05:00
Abdul Wahab 0aec856f45 update 2026-09-01 10:08:03 +05:00
Abdul Wahab 89c842e5f4 update 2026-09-01 10:07:07 +05:00
Abdul Wahab a5cf9266e6 Add new design context document 2026-09-01 10:06:21 +05:00
Abdul Wahab bba73a2283 update 2026-09-01 10:05:59 +05:00
Abdul Wahab 33ea98ae3e update 2026-09-01 10:05:00 +05:00
Abdul Wahab 7e9a873499 udpate 2026-09-01 10:05:00 +05:00
Abdul Wahab 4199ad4bc4 update 2026-09-01 10:05:00 +05:00
Abdul WahabandCursor 5400675585 Fix dead links in PROVENANCE.md after docs trim
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul WahabandCursor 3202a3aab5 Trim design-context-categorization docs to prototype, provenance, and index
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul WahabandCursor 4f069a39ef Untrack the picker gallery toolchain; tmp is scratch again
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul WahabandCursor ba999bd799 Remove unreferenced mode illustration binaries
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul Wahab 6343ec689b update 2026-09-01 10:04:59 +05:00
Abdul Wahab 8b6bbaef3d update 2026-09-01 10:04:59 +05:00
Abdul Wahab 6cfeb7d374 update 2026-09-01 10:04:59 +05:00
Abdul Wahab bccbc98c9b update 2026-09-01 10:04:59 +05:00
Abdul Wahab 07c8d43b5d update 2026-09-01 10:04:59 +05:00
Abdul Wahab 2fd4c86794 update 2026-09-01 10:04:59 +05:00
Abdul Wahab f68c27a13f update 2026-09-01 10:04:59 +05:00
Abdul Wahab 80e26259e1 update 2026-09-01 10:04:59 +05:00
Abdul Wahab 40ff2b6eda updated 2026-09-01 10:04:59 +05:00
Abdul Wahab be7d4d9242 update 2026-09-01 10:04:59 +05:00
Abdul Wahab cc8d992d8b update 2026-09-01 10:04:59 +05:00
Abdul Wahab fa5d7474b3 update 2026-09-01 10:04:59 +05:00
Abdul Wahab 6d0dc0287d update 2026-09-01 10:04:59 +05:00
Abdul Wahab 6fbf5b952f update 2026-09-01 10:04:59 +05:00
Abdul WahabandCursor 1537368b8c Make the questionnaire ask_instruction sites sentence-initial
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul WahabandCursor 8b4507db53 Regenerate bun.lock after rebase onto main
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul WahabandCursor a256b6e42b Scope the comp's authority and bind builds to the recorded world.
The comp rules composition; the world rules material. Adds the authority
ranking to new-work.md, rewrite and token discipline to the craft floor,
token and motion evidence checks to the finish reviewer, derived text
inks to the questionnaire seed, and stale-seed disclosure at attended
stops. From the ekihana run's confirmed drift.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:03:52 +05:00
Abdul WahabandCursor 9fbda768e6 Launch the picker in the harness browser first.
Name Cursor browser_navigate as the in-IDE viewport, then other in-app tools, then the system opener. Same ladder in serve-question stdout so agents stop using macOS open while the IDE browser works.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 72fec2849c Dedupe the seed interview against the browser questionnaire.
On the questionnaire path, chat now asks only assets, named references, and one anti-reference; color, type, and motion stay in the picker. Interview-only still asks all five. visual-cues.md drops the retired Q-keys and re-anchors font pairs from surfaces, references, and assets.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 44aa29fb63 Constrain dealt font pairs to Latin-script faces and English copy.
CJK and other non-Latin families render the picker's previews and scale sheets wrong until language-aware pairs exist; the fallback deck comment matches the new visual-cues Step 6 rule.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor c582fd4699 Require verbatim skip quote for seed pause and fix hidden doc shell.
Direction rolls on projects without DESIGN.md now refuse until the user accepts document --seed or re-runs with --seed-declined carrying their actual skip answer; IMPECCABLE_SEED_DECLINED=1 remains the unattended harness escape. Hide the design context shell when [hidden] so it does not reserve viewport space. Extend concept-seed tests for the seed gate.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 5758e4c6bd Recommend the seed questionnaire before rolling a direction on world-less projects
When new-work reaches world creation and DESIGN.md is missing, pause and
offer /impeccable document --seed (skippable) so the visual world can come
from the user's own choices instead of an assigned roll.

AI-assisted change (Cursor agent), directed by the maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor b56ca30fcd Restore dry-run guard around the release build step
The picker staleness check added on this branch had replaced main's
dry-run branch, so `--dry-run` executed the real build. Keep the picker
hash check but only on a real run, matching main's dry-run contract.

AI-assisted (agent-implemented, maintainer-directed).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 601a7a8755 Enrich design context Product, Brand, and Color sections.
Add product conversion/principles/surface copy and leading-surface badge; Brand voice and principles blocks; Color ink chips plus as-dealt/edited provenance tags. Extend visual-cues schema. Polish font-pair preview cells 24–25 and sync gallery replica.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor b4cfe626ff Polish Portfolio type-board captions for font-pair preview cells 19–20.
Add gallery avatars, tighten the experience index layout, and sync the improved previews gallery replica.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor a975a8bcc1 Fix font-pair preview polish for cells 22–24 and sync gallery experiments.
Tighten Landing, App UI, and Docs type-board copy, color, spacing, and gallery experiments so the improved preview gallery matches the live picker.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor da94340e9b Scale Docs board carry bars and fix Portfolio preview cells 19–21.
Board-stage read artboards get thicker bar weight in picker.css; the gallery replica adds matching experiments plus desktop caption circles for Portfolio restrained/committed/drenched and drenched color roles on cell 21.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 457c2d877c Improve strategy previews for Docs wireframe weight and App UI color roles.
Boost docs strategy-stage line and dot weight to match App UI legibility, keep phone artboard lines proportional via pv-text scaling, and extend the gallery replica experiments for cells 14–18.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 470b655096 Number questionnaire preview gallery cells 1 through 75.
Add sequential labels to figcaptions and iframe titles in the gallery builder, then regenerate baseline and improved HTML.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor d886003879 Track questionnaire previews gallery and color strategy experiments.
Un-ignore tmp/questionaire for the gallery toolchain, baseline HTML, and the improved replica with drenched, committed, and full-palette color fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul Wahab 55617ac5d8 update 2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 60130bcea9 Add strategy-band contrast alert and expand design context fields.
Mirror the screen 02 neutral contrast badge on the strategy strip, share alert
painting between both strips, and render richer PRODUCT.md context in the
design document with matching visual-cues schema guidance.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor ae1c632594 Improve picker finish screen, neutral contrast alert, and DCX tile rest states.
Replace the neutral palette hint warning with an on-swatch badge, simplify the finish loader screen, and keep typography/material vignettes ink-only until hover.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 6d684d2d6a Mute design-context tile accents at rest like Color.
Product, Brand, Typography, and Material vignettes now use anim-join so their gold accent reads at 25% opacity before hover and brightens on hover, matching the Color tile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor adabd62011 Move type scale and layout ahead of the five-card hub.
Type scale and layout return to the linear path before configure; the hub shrinks to five cards with updated navigation. Also fixes palette tooltip clipping, drops the design-context masthead and icon hub notes, and locks the mosaic shell to the viewport without overflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 0b867f0e25 Tighten configure hub pill sizing.
Pills shrink to content width and use slightly more padding so values read as compact chips rather than full-width rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor b629daef01 Refine configure hub cards with pills, notes, and layout polish.
Add the intro lede, per-row patina answer marks with DOM-sourced notes, mode-tile hover, and tighter card typography including inline pills and title arrows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 80211f3364 Add configure hub after the font pair screen.
Let questionnaire runs finish early or jump into the seven remaining questions from a summary card grid, with hub navigation and per-answer summaries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 5dffc569ff Warn on screen 02 when the palette neutral fails contrast
Add neutralContrastIssue() with a 7:1 ink floor and a 3:1 primary
separation floor, re-run it from renderPreview(), and paint the palette
hint in vermilion while the working neutral is bad. The warning clears
as soon as the neutral is fixed.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 38b7029184 Build out the layout screen's three compositions
Each option now draws its own page rather than a shared field with a
different column count. Simple grid holds strict paired rows at equal
image measures, balanced alternates wide images against captions, and
freeform crops a lead, adds an upper portrait, and spills the card row
into a set of prints with their own captions and marks. All three carry
handset sequences at the reference measures.

Behind them: one shared alignment-grid component mounted on every body
that asks for it, code-native nav primitives placed at the reference
measures rather than left to their grids, a project image-tone contract
the boards key their fills on, and footer link groups.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor a5083e23e1 Default the color question to full palette and color the type boards
The default lives in each surface's fallback, not in the markup: show()
checks whichever row the fallback names every time a tab opens, so the
checked attribute on the option never survived. Persuade, Operate, and
Read now open on Full palette. Experience keeps Restrained because four
working colors are not on its allow list, and a default pointing at a
blocked row would leave that tab with nothing selected.

The font screen's boards also take the chosen palette under Restrained,
which is the one answer no strategy block rewrites. They were drawing a
page the palette had never reached while screen 03 kept its color under
the same answer.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor a34c9828aa Add the layout screen's portfolio board and default color to full palette
The index board grows the parts only a portfolio has: a project rail, a
support row, and a pagination rail restated at the foot of the handset
stack. They mount behind a prop rather than for every board, because the
motion screen reads the same index body through :nth-of-type and a stray
sibling would shift its scenes. Their sheet loads after the boundary,
corner, and depth sheets so it wins ties on source order.

The color strategy question now opens on Full palette.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 05f228728e Fix uneven gallery squares and size the phone section copy
The card row's squares came out three different widths: centering the
cards in the gallery shrank each one to its own label, and the square is
a share of its card. The cards take their column again and their contents
do the centering.

On the handset the section title and its paragraph were the smallest type
on a board that has room for more, so both take a step up.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor e2048614b5 Improve font pair and color strategy questionnaire screens
Font pair screen: drop the phone footer from the type boards, widen the
hero picture, split the editorial band evenly with the card row, center
the desktop cards, and fix the label line heights. The handset takes a
26px headline, stepped section spacing instead of one uniform gap, and a
single centered proof item between chevrons. Every word slot on these
boards now draws lorem rather than product copy, so a pair is judged on
its setting.

Color strategy screen: one-line question, an options panel that hugs its
rows, a preview whose height is free again, and the CTA stack in the same
place the font screen keeps it.

Also nudges the type scale specimen's top and left padding.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul Wahab 7d4e1bff0c update 2026-09-01 10:02:40 +05:00
Abdul Wahab 3214ead9c2 update 2026-09-01 10:02:40 +05:00
Abdul Wahab b12b67d344 update 2026-09-01 10:02:40 +05:00
Abdul Wahab cadb70a09e update 2026-09-01 10:02:40 +05:00
Abdul WahabandCursor bf8e3ee0c2 Add standalone design context prototype and WHAT spec.
Ship the browsable mosaic document as an isolated HTML file and document the build intent in WHAT.md so the categorization work has a concrete UI target.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul Wahab c06bf3f558 Add design doc 2026-09-01 10:02:40 +05:00
Abdul Wahab ecadbc9556 update 2026-09-01 10:02:40 +05:00
Abdul Wahab 4c1fbbc7c0 update 2026-09-01 10:02:40 +05:00
Abdul Wahab 4b0c4a5e80 update 2026-09-01 10:02:40 +05:00
Abdul Wahab b4110667c9 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 4d919ec519 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 3e67293747 update 2026-09-01 10:02:39 +05:00
Abdul Wahab ca4f39e01d update 2026-09-01 10:02:39 +05:00
Abdul Wahab e0ddad3198 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 24cea3bd60 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 6b30ce5d02 update 2026-09-01 10:02:39 +05:00
Abdul Wahab cf80c3df1a update 2026-09-01 10:02:39 +05:00
Abdul Wahab 29598150b4 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 2feb2bdb9d update 2026-09-01 10:02:39 +05:00
Abdul Wahab 035e191ce5 udpate 2026-09-01 10:02:39 +05:00
Abdul Wahab 2f491f54c3 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 1be3f1145b Update 2026-09-01 10:02:39 +05:00
Abdul Wahab 1d192d1f39 Update 2026-09-01 10:02:39 +05:00
Abdul Wahab ba9b1cd8c3 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 8489467b99 Update 2026-09-01 10:02:39 +05:00
Abdul Wahab 0db6186617 Update 2026-09-01 10:02:39 +05:00
Abdul Wahab 154a84df56 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 336ee34b73 update 2026-09-01 10:02:39 +05:00
Abdul Wahab f3c26f1f42 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 5602cdfc7e update 2026-09-01 10:02:39 +05:00
Abdul Wahab 7af6e831fb update 2026-09-01 10:02:39 +05:00
Abdul Wahab 83b4fbcb5d update 2026-09-01 10:02:39 +05:00
Abdul Wahab e9f36ad9d8 update 2026-09-01 10:02:39 +05:00
Abdul WahabandCursor 28abe9a071 Improve strategy preview wireframes on desktop and phone.
Restrained mode now reads the palette like screen 02: primary-tinted
headline, tertiary eyebrow, secondary gradient hero, outlined ghost
CTA, and four-role gallery accents. Artboards lift with a soft shadow;
the phone gains surface bars and a second copy line. Each strategy
block sets matching image-gradient stops so committed, full palette,
and drenched transitions stay depthful instead of flat fills.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul Wahab 6444deb361 update 2026-09-01 10:02:39 +05:00
Abdul WahabandCursor 9af6fc1cb7 Restyle font pair cards after fonttrio's pairing anatomy.
The heading family name sits big in its own face; one sentence in the
body face names its partner in bold and carries the why line. The
sentence demonstrates the body font, so the Heading/Body labels go.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul WahabandCursor 51ce043130 Rework typography screen to match the questionnaire pattern.
Screen 04 drops the kicker and the text eyebrow, renders six large
scrollable specimen cards (heading and body each labeled and set in
their own face), and reuses screen 03's dual desktop-plus-phone
artboards with both hero copy blocks typeset live. The type hero
widens its copy column so wide display faces clear the image block.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul WahabandCursor 626bfbb0a8 Add color strategy screen and font-pair pipeline step.
Screen 03 lets operators compare restrained through drenched strategies on the dual-artboard preview; visual-cues now composes fonts.json before launch, and the picker server exposes it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul WahabandCursor f7751fad0c Improve palette preview: dual artboard, drop register wiring.
Show landing and app wireframes side by side with palette-driven accents instead of PRODUCT.md register and /context.json, matching v4's per-surface modes. Assisted-by: Cursor AI agent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul WahabandCursor d64909decf Reconcile questionnaire branch with main's site split and redesign
Post-rebase repairs after main moved 226 commits (site/ split to the
private impeccable-site repo, DESIGN.md spec now eight sections, register
extraction replaced by extractSectionValue, seed mode rewritten):

- document.md: transplant this branch's seed pipeline (assets, interview,
  visual cues, picker) onto main's rewritten file; adopt the canonical
  section vocabulary. The interplay with new-work's workshop is left for
  PR review.
- picker: vendor kinpaku tokens/kit CSS, hero art, and favicon under
  picker/ (site/ no longer exists here); drop the sharp resize step and
  ship the compressed JPEG directly; declare astro/sharp-free deps.
- picker-server: read the register via extractSectionValue('Register')
  since extractRegister is gone from context.mjs.
- palette.mjs / utils.js / test-suites.mjs / package.json / release.mjs:
  rebuild as main's version plus this branch's additions, undoing hunks
  the -X theirs replay had clobbered.
- craft.md / shape.md: reset to main (this branch's net change was zero).
- progress bar: animate transform instead of width (design hook finding).

Validation: bun run build green (prose validators clean), bun run test
198/198, picker-server suite 7/7.

AI-assisted (agent-implemented, maintainer-directed).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul WahabandCursor c19c349fc6 Add screen 02 palette picker: cue deck, sampling rings, band tools
Deck of cue cards + seed palettes with scroll-snap browsing, draggable
color-sampling rings with loupe, four-band panel (copy hex+OKLCH, tint
strip, native custom color, card-local reset), /palettes.json route.

AI-assisted (agent-implemented, maintainer-directed).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:05 +05:00
Abdul Wahab ba7f3c8891 Add browser questionnaire foundation
Prepared with AI assistance.
2026-09-01 10:02:05 +05:00
Abdul Wahab 1f20997a6d Revert "Default the gemini provider to Nano Banana Pro."
This reverts commit 666d21dab4755efcf27f0aa34954ca91d8f67e39.
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 72ba69e3a4 Default the gemini provider to Nano Banana Pro.
gemini-3-pro-image is Google's strongest image model; IMAGE_GEN_MODEL still overrides for the cheaper flash tier.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 98ab04b94b Improve visual cue heroes for color real estate and Gemini safety.
Ban hex codes from image prompts to stop Nano Banana swatch strips, require crisp deep focus and set-dressed surfaces inside close product-world scenes, and add wordless and real-estate inspection retries. The Gemini wrapper now converts JPEG bytes to PNG and infers AQ.-prefixed keys.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor c23c587ed5 Add provider-flexible image API path and dedupe visual cue compiles.
Ship image-gen.mjs with bfl and gemini backends plus custom delegation, document the keyless flow in image-api.md, and tighten seed Step 4 so missing keys ask once and opt-out falls back to text-only seeding. Compile now removes in-folder hero intermediates so each cue keeps one PNG.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor a0f7fae4e2 Speed up visual cues wave: drop smoke test, slim spawns, brief-only reads.
Replace preflight generation with harness tool presence, move territories into brief.md, forbid specialist repo reads, and document native tool output paths.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor af01134825 Drop artifact sheets from visual cues for faster hero-only pipeline.
Each specialist now generates one hero image instead of a second chroma-key sheet, and the compile step replaces crop with a simpler cues.json shape.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul Wahab 93e0571f20 update 2026-09-01 10:00:42 +05:00
Abdul WahabandCursor f8981c61dd Switch artifact sheets to per-cue chroma keys kept in crops
Drop the two-pass matte pipeline in favor of one chroma-key sheet per
cue: each specialist picks the farthest-hue key, stages larger artifacts
with shadowless crisp edges, reports CHROMA in its reply, and crop records
the hex in cues.json for browser keying downstream.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 7593ec9d93 Matte transparent artifacts from two sheet passes; require square, well-lit generations
Artifact crops now ship with real alpha: each sheet is generated twice
(cream, then black via image edit) and fused by a new difference-matting
command, sidestepping models' inability to emit alpha and the chroma
key's spill. Crop and matte reject non-square inputs so squareness must
come from the generation call's 1:1 parameter, and the hero prompt now
demands bright, generous light so palettes stay sampleable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor a84002d69b Fix cross-subagent image collisions on shared output filenames
A live run shipped one hero under three slugs: parallel subagents all
wrote the generation tool's output as a generic hero.png in the shared
project assets folder, then copied and referenced each other's files.
Spawn tasks now require slug-based output filenames, using only the
tool-reported path, a verify-your-own-files check before reporting, and
the parent hashes all heroes before cropping and re-spawns duplicates.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 2ae67485fb Stage hero palettes as large color fields, framed close
Live cues rendered the good colors as slivers: the hero prompt asked for
an atmospheric scene with a 60% neutral ground and a small accent. The
skeleton now frames tight on the subject and stages each color as a
large unbroken field (neutral about half, primary a continuous third,
tertiary small but legible), with the rationale in the fill rules.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 6e4dcef80c Fix: em dashes in the territory example set
AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 0473c9f7e0 Give every territory a hue ground and gate image gen on self-critique
Investigation of a live run showed five of six palettes converging on the
brief's deep-teal anchor: territories were scenic but hue-silent, and the
anchor reached every spawn unscoped. Territories now must close on a hue
ground (no two in one family), the anchor is owned by exactly one
territory with everyone else told to leave it, spawn tasks may not be
compressed, and each subagent critiques its palette and concept against
the rules before generating.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 71f683ddb6 Harden the parallel mandate and keep six cues on the serial path
Name the common spawn tools, require all six spawns in a single message,
and call out spawn-await-spawn as the serial-loop failure it is. The
no-subagent fallback now runs all six personas instead of four, so the
user gets six cues on every path.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor a6d818b31c Assign territories upfront and run the studio as one wave
Drop the sequential palette chain: the parent carves six visual,
emotional, or strategic territories from the brief, then all six
personas spawn in one parallel batch, each composing inside its own
territory and generating both images in the same task.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 4bf39fe09d Split the studio into a palette chain and an image wave
Personas now compose sequentially, each shown the color territories the
earlier personas claimed, replacing the similarity script and peer
critique as the uniqueness mechanism. Generation moves to a parallel wave
of dedicated image-prompt specialists that stage the finished palettes.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul Wahab e237df2b11 Revert "Replace peer critique with a similarity convergence loop"
This reverts commit 57e45628f39149cf3700da4a431f66a595754d1d.
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 24aaabdd26 Replace peer critique with a similarity convergence loop
Drop the six-way critique wave from the visual-cues studio: after the
compose wave, the OKLab similarity script loops with revision waves for
flagged personas only (capped at three) until every verdict is clear,
then the generation wave stages the settled palettes.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 54a5cbd379 Unify studio flow on wave numbering
Drop the parallel "round" vocabulary from the visual-cues studio so one
counting system covers the three spawn waves; the subagent template calls
the palette a first draft instead.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor a0fd0475f5 Compose cue palettes with six competing persona subagents
Replace the single-mind palette composition in the visual-cues pipeline
with a three-wave studio: six persona specialists compose in parallel, a
new OKLab similarity command plus peer critiques build each persona's
scorecard, then each revises and generates its hero and artifact sheet.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor ec83174eb2 Generate visual cues for document seed color picks
Document seed mode's palette pick used to ask the user to imagine colors
from words. Add a visual-cues pipeline: compose four-hex palettes from the
brief (mood, references, PRODUCT.md), generate a hero scene plus a cream
artifact sheet per concept, crop the sheet into isolated artifacts, and
write cues.json so the user can pick a direction by eye.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul Wahab f92adeab17 Revert "Recognize the seed-collected image key in shape and craft."
This reverts commit 70eddbe2742948e1b8e95a3ef515de4e85b55a80.
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 65ac2a5f13 Recognize the seed-collected image key in shape and craft.
A stored IMAGE_GEN_API_KEY in .impeccable/.env now counts as image generation being available for shape probes and the craft codex flow. The key ask itself stays exclusive to document seed mode; shape and craft still never prompt for APIs or tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 840165463c Add editor voice and capability-gated visual cues to document seed.
Seed questions now avoid skill vocabulary (mirroring init.md's magazine-editor line), and a new step offers 2-4 generated palette cue images: native image_gen when the harness has it, otherwise an opt-in IMAGE_GEN_API_KEY in gitignored .impeccable/.env.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 94ed8d7d45 Extend document seed mode to gather brand assets first.
Ask for logos, reference images, and moodboards before the five-question interview so the first DESIGN.md seed can ground taste selections in real visual context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:41 +05:00
github-actions[bot] 94b7f34f6e Sync generated provider output 2026-09-01 04:23:23 +00:00
Paul BakausandGitHub 6fe900dbb4 Improve incumbent evidence and direction fusion (#689)
Prefer committed visual goldens when the app cannot run and make assigned-system translation explicit when a pinned register conflicts with literal materials.\n\nAI-assisted: prepared with Codex under @pbakaus direction.
2026-09-01 00:22:49 -04:00
github-actions[bot] 2c8816a3ce Sync generated provider output 2026-09-01 04:01:22 +00:00
Paul BakausandGitHub 632912b5ae Fix live script response encoding (#690)
Declare UTF-8 on the generated live and detector JavaScript responses and cover both endpoints with integration assertions.\n\nAI-assisted: prepared with Codex under @pbakaus direction.
2026-09-01 00:00:51 -04:00
Paul BakausandGitHub 85d82c0afc Fix PRODUCT schema drift (#688)
Update the public init description and migrate the repository product record to the current stamped schema without changing its established product truths.\n\nAI-assisted: prepared with Codex under @pbakaus direction.
2026-09-01 00:00:43 -04:00
github-actions[bot] 5b585c0885 Sync generated provider output 2026-09-01 03:49:18 +00:00
Paul BakausandGitHub 187790826d Fix concept seed under symlinked installs (#686)
Resolve the CLI entry path through realpath and cover linked skill directories on Unix and Windows junctions.

AI-assisted change: implemented with Codex under @pbakaus direction.
2026-08-31 23:48:43 -04:00
github-actions[bot] 40b5151237 Sync generated provider output 2026-09-01 03:28:21 +00:00
Paul BakausandGitHub 1bcdf80f91 Fix radius var fallback detection (#687)
Strip closing var() parentheses before resolving fallback radius tokens, preserving on-scale values and actionable ignore values.

AI-assisted change: implemented with Codex under @pbakaus direction.
2026-08-31 23:27:49 -04:00
github-actions[bot] d3f4cc8f4b Sync generated provider output 2026-09-01 02:43:18 +00:00
Abdul WahabandGitHub 5211bdf4b1 Fix: critique snapshot close path (#660)
Preserve critique trend history while closing the exact processed snapshot. Track target identity and content freshness for local files and URLs, isolate colliding streams, and prevent legacy snapshots from resurfacing after a modern close.

Validated with 50 focused tests, a clean 17-provider build, 834 core tests, passing GitHub CI and Cursor Bugbot, Greptile 5/5, and zero unresolved review threads.

AI-assisted maintainer repair: implemented and reviewed with Codex under @pbakaus direction.
2026-08-31 22:42:44 -04:00
Abdul WahabandGitHub bfafc7dbcb Fix plugin script path resolution (#535)
Makes plugin instructions execute the installed plugin copy, safely quotes paths containing spaces, removes the broad Node pre-approval, and ships guarded generated plugin output.\n\nAI-assisted maintainer conflict resolution, review, and validation by Codex under maintainer direction.
2026-08-31 20:16:04 -04:00
github-actions[bot] 0bc8643b51 Sync generated provider output 2026-09-01 00:11:15 +00:00
Abdul WahabandGitHub 4fb66a15e7 Fix prompt embedding for install paths with spaces (#676)
Resolves the embed helper with a filesystem path, reports fallback accurately, and adds regression coverage while leaving generated harness output to the post-merge sync.\n\nAI-assisted maintainer repair, review, and validation by Codex under maintainer direction.
2026-08-31 20:10:41 -04:00
299 changed files with 49470 additions and 570 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .agents/skills/impeccable/scripts/...` command in this skill and its references, and `.agents/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -97,7 +97,7 @@ Codex failure accounting: final Run Notes must include target slug, ignore list,
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Codex final-answer note: `$impeccable critique` produces a report artifact, so the final chat response should intentionally exceed the usual concise close-out style. Do not title the final response "Critique Summary" unless the user explicitly asked for a summary.
@@ -219,7 +219,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .agents/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .agents/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .agents/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .agents/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .agents/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `$impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -91,7 +91,7 @@
import crypto from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -16,8 +16,9 @@
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs latest <slug> [--json]
* node critique-storage.mjs trend <slug> [limit]
* node critique-storage.mjs close <resolved-target> <snapshot-file>
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Return an exact content fingerprint for a local file target. URLs and
* non-files return null because their content is not available here.
*
* The fingerprint deliberately describes bytes, not Git state or mtimes:
* critique often assesses an uncommitted file, and a later polish run should
* inherit that backlog when the bytes are unchanged regardless of staging.
*/
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
if (!target || /^https?:\/\//i.test(target)) return null;
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
}
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
if (!target || typeof target !== 'string') return null;
if (/^https?:\/\//i.test(target)) {
try {
const url = new URL(target);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.origin}${pathname}`;
} catch {
return null;
}
}
const filePath = resolveLocalTargetPath(target, { cwd });
return filePath ? `file:${filePath}` : null;
}
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
const filePath = resolveLocalTargetPath(target, { cwd });
if (!filePath) return null;
try {
if (!fs.statSync(filePath).isFile()) return null;
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
} catch {
return null;
}
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
const contents = `${front}\n${body.trim()}\n`;
// A second critique can finish in the same UTC second. Use exclusive
// creation and a fixed-width suffix so concurrent writers cannot replace
// history and lexical ordering still keeps collision entries newest.
for (let collision = 0; collision <= 9999; collision += 1) {
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
try {
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
return filePath;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
}
}
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
}
function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
} else if (value === 'true' || value === 'false') {
value = value === 'true';
}
out[key] = value;
}
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
/**
* Return snapshot files matching `suffix`, sorted oldest → newest.
*/
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
function readSnapshot(filePath) {
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
function snapshotTargetIdentity(snapshot) {
const targetPath = snapshot?.meta.target_path;
return snapshot?.meta.target_identity
|| (targetPath ? `file:${targetPath}` : null);
}
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
}
function readNewestSnapshotForIdentity(
slug,
targetIdentity,
{ cwd = process.cwd() } = {},
) {
const matches = listSnapshots(`__${slug}.md`, cwd)
.map(readSnapshot)
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
return matches.at(-1) || null;
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
const latest = readNewestSnapshot(slug, { cwd });
return latest?.meta.closed === true ? null : latest;
}
/**
* Mark one exact snapshot closed without deleting the score history consumed
* by `trend`. Exact identity matters: a newer critique may land after polish
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
* may be the absolute path returned by readLatestSnapshot() or the basename
* emitted by `latest --json`. Returns the path marked closed, or null.
*/
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
const dir = path.resolve(getCritiqueDir(cwd));
const snapshotPath = path.isAbsolute(snapshotFile)
? path.resolve(snapshotFile)
: path.resolve(dir, snapshotFile);
const filename = path.basename(snapshotPath);
if (
path.dirname(snapshotPath) !== dir
|| !SNAPSHOT_FILENAME.test(filename)
) return null;
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) return null;
snapshot = readSnapshot(snapshotPath);
} catch {
return null;
}
if (!snapshot || snapshot.meta.closed === true) return null;
const closedBody = snapshot.body.replace(
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
'$1\nclosed: true$2',
);
if (closedBody === snapshot.body) {
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
}
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
return snapshot.path;
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
const identifiedSlugs = new Set(
snapshots
.filter((snapshot) => snapshotTargetIdentity(snapshot))
.map((snapshot) => snapshot.meta.slug),
);
const latestByTarget = new Map();
for (const snapshot of snapshots) {
if (!snapshot?.meta.slug) continue;
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
// one. Keep each known identity's latest open/closed state independent so
// closing one target cannot hide another target's live backlog. Once a
// slug has any identity-aware snapshot, its older legacy records are no
// longer independently routable and must not resurface as zombie work.
const targetIdentity = snapshotTargetIdentity(snapshot);
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
latestByTarget.set(streamKey, snapshot);
}
return [...latestByTarget.values()]
.filter((snapshot) => snapshot.meta.closed !== true)
.sort((a, b) => a.path.localeCompare(b.path))
.at(-1) || null;
}
/**
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
// callers never have to run the slug step separately. Anything containing a
// path or URL marker is resolved through slugFromTarget.
function isReadySlug(value) {
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
}
function coerceSlug(value) {
if (!value) return null;
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
if (isReadySlug(value)) return value;
return slugFromTarget(value);
}
@@ -181,14 +322,124 @@ function main(argv) {
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
// The helper, not caller-provided metadata, owns the target fingerprint.
// This makes the snapshot describe the exact file bytes critique saw.
delete meta.target_fingerprint;
delete meta.target_path;
delete meta.target_identity;
const targetIdentity = resolveTargetIdentity(slugArg);
if (targetIdentity) meta.target_identity = targetIdentity;
const targetFingerprint = fingerprintTarget(slugArg);
if (targetFingerprint) {
meta.target_fingerprint = targetFingerprint;
meta.target_path = resolveLocalTargetPath(slugArg);
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(coerceSlug(args[0]));
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
const target = args[0];
const format = args[1];
const slug = coerceSlug(target);
if (!slug || (format && format !== '--json')) {
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
process.exit(1);
}
const targetFingerprint = fingerprintTarget(target);
const targetPath = resolveLocalTargetPath(target);
const targetIdentity = resolveTargetIdentity(target);
const readySlug = isReadySlug(target);
const newestForSlug = readNewestSnapshot(slug);
if (!newestForSlug) { process.exit(2); }
// Concrete targets select the newest snapshot for their exact identity,
// not merely the newest filename for a lossy slug. This keeps distinct
// targets such as foo/bar and foo-bar from hiding each other's backlog.
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
let latest = exactSnapshot;
if (!latest && !readySlug) {
// Legacy snapshots have no identity. Preserve their old explicit
// path/URL behavior only when no known target identity was selected.
latest = readNewestSnapshotForIdentity(slug, null);
}
if (!latest) latest = newestForSlug;
if (latest.meta.closed === true) { process.exit(2); }
const recordedTargetPath = latest.meta.target_path;
const recordedTargetIdentity = snapshotTargetIdentity(latest);
const matchingIdentity = recordedTargetIdentity === targetIdentity;
// Bare slugs remain a supported lookup mode, including for URL
// snapshots. But when a same-named local file exists, the request is
// ambiguous unless that exact file owns the snapshot identity.
if (readySlug && !recordedTargetIdentity) {
process.stderr.write(
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
);
process.exit(2);
}
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
process.stderr.write(
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
);
process.exit(2);
}
const concreteTarget = !readySlug || matchingIdentity;
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
process.exit(2);
}
const concreteLocalTarget = concreteTarget && targetPath;
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
closeSnapshot(latest.path);
process.exit(2);
}
if (format === '--json') {
process.stdout.write(JSON.stringify({
snapshot_file: path.basename(latest.path),
body: latest.body,
}, null, 2) + '\n');
} else {
process.stdout.write(latest.body);
}
return;
}
case 'close': {
const [slugArg, snapshotFile, ...extra] = args;
const slug = coerceSlug(slugArg);
if (!slug || !snapshotFile || extra.length > 0) {
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
process.exit(1);
}
if (
path.basename(snapshotFile) !== snapshotFile
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|| !snapshotFile.endsWith(`__${slug}.md`)
) process.exit(2);
// A slug and filename are not enough to prove ownership because two
// distinct targets can normalize to the same slug. Modern snapshots
// carry a canonical identity, so require the supplied resolved target
// to match it before allowing the exact snapshot to be closed. Legacy
// snapshots without identity retain their historical close behavior.
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
snapshot = readSnapshot(snapshotPath);
} catch {
process.exit(2);
}
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
if (
recordedTargetIdentity
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
) process.exit(2);
const closed = closeSnapshot(snapshotFile);
if (!closed) { process.exit(2); }
process.stdout.write(`${closed}\n`);
return;
}
case 'trend': {
@@ -197,7 +448,7 @@ function main(argv) {
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
process.exit(1);
}
}
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
let embedded = false;
try {
const { spawnSync } = await import('node:child_process');
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
embedded = !result.error && result.status === 0;
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
if (plateCtx && plateCtx.chroma) {
const frac = await keyChroma(out, plateCtx.chroma);
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
+2 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "impeccable",
"metadata": {
"description": "Design fluency for AI harnesses. 1 skill, 23 commands, and curated anti-patterns for impeccable frontend design."
"description": "Design fluency for AI harnesses. 1 skill, 24 commands, and curated anti-patterns for impeccable frontend design."
},
"owner": {
"name": "Paul Bakaus",
@@ -11,7 +11,7 @@
"plugins": [
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "4.1.2",
"author": {
"name": "Paul Bakaus",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "4.1.2",
"author": {
"name": "Paul Bakaus",
+1 -1
View File
@@ -20,7 +20,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .claude/skills/impeccable/scripts/...` command in this skill and its references, and `.claude/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Structure your feedback as a design director would:
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .claude/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .claude/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .claude/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .claude/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -91,7 +91,7 @@
import crypto from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -16,8 +16,9 @@
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs latest <slug> [--json]
* node critique-storage.mjs trend <slug> [limit]
* node critique-storage.mjs close <resolved-target> <snapshot-file>
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Return an exact content fingerprint for a local file target. URLs and
* non-files return null because their content is not available here.
*
* The fingerprint deliberately describes bytes, not Git state or mtimes:
* critique often assesses an uncommitted file, and a later polish run should
* inherit that backlog when the bytes are unchanged regardless of staging.
*/
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
if (!target || /^https?:\/\//i.test(target)) return null;
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
}
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
if (!target || typeof target !== 'string') return null;
if (/^https?:\/\//i.test(target)) {
try {
const url = new URL(target);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.origin}${pathname}`;
} catch {
return null;
}
}
const filePath = resolveLocalTargetPath(target, { cwd });
return filePath ? `file:${filePath}` : null;
}
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
const filePath = resolveLocalTargetPath(target, { cwd });
if (!filePath) return null;
try {
if (!fs.statSync(filePath).isFile()) return null;
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
} catch {
return null;
}
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
const contents = `${front}\n${body.trim()}\n`;
// A second critique can finish in the same UTC second. Use exclusive
// creation and a fixed-width suffix so concurrent writers cannot replace
// history and lexical ordering still keeps collision entries newest.
for (let collision = 0; collision <= 9999; collision += 1) {
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
try {
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
return filePath;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
}
}
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
}
function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
} else if (value === 'true' || value === 'false') {
value = value === 'true';
}
out[key] = value;
}
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
/**
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
function readSnapshot(filePath) {
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
function snapshotTargetIdentity(snapshot) {
const targetPath = snapshot?.meta.target_path;
return snapshot?.meta.target_identity
|| (targetPath ? `file:${targetPath}` : null);
}
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
}
function readNewestSnapshotForIdentity(
slug,
targetIdentity,
{ cwd = process.cwd() } = {},
) {
const matches = listSnapshots(`__${slug}.md`, cwd)
.map(readSnapshot)
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
return matches.at(-1) || null;
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
const latest = readNewestSnapshot(slug, { cwd });
return latest?.meta.closed === true ? null : latest;
}
/**
* Mark one exact snapshot closed without deleting the score history consumed
* by `trend`. Exact identity matters: a newer critique may land after polish
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
* may be the absolute path returned by readLatestSnapshot() or the basename
* emitted by `latest --json`. Returns the path marked closed, or null.
*/
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
const dir = path.resolve(getCritiqueDir(cwd));
const snapshotPath = path.isAbsolute(snapshotFile)
? path.resolve(snapshotFile)
: path.resolve(dir, snapshotFile);
const filename = path.basename(snapshotPath);
if (
path.dirname(snapshotPath) !== dir
|| !SNAPSHOT_FILENAME.test(filename)
) return null;
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) return null;
snapshot = readSnapshot(snapshotPath);
} catch {
return null;
}
if (!snapshot || snapshot.meta.closed === true) return null;
const closedBody = snapshot.body.replace(
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
'$1\nclosed: true$2',
);
if (closedBody === snapshot.body) {
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
}
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
return snapshot.path;
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
const identifiedSlugs = new Set(
snapshots
.filter((snapshot) => snapshotTargetIdentity(snapshot))
.map((snapshot) => snapshot.meta.slug),
);
const latestByTarget = new Map();
for (const snapshot of snapshots) {
if (!snapshot?.meta.slug) continue;
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
// one. Keep each known identity's latest open/closed state independent so
// closing one target cannot hide another target's live backlog. Once a
// slug has any identity-aware snapshot, its older legacy records are no
// longer independently routable and must not resurface as zombie work.
const targetIdentity = snapshotTargetIdentity(snapshot);
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
latestByTarget.set(streamKey, snapshot);
}
return [...latestByTarget.values()]
.filter((snapshot) => snapshot.meta.closed !== true)
.sort((a, b) => a.path.localeCompare(b.path))
.at(-1) || null;
}
/**
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
// callers never have to run the slug step separately. Anything containing a
// path or URL marker is resolved through slugFromTarget.
function isReadySlug(value) {
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
}
function coerceSlug(value) {
if (!value) return null;
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
if (isReadySlug(value)) return value;
return slugFromTarget(value);
}
@@ -181,14 +322,124 @@ function main(argv) {
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
// The helper, not caller-provided metadata, owns the target fingerprint.
// This makes the snapshot describe the exact file bytes critique saw.
delete meta.target_fingerprint;
delete meta.target_path;
delete meta.target_identity;
const targetIdentity = resolveTargetIdentity(slugArg);
if (targetIdentity) meta.target_identity = targetIdentity;
const targetFingerprint = fingerprintTarget(slugArg);
if (targetFingerprint) {
meta.target_fingerprint = targetFingerprint;
meta.target_path = resolveLocalTargetPath(slugArg);
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(coerceSlug(args[0]));
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
const target = args[0];
const format = args[1];
const slug = coerceSlug(target);
if (!slug || (format && format !== '--json')) {
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
process.exit(1);
}
const targetFingerprint = fingerprintTarget(target);
const targetPath = resolveLocalTargetPath(target);
const targetIdentity = resolveTargetIdentity(target);
const readySlug = isReadySlug(target);
const newestForSlug = readNewestSnapshot(slug);
if (!newestForSlug) { process.exit(2); }
// Concrete targets select the newest snapshot for their exact identity,
// not merely the newest filename for a lossy slug. This keeps distinct
// targets such as foo/bar and foo-bar from hiding each other's backlog.
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
let latest = exactSnapshot;
if (!latest && !readySlug) {
// Legacy snapshots have no identity. Preserve their old explicit
// path/URL behavior only when no known target identity was selected.
latest = readNewestSnapshotForIdentity(slug, null);
}
if (!latest) latest = newestForSlug;
if (latest.meta.closed === true) { process.exit(2); }
const recordedTargetPath = latest.meta.target_path;
const recordedTargetIdentity = snapshotTargetIdentity(latest);
const matchingIdentity = recordedTargetIdentity === targetIdentity;
// Bare slugs remain a supported lookup mode, including for URL
// snapshots. But when a same-named local file exists, the request is
// ambiguous unless that exact file owns the snapshot identity.
if (readySlug && !recordedTargetIdentity) {
process.stderr.write(
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
);
process.exit(2);
}
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
process.stderr.write(
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
);
process.exit(2);
}
const concreteTarget = !readySlug || matchingIdentity;
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
process.exit(2);
}
const concreteLocalTarget = concreteTarget && targetPath;
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
closeSnapshot(latest.path);
process.exit(2);
}
if (format === '--json') {
process.stdout.write(JSON.stringify({
snapshot_file: path.basename(latest.path),
body: latest.body,
}, null, 2) + '\n');
} else {
process.stdout.write(latest.body);
}
return;
}
case 'close': {
const [slugArg, snapshotFile, ...extra] = args;
const slug = coerceSlug(slugArg);
if (!slug || !snapshotFile || extra.length > 0) {
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
process.exit(1);
}
if (
path.basename(snapshotFile) !== snapshotFile
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|| !snapshotFile.endsWith(`__${slug}.md`)
) process.exit(2);
// A slug and filename are not enough to prove ownership because two
// distinct targets can normalize to the same slug. Modern snapshots
// carry a canonical identity, so require the supplied resolved target
// to match it before allowing the exact snapshot to be closed. Legacy
// snapshots without identity retain their historical close behavior.
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
snapshot = readSnapshot(snapshotPath);
} catch {
process.exit(2);
}
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
if (
recordedTargetIdentity
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
) process.exit(2);
const closed = closeSnapshot(snapshotFile);
if (!closed) { process.exit(2); }
process.stdout.write(`${closed}\n`);
return;
}
case 'trend': {
@@ -197,7 +448,7 @@ function main(argv) {
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
process.exit(1);
}
}
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
let embedded = false;
try {
const { spawnSync } = await import('node:child_process');
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
embedded = !result.error && result.status === 0;
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
if (plateCtx && plateCtx.chroma) {
const frac = await keyChroma(out, plateCtx.chroma);
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
+1 -1
View File
@@ -15,7 +15,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .cursor/skills/impeccable/scripts/...` command in this skill and its references, and `.cursor/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Structure your feedback as a design director would:
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .cursor/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .cursor/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .cursor/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .cursor/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .cursor/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -91,7 +91,7 @@
import crypto from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -16,8 +16,9 @@
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs latest <slug> [--json]
* node critique-storage.mjs trend <slug> [limit]
* node critique-storage.mjs close <resolved-target> <snapshot-file>
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Return an exact content fingerprint for a local file target. URLs and
* non-files return null because their content is not available here.
*
* The fingerprint deliberately describes bytes, not Git state or mtimes:
* critique often assesses an uncommitted file, and a later polish run should
* inherit that backlog when the bytes are unchanged regardless of staging.
*/
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
if (!target || /^https?:\/\//i.test(target)) return null;
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
}
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
if (!target || typeof target !== 'string') return null;
if (/^https?:\/\//i.test(target)) {
try {
const url = new URL(target);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.origin}${pathname}`;
} catch {
return null;
}
}
const filePath = resolveLocalTargetPath(target, { cwd });
return filePath ? `file:${filePath}` : null;
}
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
const filePath = resolveLocalTargetPath(target, { cwd });
if (!filePath) return null;
try {
if (!fs.statSync(filePath).isFile()) return null;
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
} catch {
return null;
}
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
const contents = `${front}\n${body.trim()}\n`;
// A second critique can finish in the same UTC second. Use exclusive
// creation and a fixed-width suffix so concurrent writers cannot replace
// history and lexical ordering still keeps collision entries newest.
for (let collision = 0; collision <= 9999; collision += 1) {
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
try {
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
return filePath;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
}
}
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
}
function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
} else if (value === 'true' || value === 'false') {
value = value === 'true';
}
out[key] = value;
}
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
/**
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
function readSnapshot(filePath) {
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
function snapshotTargetIdentity(snapshot) {
const targetPath = snapshot?.meta.target_path;
return snapshot?.meta.target_identity
|| (targetPath ? `file:${targetPath}` : null);
}
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
}
function readNewestSnapshotForIdentity(
slug,
targetIdentity,
{ cwd = process.cwd() } = {},
) {
const matches = listSnapshots(`__${slug}.md`, cwd)
.map(readSnapshot)
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
return matches.at(-1) || null;
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
const latest = readNewestSnapshot(slug, { cwd });
return latest?.meta.closed === true ? null : latest;
}
/**
* Mark one exact snapshot closed without deleting the score history consumed
* by `trend`. Exact identity matters: a newer critique may land after polish
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
* may be the absolute path returned by readLatestSnapshot() or the basename
* emitted by `latest --json`. Returns the path marked closed, or null.
*/
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
const dir = path.resolve(getCritiqueDir(cwd));
const snapshotPath = path.isAbsolute(snapshotFile)
? path.resolve(snapshotFile)
: path.resolve(dir, snapshotFile);
const filename = path.basename(snapshotPath);
if (
path.dirname(snapshotPath) !== dir
|| !SNAPSHOT_FILENAME.test(filename)
) return null;
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) return null;
snapshot = readSnapshot(snapshotPath);
} catch {
return null;
}
if (!snapshot || snapshot.meta.closed === true) return null;
const closedBody = snapshot.body.replace(
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
'$1\nclosed: true$2',
);
if (closedBody === snapshot.body) {
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
}
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
return snapshot.path;
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
const identifiedSlugs = new Set(
snapshots
.filter((snapshot) => snapshotTargetIdentity(snapshot))
.map((snapshot) => snapshot.meta.slug),
);
const latestByTarget = new Map();
for (const snapshot of snapshots) {
if (!snapshot?.meta.slug) continue;
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
// one. Keep each known identity's latest open/closed state independent so
// closing one target cannot hide another target's live backlog. Once a
// slug has any identity-aware snapshot, its older legacy records are no
// longer independently routable and must not resurface as zombie work.
const targetIdentity = snapshotTargetIdentity(snapshot);
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
latestByTarget.set(streamKey, snapshot);
}
return [...latestByTarget.values()]
.filter((snapshot) => snapshot.meta.closed !== true)
.sort((a, b) => a.path.localeCompare(b.path))
.at(-1) || null;
}
/**
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
// callers never have to run the slug step separately. Anything containing a
// path or URL marker is resolved through slugFromTarget.
function isReadySlug(value) {
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
}
function coerceSlug(value) {
if (!value) return null;
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
if (isReadySlug(value)) return value;
return slugFromTarget(value);
}
@@ -181,14 +322,124 @@ function main(argv) {
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
// The helper, not caller-provided metadata, owns the target fingerprint.
// This makes the snapshot describe the exact file bytes critique saw.
delete meta.target_fingerprint;
delete meta.target_path;
delete meta.target_identity;
const targetIdentity = resolveTargetIdentity(slugArg);
if (targetIdentity) meta.target_identity = targetIdentity;
const targetFingerprint = fingerprintTarget(slugArg);
if (targetFingerprint) {
meta.target_fingerprint = targetFingerprint;
meta.target_path = resolveLocalTargetPath(slugArg);
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(coerceSlug(args[0]));
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
const target = args[0];
const format = args[1];
const slug = coerceSlug(target);
if (!slug || (format && format !== '--json')) {
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
process.exit(1);
}
const targetFingerprint = fingerprintTarget(target);
const targetPath = resolveLocalTargetPath(target);
const targetIdentity = resolveTargetIdentity(target);
const readySlug = isReadySlug(target);
const newestForSlug = readNewestSnapshot(slug);
if (!newestForSlug) { process.exit(2); }
// Concrete targets select the newest snapshot for their exact identity,
// not merely the newest filename for a lossy slug. This keeps distinct
// targets such as foo/bar and foo-bar from hiding each other's backlog.
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
let latest = exactSnapshot;
if (!latest && !readySlug) {
// Legacy snapshots have no identity. Preserve their old explicit
// path/URL behavior only when no known target identity was selected.
latest = readNewestSnapshotForIdentity(slug, null);
}
if (!latest) latest = newestForSlug;
if (latest.meta.closed === true) { process.exit(2); }
const recordedTargetPath = latest.meta.target_path;
const recordedTargetIdentity = snapshotTargetIdentity(latest);
const matchingIdentity = recordedTargetIdentity === targetIdentity;
// Bare slugs remain a supported lookup mode, including for URL
// snapshots. But when a same-named local file exists, the request is
// ambiguous unless that exact file owns the snapshot identity.
if (readySlug && !recordedTargetIdentity) {
process.stderr.write(
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
);
process.exit(2);
}
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
process.stderr.write(
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
);
process.exit(2);
}
const concreteTarget = !readySlug || matchingIdentity;
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
process.exit(2);
}
const concreteLocalTarget = concreteTarget && targetPath;
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
closeSnapshot(latest.path);
process.exit(2);
}
if (format === '--json') {
process.stdout.write(JSON.stringify({
snapshot_file: path.basename(latest.path),
body: latest.body,
}, null, 2) + '\n');
} else {
process.stdout.write(latest.body);
}
return;
}
case 'close': {
const [slugArg, snapshotFile, ...extra] = args;
const slug = coerceSlug(slugArg);
if (!slug || !snapshotFile || extra.length > 0) {
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
process.exit(1);
}
if (
path.basename(snapshotFile) !== snapshotFile
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|| !snapshotFile.endsWith(`__${slug}.md`)
) process.exit(2);
// A slug and filename are not enough to prove ownership because two
// distinct targets can normalize to the same slug. Modern snapshots
// carry a canonical identity, so require the supplied resolved target
// to match it before allowing the exact snapshot to be closed. Legacy
// snapshots without identity retain their historical close behavior.
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
snapshot = readSnapshot(snapshotPath);
} catch {
process.exit(2);
}
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
if (
recordedTargetIdentity
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
) process.exit(2);
const closed = closeSnapshot(snapshotFile);
if (!closed) { process.exit(2); }
process.stdout.write(`${closed}\n`);
return;
}
case 'trend': {
@@ -197,7 +448,7 @@ function main(argv) {
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
process.exit(1);
}
}
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
let embedded = false;
try {
const { spawnSync } = await import('node:child_process');
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
embedded = !result.error && result.status === 0;
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
if (plateCtx && plateCtx.chroma) {
const frac = await keyChroma(out, plateCtx.chroma);
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
+1 -1
View File
@@ -14,7 +14,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .gemini/skills/impeccable/scripts/...` command in this skill and its references, and `.gemini/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Structure your feedback as a design director would:
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .gemini/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .gemini/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .gemini/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .gemini/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .gemini/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -91,7 +91,7 @@
import crypto from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -16,8 +16,9 @@
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs latest <slug> [--json]
* node critique-storage.mjs trend <slug> [limit]
* node critique-storage.mjs close <resolved-target> <snapshot-file>
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Return an exact content fingerprint for a local file target. URLs and
* non-files return null because their content is not available here.
*
* The fingerprint deliberately describes bytes, not Git state or mtimes:
* critique often assesses an uncommitted file, and a later polish run should
* inherit that backlog when the bytes are unchanged regardless of staging.
*/
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
if (!target || /^https?:\/\//i.test(target)) return null;
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
}
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
if (!target || typeof target !== 'string') return null;
if (/^https?:\/\//i.test(target)) {
try {
const url = new URL(target);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.origin}${pathname}`;
} catch {
return null;
}
}
const filePath = resolveLocalTargetPath(target, { cwd });
return filePath ? `file:${filePath}` : null;
}
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
const filePath = resolveLocalTargetPath(target, { cwd });
if (!filePath) return null;
try {
if (!fs.statSync(filePath).isFile()) return null;
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
} catch {
return null;
}
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
const contents = `${front}\n${body.trim()}\n`;
// A second critique can finish in the same UTC second. Use exclusive
// creation and a fixed-width suffix so concurrent writers cannot replace
// history and lexical ordering still keeps collision entries newest.
for (let collision = 0; collision <= 9999; collision += 1) {
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
try {
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
return filePath;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
}
}
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
}
function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
} else if (value === 'true' || value === 'false') {
value = value === 'true';
}
out[key] = value;
}
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
/**
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
function readSnapshot(filePath) {
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
function snapshotTargetIdentity(snapshot) {
const targetPath = snapshot?.meta.target_path;
return snapshot?.meta.target_identity
|| (targetPath ? `file:${targetPath}` : null);
}
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
}
function readNewestSnapshotForIdentity(
slug,
targetIdentity,
{ cwd = process.cwd() } = {},
) {
const matches = listSnapshots(`__${slug}.md`, cwd)
.map(readSnapshot)
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
return matches.at(-1) || null;
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
const latest = readNewestSnapshot(slug, { cwd });
return latest?.meta.closed === true ? null : latest;
}
/**
* Mark one exact snapshot closed without deleting the score history consumed
* by `trend`. Exact identity matters: a newer critique may land after polish
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
* may be the absolute path returned by readLatestSnapshot() or the basename
* emitted by `latest --json`. Returns the path marked closed, or null.
*/
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
const dir = path.resolve(getCritiqueDir(cwd));
const snapshotPath = path.isAbsolute(snapshotFile)
? path.resolve(snapshotFile)
: path.resolve(dir, snapshotFile);
const filename = path.basename(snapshotPath);
if (
path.dirname(snapshotPath) !== dir
|| !SNAPSHOT_FILENAME.test(filename)
) return null;
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) return null;
snapshot = readSnapshot(snapshotPath);
} catch {
return null;
}
if (!snapshot || snapshot.meta.closed === true) return null;
const closedBody = snapshot.body.replace(
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
'$1\nclosed: true$2',
);
if (closedBody === snapshot.body) {
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
}
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
return snapshot.path;
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
const identifiedSlugs = new Set(
snapshots
.filter((snapshot) => snapshotTargetIdentity(snapshot))
.map((snapshot) => snapshot.meta.slug),
);
const latestByTarget = new Map();
for (const snapshot of snapshots) {
if (!snapshot?.meta.slug) continue;
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
// one. Keep each known identity's latest open/closed state independent so
// closing one target cannot hide another target's live backlog. Once a
// slug has any identity-aware snapshot, its older legacy records are no
// longer independently routable and must not resurface as zombie work.
const targetIdentity = snapshotTargetIdentity(snapshot);
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
latestByTarget.set(streamKey, snapshot);
}
return [...latestByTarget.values()]
.filter((snapshot) => snapshot.meta.closed !== true)
.sort((a, b) => a.path.localeCompare(b.path))
.at(-1) || null;
}
/**
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
// callers never have to run the slug step separately. Anything containing a
// path or URL marker is resolved through slugFromTarget.
function isReadySlug(value) {
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
}
function coerceSlug(value) {
if (!value) return null;
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
if (isReadySlug(value)) return value;
return slugFromTarget(value);
}
@@ -181,14 +322,124 @@ function main(argv) {
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
// The helper, not caller-provided metadata, owns the target fingerprint.
// This makes the snapshot describe the exact file bytes critique saw.
delete meta.target_fingerprint;
delete meta.target_path;
delete meta.target_identity;
const targetIdentity = resolveTargetIdentity(slugArg);
if (targetIdentity) meta.target_identity = targetIdentity;
const targetFingerprint = fingerprintTarget(slugArg);
if (targetFingerprint) {
meta.target_fingerprint = targetFingerprint;
meta.target_path = resolveLocalTargetPath(slugArg);
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(coerceSlug(args[0]));
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
const target = args[0];
const format = args[1];
const slug = coerceSlug(target);
if (!slug || (format && format !== '--json')) {
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
process.exit(1);
}
const targetFingerprint = fingerprintTarget(target);
const targetPath = resolveLocalTargetPath(target);
const targetIdentity = resolveTargetIdentity(target);
const readySlug = isReadySlug(target);
const newestForSlug = readNewestSnapshot(slug);
if (!newestForSlug) { process.exit(2); }
// Concrete targets select the newest snapshot for their exact identity,
// not merely the newest filename for a lossy slug. This keeps distinct
// targets such as foo/bar and foo-bar from hiding each other's backlog.
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
let latest = exactSnapshot;
if (!latest && !readySlug) {
// Legacy snapshots have no identity. Preserve their old explicit
// path/URL behavior only when no known target identity was selected.
latest = readNewestSnapshotForIdentity(slug, null);
}
if (!latest) latest = newestForSlug;
if (latest.meta.closed === true) { process.exit(2); }
const recordedTargetPath = latest.meta.target_path;
const recordedTargetIdentity = snapshotTargetIdentity(latest);
const matchingIdentity = recordedTargetIdentity === targetIdentity;
// Bare slugs remain a supported lookup mode, including for URL
// snapshots. But when a same-named local file exists, the request is
// ambiguous unless that exact file owns the snapshot identity.
if (readySlug && !recordedTargetIdentity) {
process.stderr.write(
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
);
process.exit(2);
}
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
process.stderr.write(
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
);
process.exit(2);
}
const concreteTarget = !readySlug || matchingIdentity;
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
process.exit(2);
}
const concreteLocalTarget = concreteTarget && targetPath;
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
closeSnapshot(latest.path);
process.exit(2);
}
if (format === '--json') {
process.stdout.write(JSON.stringify({
snapshot_file: path.basename(latest.path),
body: latest.body,
}, null, 2) + '\n');
} else {
process.stdout.write(latest.body);
}
return;
}
case 'close': {
const [slugArg, snapshotFile, ...extra] = args;
const slug = coerceSlug(slugArg);
if (!slug || !snapshotFile || extra.length > 0) {
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
process.exit(1);
}
if (
path.basename(snapshotFile) !== snapshotFile
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|| !snapshotFile.endsWith(`__${slug}.md`)
) process.exit(2);
// A slug and filename are not enough to prove ownership because two
// distinct targets can normalize to the same slug. Modern snapshots
// carry a canonical identity, so require the supplied resolved target
// to match it before allowing the exact snapshot to be closed. Legacy
// snapshots without identity retain their historical close behavior.
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
snapshot = readSnapshot(snapshotPath);
} catch {
process.exit(2);
}
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
if (
recordedTargetIdentity
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
) process.exit(2);
const closed = closeSnapshot(snapshotFile);
if (!closed) { process.exit(2); }
process.stdout.write(`${closed}\n`);
return;
}
case 'trend': {
@@ -197,7 +448,7 @@ function main(argv) {
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
process.exit(1);
}
}
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
let embedded = false;
try {
const { spawnSync } = await import('node:child_process');
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
embedded = !result.error && result.status === 0;
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
if (plateCtx && plateCtx.chroma) {
const frac = await keyChroma(out, plateCtx.chroma);
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
+1 -1
View File
@@ -17,7 +17,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .github/skills/impeccable/scripts/...` command in this skill and its references, and `.github/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Structure your feedback as a design director would:
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .github/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .github/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .github/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .github/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .github/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -91,7 +91,7 @@
import crypto from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -16,8 +16,9 @@
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs latest <slug> [--json]
* node critique-storage.mjs trend <slug> [limit]
* node critique-storage.mjs close <resolved-target> <snapshot-file>
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Return an exact content fingerprint for a local file target. URLs and
* non-files return null because their content is not available here.
*
* The fingerprint deliberately describes bytes, not Git state or mtimes:
* critique often assesses an uncommitted file, and a later polish run should
* inherit that backlog when the bytes are unchanged regardless of staging.
*/
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
if (!target || /^https?:\/\//i.test(target)) return null;
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
}
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
if (!target || typeof target !== 'string') return null;
if (/^https?:\/\//i.test(target)) {
try {
const url = new URL(target);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.origin}${pathname}`;
} catch {
return null;
}
}
const filePath = resolveLocalTargetPath(target, { cwd });
return filePath ? `file:${filePath}` : null;
}
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
const filePath = resolveLocalTargetPath(target, { cwd });
if (!filePath) return null;
try {
if (!fs.statSync(filePath).isFile()) return null;
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
} catch {
return null;
}
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
const contents = `${front}\n${body.trim()}\n`;
// A second critique can finish in the same UTC second. Use exclusive
// creation and a fixed-width suffix so concurrent writers cannot replace
// history and lexical ordering still keeps collision entries newest.
for (let collision = 0; collision <= 9999; collision += 1) {
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
try {
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
return filePath;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
}
}
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
}
function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
} else if (value === 'true' || value === 'false') {
value = value === 'true';
}
out[key] = value;
}
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
/**
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
function readSnapshot(filePath) {
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
function snapshotTargetIdentity(snapshot) {
const targetPath = snapshot?.meta.target_path;
return snapshot?.meta.target_identity
|| (targetPath ? `file:${targetPath}` : null);
}
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
}
function readNewestSnapshotForIdentity(
slug,
targetIdentity,
{ cwd = process.cwd() } = {},
) {
const matches = listSnapshots(`__${slug}.md`, cwd)
.map(readSnapshot)
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
return matches.at(-1) || null;
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
const latest = readNewestSnapshot(slug, { cwd });
return latest?.meta.closed === true ? null : latest;
}
/**
* Mark one exact snapshot closed without deleting the score history consumed
* by `trend`. Exact identity matters: a newer critique may land after polish
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
* may be the absolute path returned by readLatestSnapshot() or the basename
* emitted by `latest --json`. Returns the path marked closed, or null.
*/
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
const dir = path.resolve(getCritiqueDir(cwd));
const snapshotPath = path.isAbsolute(snapshotFile)
? path.resolve(snapshotFile)
: path.resolve(dir, snapshotFile);
const filename = path.basename(snapshotPath);
if (
path.dirname(snapshotPath) !== dir
|| !SNAPSHOT_FILENAME.test(filename)
) return null;
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) return null;
snapshot = readSnapshot(snapshotPath);
} catch {
return null;
}
if (!snapshot || snapshot.meta.closed === true) return null;
const closedBody = snapshot.body.replace(
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
'$1\nclosed: true$2',
);
if (closedBody === snapshot.body) {
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
}
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
return snapshot.path;
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
const identifiedSlugs = new Set(
snapshots
.filter((snapshot) => snapshotTargetIdentity(snapshot))
.map((snapshot) => snapshot.meta.slug),
);
const latestByTarget = new Map();
for (const snapshot of snapshots) {
if (!snapshot?.meta.slug) continue;
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
// one. Keep each known identity's latest open/closed state independent so
// closing one target cannot hide another target's live backlog. Once a
// slug has any identity-aware snapshot, its older legacy records are no
// longer independently routable and must not resurface as zombie work.
const targetIdentity = snapshotTargetIdentity(snapshot);
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
latestByTarget.set(streamKey, snapshot);
}
return [...latestByTarget.values()]
.filter((snapshot) => snapshot.meta.closed !== true)
.sort((a, b) => a.path.localeCompare(b.path))
.at(-1) || null;
}
/**
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
// callers never have to run the slug step separately. Anything containing a
// path or URL marker is resolved through slugFromTarget.
function isReadySlug(value) {
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
}
function coerceSlug(value) {
if (!value) return null;
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
if (isReadySlug(value)) return value;
return slugFromTarget(value);
}
@@ -181,14 +322,124 @@ function main(argv) {
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
// The helper, not caller-provided metadata, owns the target fingerprint.
// This makes the snapshot describe the exact file bytes critique saw.
delete meta.target_fingerprint;
delete meta.target_path;
delete meta.target_identity;
const targetIdentity = resolveTargetIdentity(slugArg);
if (targetIdentity) meta.target_identity = targetIdentity;
const targetFingerprint = fingerprintTarget(slugArg);
if (targetFingerprint) {
meta.target_fingerprint = targetFingerprint;
meta.target_path = resolveLocalTargetPath(slugArg);
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(coerceSlug(args[0]));
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
const target = args[0];
const format = args[1];
const slug = coerceSlug(target);
if (!slug || (format && format !== '--json')) {
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
process.exit(1);
}
const targetFingerprint = fingerprintTarget(target);
const targetPath = resolveLocalTargetPath(target);
const targetIdentity = resolveTargetIdentity(target);
const readySlug = isReadySlug(target);
const newestForSlug = readNewestSnapshot(slug);
if (!newestForSlug) { process.exit(2); }
// Concrete targets select the newest snapshot for their exact identity,
// not merely the newest filename for a lossy slug. This keeps distinct
// targets such as foo/bar and foo-bar from hiding each other's backlog.
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
let latest = exactSnapshot;
if (!latest && !readySlug) {
// Legacy snapshots have no identity. Preserve their old explicit
// path/URL behavior only when no known target identity was selected.
latest = readNewestSnapshotForIdentity(slug, null);
}
if (!latest) latest = newestForSlug;
if (latest.meta.closed === true) { process.exit(2); }
const recordedTargetPath = latest.meta.target_path;
const recordedTargetIdentity = snapshotTargetIdentity(latest);
const matchingIdentity = recordedTargetIdentity === targetIdentity;
// Bare slugs remain a supported lookup mode, including for URL
// snapshots. But when a same-named local file exists, the request is
// ambiguous unless that exact file owns the snapshot identity.
if (readySlug && !recordedTargetIdentity) {
process.stderr.write(
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
);
process.exit(2);
}
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
process.stderr.write(
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
);
process.exit(2);
}
const concreteTarget = !readySlug || matchingIdentity;
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
process.exit(2);
}
const concreteLocalTarget = concreteTarget && targetPath;
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
closeSnapshot(latest.path);
process.exit(2);
}
if (format === '--json') {
process.stdout.write(JSON.stringify({
snapshot_file: path.basename(latest.path),
body: latest.body,
}, null, 2) + '\n');
} else {
process.stdout.write(latest.body);
}
return;
}
case 'close': {
const [slugArg, snapshotFile, ...extra] = args;
const slug = coerceSlug(slugArg);
if (!slug || !snapshotFile || extra.length > 0) {
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
process.exit(1);
}
if (
path.basename(snapshotFile) !== snapshotFile
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|| !snapshotFile.endsWith(`__${slug}.md`)
) process.exit(2);
// A slug and filename are not enough to prove ownership because two
// distinct targets can normalize to the same slug. Modern snapshots
// carry a canonical identity, so require the supplied resolved target
// to match it before allowing the exact snapshot to be closed. Legacy
// snapshots without identity retain their historical close behavior.
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
snapshot = readSnapshot(snapshotPath);
} catch {
process.exit(2);
}
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
if (
recordedTargetIdentity
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
) process.exit(2);
const closed = closeSnapshot(snapshotFile);
if (!closed) { process.exit(2); }
process.stdout.write(`${closed}\n`);
return;
}
case 'trend': {
@@ -197,7 +448,7 @@ function main(argv) {
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
process.exit(1);
}
}
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
let embedded = false;
try {
const { spawnSync } = await import('node:child_process');
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
embedded = !result.error && result.status === 0;
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
if (plateCtx && plateCtx.chroma) {
const frac = await keyChroma(out, plateCtx.chroma);
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
@@ -21,10 +21,12 @@ concurrency:
env:
GENERATED_PATHS: >-
.agent
.agents
.claude
.cursor
.gemini
.github/agents
.github/skills
.grok
.hermes
@@ -35,6 +37,7 @@ env:
.rovodev
.trae
.trae-cn
.veto
.vibe
plugin
+14
View File
@@ -61,6 +61,14 @@ Thumbs.db
.impeccable/live/deferred-svelte-component-accepts.json
.impeccable/history/
.impeccable/config.local.json
# API keys for optional image-generation cues (document seed mode).
.impeccable/.env
# Generated visual-cue images + cues.json (document seed Step 4).
.impeccable/visual-cues/
# The design-context store itself is the user's own record and is trackable;
# only its session state and its generated exports stay local.
.impeccable/design-context/runtime/
.impeccable/design-context/exports/
**/.impeccable/hook.pending.json
.impeccable/provider-smoke/
src/__impeccable_provider_smoke_*.html
@@ -129,3 +137,9 @@ tmp/
# PNGs, the old card backup). The canonical generator is `bun run og-image`
# (scripts/generate-og-image.js); this dir is throwaway and safe to delete.
.og-build/
# Intermediate and generated output for the picker build. Release builds sync
# the generated picker into the tracked provider harness directories.
build-picker/
skill/scripts/picker/
+1 -1
View File
@@ -20,7 +20,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .grok/skills/impeccable/scripts/...` command in this skill and its references, and `.grok/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Structure your feedback as a design director would:
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .grok/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .grok/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .grok/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .grok/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .grok/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .grok/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .grok/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
+1 -1
View File
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -91,7 +91,7 @@
import crypto from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -16,8 +16,9 @@
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs latest <slug> [--json]
* node critique-storage.mjs trend <slug> [limit]
* node critique-storage.mjs close <resolved-target> <snapshot-file>
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Return an exact content fingerprint for a local file target. URLs and
* non-files return null because their content is not available here.
*
* The fingerprint deliberately describes bytes, not Git state or mtimes:
* critique often assesses an uncommitted file, and a later polish run should
* inherit that backlog when the bytes are unchanged regardless of staging.
*/
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
if (!target || /^https?:\/\//i.test(target)) return null;
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
}
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
if (!target || typeof target !== 'string') return null;
if (/^https?:\/\//i.test(target)) {
try {
const url = new URL(target);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.origin}${pathname}`;
} catch {
return null;
}
}
const filePath = resolveLocalTargetPath(target, { cwd });
return filePath ? `file:${filePath}` : null;
}
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
const filePath = resolveLocalTargetPath(target, { cwd });
if (!filePath) return null;
try {
if (!fs.statSync(filePath).isFile()) return null;
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
} catch {
return null;
}
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
const contents = `${front}\n${body.trim()}\n`;
// A second critique can finish in the same UTC second. Use exclusive
// creation and a fixed-width suffix so concurrent writers cannot replace
// history and lexical ordering still keeps collision entries newest.
for (let collision = 0; collision <= 9999; collision += 1) {
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
try {
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
return filePath;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
}
}
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
}
function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
} else if (value === 'true' || value === 'false') {
value = value === 'true';
}
out[key] = value;
}
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
/**
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
function readSnapshot(filePath) {
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
function snapshotTargetIdentity(snapshot) {
const targetPath = snapshot?.meta.target_path;
return snapshot?.meta.target_identity
|| (targetPath ? `file:${targetPath}` : null);
}
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
}
function readNewestSnapshotForIdentity(
slug,
targetIdentity,
{ cwd = process.cwd() } = {},
) {
const matches = listSnapshots(`__${slug}.md`, cwd)
.map(readSnapshot)
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
return matches.at(-1) || null;
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
const latest = readNewestSnapshot(slug, { cwd });
return latest?.meta.closed === true ? null : latest;
}
/**
* Mark one exact snapshot closed without deleting the score history consumed
* by `trend`. Exact identity matters: a newer critique may land after polish
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
* may be the absolute path returned by readLatestSnapshot() or the basename
* emitted by `latest --json`. Returns the path marked closed, or null.
*/
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
const dir = path.resolve(getCritiqueDir(cwd));
const snapshotPath = path.isAbsolute(snapshotFile)
? path.resolve(snapshotFile)
: path.resolve(dir, snapshotFile);
const filename = path.basename(snapshotPath);
if (
path.dirname(snapshotPath) !== dir
|| !SNAPSHOT_FILENAME.test(filename)
) return null;
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) return null;
snapshot = readSnapshot(snapshotPath);
} catch {
return null;
}
if (!snapshot || snapshot.meta.closed === true) return null;
const closedBody = snapshot.body.replace(
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
'$1\nclosed: true$2',
);
if (closedBody === snapshot.body) {
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
}
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
return snapshot.path;
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
const identifiedSlugs = new Set(
snapshots
.filter((snapshot) => snapshotTargetIdentity(snapshot))
.map((snapshot) => snapshot.meta.slug),
);
const latestByTarget = new Map();
for (const snapshot of snapshots) {
if (!snapshot?.meta.slug) continue;
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
// one. Keep each known identity's latest open/closed state independent so
// closing one target cannot hide another target's live backlog. Once a
// slug has any identity-aware snapshot, its older legacy records are no
// longer independently routable and must not resurface as zombie work.
const targetIdentity = snapshotTargetIdentity(snapshot);
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
latestByTarget.set(streamKey, snapshot);
}
return [...latestByTarget.values()]
.filter((snapshot) => snapshot.meta.closed !== true)
.sort((a, b) => a.path.localeCompare(b.path))
.at(-1) || null;
}
/**
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
// callers never have to run the slug step separately. Anything containing a
// path or URL marker is resolved through slugFromTarget.
function isReadySlug(value) {
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
}
function coerceSlug(value) {
if (!value) return null;
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
if (isReadySlug(value)) return value;
return slugFromTarget(value);
}
@@ -181,14 +322,124 @@ function main(argv) {
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
// The helper, not caller-provided metadata, owns the target fingerprint.
// This makes the snapshot describe the exact file bytes critique saw.
delete meta.target_fingerprint;
delete meta.target_path;
delete meta.target_identity;
const targetIdentity = resolveTargetIdentity(slugArg);
if (targetIdentity) meta.target_identity = targetIdentity;
const targetFingerprint = fingerprintTarget(slugArg);
if (targetFingerprint) {
meta.target_fingerprint = targetFingerprint;
meta.target_path = resolveLocalTargetPath(slugArg);
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(coerceSlug(args[0]));
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
const target = args[0];
const format = args[1];
const slug = coerceSlug(target);
if (!slug || (format && format !== '--json')) {
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
process.exit(1);
}
const targetFingerprint = fingerprintTarget(target);
const targetPath = resolveLocalTargetPath(target);
const targetIdentity = resolveTargetIdentity(target);
const readySlug = isReadySlug(target);
const newestForSlug = readNewestSnapshot(slug);
if (!newestForSlug) { process.exit(2); }
// Concrete targets select the newest snapshot for their exact identity,
// not merely the newest filename for a lossy slug. This keeps distinct
// targets such as foo/bar and foo-bar from hiding each other's backlog.
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
let latest = exactSnapshot;
if (!latest && !readySlug) {
// Legacy snapshots have no identity. Preserve their old explicit
// path/URL behavior only when no known target identity was selected.
latest = readNewestSnapshotForIdentity(slug, null);
}
if (!latest) latest = newestForSlug;
if (latest.meta.closed === true) { process.exit(2); }
const recordedTargetPath = latest.meta.target_path;
const recordedTargetIdentity = snapshotTargetIdentity(latest);
const matchingIdentity = recordedTargetIdentity === targetIdentity;
// Bare slugs remain a supported lookup mode, including for URL
// snapshots. But when a same-named local file exists, the request is
// ambiguous unless that exact file owns the snapshot identity.
if (readySlug && !recordedTargetIdentity) {
process.stderr.write(
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
);
process.exit(2);
}
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
process.stderr.write(
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
);
process.exit(2);
}
const concreteTarget = !readySlug || matchingIdentity;
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
process.exit(2);
}
const concreteLocalTarget = concreteTarget && targetPath;
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
closeSnapshot(latest.path);
process.exit(2);
}
if (format === '--json') {
process.stdout.write(JSON.stringify({
snapshot_file: path.basename(latest.path),
body: latest.body,
}, null, 2) + '\n');
} else {
process.stdout.write(latest.body);
}
return;
}
case 'close': {
const [slugArg, snapshotFile, ...extra] = args;
const slug = coerceSlug(slugArg);
if (!slug || !snapshotFile || extra.length > 0) {
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
process.exit(1);
}
if (
path.basename(snapshotFile) !== snapshotFile
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|| !snapshotFile.endsWith(`__${slug}.md`)
) process.exit(2);
// A slug and filename are not enough to prove ownership because two
// distinct targets can normalize to the same slug. Modern snapshots
// carry a canonical identity, so require the supplied resolved target
// to match it before allowing the exact snapshot to be closed. Legacy
// snapshots without identity retain their historical close behavior.
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
snapshot = readSnapshot(snapshotPath);
} catch {
process.exit(2);
}
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
if (
recordedTargetIdentity
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
) process.exit(2);
const closed = closeSnapshot(snapshotFile);
if (!closed) { process.exit(2); }
process.stdout.write(`${closed}\n`);
return;
}
case 'trend': {
@@ -197,7 +448,7 @@ function main(argv) {
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
process.exit(1);
}
}
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
let embedded = false;
try {
const { spawnSync } = await import('node:child_process');
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
embedded = !result.error && result.status === 0;
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
if (plateCtx && plateCtx.chroma) {
const frac = await keyChroma(out, plateCtx.chroma);
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
+1 -1
View File
@@ -15,7 +15,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .hermes/skills/impeccable/scripts/...` command in this skill and its references, and `.hermes/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Structure your feedback as a design director would:
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .hermes/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .hermes/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .hermes/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .hermes/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .hermes/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .hermes/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .hermes/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -91,7 +91,7 @@
import crypto from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -16,8 +16,9 @@
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs latest <slug> [--json]
* node critique-storage.mjs trend <slug> [limit]
* node critique-storage.mjs close <resolved-target> <snapshot-file>
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Return an exact content fingerprint for a local file target. URLs and
* non-files return null because their content is not available here.
*
* The fingerprint deliberately describes bytes, not Git state or mtimes:
* critique often assesses an uncommitted file, and a later polish run should
* inherit that backlog when the bytes are unchanged regardless of staging.
*/
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
if (!target || /^https?:\/\//i.test(target)) return null;
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
}
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
if (!target || typeof target !== 'string') return null;
if (/^https?:\/\//i.test(target)) {
try {
const url = new URL(target);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.origin}${pathname}`;
} catch {
return null;
}
}
const filePath = resolveLocalTargetPath(target, { cwd });
return filePath ? `file:${filePath}` : null;
}
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
const filePath = resolveLocalTargetPath(target, { cwd });
if (!filePath) return null;
try {
if (!fs.statSync(filePath).isFile()) return null;
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
} catch {
return null;
}
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
const contents = `${front}\n${body.trim()}\n`;
// A second critique can finish in the same UTC second. Use exclusive
// creation and a fixed-width suffix so concurrent writers cannot replace
// history and lexical ordering still keeps collision entries newest.
for (let collision = 0; collision <= 9999; collision += 1) {
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
try {
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
return filePath;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
}
}
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
}
function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
} else if (value === 'true' || value === 'false') {
value = value === 'true';
}
out[key] = value;
}
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
/**
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
function readSnapshot(filePath) {
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
function snapshotTargetIdentity(snapshot) {
const targetPath = snapshot?.meta.target_path;
return snapshot?.meta.target_identity
|| (targetPath ? `file:${targetPath}` : null);
}
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
}
function readNewestSnapshotForIdentity(
slug,
targetIdentity,
{ cwd = process.cwd() } = {},
) {
const matches = listSnapshots(`__${slug}.md`, cwd)
.map(readSnapshot)
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
return matches.at(-1) || null;
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
const latest = readNewestSnapshot(slug, { cwd });
return latest?.meta.closed === true ? null : latest;
}
/**
* Mark one exact snapshot closed without deleting the score history consumed
* by `trend`. Exact identity matters: a newer critique may land after polish
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
* may be the absolute path returned by readLatestSnapshot() or the basename
* emitted by `latest --json`. Returns the path marked closed, or null.
*/
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
const dir = path.resolve(getCritiqueDir(cwd));
const snapshotPath = path.isAbsolute(snapshotFile)
? path.resolve(snapshotFile)
: path.resolve(dir, snapshotFile);
const filename = path.basename(snapshotPath);
if (
path.dirname(snapshotPath) !== dir
|| !SNAPSHOT_FILENAME.test(filename)
) return null;
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) return null;
snapshot = readSnapshot(snapshotPath);
} catch {
return null;
}
if (!snapshot || snapshot.meta.closed === true) return null;
const closedBody = snapshot.body.replace(
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
'$1\nclosed: true$2',
);
if (closedBody === snapshot.body) {
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
}
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
return snapshot.path;
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
const identifiedSlugs = new Set(
snapshots
.filter((snapshot) => snapshotTargetIdentity(snapshot))
.map((snapshot) => snapshot.meta.slug),
);
const latestByTarget = new Map();
for (const snapshot of snapshots) {
if (!snapshot?.meta.slug) continue;
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
// one. Keep each known identity's latest open/closed state independent so
// closing one target cannot hide another target's live backlog. Once a
// slug has any identity-aware snapshot, its older legacy records are no
// longer independently routable and must not resurface as zombie work.
const targetIdentity = snapshotTargetIdentity(snapshot);
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
latestByTarget.set(streamKey, snapshot);
}
return [...latestByTarget.values()]
.filter((snapshot) => snapshot.meta.closed !== true)
.sort((a, b) => a.path.localeCompare(b.path))
.at(-1) || null;
}
/**
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
// callers never have to run the slug step separately. Anything containing a
// path or URL marker is resolved through slugFromTarget.
function isReadySlug(value) {
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
}
function coerceSlug(value) {
if (!value) return null;
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
if (isReadySlug(value)) return value;
return slugFromTarget(value);
}
@@ -181,14 +322,124 @@ function main(argv) {
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
// The helper, not caller-provided metadata, owns the target fingerprint.
// This makes the snapshot describe the exact file bytes critique saw.
delete meta.target_fingerprint;
delete meta.target_path;
delete meta.target_identity;
const targetIdentity = resolveTargetIdentity(slugArg);
if (targetIdentity) meta.target_identity = targetIdentity;
const targetFingerprint = fingerprintTarget(slugArg);
if (targetFingerprint) {
meta.target_fingerprint = targetFingerprint;
meta.target_path = resolveLocalTargetPath(slugArg);
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(coerceSlug(args[0]));
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
const target = args[0];
const format = args[1];
const slug = coerceSlug(target);
if (!slug || (format && format !== '--json')) {
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
process.exit(1);
}
const targetFingerprint = fingerprintTarget(target);
const targetPath = resolveLocalTargetPath(target);
const targetIdentity = resolveTargetIdentity(target);
const readySlug = isReadySlug(target);
const newestForSlug = readNewestSnapshot(slug);
if (!newestForSlug) { process.exit(2); }
// Concrete targets select the newest snapshot for their exact identity,
// not merely the newest filename for a lossy slug. This keeps distinct
// targets such as foo/bar and foo-bar from hiding each other's backlog.
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
let latest = exactSnapshot;
if (!latest && !readySlug) {
// Legacy snapshots have no identity. Preserve their old explicit
// path/URL behavior only when no known target identity was selected.
latest = readNewestSnapshotForIdentity(slug, null);
}
if (!latest) latest = newestForSlug;
if (latest.meta.closed === true) { process.exit(2); }
const recordedTargetPath = latest.meta.target_path;
const recordedTargetIdentity = snapshotTargetIdentity(latest);
const matchingIdentity = recordedTargetIdentity === targetIdentity;
// Bare slugs remain a supported lookup mode, including for URL
// snapshots. But when a same-named local file exists, the request is
// ambiguous unless that exact file owns the snapshot identity.
if (readySlug && !recordedTargetIdentity) {
process.stderr.write(
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
);
process.exit(2);
}
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
process.stderr.write(
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
);
process.exit(2);
}
const concreteTarget = !readySlug || matchingIdentity;
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
process.exit(2);
}
const concreteLocalTarget = concreteTarget && targetPath;
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
closeSnapshot(latest.path);
process.exit(2);
}
if (format === '--json') {
process.stdout.write(JSON.stringify({
snapshot_file: path.basename(latest.path),
body: latest.body,
}, null, 2) + '\n');
} else {
process.stdout.write(latest.body);
}
return;
}
case 'close': {
const [slugArg, snapshotFile, ...extra] = args;
const slug = coerceSlug(slugArg);
if (!slug || !snapshotFile || extra.length > 0) {
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
process.exit(1);
}
if (
path.basename(snapshotFile) !== snapshotFile
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|| !snapshotFile.endsWith(`__${slug}.md`)
) process.exit(2);
// A slug and filename are not enough to prove ownership because two
// distinct targets can normalize to the same slug. Modern snapshots
// carry a canonical identity, so require the supplied resolved target
// to match it before allowing the exact snapshot to be closed. Legacy
// snapshots without identity retain their historical close behavior.
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
snapshot = readSnapshot(snapshotPath);
} catch {
process.exit(2);
}
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
if (
recordedTargetIdentity
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
) process.exit(2);
const closed = closeSnapshot(snapshotFile);
if (!closed) { process.exit(2); }
process.stdout.write(`${closed}\n`);
return;
}
case 'trend': {
@@ -197,7 +448,7 @@ function main(argv) {
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
process.exit(1);
}
}
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
let embedded = false;
try {
const { spawnSync } = await import('node:child_process');
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
embedded = !result.error && result.status === 0;
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
if (plateCtx && plateCtx.chroma) {
const frac = await keyChroma(out, plateCtx.chroma);
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
+37 -1
View File
@@ -4,7 +4,8 @@
"ignoreFiles": [
"tests/fixtures/**",
"tests/framework-fixtures/**",
"tests/detect-antipatterns.test.js"
"tests/detect-antipatterns.test.js",
"picker/styles/design-context.css"
],
"ignoreValues": [
{
@@ -15,6 +16,41 @@
],
"createdAt": "2026-07-17T00:00:00.000Z",
"reason": "Live overlay chrome is injected over arbitrary host pages and builds a self-contained UI with its own small type scale; DESIGN.md's ramp describes the impeccable website, not this widget"
},
{
"rule": "broken-image",
"value": "*",
"files": [
"picker/pages/index.astro"
],
"createdAt": "2026-07-23T14:51:18.241Z"
},
{
"rule": "design-system-font-size",
"value": "*",
"files": [
"picker/styles/picker.css"
],
"createdAt": "2026-07-23T21:00:00.000Z",
"reason": "The picker is a self-contained questionnaire widget with its own kit-token type scale, and screen 04's cards render arbitrary Google Fonts at specimen sizes; DESIGN.md's ramp describes the impeccable website, not this tool (same footing as the live-browser.js entry)"
},
{
"rule": "design-system-font",
"value": "*",
"files": [
"picker/scripts/design-context.js"
],
"createdAt": "2026-07-30T13:11:54.730Z",
"reason": "Runtime font loader: builds a Google Fonts URL from the family the agent/user chose in the questionnaire; no literal face is being introduced"
},
{
"rule": "bounce-easing",
"value": "cubic-bezier(.34, 1.56, .64, 1)",
"files": [
"picker/scripts/palette-picker.js"
],
"createdAt": "2026-08-20T02:53:58.453Z",
"reason": "plan-18 step 10c: byte-exact port from tmp/motion-previews-premium.html installChoreographedPremium spring stagger"
}
]
},
+1 -1
View File
@@ -15,7 +15,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .kiro/skills/impeccable/scripts/...` command in this skill and its references, and `.kiro/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Structure your feedback as a design director would:
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .kiro/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .kiro/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .kiro/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .kiro/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .kiro/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .kiro/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .kiro/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
+1 -1
View File
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -91,7 +91,7 @@
import crypto from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -16,8 +16,9 @@
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs latest <slug> [--json]
* node critique-storage.mjs trend <slug> [limit]
* node critique-storage.mjs close <resolved-target> <snapshot-file>
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Return an exact content fingerprint for a local file target. URLs and
* non-files return null because their content is not available here.
*
* The fingerprint deliberately describes bytes, not Git state or mtimes:
* critique often assesses an uncommitted file, and a later polish run should
* inherit that backlog when the bytes are unchanged regardless of staging.
*/
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
if (!target || /^https?:\/\//i.test(target)) return null;
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
}
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
if (!target || typeof target !== 'string') return null;
if (/^https?:\/\//i.test(target)) {
try {
const url = new URL(target);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.origin}${pathname}`;
} catch {
return null;
}
}
const filePath = resolveLocalTargetPath(target, { cwd });
return filePath ? `file:${filePath}` : null;
}
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
const filePath = resolveLocalTargetPath(target, { cwd });
if (!filePath) return null;
try {
if (!fs.statSync(filePath).isFile()) return null;
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
} catch {
return null;
}
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
const contents = `${front}\n${body.trim()}\n`;
// A second critique can finish in the same UTC second. Use exclusive
// creation and a fixed-width suffix so concurrent writers cannot replace
// history and lexical ordering still keeps collision entries newest.
for (let collision = 0; collision <= 9999; collision += 1) {
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
try {
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
return filePath;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
}
}
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
}
function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
} else if (value === 'true' || value === 'false') {
value = value === 'true';
}
out[key] = value;
}
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
/**
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
function readSnapshot(filePath) {
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
function snapshotTargetIdentity(snapshot) {
const targetPath = snapshot?.meta.target_path;
return snapshot?.meta.target_identity
|| (targetPath ? `file:${targetPath}` : null);
}
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
}
function readNewestSnapshotForIdentity(
slug,
targetIdentity,
{ cwd = process.cwd() } = {},
) {
const matches = listSnapshots(`__${slug}.md`, cwd)
.map(readSnapshot)
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
return matches.at(-1) || null;
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
const latest = readNewestSnapshot(slug, { cwd });
return latest?.meta.closed === true ? null : latest;
}
/**
* Mark one exact snapshot closed without deleting the score history consumed
* by `trend`. Exact identity matters: a newer critique may land after polish
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
* may be the absolute path returned by readLatestSnapshot() or the basename
* emitted by `latest --json`. Returns the path marked closed, or null.
*/
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
const dir = path.resolve(getCritiqueDir(cwd));
const snapshotPath = path.isAbsolute(snapshotFile)
? path.resolve(snapshotFile)
: path.resolve(dir, snapshotFile);
const filename = path.basename(snapshotPath);
if (
path.dirname(snapshotPath) !== dir
|| !SNAPSHOT_FILENAME.test(filename)
) return null;
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) return null;
snapshot = readSnapshot(snapshotPath);
} catch {
return null;
}
if (!snapshot || snapshot.meta.closed === true) return null;
const closedBody = snapshot.body.replace(
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
'$1\nclosed: true$2',
);
if (closedBody === snapshot.body) {
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
}
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
return snapshot.path;
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
const identifiedSlugs = new Set(
snapshots
.filter((snapshot) => snapshotTargetIdentity(snapshot))
.map((snapshot) => snapshot.meta.slug),
);
const latestByTarget = new Map();
for (const snapshot of snapshots) {
if (!snapshot?.meta.slug) continue;
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
// one. Keep each known identity's latest open/closed state independent so
// closing one target cannot hide another target's live backlog. Once a
// slug has any identity-aware snapshot, its older legacy records are no
// longer independently routable and must not resurface as zombie work.
const targetIdentity = snapshotTargetIdentity(snapshot);
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
latestByTarget.set(streamKey, snapshot);
}
return [...latestByTarget.values()]
.filter((snapshot) => snapshot.meta.closed !== true)
.sort((a, b) => a.path.localeCompare(b.path))
.at(-1) || null;
}
/**
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
// callers never have to run the slug step separately. Anything containing a
// path or URL marker is resolved through slugFromTarget.
function isReadySlug(value) {
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
}
function coerceSlug(value) {
if (!value) return null;
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
if (isReadySlug(value)) return value;
return slugFromTarget(value);
}
@@ -181,14 +322,124 @@ function main(argv) {
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
// The helper, not caller-provided metadata, owns the target fingerprint.
// This makes the snapshot describe the exact file bytes critique saw.
delete meta.target_fingerprint;
delete meta.target_path;
delete meta.target_identity;
const targetIdentity = resolveTargetIdentity(slugArg);
if (targetIdentity) meta.target_identity = targetIdentity;
const targetFingerprint = fingerprintTarget(slugArg);
if (targetFingerprint) {
meta.target_fingerprint = targetFingerprint;
meta.target_path = resolveLocalTargetPath(slugArg);
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(coerceSlug(args[0]));
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
const target = args[0];
const format = args[1];
const slug = coerceSlug(target);
if (!slug || (format && format !== '--json')) {
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
process.exit(1);
}
const targetFingerprint = fingerprintTarget(target);
const targetPath = resolveLocalTargetPath(target);
const targetIdentity = resolveTargetIdentity(target);
const readySlug = isReadySlug(target);
const newestForSlug = readNewestSnapshot(slug);
if (!newestForSlug) { process.exit(2); }
// Concrete targets select the newest snapshot for their exact identity,
// not merely the newest filename for a lossy slug. This keeps distinct
// targets such as foo/bar and foo-bar from hiding each other's backlog.
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
let latest = exactSnapshot;
if (!latest && !readySlug) {
// Legacy snapshots have no identity. Preserve their old explicit
// path/URL behavior only when no known target identity was selected.
latest = readNewestSnapshotForIdentity(slug, null);
}
if (!latest) latest = newestForSlug;
if (latest.meta.closed === true) { process.exit(2); }
const recordedTargetPath = latest.meta.target_path;
const recordedTargetIdentity = snapshotTargetIdentity(latest);
const matchingIdentity = recordedTargetIdentity === targetIdentity;
// Bare slugs remain a supported lookup mode, including for URL
// snapshots. But when a same-named local file exists, the request is
// ambiguous unless that exact file owns the snapshot identity.
if (readySlug && !recordedTargetIdentity) {
process.stderr.write(
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
);
process.exit(2);
}
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
process.stderr.write(
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
);
process.exit(2);
}
const concreteTarget = !readySlug || matchingIdentity;
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
process.exit(2);
}
const concreteLocalTarget = concreteTarget && targetPath;
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
closeSnapshot(latest.path);
process.exit(2);
}
if (format === '--json') {
process.stdout.write(JSON.stringify({
snapshot_file: path.basename(latest.path),
body: latest.body,
}, null, 2) + '\n');
} else {
process.stdout.write(latest.body);
}
return;
}
case 'close': {
const [slugArg, snapshotFile, ...extra] = args;
const slug = coerceSlug(slugArg);
if (!slug || !snapshotFile || extra.length > 0) {
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
process.exit(1);
}
if (
path.basename(snapshotFile) !== snapshotFile
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|| !snapshotFile.endsWith(`__${slug}.md`)
) process.exit(2);
// A slug and filename are not enough to prove ownership because two
// distinct targets can normalize to the same slug. Modern snapshots
// carry a canonical identity, so require the supplied resolved target
// to match it before allowing the exact snapshot to be closed. Legacy
// snapshots without identity retain their historical close behavior.
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
snapshot = readSnapshot(snapshotPath);
} catch {
process.exit(2);
}
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
if (
recordedTargetIdentity
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
) process.exit(2);
const closed = closeSnapshot(snapshotFile);
if (!closed) { process.exit(2); }
process.stdout.write(`${closed}\n`);
return;
}
case 'trend': {
@@ -197,7 +448,7 @@ function main(argv) {
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
process.exit(1);
}
}
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
let embedded = false;
try {
const { spawnSync } = await import('node:child_process');
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
embedded = !result.error && result.status === 0;
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
if (plateCtx && plateCtx.chroma) {
const frac = await keyChroma(out, plateCtx.chroma);
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
+1 -1
View File
@@ -20,7 +20,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .opencode/skills/impeccable/scripts/...` command in this skill and its references, and `.opencode/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Structure your feedback as a design director would:
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .opencode/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .opencode/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .opencode/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .opencode/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .opencode/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .opencode/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .opencode/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -91,7 +91,7 @@
import crypto from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -703,7 +703,28 @@ export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = pro
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -16,8 +16,9 @@
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs latest <slug> [--json]
* node critique-storage.mjs trend <slug> [limit]
* node critique-storage.mjs close <resolved-target> <snapshot-file>
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Return an exact content fingerprint for a local file target. URLs and
* non-files return null because their content is not available here.
*
* The fingerprint deliberately describes bytes, not Git state or mtimes:
* critique often assesses an uncommitted file, and a later polish run should
* inherit that backlog when the bytes are unchanged regardless of staging.
*/
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
if (!target || /^https?:\/\//i.test(target)) return null;
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
}
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
if (!target || typeof target !== 'string') return null;
if (/^https?:\/\//i.test(target)) {
try {
const url = new URL(target);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.origin}${pathname}`;
} catch {
return null;
}
}
const filePath = resolveLocalTargetPath(target, { cwd });
return filePath ? `file:${filePath}` : null;
}
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
const filePath = resolveLocalTargetPath(target, { cwd });
if (!filePath) return null;
try {
if (!fs.statSync(filePath).isFile()) return null;
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
} catch {
return null;
}
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
const contents = `${front}\n${body.trim()}\n`;
// A second critique can finish in the same UTC second. Use exclusive
// creation and a fixed-width suffix so concurrent writers cannot replace
// history and lexical ordering still keeps collision entries newest.
for (let collision = 0; collision <= 9999; collision += 1) {
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
try {
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
return filePath;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
}
}
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
}
function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
} else if (value === 'true' || value === 'false') {
value = value === 'true';
}
out[key] = value;
}
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
/**
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
function readSnapshot(filePath) {
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
function snapshotTargetIdentity(snapshot) {
const targetPath = snapshot?.meta.target_path;
return snapshot?.meta.target_identity
|| (targetPath ? `file:${targetPath}` : null);
}
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
}
function readNewestSnapshotForIdentity(
slug,
targetIdentity,
{ cwd = process.cwd() } = {},
) {
const matches = listSnapshots(`__${slug}.md`, cwd)
.map(readSnapshot)
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
return matches.at(-1) || null;
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
const latest = readNewestSnapshot(slug, { cwd });
return latest?.meta.closed === true ? null : latest;
}
/**
* Mark one exact snapshot closed without deleting the score history consumed
* by `trend`. Exact identity matters: a newer critique may land after polish
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
* may be the absolute path returned by readLatestSnapshot() or the basename
* emitted by `latest --json`. Returns the path marked closed, or null.
*/
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
const dir = path.resolve(getCritiqueDir(cwd));
const snapshotPath = path.isAbsolute(snapshotFile)
? path.resolve(snapshotFile)
: path.resolve(dir, snapshotFile);
const filename = path.basename(snapshotPath);
if (
path.dirname(snapshotPath) !== dir
|| !SNAPSHOT_FILENAME.test(filename)
) return null;
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) return null;
snapshot = readSnapshot(snapshotPath);
} catch {
return null;
}
if (!snapshot || snapshot.meta.closed === true) return null;
const closedBody = snapshot.body.replace(
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
'$1\nclosed: true$2',
);
if (closedBody === snapshot.body) {
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
}
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
return snapshot.path;
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
const identifiedSlugs = new Set(
snapshots
.filter((snapshot) => snapshotTargetIdentity(snapshot))
.map((snapshot) => snapshot.meta.slug),
);
const latestByTarget = new Map();
for (const snapshot of snapshots) {
if (!snapshot?.meta.slug) continue;
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
// one. Keep each known identity's latest open/closed state independent so
// closing one target cannot hide another target's live backlog. Once a
// slug has any identity-aware snapshot, its older legacy records are no
// longer independently routable and must not resurface as zombie work.
const targetIdentity = snapshotTargetIdentity(snapshot);
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
latestByTarget.set(streamKey, snapshot);
}
return [...latestByTarget.values()]
.filter((snapshot) => snapshot.meta.closed !== true)
.sort((a, b) => a.path.localeCompare(b.path))
.at(-1) || null;
}
/**
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
// callers never have to run the slug step separately. Anything containing a
// path or URL marker is resolved through slugFromTarget.
function isReadySlug(value) {
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
}
function coerceSlug(value) {
if (!value) return null;
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
if (isReadySlug(value)) return value;
return slugFromTarget(value);
}
@@ -181,14 +322,124 @@ function main(argv) {
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
// The helper, not caller-provided metadata, owns the target fingerprint.
// This makes the snapshot describe the exact file bytes critique saw.
delete meta.target_fingerprint;
delete meta.target_path;
delete meta.target_identity;
const targetIdentity = resolveTargetIdentity(slugArg);
if (targetIdentity) meta.target_identity = targetIdentity;
const targetFingerprint = fingerprintTarget(slugArg);
if (targetFingerprint) {
meta.target_fingerprint = targetFingerprint;
meta.target_path = resolveLocalTargetPath(slugArg);
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(coerceSlug(args[0]));
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
const target = args[0];
const format = args[1];
const slug = coerceSlug(target);
if (!slug || (format && format !== '--json')) {
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
process.exit(1);
}
const targetFingerprint = fingerprintTarget(target);
const targetPath = resolveLocalTargetPath(target);
const targetIdentity = resolveTargetIdentity(target);
const readySlug = isReadySlug(target);
const newestForSlug = readNewestSnapshot(slug);
if (!newestForSlug) { process.exit(2); }
// Concrete targets select the newest snapshot for their exact identity,
// not merely the newest filename for a lossy slug. This keeps distinct
// targets such as foo/bar and foo-bar from hiding each other's backlog.
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
let latest = exactSnapshot;
if (!latest && !readySlug) {
// Legacy snapshots have no identity. Preserve their old explicit
// path/URL behavior only when no known target identity was selected.
latest = readNewestSnapshotForIdentity(slug, null);
}
if (!latest) latest = newestForSlug;
if (latest.meta.closed === true) { process.exit(2); }
const recordedTargetPath = latest.meta.target_path;
const recordedTargetIdentity = snapshotTargetIdentity(latest);
const matchingIdentity = recordedTargetIdentity === targetIdentity;
// Bare slugs remain a supported lookup mode, including for URL
// snapshots. But when a same-named local file exists, the request is
// ambiguous unless that exact file owns the snapshot identity.
if (readySlug && !recordedTargetIdentity) {
process.stderr.write(
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
);
process.exit(2);
}
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
process.stderr.write(
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
);
process.exit(2);
}
const concreteTarget = !readySlug || matchingIdentity;
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
process.exit(2);
}
const concreteLocalTarget = concreteTarget && targetPath;
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
closeSnapshot(latest.path);
process.exit(2);
}
if (format === '--json') {
process.stdout.write(JSON.stringify({
snapshot_file: path.basename(latest.path),
body: latest.body,
}, null, 2) + '\n');
} else {
process.stdout.write(latest.body);
}
return;
}
case 'close': {
const [slugArg, snapshotFile, ...extra] = args;
const slug = coerceSlug(slugArg);
if (!slug || !snapshotFile || extra.length > 0) {
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
process.exit(1);
}
if (
path.basename(snapshotFile) !== snapshotFile
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|| !snapshotFile.endsWith(`__${slug}.md`)
) process.exit(2);
// A slug and filename are not enough to prove ownership because two
// distinct targets can normalize to the same slug. Modern snapshots
// carry a canonical identity, so require the supplied resolved target
// to match it before allowing the exact snapshot to be closed. Legacy
// snapshots without identity retain their historical close behavior.
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
snapshot = readSnapshot(snapshotPath);
} catch {
process.exit(2);
}
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
if (
recordedTargetIdentity
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
) process.exit(2);
const closed = closeSnapshot(snapshotFile);
if (!closed) { process.exit(2); }
process.stdout.write(`${closed}\n`);
return;
}
case 'trend': {
@@ -197,7 +448,7 @@ function main(argv) {
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
process.exit(1);
}
}
@@ -995,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -431,12 +431,15 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
let embedded = false;
try {
const { spawnSync } = await import('node:child_process');
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
embedded = !result.error && result.status === 0;
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
if (plateCtx && plateCtx.chroma) {
const frac = await keyChroma(out, plateCtx.chroma);
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
@@ -768,7 +768,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -777,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
+1 -1
View File
@@ -18,7 +18,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .pi/skills/impeccable/scripts/...` command in this skill and its references, and `.pi/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
+3 -3
View File
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Structure your feedback as a design director would:
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .pi/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
+1 -1
View File
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .pi/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .pi/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .pi/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .pi/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .pi/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .pi/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
+1 -1
View File
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.

Some files were not shown because too many files have changed in this diff Show More