diff --git a/.agent/skills/impeccable/SKILL.md b/.agent/skills/impeccable/SKILL.md index dfd06d372..f5da1671f 100644 --- a/.agent/skills/impeccable/SKILL.md +++ b/.agent/skills/impeccable/SKILL.md @@ -1,7 +1,7 @@ --- name: impeccable description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks. -version: 4.1.1 +version: 4.1.2 license: Apache 2.0 allowed-tools: - Bash(npx impeccable *) diff --git a/.agent/skills/impeccable/reference/degraded/finish-reviewer.md b/.agent/skills/impeccable/reference/degraded/finish-reviewer.md index 5b9331217..e2a4fd130 100644 --- a/.agent/skills/impeccable/reference/degraded/finish-reviewer.md +++ b/.agent/skills/impeccable/reference/degraded/finish-reviewer.md @@ -17,7 +17,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s); 0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round. 1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all. -2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. +2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement. 3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition. 4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport. 5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material. diff --git a/.agent/skills/impeccable/reference/hooks.md b/.agent/skills/impeccable/reference/hooks.md index ccc05f11d..188215495 100644 --- a/.agent/skills/impeccable/reference/hooks.md +++ b/.agent/skills/impeccable/reference/hooks.md @@ -2,9 +2,9 @@ Manage the **design detector hook** for the current project. -The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout. -The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`. Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end. @@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores. -Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch. +Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch. On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`. ``` 3. If `` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`." -4. If `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." +4. If `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file." 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. diff --git a/.agent/skills/impeccable/reference/new-work.md b/.agent/skills/impeccable/reference/new-work.md index 05e747e10..8647c52c7 100644 --- a/.agent/skills/impeccable/reference/new-work.md +++ b/.agent/skills/impeccable/reference/new-work.md @@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u 4. Run `node .agent/skills/impeccable/scripts/concept-seed.mjs --scope direction --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 IMPECCABLE’S 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 ` 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 .agent/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--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 `, repeating while it exits 3; the ANSWER prints as JSON. 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. 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 .agent/skills/impeccable/scripts/serve-question.mjs --start --payload ` (`--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 `, 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 --reroll ` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key --payload `, 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. @@ -68,7 +68,7 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su ## 5. Record the decision -Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract. +Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract. On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md. @@ -86,7 +86,7 @@ For `shape`, return the selected direction to [shape.md](shape.md) and stop befo ## 6. Build with full commitment -When an approved comp exists, the comp is king, and the build happens in phases. The comp is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words, and difficulty never infers a downgrade. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses. +When an approved comp exists, the comp is king, and the build happens in phases. The comp is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words, and difficulty never infers a downgrade. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Every color the brief records gets that comparison by number, not by eye: sample the build screenshot's ground, dominant fields, and accents the same way each record was taken (an interior patch average where the record is an average, both end colors where the record is a gradient) and set each value against its recorded counterpart (sampled from the comp itself when the brief lacks one), and when a texture or tile paints over a base token, measure the net on-screen value, because the eye files a drifted color under the same color word and the number is what catches it. Judge the gap like a colorist, not a diff tool: a difference with a color name (warmer, grayer, darker than the record) is drift to fix, while a few digits of render and compression noise are the same color. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses. Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk. @@ -113,6 +113,8 @@ Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccabl Act on the disposition word; there are exactly four. **recapture**: the evidence failed, not the build. Recapture what the return names under the capture-validity rules, then run a full review over the new evidence. A review conducted on invalid evidence binds nothing, and a verdict pass may never follow it. **rebuild**: fidelity failed wholesale, not in patches. Skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a fresh full review, never a verdict pass; a rebuild replaces regions wholesale, so the whole matrix runs again over the recaptures. Tell the user what is happening rather than asking permission to fix a failure. Consult the user only on a second rebuild directive, both verdicts on the table, or when rebuilding would discard content the user approved. **ship**: nothing is owed; report the verdict at its scope and continue to the documenter. **fix**: apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever decides, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Do not run a second detector. +A rebuild and a fix round share one asset rule: a raster either round creates or replaces is still asset work under [visualize.md](visualize.md)'s Produce section and keeps its **provenance** like every build raster, and a raster the round abandons is deleted in the same batch. Before either round's result goes back for review or verdict, run `node .agent/skills/impeccable/scripts/embed-prompt.mjs --scan ` over the directories the artifact's rasters ship from and clear every file it reports by embedding what it is missing: the exact generation prompt for a produced raster, the origin for a sourced, stock, or pre-existing one. The scan only reads; deletion is reserved for rasters the round abandoned, never for a file the scan flagged. + Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice. Then spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, the artifact path, the direction contract, PRODUCT.md, the [document.md](document.md) reference path, and the boundary to write at; it records DESIGN.md and the sidecar from the built world, ground truth over intention; without subagents the pass runs from [degraded/documenter.md](degraded/documenter.md). The documenter runs after the last correction lands: when any fix round follows the documentation, re-run the documenter over the changed surface, because a DESIGN.md describing a layout that no longer exists turns defects into system guidance. A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded. diff --git a/.agent/skills/impeccable/reference/visualize.md b/.agent/skills/impeccable/reference/visualize.md index f097f22e3..ee65d3870 100644 --- a/.agent/skills/impeccable/reference/visualize.md +++ b/.agent/skills/impeccable/reference/visualize.md @@ -33,6 +33,8 @@ After approval, record the choice where tools can find it: the approved comp's p Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp. Everything the comp does not show gets built from this record; without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick is the compliance-token version of commitment. An element never written down is the element the build silently drops; the direction contract's 150 words cannot carry this list, so it lives here. +The record is sampled, never estimated: read the comp's page **ground**, each dominant field, and each accent's actual hex from its pixels (ImageMagick, Python with PIL, any pixel-reading tool on the machine) and write the values into the same record. Take a flat field from any interior pixel, a textured or grainy one as the average of an interior patch (crop a swatch, scale it to one pixel), and a gradient as its two end colors; never sample an edge, where antialiasing blends neighbors into colors the design never chose. An adjective is a direction, not a record: cream covers everything from near-white to beige, charcoal a third of the value scale, and wherever no number pins a color, the rendition prior picks the spot. Sampled values supersede the palette chips on the decision and composition cards: those were authored before this comp existed, and a chip that disagrees with the comp's pixels is a draft the approval retired. + The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; so is any texture by name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice; it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly (diagrams with countable elements, controls, flat shape systems) and ends where drawing skill begins; an instruction-manual world keeps its illustrations as line-art illustrations, not diagrams. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. Dropping an image-native region is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity; "no photography on hand" forbids fake proof, not an illustrated hero. The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped. @@ -43,7 +45,9 @@ The comp is a north star, not something to trace, and know what that allows: tra ## Produce only the assets the build needs -Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. Prefer generating build-critical imagery in the build thread when the budget allows; when a subagent produces assets instead, every asset carries its prompt, and the builder reads those prompts before composing. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .agent/skills/impeccable/scripts/embed-prompt.mjs --prompt ""` so the intent lives inside the file and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image. +Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. Prefer generating build-critical imagery in the build thread when the budget allows; when a subagent produces assets instead, every asset carries its prompt, and the builder reads those prompts before composing. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .agent/skills/impeccable/scripts/embed-prompt.mjs --prompt ""` with the exact string the generation tool received, pasted whole, so the intent lives inside the file and survives copies between machines and harnesses; a summary reconstructed from memory records an asset that was never made. `--read` recovers the prompt from any impeccable-generated image, and `--scan ` lists every raster in a directory still missing one. The embedded prompt plus the asset's row in the written inventory is the raster's **provenance**, and every raster the artifact references carries it; a sourced, stock, or pre-existing raster with no generation prompt embeds its origin instead. + +Provenance is owed for the run, not the build phase: a raster created or replaced later, in a fix batch or a reviewer's rebuild, is produced under this same section, prompt embedded and inventory row added, because the inventory is how the next thread knows what ships. A raster a fix abandons or supersedes is deleted from the assets directory in the same batch; an unreferenced raster with no record is a provenance leak, not a spare. When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and the runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Without subagents, produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists. diff --git a/.agent/skills/impeccable/scripts/context.mjs b/.agent/skills/impeccable/scripts/context.mjs index 203bb378e..ea5cad4c0 100644 --- a/.agent/skills/impeccable/scripts/context.mjs +++ b/.agent/skills/impeccable/scripts/context.mjs @@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() { } } +// Destroy fetch's global undici dispatcher before process.exit(): a live +// keep-alive socket trips a libuv assertion on Windows/Node 24 after a +// successful boot (nodejs/node#56645, issue #573). +async function destroyFetchDispatcher() { + const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')]; + if (dispatcher && typeof dispatcher.destroy === 'function') { + try { await dispatcher.destroy(); } catch { /* exit regardless */ } + } +} + +// Drain the boot payload before process.exit(): a live pipe that has not +// flushed yet is truncated when Node tears down (issue #573 review). Then +// close fetch so Windows teardown does not abort on the keep-alive socket. +async function finishCli(output) { + await new Promise((resolve) => { + process.stdout.write(output, () => resolve()); + }); + await destroyFetchDispatcher(); + process.exit(0); +} + // Two instructions used to sit in one directive: ask, and "if they agree, run // it". Nothing gated the second on an answer, and the same sentence said to // continue without waiting, so a run that could never establish agreement was @@ -1159,8 +1180,7 @@ async function cli() { appendImageToolsDirective(parts); appendStalenessDirective(parts, ctx, cliOptions); if (updateDirective) parts.push(updateDirective); - process.stdout.write(parts.join('\n\n---\n\n') + '\n'); - process.exit(0); + await finishCli(parts.join('\n\n---\n\n') + '\n'); } const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`]; if (ctx.hasDesign) { @@ -1206,7 +1226,7 @@ async function cli() { } } if (updateDirective) parts.push(updateDirective); - process.stdout.write(parts.join('\n\n---\n\n') + '\n'); + await finishCli(parts.join('\n\n---\n\n') + '\n'); } function parseCliOptions(args) { diff --git a/.agent/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.agent/skills/impeccable/scripts/detector/browser/injected/index.mjs index 4cf648906..6eb971450 100644 --- a/.agent/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.agent/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -626,7 +626,7 @@ if (IS_BROWSER) { if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); - const solidBg = parseRgb(currentStyle.backgroundColor); + const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor); if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; current = current.parentElement; } @@ -688,7 +688,7 @@ if (IS_BROWSER) { // starve the url()-backed texts this mode exists to sample. if (options.imageOnly && !reasons.includes('image background')) continue; - const textColor = parseRgb(style.color); + const textColor = parseRgb(style.color) || parseAnyColor(style.color); const fontSize = parseFloat(style.fontSize) || 16; const fontWeight = parseInt(style.fontWeight) || 400; const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); @@ -985,7 +985,7 @@ if (IS_BROWSER) { return sample; } } - const bg = parseRgb(style.backgroundColor); + const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; return { status: 'unresolved', reason: 'no readable background' }; } @@ -1115,7 +1115,7 @@ if (IS_BROWSER) { } const style = getComputedStyle(el); - const textColor = parseRgb(style.color) || candidate.textColor; + const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor; if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; const rect = getDirectTextRect(el) || el.getBoundingClientRect(); diff --git a/.agent/skills/impeccable/scripts/detector/design-system.mjs b/.agent/skills/impeccable/scripts/detector/design-system.mjs index 5c9a949e6..28e01b1d2 100644 --- a/.agent/skills/impeccable/scripts/detector/design-system.mjs +++ b/.agent/skills/impeccable/scripts/detector/design-system.mjs @@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs']; // CLI can't import (separate tree). `.git` and `package.json` are the common // boundaries; `.impeccable` is our own project marker. const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable']; +// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared +// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`) +// or a marker file beside apps/ or packages/ children. +const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json']; +const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages']; const COLOR_CHANNEL_TOLERANCE = 6; // Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the // difference between a documented shadow and drift), so shadow matching cannot @@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) { } } +// Same two groups as context.mjs's readProjectPatternGroups: Impeccable +// projectRoots govern any path they match (positive or negated); package-manager +// globs only apply to paths the Impeccable group does not match. +function readWorkspacePatternGroups(dir) { + const impeccable = []; + for (const name of ['config.json', 'config.local.json']) { + const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots; + if (Array.isArray(roots)) { + impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim())); + } + } + const pkg = []; + const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces; + if (Array.isArray(workspaces)) pkg.push(...workspaces); + else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages); + const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages; + if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages); + try { + let inPackages = false; + for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) { + const trimmed = stripInlineYamlComment(line).trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/); + if (flow) { + pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)); + break; + } + if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; } + if (!inPackages) continue; + const item = trimmed.match(/^-\s*(.+)$/); + if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, '')); + else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break; + } + } catch { /* no pnpm-workspace.yaml */ } + return [impeccable, pkg]; +} + +function readWorkspacePatterns(dir) { + return readWorkspacePatternGroups(dir).flat(); +} + +function isMonorepoRoot(dir) { + if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true; + if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false; + return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => { + try { + return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory()); + } catch { + return false; + } + }); +} + +function monorepoOwnsPath(root, boundaryDir) { + const rel = path.relative(root, boundaryDir); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false; + const relSegments = rel.split(path.sep).filter(Boolean); + + function normalizeWorkspacePattern(pattern) { + return String(pattern || '') + .trim() + .replace(/^['"]|['"]$/g, '') + .replace(/^\.\//, '') + .replace(/\/+$/, ''); + } + + function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function segmentMatches(patternSegment, relSegment) { + if (patternSegment === '*') return true; + if (!patternSegment.includes('*')) return patternSegment === relSegment; + const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`); + return re.test(relSegment); + } + + function matchGlobSegments(patternSegments, relSegments) { + function rec(pi, ri) { + if (pi === patternSegments.length) return ri === relSegments.length; + if (patternSegments[pi] === '**') { + if (pi === patternSegments.length - 1) return true; + for (let k = ri; k <= relSegments.length; k++) { + if (rec(pi + 1, k)) return true; + } + return false; + } + if (ri >= relSegments.length) return false; + if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false; + return rec(pi + 1, ri + 1); + } + return rec(0, 0); + } + + // Negations like !packages/excluded must also cover nested dirs under that path. + function matchesNegation(pattern) { + const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean); + if (!patternSegments.length) return false; + if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments); + if (relSegments.length < patternSegments.length) return false; + for (let i = 0; i < patternSegments.length; i++) { + if (!segmentMatches(patternSegments[i], relSegments[i])) return false; + } + return true; + } + + // Positive globs identify workspace packages at exact depth (`*` is a direct + // child). A nested package.json under that package is still owned: the + // ancestor directory of glob length must itself be a package. + function positiveOwns(pattern) { + const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean); + if (!patternSegments.length) return false; + if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments); + if (relSegments.length < patternSegments.length) return false; + for (let i = 0; i < patternSegments.length; i++) { + if (!segmentMatches(patternSegments[i], relSegments[i])) return false; + } + if (relSegments.length === patternSegments.length) return true; + const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length)); + return fs.existsSync(path.join(ancestorDir, 'package.json')); + } + + function groupOwns(rawPatterns) { + const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean); + if (!patterns.length) return null; + const excluded = patterns.some((pattern) => ( + pattern.startsWith('!') && matchesNegation(pattern.slice(1)) + )); + const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns); + if (!excluded && !included) return null; + if (excluded) return false; + return true; + } + + const [impeccable, pkg] = readWorkspacePatternGroups(root); + const fromImpeccable = groupOwns(impeccable); + if (fromImpeccable !== null) return fromImpeccable; + const fromPkg = groupOwns(pkg); + if (fromPkg !== null) return fromPkg; + if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) { + return false; + } + return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]); +} + +// Both forms of the home directory. The walk compares path strings, and a +// symlinked home (e.g. /home -> /var/home) never string-matches the physical +// paths a cwd-resolved target produces, which would let the post-boundary walk +// sail through $HOME and inherit from it. +function homeDirForms() { + const homeDir = path.resolve(os.homedir()); + const forms = new Set([homeDir]); + try { + forms.add(fs.realpathSync(homeDir)); + } catch { /* keep the logical form only */ } + return forms; +} + // Walk up from `startDir` to the directory that governs the target's design // system, mirroring skill/scripts/context.mjs's project-boundary semantics: // // - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the // design root — that's where the rules live. // - A directory carrying a project marker (.git / package.json / .impeccable) -// but no DESIGN.md is a project BOUNDARY: the walk stops with no design -// system, so a sibling project never inherits a parent's or cwd's rules. +// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits +// the ancestor DESIGN.md only when that ancestor's workspace declarations +// include the path (negations win; a nested package under a matched +// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm +// with no globs) still own apps/ and packages/. A stray nested +// package that matches no glob does not inherit. This is detect's +// contamination contract, not skill-context's repoRoot fallback for +// excluded paths. A nested separate repository (.git with no workspace +// declaration) still inherits nothing (issue #570). // - Reaching the home directory / filesystem root with neither means no // design system at all — never process.cwd()'s. // @@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) { // runs out. This is the fix for cross-project contamination. export function findDesignRoot(startDir) { let dir = path.resolve(startDir); - const homeDir = path.resolve(os.homedir()); + const homeDirs = homeDirForms(); + let boundary = null; while (true) { - if (resolveDesignMdPath(dir)) return { dir, hasDesign: true }; - if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) { - return { dir, hasDesign: false }; + if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true }; + if (boundary) { + // Past the boundary the walk only looks for the monorepo root that owns + // the workspace path (workspace globs including negations, or marker-only + // apps/packages fallback). Monorepo-root before .git, same order as + // context.mjs: a workspace root carrying its own .git is still recognized, + // while a .git that declares no workspaces is a separate repository and + // stops the walk with nothing inherited. The home directory is never an + // owning root, same as context.mjs's findMonorepoRoot, which stops at + // homeDir before its monorepo check. + if (!homeDirs.has(dir) && isMonorepoRoot(dir)) { + if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) }; + return boundary; + } + if (fs.existsSync(path.join(dir, '.git'))) return boundary; + } else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) { + boundary = { dir, hasDesign: false }; + // A boundary that is itself a monorepo root, or a separate repository + // with its own .git, inherits nothing from above. + if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary; } - if (dir === homeDir) return null; + if (homeDirs.has(dir)) return boundary; const parent = path.dirname(dir); - if (parent === dir) return null; + if (parent === dir) return boundary; dir = parent; } } diff --git a/.agent/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agent/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8d5bce3f6..8893bb323 100644 --- a/.agent/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agent/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -773,14 +773,22 @@ function extractColorFunctionTokens(value) { function parseGradientColors(bgImage) { if (!bgImage || !bgImage.includes('gradient')) return []; const colors = []; + const tokenSpans = []; + let from = 0; // Stops arrive in whatever syntax the author wrote and the browser kept. // A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used // to read as a gradient with no stops at all. for (const token of extractColorFunctionTokens(bgImage)) { + const start = bgImage.indexOf(token, from); + if (start < 0) break; + tokenSpans.push({ start, end: start + token.length }); + from = start + token.length; const c = parseAnyColor(token); if (c) colors.push(c); } for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { + // Nested hex inside color-mix is an ingredient, not a stop (issue #578). + if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue; const h = m[1]; if (h.length === 6) { colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 }); @@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) { return results; } -// Decorative grid or line-field backgrounds drawn with hairline +// Decorative two-axis grid backgrounds drawn with hairline // linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML // pattern pass and the regex source engine so standalone CSS, component // styles, and inline styles receive the same coverage. Both signals must // co-occur in one declaration block; unrelated rules must not add up across -// the file. Returns [{ index, snippet }], capped at one finding per source to -// match the page-level HTML check's existing behavior. +// the file. A single hairline is a line, divider, or rail, not a grid, even +// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one +// finding per source to match the page-level HTML check's existing behavior. function scanCssTextForGridBackground(content) { const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi; const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi; const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i; - const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i; const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/; - const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/; const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi; const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi; let blk; @@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) { } if (hairlineCount === 0) continue; const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined); - const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined); - if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) { + if (hairlineCount >= 2 && hasPxCell) { return [{ index: blk.index, - snippet: hairlineCount >= 2 - ? 'two-axis grid-line gradient background' - : 'px-tiled hairline line-field background', + snippet: 'two-axis grid-line gradient background', }]; } } @@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) { } // Check for neon text (vivid cyan/purple color on dark background) - const textColor = parseRgb(style.color); + const textColor = parseRgb(style.color) || parseAnyColor(style.color); if (textColor && hasChroma(textColor, 80)) { const hue = getHue(textColor); const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310); @@ -7281,7 +7285,7 @@ if (IS_BROWSER) { if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); - const solidBg = parseRgb(currentStyle.backgroundColor); + const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor); if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; current = current.parentElement; } @@ -7343,7 +7347,7 @@ if (IS_BROWSER) { // starve the url()-backed texts this mode exists to sample. if (options.imageOnly && !reasons.includes('image background')) continue; - const textColor = parseRgb(style.color); + const textColor = parseRgb(style.color) || parseAnyColor(style.color); const fontSize = parseFloat(style.fontSize) || 16; const fontWeight = parseInt(style.fontWeight) || 400; const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); @@ -7640,7 +7644,7 @@ if (IS_BROWSER) { return sample; } } - const bg = parseRgb(style.backgroundColor); + const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; return { status: 'unresolved', reason: 'no readable background' }; } @@ -7770,7 +7774,7 @@ if (IS_BROWSER) { } const style = getComputedStyle(el); - const textColor = parseRgb(style.color) || candidate.textColor; + const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor; if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; const rect = getDirectTextRect(el) || el.getBoundingClientRect(); diff --git a/.agent/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.agent/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 8bce32525..26ca4f390 100644 --- a/.agent/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.agent/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) { } const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); +const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']); const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']); const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']); @@ -256,6 +257,153 @@ function stripCssComments(content) { return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')); } +function blankHtmlComments(text) { + return text.replace(//g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function blankCssLineCommentsInStyleBlocks(text) { + const re = /]*>([\s\S]*?)<\/style>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + const inner = match[1]; + const openLength = match[0].length - inner.length - ''.length; + output += text.slice(lastIndex, match.index); + output += match[0].slice(0, openLength); + output += blankCssLineComments(inner); + output += match[0].slice(openLength + inner.length); + lastIndex = re.lastIndex; + } + return output + text.slice(lastIndex); +} + +function blankHtmlAndCssCommentsOutsideScripts(text) { + const re = /]*>[\s\S]*?<\/script>/gi; + let output = ''; + let lastIndex = 0; + let match; + while ((match = re.exec(text)) !== null) { + output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index)))); + output += match[0]; + lastIndex = re.lastIndex; + } + return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex)))); +} + +function blankCssLineComments(text) { + let output = ''; + let state = 'code'; + let urlDepth = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + if (state === 'line') { + if (char === '\n') { + output += '\n'; + state = 'code'; + } else { + output += ' '; + } + continue; + } + if (state === 'single' || state === 'double') { + output += char; + if (char === '\\' && next) { + output += next; + i++; + } else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) { + state = 'code'; + } + continue; + } + const prev = output.length ? output[output.length - 1] : ''; + if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') { + output += ' '; + i++; + state = 'line'; + continue; + } + if (char === "'") state = 'single'; + else if (char === '"') state = 'double'; + if (char === '(') { + const behind = output.replace(/\s+$/, ''); + if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++; + } else if (char === ')' && urlDepth) { + urlDepth--; + } + output += char; + } + return output; +} + +function findAstroFrontmatterClose(text) { + if (!text.startsWith('---')) return -1; + let cursor = text.indexOf('\n'); + if (cursor === -1) return -1; + cursor += 1; + while (cursor < text.length) { + if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) { + let end = cursor + 3; + while (text[end] === ' ' || text[end] === '\t') end++; + if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1; + } + const char = text[cursor]; + const next = text[cursor + 1]; + if (char === "'" || char === '"') { + const close = findQuotedStringEnd(text, cursor, char); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '`') { + const close = findTemplateLiteralEnd(text, cursor); + if (close === -1) return -1; + cursor = close + 1; + continue; + } + if (char === '/' && next === '/') { + const lineEnd = text.indexOf('\n', cursor); + if (lineEnd === -1) return -1; + cursor = lineEnd; + continue; + } + if (char === '/' && next === '*') { + const commentEnd = text.indexOf('*/', cursor + 2); + if (commentEnd === -1) return -1; + cursor = commentEnd + 2; + continue; + } + if (char === '/' && next !== '/' && next !== '*') { + const close = findRegexLiteralEnd(text, cursor); + if (close !== -1) { + cursor = close + 1; + continue; + } + } + cursor++; + } + return -1; +} + +function blankAstroFrontmatterComments(text) { + const close = findAstroFrontmatterClose(text); + if (close === -1) return text; + return stripJsComments(text.slice(0, close)) + text.slice(close); +} + +function blankCommentsForMatchers(text, ext) { + if (PAGE_ANALYZER_EXTS.has(ext)) { + const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text; + return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter); + } + if (STYLESHEET_EXTS.has(ext)) { + const withoutBlocks = stripCssComments(text); + return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks); + } + return text; +} + function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } @@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) { const ext = extFromFilePath(filePath); const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, { jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx', - }) : content; + }) : blankCommentsForMatchers(content, ext); const source = stripCssInJsComments(commentStrippedSource, ext); const lines = source.split('\n'); // Run regex matchers on the full file content (catches Tailwind classes, inline styles) // Enable block context for CSS files where related properties span multiple lines - const cssLike = new Set(['.css', '.scss', '.sass', '.less']); - findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, { + findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, { profile, phase: 'source', })); @@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) { scanCssTextForPseudoStripe(text).map(hit => finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length)); - if (cssLike.has(ext)) { + if (STYLESHEET_EXTS.has(ext)) { findings.push(...scanInsetStripeCss(content, filePath)); findings.push(...pseudoStripeFindings(content, 0)); } @@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) { }, () => extractStyleBlocks(content, ext)) : extractStyleBlocks(content, ext); for (const block of styleBlocks) { - const blockLines = block.content.split('\n'); + const blockContent = blankCssLineComments(stripCssComments(block.content)); + const blockLines = blockContent.split('\n'); findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, { profile, phase: 'style-block', @@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) { // 1-based, so the offset is startLine - 2; startLine - 1 double-counted and // reported every selector one line low. runRegexMatchers keeps startLine - 1 // because it indexes its split lines from zero. - findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2)); - findings.push(...pseudoStripeFindings(block.content, block.startLine - 2)); + findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2)); + findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2)); } // Extract and scan CSS-in-JS template literals diff --git a/.agent/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.agent/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index caf4aff44..cc36ac9b5 100644 --- a/.agent/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.agent/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs @@ -835,10 +835,11 @@ class StaticElement { } } closest(selector) { + const matcher = this._doc.matcherFor(selector); let cur = this.node; while (cur && cur.type === 'tag') { try { - if (this._doc.is(cur, selector)) return this._doc.wrap(cur); + if (matcher(cur)) return this._doc.wrap(cur); } catch { return null; } @@ -862,9 +863,10 @@ class StaticDocument { this.root = root; this.selectAll = modules.selectAll; this.selectOne = modules.selectOne; - this.is = modules.is; + this.compile = modules.compile; this.domutils = modules.domutils; this._wrappers = new WeakMap(); + this._compiledSelectors = new Map(); this._styleMap = new WeakMap(); this._hoverStyleMap = new WeakMap(); this._accentDashPseudo = new WeakSet(); @@ -882,6 +884,20 @@ class StaticDocument { } return wrapped; } + matcherFor(selector) { + let matcher = this._compiledSelectors.get(selector); + if (!matcher) { + try { + matcher = this.compile(selector); + } catch (err) { + // Cache the failure as a rethrower so a bad selector still reaches + // closest()'s catch on every call, first and repeat alike. + matcher = () => { throw err; }; + } + this._compiledSelectors.set(selector, matcher); + } + return matcher; + } querySelectorAll(selector) { try { return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node)); @@ -948,8 +964,34 @@ function buildStaticWindow(staticDoc) { }; } +function resolveLinkedCssPath(fileDir, href) { + const stripped = href.split(/[?#]/)[0]; + const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); + if (!rootRelative) return path.resolve(fileDir, stripped); + // Drop "." and reject ".." so /../outside.css cannot walk out of dir. + const segments = stripped.replace(/^\/+/, '').split(/[/\\]/).filter(p => p && p !== '.'); + if (segments.some(p => p === '..')) return path.join(fileDir, segments.filter(p => p !== '..').join(path.sep)); + const rel = segments.join(path.sep); + let dir = fileDir; + for (;;) { + const parent = path.dirname(dir); + if (parent === dir) break; // never use the filesystem root as document root + try { + const candidate = path.join(dir, rel); + if (fs.statSync(candidate).isFile()) return candidate; + } catch { /* missing or unreadable candidate */ } + // Stop at the project root so a coincidental ~/static/app.css cannot win. + try { + if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) break; + } catch { /* unreadable marker */ } + dir = parent; + } + return path.join(fileDir, rel); +} + function collectStaticCssText(root, fileDir, profile, filePath, modules) { const styleTexts = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } @@ -958,10 +1000,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) { const rel = link.attribs?.rel || ''; const href = link.attribs?.href || ''; if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue; - // Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a - // literal path with the query in it; a versioned link otherwise made the - // whole stylesheet invisible to every element-level check. - const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]); + // Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs + // must not resolve as OS-absolute paths; otherwise the whole stylesheet is + // invisible to every element-level check. + const cssPath = resolveLinkedCssPath(fileDir, href); try { const css = profileStep(profile, { engine: 'static-html', @@ -971,7 +1013,14 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) { detail: href, }, () => fs.readFileSync(cssPath, 'utf-8')); styleTexts.push(css); - } catch { /* skip unreadable */ } + } catch { + if (!warnedMissingStylesheets.has(cssPath)) { + warnedMissingStylesheets.add(cssPath); + process.stderr.write( + `impeccable detect: could not read linked stylesheet ${href} (resolved to ${cssPath}); color and custom-property rules will be incomplete\n` + ); + } + } } return styleTexts.join('\n'); } diff --git a/.agent/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.agent/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9e7a429a2..b4efa84ac 100644 --- a/.agent/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.agent/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -134,7 +134,7 @@ async function detectHtml(filePath, options = {}) { parseDocument: htmlparser2.parseDocument, selectAll: cssSelect.selectAll, selectOne: cssSelect.selectOne, - is: cssSelect.is, + compile: cssSelect.compile, csstree, domutils, }; diff --git a/.agent/skills/impeccable/scripts/detector/rules/checks.mjs b/.agent/skills/impeccable/scripts/detector/rules/checks.mjs index 8a5654625..ee65af7af 100644 --- a/.agent/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.agent/skills/impeccable/scripts/detector/rules/checks.mjs @@ -721,20 +721,19 @@ function scanCssTextForGlow(content) { return results; } -// Decorative grid or line-field backgrounds drawn with hairline +// Decorative two-axis grid backgrounds drawn with hairline // linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML // pattern pass and the regex source engine so standalone CSS, component // styles, and inline styles receive the same coverage. Both signals must // co-occur in one declaration block; unrelated rules must not add up across -// the file. Returns [{ index, snippet }], capped at one finding per source to -// match the page-level HTML check's existing behavior. +// the file. A single hairline is a line, divider, or rail, not a grid, even +// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one +// finding per source to match the page-level HTML check's existing behavior. function scanCssTextForGridBackground(content) { const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi; const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi; const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i; - const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i; const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/; - const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/; const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi; const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi; let blk; @@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) { } if (hairlineCount === 0) continue; const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined); - const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined); - if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) { + if (hairlineCount >= 2 && hasPxCell) { return [{ index: blk.index, - snippet: hairlineCount >= 2 - ? 'two-axis grid-line gradient background' - : 'px-tiled hairline line-field background', + snippet: 'two-axis grid-line gradient background', }]; } } @@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) { } // Check for neon text (vivid cyan/purple color on dark background) - const textColor = parseRgb(style.color); + const textColor = parseRgb(style.color) || parseAnyColor(style.color); if (textColor && hasChroma(textColor, 80)) { const hue = getHue(textColor); const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310); diff --git a/.agent/skills/impeccable/scripts/detector/shared/color.mjs b/.agent/skills/impeccable/scripts/detector/shared/color.mjs index d2524ce52..983e15330 100644 --- a/.agent/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.agent/skills/impeccable/scripts/detector/shared/color.mjs @@ -103,14 +103,22 @@ function extractColorFunctionTokens(value) { function parseGradientColors(bgImage) { if (!bgImage || !bgImage.includes('gradient')) return []; const colors = []; + const tokenSpans = []; + let from = 0; // Stops arrive in whatever syntax the author wrote and the browser kept. // A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used // to read as a gradient with no stops at all. for (const token of extractColorFunctionTokens(bgImage)) { + const start = bgImage.indexOf(token, from); + if (start < 0) break; + tokenSpans.push({ start, end: start + token.length }); + from = start + token.length; const c = parseAnyColor(token); if (c) colors.push(c); } for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { + // Nested hex inside color-mix is an ingredient, not a stop (issue #578). + if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue; const h = m[1]; if (h.length === 6) { colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 }); diff --git a/.agent/skills/impeccable/scripts/doctor.mjs b/.agent/skills/impeccable/scripts/doctor.mjs index ca3105809..b311f0366 100644 --- a/.agent/skills/impeccable/scripts/doctor.mjs +++ b/.agent/skills/impeccable/scripts/doctor.mjs @@ -33,13 +33,8 @@ import { stampProductSchema, } from './lib/artifact-schema.mjs'; import { - checkBuildPathUnset, - checkConfig, - checkDesignSidecar, + collectBootFindingGroups, checkNativePlatformEvidence, - checkProduct, - checkProjectRoots, - checkSurfaceBriefs, designSidecarCandidatesFor, } from './lib/staleness.mjs'; import { @@ -106,34 +101,30 @@ async function collect(cwd, targetOptions) { extractPlatform, readFile: safeRead, }); + const bootFindings = collectBootFindingGroups(ctx, { + absDesignPath, + sidecarCandidates, + projectRootPatterns: readProjectRootPatterns(ctx.repoRoot), + targetCandidates: workspaceCandidates, + }); const findings = [ - ...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'), - ...(ctx.product - ? checkNativePlatformEvidence({ - projectRoot, - platform: ctx.platform, - product: ctx.product, - productPath: ctx.productPath, - }) - : []), - ...checkDesignSidecar({ designPath: absDesignPath, sidecarCandidates, projectRoot }), + ...bootFindings.product, + ...bootFindings.nativePlatform, + ...bootFindings.designSidecar, ...checkDesignDrift({ designPath: absDesignPath, projectRoot }), ...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }), - ...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }), - ...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }), + ...bootFindings.config, + ...bootFindings.buildPath, ...checkDetectorIgnores({ projectRoot, knownRuleIds }), - ...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }), + ...bootFindings.surfaceBriefs, ...checkHookInstallation({ projectRoot, repoRoot: ctx.repoRoot, providerId: IMPECCABLE_PROVIDER_ID, }), ...checkLegacyLiveState({ projectRoot }), - ...checkProjectRoots({ - patterns: readProjectRootPatterns(ctx.repoRoot), - candidates: workspaceCandidates, - }), + ...bootFindings.projectRoots, ...workspaceResult.findings, ]; diff --git a/.agent/skills/impeccable/scripts/embed-prompt.mjs b/.agent/skills/impeccable/scripts/embed-prompt.mjs index 72a03b1ca..a5e7614c2 100644 --- a/.agent/skills/impeccable/scripts/embed-prompt.mjs +++ b/.agent/skills/impeccable/scripts/embed-prompt.mjs @@ -5,6 +5,7 @@ // node embed-prompt.mjs --prompt "the prompt text" // node embed-prompt.mjs --prompt-file prompt.txt // node embed-prompt.mjs --read +// node embed-prompt.mjs --scan # list rasters missing a prompt; exit 3 when any // // Formats: PNG (tEXt chunk, keyword "impeccable:prompt"), JPEG (COM segment). // WebP and anything else fall back to a `.json` sidecar; --read checks @@ -21,8 +22,49 @@ const KEYWORD = 'impeccable:prompt'; const args = process.argv.slice(2); const file = args.find(a => !a.startsWith('--')); const readMode = args.includes('--read'); +const scanMode = args.includes('--scan'); const argOf = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; }; +function promptOf(imagePath) { + const b = fs.readFileSync(imagePath); + let prompt = null; + if (b.length > 8 && b.readUInt32BE(0) === 0x89504e47) prompt = readPngText(b); + else if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8) prompt = readJpegCom(b); + if (prompt == null && fs.existsSync(`${imagePath}.json`)) { + try { prompt = JSON.parse(fs.readFileSync(`${imagePath}.json`, 'utf8')).prompt ?? null; } catch { /* stays null */ } + } + return prompt; +} + +if (scanMode) { + const targets = args.filter(a => !a.startsWith('--')); + if (targets.length === 0) { console.error('embed-prompt: --scan needs at least one directory'); process.exit(1); } + const RASTER = /\.(png|jpe?g|webp)$/i; + const rasters = []; + const walk = (p, isRoot) => { + const stat = fs.statSync(p); + if (stat.isDirectory()) { + const base = p.replace(/\/+$/, '').split('/').pop(); + // Skip installed deps and hidden dirs found during the walk, but honor a + // hidden dir the caller passed explicitly (e.g. .impeccable/mocks). + if (!isRoot && (base === 'node_modules' || base.startsWith('.'))) return; + for (const entry of fs.readdirSync(p)) walk(`${p.replace(/\/+$/, '')}/${entry}`, false); + } else if (RASTER.test(p)) { + rasters.push(p); + } + }; + for (const target of targets) { + if (!fs.existsSync(target)) { console.error(`embed-prompt: no such path ${target}`); process.exit(1); } + walk(target, true); + } + let missing = 0; + for (const raster of rasters) { + if (promptOf(raster) == null) { console.log(`MISSING: ${raster}`); missing++; } + } + console.log(`SCAN: ${rasters.length} raster${rasters.length === 1 ? '' : 's'}, ${missing} missing`); + process.exit(missing > 0 ? 3 : 0); +} + if (!file || !fs.existsSync(file)) { console.error('embed-prompt: image file required'); process.exit(1); } const buf = fs.readFileSync(file); diff --git a/.agent/skills/impeccable/scripts/hook-admin.mjs b/.agent/skills/impeccable/scripts/hook-admin.mjs index e8d9e2ada..0d8cbaf94 100644 --- a/.agent/skills/impeccable/scripts/hook-admin.mjs +++ b/.agent/skills/impeccable/scripts/hook-admin.mjs @@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { - matcher: 'Edit|Write|MultiEdit', + matcher: 'Edit|Write', hooks: [ { type: 'command', diff --git a/.agent/skills/impeccable/scripts/hook-lib.mjs b/.agent/skills/impeccable/scripts/hook-lib.mjs index 794ac59a1..1e709b11a 100644 --- a/.agent/skills/impeccable/scripts/hook-lib.mjs +++ b/.agent/skills/impeccable/scripts/hook-lib.mjs @@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) { } // Whether the per-edit pass for this harness should defer non-immediate -// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop -// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for -// them would silently drop the non-immediate rules entirely. +// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch +// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so +// deferring for them would silently drop the non-immediate rules entirely. export function perEditTieringActive(config, harness) { if (harness === 'cursor' || harness === 'github') return false; return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; @@ -1251,18 +1251,50 @@ export function resolveHarness(env = {}, event = null) { const explicit = env?.IMPECCABLE_HOOK_HARNESS; if (explicit === 'cursor') return 'cursor'; if (explicit === 'github') return 'github'; - if (explicit === 'claude' || explicit === 'codex') return 'claude'; - // GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and - // has no `tool_name`/`tool_input`. That shape is the discriminator. + if (explicit === 'grok') return 'grok'; + if (explicit === 'claude') return 'claude'; + if (explicit === 'codex') return 'codex'; + // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no + // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`. + // Check Grok first: the old GitHub heuristic (`toolName` and no + // `tool_input`) also matches Grok, which is how live PostToolUse was + // classified as Copilot and then skipped with no-file-path (#646). + if (looksLikeGrokEnvelope(event)) return 'grok'; if (event && typeof event === 'object' && (typeof event.toolName === 'string' || event.toolArgs !== undefined) && event.tool_name === undefined && event.tool_input === undefined) { return 'github'; } if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor'; + // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting + // it here means an already-installed Codex hook emits the Codex Stop + // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS. + // https://developers.openai.com/codex/hooks#stop + if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex'; return 'claude'; } +function looksLikeGrokEnvelope(event) { + if (!event || typeof event !== 'object') return false; + if (event.hook_event_name !== undefined + || event.tool_name !== undefined + || event.tool_input !== undefined) { + return false; + } + if (event.toolArgs !== undefined) return false; + if (typeof event.hookEventName === 'string') return true; + return typeof event.toolName === 'string' && event.toolInput !== undefined; +} + +// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's +// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any +// normalize, so both casings must match here. +export function isStopEvent(event) { + if (!event || typeof event !== 'object') return false; + const name = event.hook_event_name || event.hookEventName; + return typeof name === 'string' && name.toLowerCase() === 'stop'; +} + // GitHub Copilot's postToolUse payload is // { sessionId, timestamp, cwd, toolName, toolArgs, toolResult } // mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape. @@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) { }; } +// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` / +// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a +// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no +// stripping here). Only the fields the hook reads are copied; the event +// name stays camelCase because routing already happened on the raw stdin +// (isStopEvent) and nothing downstream reads `hook_event_name`. +function normalizeGrokEvent(event, projectCwd) { + const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd; + const sessionId = event.sessionId || event.session_id || 'unknown'; + const rawInput = event.toolInput ?? event.tool_input; + const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) + ? { ...rawInput } + : {}; + const out = { + ...event, + cwd, + session_id: sessionId, + tool_name: event.toolName || event.tool_name || null, + tool_input: toolInput, + }; + if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) { + out.stop_hook_active = event.stopHookActive; + } + return out; +} + export function normalizeHookEvent(event, projectCwd, harness = 'claude') { if (!event || typeof event !== 'object') return event; if (harness === 'github') return normalizeGitHubEvent(event, projectCwd); + if (harness === 'grok') return normalizeGrokEvent(event, projectCwd); if (harness !== 'cursor') return event; const cwd = event.cwd @@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // findings stop being remembered and a reintroduced one reads as fresh. // Only the immediate tier is remembered: a deferred finding the per-edit // pass never reported must still read as fresh to the Stop deep pass. - rememberFindings(cache, sessionId, filePath, immediate); + // + // Grok ignores PostToolUse stdout, so Stop is the user-visible pass. + // Remembering here would dedupe those findings out of Stop. Touch the + // file so Stop has it, and leave the finding list empty. + if (harness === 'grok') { + touchFile(cache, sessionId, filePath); + } else { + rememberFindings(cache, sessionId, filePath, immediate); + } cacheDirty = true; if (fresh.length > 0) { @@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20; * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI - * files. Output uses the Stop hookSpecificOutput channel: additionalContext - * is delivered to the model and the conversation continues so it can act. + * files. Output goes out on the harness's Stop continuation channel: Claude + * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes + * a decision: "block" whose reason becomes the continuation prompt. Either + * way the findings reach the model and the conversation continues so it + * can act. */ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; @@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); } - // Claude Code's Stop-hook contract: `stop_hook_active` is true when this - // hook is being re-invoked only because a prior invocation kept the turn - // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and - // re-blocking now would loop until Claude Code's consecutive-block cap - // force-ends the turn (issue #400). The prior fire already surfaced the - // findings; whether to act on them is the agent's call. Exit fast with no - // output before any scan. Only Claude Code sends this field; other - // harnesses omit it, so the strict `=== true` is a no-op for them. This - // guard makes the loop impossible regardless of the finding cache key's - // line-number sensitivity (out of scope here; see findingCacheKey). + const harness = resolveHarness(env, event); + audit.harness = harness; + event = normalizeHookEvent(event, cwd, harness); + + // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is + // being re-invoked only because a prior invocation kept the turn alive + // (Claude Code via hookSpecificOutput.additionalContext, Codex via a + // decision: "block" continuation). Re-scanning and re-blocking now could + // loop (issue #400). The prior fire already surfaced the findings; + // whether to act on them is the agent's call. Exit fast with no output + // before any scan. Claude Code and Codex both send this field: Codex + // mirrors the Claude contract (StopCommandInput in + // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the + // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok + // sends `stopHookActive`, copied onto the snake_case field above. Cursor + // and GitHub Copilot omit the field, so the strict `=== true` is a no-op + // for them. The guard makes the loop impossible regardless of the finding + // cache key's line-number sensitivity (out of scope here; see + // findingCacheKey). if (event.stop_hook_active === true) { return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started }); } - const harness = resolveHarness(env, event); - audit.harness = harness; + // Grok fires Stop twice: `end_turn` (the gate that can inject + // additionalContext) then an observe-only `shutdown`. A second deep + // pass would re-emit the same findings. Claude omits `reason`; only + // skip when Grok named a reason that is not end_turn. + if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') { + return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started }); + } // A Stop event carries no file, so the session cwd is the project. // Umbrella-dir launches keyed their per-edit cache to the edited file's @@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const freshGroups = []; let scanned = 0; + let cacheDirty = false; for (const filePath of touched) { if (scanned >= STOP_MAX_FILES) break; if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; @@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } let findings; + let detectorThrew = false; const useHtmlEngine = configuredExt ? configuredExt.engine === 'html' : (ext === '.html' || ext === '.htm'); if (useHtmlEngine && typeof det.detectHtml === 'function') { - try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; } } else { - try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; } } + // A detector failure tells us nothing about the file. Leave whatever + // was remembered alone rather than recording an empty scan as truth. + if (detectorThrew) continue; + // Full rule set: no tier split here. Config/inline ignores still apply, // and the session dedupe drops everything the per-edit pass (or an // earlier Stop pass) already surfaced. const filtered = filterFindings(findings || [], content, ext, config); const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Sync to the live scan, including empty. Remembering only `fresh` + // (or skipping the write on a clean Stop) left stale keys in place, so + // a finding that was fixed and later reintroduced never fired again. + rememberFindings(cache, sessionId, filePath, filtered); + cacheDirty = true; if (fresh.length > 0) { - rememberFindings(cache, sessionId, filePath, fresh); freshGroups.push({ filePath, findings: fresh }); } } audit.scannedFiles = scanned; if (freshGroups.length === 0) { + if (cacheDirty) persistCache(projectCwd, cache); return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } @@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no ); commitFooterShown(cache, sessionId, text); - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear; the notice flags ride along. + // Persist the live finding set so the next Stop fire is silent unless + // new issues appear; the notice flags ride along. persistCache(projectCwd, cache); return { exitCode: 0, @@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'github') { return JSON.stringify({ additionalContext: text }); } + // Codex shares Claude Code's PostToolUse additional-context shape, but its + // Stop schema rejects unknown fields. Findings that should continue the + // turn must be a top-level blocking decision. + // https://developers.openai.com/codex/hooks#stop (schema of record: + // codex-rs/hooks/src/schema.rs, StopCommandOutputWire) + if (harness === 'codex' && eventName === 'Stop') { + if (!String(text ?? '').trim()) return ''; + return JSON.stringify({ decision: 'block', reason: text }); + } return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, }); diff --git a/.agent/skills/impeccable/scripts/hook.mjs b/.agent/skills/impeccable/scripts/hook.mjs index 5813ea4f2..a190ad697 100644 --- a/.agent/skills/impeccable/scripts/hook.mjs +++ b/.agent/skills/impeccable/scripts/hook.mjs @@ -2,15 +2,17 @@ /** * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by - * `hook_event_name`: + * Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin + * and routes by Stop vs everything else. Claude uses `hook_event_name: + * "Stop"`; Grok uses `hookEventName: "stop"`. * * - PostToolUse: runs the immediate-tier detector rules against the touched * file and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * `hookSpecificOutput.additionalContext` when findings exist. Grok + * discards that stdout; the scan still warms the session cache for Stop. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the harness-specific continuation channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. @@ -19,7 +21,7 @@ * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -28,10 +30,9 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } -function isStopEvent(stdinJson) { +function stdinIsStop(stdinJson) { try { - const event = JSON.parse(stdinJson); - return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + return isStopEvent(JSON.parse(stdinJson)); } catch { // Malformed stdin falls through to runHook, which audits the skip. return false; @@ -48,7 +49,7 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const run = stdinIsStop(stdinJson) ? runStopHook : runHook; const result = await run({ stdinJson, env: inheritedEnv, diff --git a/.agent/skills/impeccable/scripts/lib/design-parser.mjs b/.agent/skills/impeccable/scripts/lib/design-parser.mjs index 7b060eeca..cab191d69 100644 --- a/.agent/skills/impeccable/scripts/lib/design-parser.mjs +++ b/.agent/skills/impeccable/scripts/lib/design-parser.mjs @@ -196,9 +196,6 @@ function parseScalar(raw) { const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g; const OKLCH_RE = /oklch\([^)]+\)/gi; -const RGBA_RE = /rgba?\([^)]+\)/gi; -const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/; -const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/; // ---------- Section splitting ---------- @@ -550,36 +547,6 @@ function detectFormat(v) { return 'unknown'; } -function scanInlineColors(lines) { - const out = []; - for (const line of lines) { - if (!/^\s*[-*]\s/.test(line)) continue; - const trimmed = line.replace(/^\s*[-*]\s+/, ''); - const color = parseColorBullet(trimmed); - if (color) out.push(color); - } - return out; -} - -function parseStitchInlineGroups(lines) { - // Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."` - // Each bullet IS its own role. Group them under the spoken role name. - const out = []; - for (const line of lines) { - if (!/^\s*[-*]\s/.test(line)) continue; - const trimmed = line.replace(/^\s*[-*]\s+/, '').trim(); - const m = trimmed.match( - /^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/ - ); - if (m) { - const role = m[1]; - const color = buildColor(role, m[2], m[3]); - out.push({ role, colors: [color] }); - } - } - return out; -} - function extractTypography(section) { if (!section) return null; const text = section.lines.join('\n'); diff --git a/.agent/skills/impeccable/scripts/lib/staleness.mjs b/.agent/skills/impeccable/scripts/lib/staleness.mjs index 80599095b..dde3b2715 100644 --- a/.agent/skills/impeccable/scripts/lib/staleness.mjs +++ b/.agent/skills/impeccable/scripts/lib/staleness.mjs @@ -488,41 +488,46 @@ export function describeWorkspaceContext(candidates = []) { // ─── Tier 1 orchestration ────────────────────────────────────────────────── /** - * Everything a boot can afford. `ctx` is the loadContext result; `extras` - * carries values the caller already computed so nothing is recomputed here. + * Everything a boot can afford, grouped by artifact so deeper reports can + * interleave their own checks without rebuilding this policy. `ctx` is the + * loadContext result; `extras` carries values the caller already computed so + * nothing is recomputed here. */ -export function collectBootFindings(ctx, extras = {}) { - if (!ctx) return []; +export function collectBootFindingGroups(ctx, extras = {}) { + if (!ctx) return {}; const projectRoot = ctx.projectRoot || process.cwd(); - const absProductPath = extras.absProductPath || null; const absDesignPath = extras.absDesignPath || null; - return [ - ...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'), + return { + product: checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'), // Only checked once a PRODUCT.md exists. Without one the boot already // emits NO_PRODUCT_MD and routes into init, which asks for the platform // directly; a second signal saying the same thing is noise. - ...(ctx.product + nativePlatform: ctx.product ? checkNativePlatformEvidence({ projectRoot, platform: ctx.platform, product: ctx.product, productPath: ctx.productPath, }) - : []), - ...checkDesignSidecar({ + : [], + designSidecar: checkDesignSidecar({ designPath: absDesignPath, sidecarCandidates: extras.sidecarCandidates || [], projectRoot, }), - ...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }), - ...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }), - ...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }), - ...(extras.projectRootPatterns + config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }), + buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }), + surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }), + projectRoots: extras.projectRootPatterns ? checkProjectRoots({ patterns: extras.projectRootPatterns, candidates: extras.targetCandidates || [], }) - : []), - ]; + : [], + }; +} + +export function collectBootFindings(ctx, extras = {}) { + return Object.values(collectBootFindingGroups(ctx, extras)).flat(); } diff --git a/.agent/skills/impeccable/scripts/lib/surface-briefs.mjs b/.agent/skills/impeccable/scripts/lib/surface-briefs.mjs index f83416f69..83783517f 100644 --- a/.agent/skills/impeccable/scripts/lib/surface-briefs.mjs +++ b/.agent/skills/impeccable/scripts/lib/surface-briefs.mjs @@ -8,6 +8,12 @@ export function getSurfaceBriefDir(projectRoot) { return path.join(projectRoot, '.impeccable', 'surfaces'); } +function normalizeRouteTarget(route) { + if (!route.startsWith('/') || route.includes('..')) return null; + const normalized = route.split(/[?#]/, 1)[0].replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/'; + return `route:${normalized}`; +} + export function normalizeSurfaceTarget(target, { projectRoot = process.cwd() } = {}) { if (!target || typeof target !== 'string' || !target.trim()) return null; const trimmed = target.trim(); @@ -21,21 +27,13 @@ export function normalizeSurfaceTarget(target, { projectRoot = process.cwd() } = return null; } } - if (/^route:/i.test(trimmed)) { - const route = trimmed.slice(trimmed.indexOf(':') + 1).trim(); - if (!route.startsWith('/') || route.includes('..')) return null; - const normalizedRoute = route.split(/[?#]/, 1)[0].replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/'; - return `route:${normalizedRoute}`; - } - if (trimmed === '/') return 'route:/'; + if (/^route:/i.test(trimmed)) return normalizeRouteTarget(trimmed.slice(trimmed.indexOf(':') + 1).trim()); + if (trimmed === '/') return normalizeRouteTarget(trimmed); if (trimmed.startsWith('/')) { const absolute = path.resolve(trimmed); const relativeToProject = path.relative(projectRoot, absolute); const isProjectFile = relativeToProject && !relativeToProject.startsWith('..') && !path.isAbsolute(relativeToProject); - if (!isProjectFile && !fs.existsSync(absolute) && !trimmed.includes('..')) { - const normalizedRoute = trimmed.split(/[?#]/, 1)[0].replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/'; - return `route:${normalizedRoute}`; - } + if (!isProjectFile && !fs.existsSync(absolute)) return normalizeRouteTarget(trimmed); } const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(projectRoot, trimmed); const rel = path.relative(projectRoot, abs); diff --git a/.agent/skills/impeccable/scripts/live-browser.js b/.agent/skills/impeccable/scripts/live-browser.js index 918dfe093..69d227b0a 100644 --- a/.agent/skills/impeccable/scripts/live-browser.js +++ b/.agent/skills/impeccable/scripts/live-browser.js @@ -4902,6 +4902,13 @@ saveSession(); } + function completeParameterGenerationIfReady() { + if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return; + if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') { + completeParameterPublication(); + } + } + function toggleTunePopover() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (tuneOpen) { closeTunePopover(); return; } @@ -5796,7 +5803,7 @@ setLiveState('CYCLING'); showOrUpdateCyclingBar(); saveSession(); - if (parameterGenerationState === 'loading') completeParameterPublication(); + completeParameterGenerationIfReady(); return; } @@ -5884,7 +5891,7 @@ refreshParamsPanel(); positionBar(); saveSession(); - if (parameterGenerationState === 'loading') completeParameterPublication(); + completeParameterGenerationIfReady(); console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.'); } catch (err) { console.error('[impeccable] Failed to mount component-preview variants:', err); @@ -6329,7 +6336,7 @@ refreshParamsPanel(); positionBar(); saveSession(); - if (parameterGenerationState === 'loading') completeParameterPublication(); + completeParameterGenerationIfReady(); console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.'); }) .catch(err => { @@ -6836,6 +6843,7 @@ const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0'); if (expected > 0) expectedVariants = expected; + completeParameterGenerationIfReady(); if (arrivedVariants > 0) { setLiveState('CYCLING'); diff --git a/.agent/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.agent/skills/impeccable/scripts/live-commit-manual-edits.mjs index 89572e759..eee702595 100644 --- a/.agent/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.agent/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -944,8 +944,42 @@ export async function commitManualEdits({ }; } + const repairContext = { + batch, + cwd, + pageUrl, + count, + provider, + env, + timeoutMs, + applyBatchToSource, + chatAvailable, + transactionId, + }; + const baseRollbackScope = collectApplyOwnedFiles(batch, cwd); const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope); + const failWithRollback = ({ + scope = baseRollbackScope, + extraFiles = [], + failed, + files = [], + details = {}, + }) => { + const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope); + return { + applied: [], + failed, + files, + cleared: 0, + count, + pageUrl, + ...details, + rolledBackFiles: rollback.rolledBackFiles, + rollbackFailures: rollback.rollbackFailures, + ...countByPage(cwd), + }; + }; let result; try { result = repairOnly @@ -965,42 +999,27 @@ export async function commitManualEdits({ chatAvailable, }); } catch (err) { - const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope); - return { - applied: [], + return failWithRollback({ failed: batch.entries.map((entry) => ({ id: entry.id, reason: err.message || String(err), candidates: candidatesForEntry(batch, entry.id), })), - files: [], - cleared: 0, - count, - pageUrl, - rolledBackFiles: rollback.rolledBackFiles, - rollbackFailures: rollback.rollbackFailures, - ...countByPage(cwd), - }; + }); } if (result.status === 'error') { const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []); - const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope); const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed'); - return { - applied: [], + return failWithRollback({ + scope: rollbackScope, + extraFiles: result.files || [], failed: failed.length > 0 ? failed : verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'), files: result.files || [], - cleared: 0, - count, - pageUrl, - notes: result.notes || [], - rolledBackFiles: rollback.rolledBackFiles, - rollbackFailures: rollback.rollbackFailures, - ...countByPage(cwd), - }; + details: { notes: result.notes || [] }, + }); } const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []); @@ -1013,72 +1032,44 @@ export async function commitManualEdits({ const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id)); if (conflictingAppliedIds.length > 0) { - const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope); const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id)); - return { - applied: [], + return failWithRollback({ + scope: rollbackScope, + extraFiles: result.files || [], failed: [ ...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'), ...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)), ], files: result.files || [], - cleared: 0, - count, - pageUrl, - notes: result.notes || [], - rolledBackFiles: rollback.rolledBackFiles, - rollbackFailures: rollback.rollbackFailures, - ...countByPage(cwd), - }; + details: { notes: result.notes || [] }, + }); } const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope); if (unreportedFiles.length > 0) { - const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]); - return { - applied: [], + return failWithRollback({ + scope: [...rollbackScope, ...unreportedFiles], + extraFiles: result.files || [], failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }), files: result.files || [], - unreportedFiles, - cleared: 0, - count, - pageUrl, - notes: result.notes || [], - rolledBackFiles: rollback.rolledBackFiles, - rollbackFailures: rollback.rollbackFailures, - ...countByPage(cwd), - }; + details: { unreportedFiles, notes: result.notes || [] }, + }); } if (result.status === 'done' && reportedAppliedIds.length === 0) { - const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope); - return { - applied: [], + return failWithRollback({ + scope: rollbackScope, + extraFiles: result.files || [], failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'), files: result.files || [], - cleared: 0, - count, - pageUrl, - notes: result.notes || [], - rolledBackFiles: rollback.rolledBackFiles, - rollbackFailures: rollback.rollbackFailures, - ...countByPage(cwd), - }; + details: { notes: result.notes || [] }, + }); } const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id)); if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) { return repairPostApplyValidation({ - batch, - cwd, - pageUrl, - count, - provider, - env, - timeoutMs, - applyBatchToSource, - chatAvailable, - transactionId, + ...repairContext, appliedEntryIds: reportedAppliedIds, files: result.files || [], failed: aiFailed, @@ -1089,21 +1080,10 @@ export async function commitManualEdits({ }); } - const verifiedAppliedIds = []; - const verificationFailed = []; - for (const entry of reportedAppliedEntries) { - const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd }); - if (failures.length === 0) { - verifiedAppliedIds.push(entry.id); - } else { - verificationFailed.push({ - id: entry.id, - reason: 'source_verification_failed', - failures, - candidates: candidatesForEntry(batch, entry.id), - }); - } - } + const { + verifiedIds: verifiedAppliedIds, + failed: verificationFailed, + } = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd }); const unreportedEntries = result.status === 'done' || result.status === 'partial' ? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id)) : []; @@ -1133,37 +1113,22 @@ export async function commitManualEdits({ reason: 'rolled_back_due_to_failed_entry_source_changed', candidates: candidatesForEntry(batch, entry.id), })); - const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope); - return { - applied: [], + return failWithRollback({ + scope: rollbackScope, + extraFiles: result.files || [], failed: [ ...leakedUnapplied, ...failed.filter((item) => !leakedIds.has(item.id)), ...rolledBackVerified, ], files: result.files || [], - cleared: 0, - count, - pageUrl, - rolledBackFiles: rollback.rolledBackFiles, - rollbackFailures: rollback.rollbackFailures, - notes: result.notes || [], - ...countByPage(cwd), - }; + details: { notes: result.notes || [] }, + }); } if (verificationFailed.length > 0) { return repairPostApplyValidation({ - batch, - cwd, - pageUrl, - count, - provider, - env, - timeoutMs, - applyBatchToSource, - chatAvailable, - transactionId, + ...repairContext, appliedEntryIds: reportedAppliedIds, files: result.files || [], failed: nonRepairFailed, @@ -1180,16 +1145,7 @@ export async function commitManualEdits({ ? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id)) : batch.entries; return repairPostApplyValidation({ - batch, - cwd, - pageUrl, - count, - provider, - env, - timeoutMs, - applyBatchToSource, - chatAvailable, - transactionId, + ...repairContext, appliedEntryIds: verifiedAppliedIds.length > 0 ? verifiedAppliedIds : postCheckEntries.map((entry) => entry.id).filter(Boolean), diff --git a/.agent/skills/impeccable/scripts/live-poll.mjs b/.agent/skills/impeccable/scripts/live-poll.mjs index 3b2f08c9f..59e21b9d3 100644 --- a/.agent/skills/impeccable/scripts/live-poll.mjs +++ b/.agent/skills/impeccable/scripts/live-poll.mjs @@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) { }); } catch (err) { event._completionAck = { ok: false, error: err.message }; + return event; } - if (!event._completionAck) { - event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult); - } + event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult); return event; } @@ -269,9 +268,11 @@ export function printPollEvent(event) { // Situational plumbing rides with the event itself: `_instructions` is the // authoritative next step, with real ids and paths substituted, so the // reference doc can stay lean and can never drift from script behavior. - if (event && typeof event === 'object' && !event._instructions) { + // A wire-supplied value must never win over the locally generated one. + if (event && typeof event === 'object') { const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR }); if (instructions) event._instructions = instructions; + else delete event._instructions; } console.log(JSON.stringify(event)); } diff --git a/.agent/skills/impeccable/scripts/live-server.mjs b/.agent/skills/impeccable/scripts/live-server.mjs index 86b7777be..dafa8bd0c 100644 --- a/.agent/skills/impeccable/scripts/live-server.mjs +++ b/.agent/skills/impeccable/scripts/live-server.mjs @@ -181,8 +181,16 @@ function chatAgentLikelyActive() { // cap at 10 MB to guard against runaway writes from a misbehaving client. const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; +const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult']; + +function stripPollerOwnedEventFields(event) { + if (!event || typeof event !== 'object') return; + for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key]; +} + function enqueueEvent(event) { if (!event) return; + stripPollerOwnedEventFields(event); // Dedupe by (session, type), except mount failures, which are per-variant: // variant 2 failing must not be swallowed because variant 1's failure is // still queued. @@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const filePath = url.searchParams.get('path'); if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); - // Confine to the project root. A bare `startsWith(cwd)` string check lets a - // sibling dir whose name extends the root name (projeto -> projeto-backup) - // slip through; compare on the relative path instead (same pattern as - // sessionFileMetadataFromPollReply below). An empty rel means the request - // resolved to the root directory itself, which this file route never serves. - const rel = path.relative(process.cwd(), absPath); + let realRoot, realTarget; + try { + realRoot = fs.realpathSync(process.cwd()); + realTarget = fs.realpathSync(absPath); + } catch { + res.writeHead(404); res.end('File not found'); return; + } + // Confine to the project root after symlink resolution. A bare + // `startsWith(cwd)` string check lets a sibling dir whose name extends the + // root name (projeto -> projeto-backup) slip through; compare on the + // relative path instead (same pattern as sessionFileMetadataFromPollReply + // below). An empty rel means the request resolved to the root directory + // itself, which this file route never serves. + const rel = path.relative(realRoot, realTarget); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; } let content; - try { content = fs.readFileSync(absPath, 'utf-8'); } + try { content = fs.readFileSync(realTarget, 'utf-8'); } catch { res.writeHead(404); res.end('File not found'); return; } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(content); @@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { res.end(JSON.stringify({ error })); return; } + stripPollerOwnedEventFields(msg); if (msg.type === 'agent_phase') { recordAgentPhase(msg.id, msg.phase, { ...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}), diff --git a/.agent/skills/impeccable/scripts/live/sveltekit-adapter.mjs b/.agent/skills/impeccable/scripts/live/sveltekit-adapter.mjs index e94c54f1e..b96e955a6 100644 --- a/.agent/skills/impeccable/scripts/live/sveltekit-adapter.mjs +++ b/.agent/skills/impeccable/scripts/live/sveltekit-adapter.mjs @@ -11,6 +11,8 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs'; + export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte'; export const SVELTE_LAYOUT_MARKER_OPEN = ''; export const SVELTE_LAYOUT_MARKER_CLOSE = ''; @@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) { && fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%'); if (!hasTemplateMarkers) return null; - const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js')) - || fs.existsSync(path.join(cwd, 'svelte.config.mjs')) - || fs.existsSync(path.join(cwd, 'svelte.config.cjs')) - || fs.existsSync(path.join(cwd, 'svelte.config.ts')); - const hasKitPackage = packageHasSvelteKit(cwd); + const hasSvelteConfig = Boolean(firstExistingFile(cwd, [ + 'svelte.config.js', + 'svelte.config.mjs', + 'svelte.config.cjs', + 'svelte.config.ts', + ])); + const hasKitPackage = hasAnyDependency(cwd, [ + '@sveltejs/kit', + '@sveltejs/vite-plugin-svelte', + 'svelte', + ]); if (!hasSvelteConfig && !hasKitPackage) return null; return { @@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) { } function findSvelteKitLayout(cwd) { - const candidates = [ + return firstExistingFile(cwd, [ 'src/routes/+layout.svelte', 'src/routes/(app)/+layout.svelte', - ]; - for (const rel of candidates) { - if (fs.existsSync(path.join(cwd, rel))) return rel; - } - return 'src/routes/+layout.svelte'; + ]) || 'src/routes/+layout.svelte'; } function defaultSvelteLayout() { return `\n\n{@render children?.()}\n`; } -function packageHasSvelteKit(cwd) { - const file = path.join(cwd, 'package.json'); - if (!fs.existsSync(file)) return false; - try { - const pkg = JSON.parse(fs.readFileSync(file, 'utf-8')); - const deps = { - ...(pkg.dependencies || {}), - ...(pkg.devDependencies || {}), - ...(pkg.peerDependencies || {}), - }; - return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte); - } catch { - return false; - } -} - function fileIncludes(file, text) { try { return fs.readFileSync(file, 'utf-8').includes(text); diff --git a/.agent/skills/impeccable/scripts/live/tanstack-adapter.mjs b/.agent/skills/impeccable/scripts/live/tanstack-adapter.mjs index 4a1c81a97..b53978f4f 100644 --- a/.agent/skills/impeccable/scripts/live/tanstack-adapter.mjs +++ b/.agent/skills/impeccable/scripts/live/tanstack-adapter.mjs @@ -19,6 +19,8 @@ import fs from 'node:fs'; import path from 'node:path'; + +import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs'; import { buildLiveScriptSrc } from './frameworks/script-src.mjs'; export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}'; @@ -42,8 +44,8 @@ const START_PACKAGES = [ ]; export function detectTanStackStartProject(cwd = process.cwd()) { - if (!packageHasTanStackStart(cwd)) return null; - const rootRoute = findRootRouteFile(cwd); + if (!hasAnyDependency(cwd, START_PACKAGES)) return null; + const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES); if (!rootRoute) return null; const ext = path.extname(rootRoute); @@ -218,29 +220,6 @@ function isManagedComponent(content) { return String(content || '').includes('impeccable-live-tanstack'); } -function findRootRouteFile(cwd) { - for (const rel of ROOT_ROUTE_CANDIDATES) { - if (fs.existsSync(path.join(cwd, rel))) return rel; - } - return null; -} - -function packageHasTanStackStart(cwd) { - const file = path.join(cwd, 'package.json'); - if (!fs.existsSync(file)) return false; - try { - const pkg = JSON.parse(fs.readFileSync(file, 'utf-8')); - const deps = { - ...(pkg.dependencies || {}), - ...(pkg.devDependencies || {}), - ...(pkg.peerDependencies || {}), - }; - return START_PACKAGES.some((name) => Boolean(deps[name])); - } catch { - return false; - } -} - function relativeImportSpecifier(fromFile, toFile) { const rel = path.posix.relative( path.posix.dirname(fromFile.split(path.sep).join('/')), diff --git a/.agent/skills/impeccable/scripts/serve-question.mjs b/.agent/skills/impeccable/scripts/serve-question.mjs index 4e80b0027..7c4ff0812 100644 --- a/.agent/skills/impeccable/scripts/serve-question.mjs +++ b/.agent/skills/impeccable/scripts/serve-question.mjs @@ -95,9 +95,16 @@ * --stop --key K kill a daemonized question. * --update --key K --payload F deliver the next hand after a re-roll: the * live page swaps to loading cards when the user re-rolls, and - * reloads into this new payload the moment it lands. + * reloads into this new payload the moment it lands. Always the + * same key the round started with; a second --start serves a new + * URL and strands the open tab on a hand that never arrives. * - * node serve-question.mjs --payload question.json [--timeout 900] [--no-open] [--port 0] + * --timeout bounds the wait for a page to arrive, never the user's decision: + * once the page heartbeats, the server lives while the page does, and exits + * only after --idle-grace seconds (default 600) pass with no beat, wide + * enough to survive a closed laptop lid mid-decision. + * + * node serve-question.mjs --payload question.json [--timeout 900] [--idle-grace 600] [--no-open] [--port 0] */ import http from 'node:http'; import fs from 'node:fs'; @@ -120,11 +127,13 @@ if (process.env.IMPECCABLE_QUESTION_DISABLED) { } // Headless self-detection, applied only where a browser is actually wanted. // --no-open means the caller opens the URL itself, and --wait / --stop / -// --schema never open anything: --wait polls a daemon whose browser question -// was already settled at --start, --stop kills one, --schema prints text. A -// spurious exit 2 from those breaks the documented loop, which polls --wait -// while it exits 3 and reads --schema before building a payload. -const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema'); +// --schema / --update never open anything: --wait polls a daemon whose +// browser question was already settled at --start, --stop kills one, +// --schema prints text, and --update hands the next round to a page that is +// already open. A spurious exit 2 from those breaks the documented loop, +// which polls --wait while it exits 3, reads --schema before building a +// payload, and delivers re-rolled hands with --update. +const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema') && !hasFlag('update'); if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) { const headless = process.env.CI || @@ -176,7 +185,20 @@ function printAnswer(raw) { } const payloadPath = arg('payload'); -const timeoutSec = Number(arg('timeout', '900')); +// --timeout bounds only the wait for a page to open; 0 is the explicit +// wait-forever. A negative or unparseable value takes the default, so a +// typo cannot disarm the no-page exit and leak the daemon. +const timeoutArg = Number(arg('timeout', '900')); +const timeoutSec = Number.isFinite(timeoutArg) && timeoutArg >= 0 ? timeoutArg : 900; +// How long the server (and the page's own delivery deadline) outlive the +// last heartbeat; a zero, negative, or unparseable value takes the default. +const idleGraceArg = Number(arg('idle-grace', '600')); +const idleGraceMs = (Number.isFinite(idleGraceArg) && idleGraceArg > 0 ? idleGraceArg : 600) * 1000; +// How long a delivered next hand may sit unclaimed before it means no page +// is coming back: --wait reads it to keep a stalled page from counting as +// closed mid-delivery, and the daemon reads it to survive until the page's +// watch claims a hand delivered moments before the idle deadline. +const NEXT_CLAIM_GRACE_MS = 10000; const portArg = Number(arg('port', '0')); const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions'); const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`); @@ -243,7 +265,19 @@ if (hasFlag('wait')) { } try { const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8')); - if (state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; } + // A silent page is not a closed one while a freshly delivered next + // hand sits unclaimed: a stalled page stops beating by design and its + // watch reloads, beating again, within seconds of the file landing. + // The suppression is age-bound because a closed tab never claims the + // hand: a file still there after the grace means no page is coming. + const midDelivery = (() => { + try { if (Date.now() - fs.statSync(path.join(QUESTION_DIR, `${key}.next.json`)).mtimeMs < NEXT_CLAIM_GRACE_MS) return true; } + catch { /* nothing delivered */ } + // The claim deletes that file before the reloaded page can beat: the + // claim stamp the server persisted covers the same bounded gap. + return Boolean(state.claimedAt) && Date.now() - state.claimedAt < NEXT_CLAIM_GRACE_MS; + })(); + if (!midDelivery && state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; } } catch { /* state mid-write */ } await new Promise((r) => setTimeout(r, 1000)); } @@ -280,10 +314,33 @@ if (hasFlag('stop')) { if (hasFlag('update')) { const key = arg('key'); if (!key || !payloadPath) { console.error('serve-question: --update needs --key and --payload'); process.exit(1); } - JSON.parse(fs.readFileSync(payloadPath, 'utf8')); - try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid, 0); } - catch { console.error('serve-question: no live question server for that key'); process.exit(2); } - fs.copyFileSync(payloadPath, path.join(QUESTION_DIR, `${key}.next.json`)); + // A hand the server cannot load must fail here, at the sender: delivered + // anyway, the page would see ready:true for a round that never renders. + const nextRound = JSON.parse(fs.readFileSync(payloadPath, 'utf8')); + if (!nextRound || !Array.isArray(nextRound.options) || nextRound.options.length === 0) { + console.error('serve-question: --update payload needs an options array; nothing was delivered. Fix the payload and rerun --update on the same key.'); + process.exit(1); + } + // Liveness mirrors --wait: a fresh page heartbeat is the primary proof, the + // kill probe is secondary, and EPERM means a sandbox blocked the signal, + // never a dead server. This is the documented re-roll delivery step, so a + // false "no live server" here strands the page mid-shuffle. + const live = (() => { + try { + const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8')); + if (state.lastBeat && Date.now() - state.lastBeat < 12000) return true; + try { process.kill(state.pid, 0); return true; } + catch (err) { return err.code === 'EPERM'; } + } catch { return false; } + })(); + if (!live) { console.error('serve-question: no live question server for that key; the page it served is gone too. Re-present the round with --start and a fresh key, or fall back to the structured question tool.'); process.exit(2); } + const deliveredFile = path.join(QUESTION_DIR, `${key}.next.json`); + fs.copyFileSync(payloadPath, deliveredFile); + // The file's mtime is the delivery clock --wait's grace reads: stamp it + // here, because a copy that preserves the source payload's older mtime + // would start the grace already spent. + const deliveredAt = new Date(); + fs.utimesSync(deliveredFile, deliveredAt, deliveredAt); console.log('next round delivered; the page reloads itself'); process.exit(0); } @@ -301,7 +358,8 @@ if (hasFlag('start')) { const logFd = fs.openSync(logFile, 'a'); const child = spawn(process.execPath, [ fileURLToPath(import.meta.url), '--payload', payloadPath, '--detached-serve', '--key', key, - '--timeout', String(timeoutSec), ...(hasFlag('open') ? [] : ['--no-open']), + '--timeout', String(timeoutSec), ...(arg('idle-grace') ? ['--idle-grace', arg('idle-grace')] : []), + ...(hasFlag('open') ? [] : ['--no-open']), ], { detached: true, stdio: ['ignore', logFd, logFd] }); child.unref(); fs.closeSync(logFd); @@ -338,6 +396,13 @@ let localImages = []; // even when the round never rendered a toggle. let buildPathDefault = null; let liveBuildPath = null; +// True between a collected re-roll or followup answer and the --update that +// replaces the round: the window where GET / must serve the wait, not the +// answered cards. The timestamp anchors the delivery deadline server-side, +// so a native refresh re-enters the wait with the time already spent, never +// with a fresh allowance. +let awaitingNext = false; +let awaitingNextSince = 0; function loadRound(json) { const parsed = JSON.parse(json); @@ -387,6 +452,9 @@ function loadRound(json) { ? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true } : null; liveBuildPath = buildPathDefault?.value ?? null; + // Last: a round that failed to load anywhere above must leave the waiting + // window open, never resurrect the answered cards. + awaitingNext = false; } try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); } const detachedKey = hasFlag('detached-serve') ? arg('key') : null; @@ -394,7 +462,11 @@ const nextFile = () => detachedKey ? path.join(QUESTION_DIR, `${detachedKey}.nex const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); -function page() { +function page(waiting = false) { + // The delivery deadline survives refreshes: a waiting page gets whatever + // remains of the original allowance, so reloading cannot renew it. Spent + // means the page renders already stalled and never starts a heartbeat. + const waitBudgetMs = waiting ? Math.max(0, awaitingNextSince + idleGraceMs - Date.now()) : idleGraceMs; const flipChip = (label) => ``; const expandChip = ``; // Structured anatomy: chips and one-line facts render when the payload @@ -866,6 +938,7 @@ function page() { not a recommendation. */ #canon { align-self: center; padding: 0 4px; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: inherit; opacity: .45; background: transparent; border: none; border-bottom: 1px dotted currentColor; cursor: pointer; transition: opacity .2s ease; } #canon:hover { opacity: .85; } + #canon[disabled] { opacity: .18; cursor: default; } .card.skeleton .media { background: var(--ks-graphite); } .shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; } .card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; } @@ -877,6 +950,8 @@ function page() { @keyframes shimmer { from { background-position: 120% 0; } to { background-position: -80% 0; } } @media (prefers-reduced-motion: reduce) { .shimmer, .card.skeleton .line { animation: none; } } .done { display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 7rem 1rem; font-family: var(--ks-font-display); font-size: 1.4rem; color: var(--ks-champagne); text-align: center; } + .stall { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 1.2rem; padding: 4.5rem 1rem; font-family: var(--ks-font-display); font-size: 1.4rem; color: var(--ks-champagne); text-align: center; } + .stall .choose { align-self: center; margin-top: 0; } @@ -945,11 +1020,22 @@ ${buildPath?.toggle ? `