From b6aab36ef695b0098149f3040f0b09541fd2e950 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 4 Sep 2026 11:40:11 -0700 Subject: [PATCH] Sync workflow: cover every generated provider path (#725) The sync's GENERATED_PATHS list was missing .agent, .codex, .veto, .github/agents and .github/hooks, so the run after #714 regenerated the other provider directories but left those carrying the Node-era hook manifests and scripts, and main's CI failed on the hook-manifest and provider-hook tests. The list now matches what bun run build:release writes, and this commit carries the regenerated output for the missing paths so main is consistent as soon as it lands. Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY Co-authored-by: Claude Code --- .agent/skills/impeccable/SKILL.md | 10 +- .../impeccable/reference/audit.native.md | 2 +- .../skills/impeccable/reference/critique.md | 14 +- .../reference/degraded/asset-producer.md | 10 +- .../reference/degraded/finish-reviewer.md | 2 +- .../reference/degraded/manual-edit-applier.md | 2 +- .agent/skills/impeccable/reference/doctor.md | 8 +- .agent/skills/impeccable/reference/hooks.md | 24 +- .agent/skills/impeccable/reference/init.md | 8 +- .agent/skills/impeccable/reference/layout.md | 2 +- .../skills/impeccable/reference/live-setup.md | 12 +- .agent/skills/impeccable/reference/live.md | 58 +- .../skills/impeccable/reference/new-work.md | 30 +- .agent/skills/impeccable/reference/polish.md | 6 +- .agent/skills/impeccable/reference/routing.md | 6 +- .agent/skills/impeccable/reference/typeset.md | 2 +- .../skills/impeccable/reference/visualize.md | 16 +- .agent/skills/impeccable/scripts/VERSION | 1 + .../skills/impeccable/scripts/build-phase.mjs | 1022 -- .../skills/impeccable/scripts/comp-diff.mjs | 391 - .../skills/impeccable/scripts/comp-spec.mjs | 513 - .../impeccable/scripts/concept-seed.mjs | 814 -- .../impeccable/scripts/context-signals.mjs | 325 - .agent/skills/impeccable/scripts/context.mjs | 1597 --- .../impeccable/scripts/critique-storage.mjs | 473 - .../skills/impeccable/scripts/detect-csp.mjs | 198 - .agent/skills/impeccable/scripts/detect.mjs | 30 - .../detector/browser/injected/index.mjs | 2204 ---- .../impeccable/scripts/detector/cli/main.mjs | 432 - .../scripts/detector/design-system.mjs | 1311 --- .../detector/detect-antipatterns-browser.js | 9104 ----------------- .../scripts/detector/detect-antipatterns.mjs | 51 - .../detector/engines/browser/detect-url.mjs | 434 - .../detector/engines/regex/detect-text.mjs | 1293 --- .../engines/static-html/css-cascade.mjs | 1242 --- .../engines/static-html/detect-html.mjs | 278 - .../engines/visual/screenshot-contrast.mjs | 189 - .../impeccable/scripts/detector/findings.mjs | 18 - .../scripts/detector/node/file-system.mjs | 213 - .../scripts/detector/profile/profiler.mjs | 166 - .../detector/registry/antipatterns.mjs | 635 -- .../scripts/detector/rules/checks.mjs | 5744 ----------- .../scripts/detector/shared/color.mjs | 596 -- .../scripts/detector/shared/constants.mjs | 127 - .../scripts/detector/shared/fonts.mjs | 30 - .../detector/shared/inline-ignores.mjs | 148 - .../scripts/detector/shared/page.mjs | 7 - .agent/skills/impeccable/scripts/doctor.mjs | 329 - .../impeccable/scripts/embed-prompt.mjs | 166 - .../skills/impeccable/scripts/font-match.mjs | 457 - .../impeccable/scripts/generate-image.mjs | 447 - .../skills/impeccable/scripts/hook-admin.mjs | 819 -- .../impeccable/scripts/hook-before-edit.mjs | 538 - .agent/skills/impeccable/scripts/hook-lib.mjs | 2490 ----- .agent/skills/impeccable/scripts/hook.mjs | 79 - .agent/skills/impeccable/scripts/impeccable | 154 + .../skills/impeccable/scripts/impeccable.cmd | 142 + .../scripts/lib/artifact-schema.mjs | 93 - .../scripts/lib/composition-catalog.mjs | 200 - .../scripts/lib/concept-catalog.mjs | 396 - .../impeccable/scripts/lib/design-parser.mjs | 880 -- .../scripts/lib/font-fingerprint.mjs | 564 - .../impeccable/scripts/lib/font-index.mjs | 130 - .../impeccable/scripts/lib/hero-checks.mjs | 246 - .../impeccable/scripts/lib/image-metrics.mjs | 306 - .../scripts/lib/impeccable-config.mjs | 640 -- .../scripts/lib/impeccable-paths.mjs | 137 - .../impeccable/scripts/lib/is-generated.mjs | 72 - .../scripts/lib/live-path-globs.mjs | 37 - .../scripts/lib/open-system-browser.mjs | 26 - .agent/skills/impeccable/scripts/lib/png.mjs | 281 - .../impeccable/scripts/lib/provider.mjs | 5 - .../skills/impeccable/scripts/lib/raster.mjs | 194 - .../impeccable/scripts/lib/roll-selection.mjs | 369 - .../impeccable/scripts/lib/staleness-deep.mjs | 485 - .../scripts/lib/staleness-notice.mjs | 169 - .../impeccable/scripts/lib/staleness.mjs | 533 - .../impeccable/scripts/lib/surface-briefs.mjs | 149 - .../impeccable/scripts/lib/target-args.mjs | 42 - .../impeccable/scripts/lib/target-slug.mjs | 33 - .../scripts/lib/template-extensions.mjs | 146 - .../skills/impeccable/scripts/live-accept.mjs | 938 -- .../skills/impeccable/scripts/live-browser.js | 285 +- .../scripts/live-commit-manual-edits.mjs | 1200 --- .../impeccable/scripts/live-complete.mjs | 107 - .../scripts/live-copy-edit-agent.mjs | 800 -- .../scripts/live-discard-manual-edits.mjs | 51 - .../skills/impeccable/scripts/live-inject.mjs | 463 - .../skills/impeccable/scripts/live-insert.mjs | 292 - .../scripts/live-manual-edit-evidence.mjs | 368 - .../skills/impeccable/scripts/live-poll.mjs | 430 - .../skills/impeccable/scripts/live-resume.mjs | 123 - .../skills/impeccable/scripts/live-server.mjs | 1698 --- .../skills/impeccable/scripts/live-status.mjs | 71 - .../skills/impeccable/scripts/live-target.mjs | 30 - .../skills/impeccable/scripts/live-wrap.mjs | 927 -- .agent/skills/impeccable/scripts/live.mjs | 334 - .../impeccable/scripts/live/accept-css.mjs | 617 -- .../impeccable/scripts/live/accept-verify.mjs | 60 - .../scripts/live/browser-script-parts.mjs | 84 - .../impeccable/scripts/live/completion.mjs | 28 - .../scripts/live/event-validation.mjs | 199 - .../scripts/live/frameworks/astro.mjs | 47 - .../scripts/live/frameworks/detect-utils.mjs | 73 - .../scripts/live/frameworks/index.mjs | 143 - .../scripts/live/frameworks/journal.mjs | 197 - .../scripts/live/frameworks/nextjs.mjs | 49 - .../scripts/live/frameworks/nuxt.mjs | 161 - .../scripts/live/frameworks/script-src.mjs | 17 - .../scripts/live/frameworks/static-html.mjs | 26 - .../scripts/live/frameworks/sveltekit.mjs | 71 - .../scripts/live/frameworks/tag-strategy.mjs | 247 - .../live/frameworks/tanstack-start.mjs | 70 - .../scripts/live/frameworks/vite-generic.mjs | 42 - .../scripts/live/generation-preflight.mjs | 149 - .../impeccable/scripts/live/insert-ui.mjs | 458 - .../impeccable/scripts/live/instructions.mjs | 142 - .../impeccable/scripts/live/manual-apply.mjs | 939 -- .../scripts/live/manual-edit-routes.mjs | 357 - .../scripts/live/manual-edits-buffer.mjs | 152 - .../impeccable/scripts/live/poll-lanes.mjs | 14 - .../scripts/live/project-ignores.mjs | 139 - .../skills/impeccable/scripts/live/roots.mjs | 508 - .../impeccable/scripts/live/session-store.mjs | 563 - .../impeccable/scripts/live/source-lock.mjs | 105 - .../impeccable/scripts/live/source-search.mjs | 105 - .../impeccable/scripts/live/svelte-ast.mjs | 969 -- .../scripts/live/svelte-component.mjs | 1366 --- .../scripts/live/sveltekit-adapter.mjs | 304 - .../scripts/live/tanstack-adapter.mjs | 259 - .../impeccable/scripts/live/ui-surfaces.mjs | 75 - .../impeccable/scripts/live/vocabulary.mjs | 171 - .agent/skills/impeccable/scripts/palette.mjs | 628 -- .agent/skills/impeccable/scripts/pin.mjs | 340 - .../impeccable/scripts/serve-question.mjs | 1783 ---- .../impeccable/scripts/surface-brief.mjs | 74 - .codex/hooks.json | 6 +- .../agents/impeccable-asset-producer.agent.md | 10 +- .../impeccable-finish-reviewer.agent.md | 2 +- .../impeccable-manual-edit-applier.agent.md | 2 +- .github/hooks/impeccable.json | 2 +- .github/workflows/sync-generated-output.yml | 5 + .veto/skills/impeccable/SKILL.md | 8 +- .../impeccable/reference/audit.native.md | 2 +- .veto/skills/impeccable/reference/critique.md | 14 +- .../reference/degraded/asset-producer.md | 10 +- .../reference/degraded/finish-reviewer.md | 2 +- .../reference/degraded/manual-edit-applier.md | 2 +- .veto/skills/impeccable/reference/doctor.md | 8 +- .veto/skills/impeccable/reference/hooks.md | 24 +- .veto/skills/impeccable/reference/init.md | 8 +- .veto/skills/impeccable/reference/layout.md | 2 +- .../skills/impeccable/reference/live-setup.md | 12 +- .veto/skills/impeccable/reference/live.md | 58 +- .veto/skills/impeccable/reference/new-work.md | 30 +- .veto/skills/impeccable/reference/polish.md | 6 +- .veto/skills/impeccable/reference/routing.md | 6 +- .veto/skills/impeccable/reference/typeset.md | 2 +- .../skills/impeccable/reference/visualize.md | 16 +- .veto/skills/impeccable/scripts/VERSION | 1 + .../skills/impeccable/scripts/build-phase.mjs | 1022 -- .veto/skills/impeccable/scripts/comp-diff.mjs | 391 - .veto/skills/impeccable/scripts/comp-spec.mjs | 513 - .../impeccable/scripts/concept-seed.mjs | 814 -- .../impeccable/scripts/context-signals.mjs | 325 - .veto/skills/impeccable/scripts/context.mjs | 1597 --- .../impeccable/scripts/critique-storage.mjs | 473 - .../skills/impeccable/scripts/detect-csp.mjs | 198 - .veto/skills/impeccable/scripts/detect.mjs | 30 - .../detector/browser/injected/index.mjs | 2204 ---- .../impeccable/scripts/detector/cli/main.mjs | 432 - .../scripts/detector/design-system.mjs | 1311 --- .../detector/detect-antipatterns-browser.js | 9104 ----------------- .../scripts/detector/detect-antipatterns.mjs | 51 - .../detector/engines/browser/detect-url.mjs | 434 - .../detector/engines/regex/detect-text.mjs | 1293 --- .../engines/static-html/css-cascade.mjs | 1242 --- .../engines/static-html/detect-html.mjs | 278 - .../engines/visual/screenshot-contrast.mjs | 189 - .../impeccable/scripts/detector/findings.mjs | 18 - .../scripts/detector/node/file-system.mjs | 213 - .../scripts/detector/profile/profiler.mjs | 166 - .../detector/registry/antipatterns.mjs | 635 -- .../scripts/detector/rules/checks.mjs | 5744 ----------- .../scripts/detector/shared/color.mjs | 596 -- .../scripts/detector/shared/constants.mjs | 127 - .../scripts/detector/shared/fonts.mjs | 30 - .../detector/shared/inline-ignores.mjs | 148 - .../scripts/detector/shared/page.mjs | 7 - .veto/skills/impeccable/scripts/doctor.mjs | 329 - .../impeccable/scripts/embed-prompt.mjs | 166 - .../skills/impeccable/scripts/font-match.mjs | 457 - .../impeccable/scripts/generate-image.mjs | 447 - .../skills/impeccable/scripts/hook-admin.mjs | 819 -- .../impeccable/scripts/hook-before-edit.mjs | 538 - .veto/skills/impeccable/scripts/hook-lib.mjs | 2490 ----- .veto/skills/impeccable/scripts/hook.mjs | 79 - .veto/skills/impeccable/scripts/impeccable | 154 + .../skills/impeccable/scripts/impeccable.cmd | 142 + .../scripts/lib/artifact-schema.mjs | 93 - .../scripts/lib/composition-catalog.mjs | 200 - .../scripts/lib/concept-catalog.mjs | 396 - .../impeccable/scripts/lib/design-parser.mjs | 880 -- .../scripts/lib/font-fingerprint.mjs | 564 - .../impeccable/scripts/lib/font-index.mjs | 130 - .../impeccable/scripts/lib/hero-checks.mjs | 246 - .../impeccable/scripts/lib/image-metrics.mjs | 306 - .../scripts/lib/impeccable-config.mjs | 640 -- .../scripts/lib/impeccable-paths.mjs | 137 - .../impeccable/scripts/lib/is-generated.mjs | 72 - .../scripts/lib/live-path-globs.mjs | 37 - .../scripts/lib/open-system-browser.mjs | 26 - .veto/skills/impeccable/scripts/lib/png.mjs | 281 - .../impeccable/scripts/lib/provider.mjs | 5 - .../skills/impeccable/scripts/lib/raster.mjs | 194 - .../impeccable/scripts/lib/roll-selection.mjs | 369 - .../impeccable/scripts/lib/staleness-deep.mjs | 485 - .../scripts/lib/staleness-notice.mjs | 169 - .../impeccable/scripts/lib/staleness.mjs | 533 - .../impeccable/scripts/lib/surface-briefs.mjs | 149 - .../impeccable/scripts/lib/target-args.mjs | 42 - .../impeccable/scripts/lib/target-slug.mjs | 33 - .../scripts/lib/template-extensions.mjs | 146 - .../skills/impeccable/scripts/live-accept.mjs | 938 -- .../skills/impeccable/scripts/live-browser.js | 285 +- .../scripts/live-commit-manual-edits.mjs | 1200 --- .../impeccable/scripts/live-complete.mjs | 107 - .../scripts/live-copy-edit-agent.mjs | 800 -- .../scripts/live-discard-manual-edits.mjs | 51 - .../skills/impeccable/scripts/live-inject.mjs | 463 - .../skills/impeccable/scripts/live-insert.mjs | 292 - .../scripts/live-manual-edit-evidence.mjs | 368 - .veto/skills/impeccable/scripts/live-poll.mjs | 430 - .../skills/impeccable/scripts/live-resume.mjs | 123 - .../skills/impeccable/scripts/live-server.mjs | 1698 --- .../skills/impeccable/scripts/live-status.mjs | 71 - .../skills/impeccable/scripts/live-target.mjs | 30 - .veto/skills/impeccable/scripts/live-wrap.mjs | 927 -- .veto/skills/impeccable/scripts/live.mjs | 334 - .../impeccable/scripts/live/accept-css.mjs | 617 -- .../impeccable/scripts/live/accept-verify.mjs | 60 - .../scripts/live/browser-script-parts.mjs | 84 - .../impeccable/scripts/live/completion.mjs | 28 - .../scripts/live/event-validation.mjs | 199 - .../scripts/live/frameworks/astro.mjs | 47 - .../scripts/live/frameworks/detect-utils.mjs | 73 - .../scripts/live/frameworks/index.mjs | 143 - .../scripts/live/frameworks/journal.mjs | 197 - .../scripts/live/frameworks/nextjs.mjs | 49 - .../scripts/live/frameworks/nuxt.mjs | 161 - .../scripts/live/frameworks/script-src.mjs | 17 - .../scripts/live/frameworks/static-html.mjs | 26 - .../scripts/live/frameworks/sveltekit.mjs | 71 - .../scripts/live/frameworks/tag-strategy.mjs | 247 - .../live/frameworks/tanstack-start.mjs | 70 - .../scripts/live/frameworks/vite-generic.mjs | 42 - .../scripts/live/generation-preflight.mjs | 149 - .../impeccable/scripts/live/insert-ui.mjs | 458 - .../impeccable/scripts/live/instructions.mjs | 142 - .../impeccable/scripts/live/manual-apply.mjs | 939 -- .../scripts/live/manual-edit-routes.mjs | 357 - .../scripts/live/manual-edits-buffer.mjs | 152 - .../impeccable/scripts/live/poll-lanes.mjs | 14 - .../scripts/live/project-ignores.mjs | 139 - .../skills/impeccable/scripts/live/roots.mjs | 508 - .../impeccable/scripts/live/session-store.mjs | 563 - .../impeccable/scripts/live/source-lock.mjs | 105 - .../impeccable/scripts/live/source-search.mjs | 105 - .../impeccable/scripts/live/svelte-ast.mjs | 969 -- .../scripts/live/svelte-component.mjs | 1366 --- .../scripts/live/sveltekit-adapter.mjs | 304 - .../scripts/live/tanstack-adapter.mjs | 259 - .../impeccable/scripts/live/ui-surfaces.mjs | 75 - .../impeccable/scripts/live/vocabulary.mjs | 171 - .veto/skills/impeccable/scripts/palette.mjs | 628 -- .veto/skills/impeccable/scripts/pin.mjs | 340 - .../impeccable/scripts/serve-question.mjs | 1783 ---- .../impeccable/scripts/surface-brief.mjs | 74 - 278 files changed, 1302 insertions(+), 121523 deletions(-) create mode 100644 .agent/skills/impeccable/scripts/VERSION delete mode 100644 .agent/skills/impeccable/scripts/build-phase.mjs delete mode 100644 .agent/skills/impeccable/scripts/comp-diff.mjs delete mode 100644 .agent/skills/impeccable/scripts/comp-spec.mjs delete mode 100644 .agent/skills/impeccable/scripts/concept-seed.mjs delete mode 100644 .agent/skills/impeccable/scripts/context-signals.mjs delete mode 100644 .agent/skills/impeccable/scripts/context.mjs delete mode 100644 .agent/skills/impeccable/scripts/critique-storage.mjs delete mode 100644 .agent/skills/impeccable/scripts/detect-csp.mjs delete mode 100644 .agent/skills/impeccable/scripts/detect.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/browser/injected/index.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/cli/main.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/design-system.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/detect-antipatterns-browser.js delete mode 100644 .agent/skills/impeccable/scripts/detector/detect-antipatterns.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/findings.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/node/file-system.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/profile/profiler.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/registry/antipatterns.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/rules/checks.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/shared/color.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/shared/constants.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/shared/fonts.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/shared/inline-ignores.mjs delete mode 100644 .agent/skills/impeccable/scripts/detector/shared/page.mjs delete mode 100644 .agent/skills/impeccable/scripts/doctor.mjs delete mode 100644 .agent/skills/impeccable/scripts/embed-prompt.mjs delete mode 100644 .agent/skills/impeccable/scripts/font-match.mjs delete mode 100644 .agent/skills/impeccable/scripts/generate-image.mjs delete mode 100644 .agent/skills/impeccable/scripts/hook-admin.mjs delete mode 100644 .agent/skills/impeccable/scripts/hook-before-edit.mjs delete mode 100644 .agent/skills/impeccable/scripts/hook-lib.mjs delete mode 100644 .agent/skills/impeccable/scripts/hook.mjs create mode 100755 .agent/skills/impeccable/scripts/impeccable create mode 100644 .agent/skills/impeccable/scripts/impeccable.cmd delete mode 100644 .agent/skills/impeccable/scripts/lib/artifact-schema.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/composition-catalog.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/concept-catalog.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/design-parser.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/font-fingerprint.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/font-index.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/hero-checks.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/image-metrics.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/impeccable-config.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/impeccable-paths.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/is-generated.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/live-path-globs.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/open-system-browser.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/png.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/provider.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/raster.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/roll-selection.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/staleness-deep.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/staleness-notice.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/staleness.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/surface-briefs.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/target-args.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/target-slug.mjs delete mode 100644 .agent/skills/impeccable/scripts/lib/template-extensions.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-accept.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-commit-manual-edits.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-complete.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-copy-edit-agent.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-discard-manual-edits.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-inject.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-insert.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-manual-edit-evidence.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-poll.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-resume.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-server.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-status.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-target.mjs delete mode 100644 .agent/skills/impeccable/scripts/live-wrap.mjs delete mode 100644 .agent/skills/impeccable/scripts/live.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/accept-css.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/accept-verify.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/browser-script-parts.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/completion.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/event-validation.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/astro.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/detect-utils.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/index.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/journal.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/nextjs.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/nuxt.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/script-src.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/static-html.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/sveltekit.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/frameworks/vite-generic.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/generation-preflight.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/insert-ui.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/instructions.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/manual-apply.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/manual-edit-routes.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/manual-edits-buffer.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/poll-lanes.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/project-ignores.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/roots.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/session-store.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/source-lock.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/source-search.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/svelte-ast.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/svelte-component.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/sveltekit-adapter.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/tanstack-adapter.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/ui-surfaces.mjs delete mode 100644 .agent/skills/impeccable/scripts/live/vocabulary.mjs delete mode 100644 .agent/skills/impeccable/scripts/palette.mjs delete mode 100644 .agent/skills/impeccable/scripts/pin.mjs delete mode 100644 .agent/skills/impeccable/scripts/serve-question.mjs delete mode 100644 .agent/skills/impeccable/scripts/surface-brief.mjs create mode 100644 .veto/skills/impeccable/scripts/VERSION delete mode 100644 .veto/skills/impeccable/scripts/build-phase.mjs delete mode 100644 .veto/skills/impeccable/scripts/comp-diff.mjs delete mode 100644 .veto/skills/impeccable/scripts/comp-spec.mjs delete mode 100644 .veto/skills/impeccable/scripts/concept-seed.mjs delete mode 100644 .veto/skills/impeccable/scripts/context-signals.mjs delete mode 100644 .veto/skills/impeccable/scripts/context.mjs delete mode 100644 .veto/skills/impeccable/scripts/critique-storage.mjs delete mode 100644 .veto/skills/impeccable/scripts/detect-csp.mjs delete mode 100644 .veto/skills/impeccable/scripts/detect.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/browser/injected/index.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/cli/main.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/design-system.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/detect-antipatterns-browser.js delete mode 100644 .veto/skills/impeccable/scripts/detector/detect-antipatterns.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/findings.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/node/file-system.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/profile/profiler.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/registry/antipatterns.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/rules/checks.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/shared/color.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/shared/constants.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/shared/fonts.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/shared/inline-ignores.mjs delete mode 100644 .veto/skills/impeccable/scripts/detector/shared/page.mjs delete mode 100644 .veto/skills/impeccable/scripts/doctor.mjs delete mode 100644 .veto/skills/impeccable/scripts/embed-prompt.mjs delete mode 100644 .veto/skills/impeccable/scripts/font-match.mjs delete mode 100644 .veto/skills/impeccable/scripts/generate-image.mjs delete mode 100644 .veto/skills/impeccable/scripts/hook-admin.mjs delete mode 100644 .veto/skills/impeccable/scripts/hook-before-edit.mjs delete mode 100644 .veto/skills/impeccable/scripts/hook-lib.mjs delete mode 100644 .veto/skills/impeccable/scripts/hook.mjs create mode 100755 .veto/skills/impeccable/scripts/impeccable create mode 100644 .veto/skills/impeccable/scripts/impeccable.cmd delete mode 100644 .veto/skills/impeccable/scripts/lib/artifact-schema.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/composition-catalog.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/concept-catalog.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/design-parser.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/font-fingerprint.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/font-index.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/hero-checks.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/image-metrics.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/impeccable-config.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/impeccable-paths.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/is-generated.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/live-path-globs.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/open-system-browser.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/png.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/provider.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/raster.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/roll-selection.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/staleness-deep.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/staleness-notice.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/staleness.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/surface-briefs.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/target-args.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/target-slug.mjs delete mode 100644 .veto/skills/impeccable/scripts/lib/template-extensions.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-accept.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-commit-manual-edits.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-complete.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-copy-edit-agent.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-discard-manual-edits.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-inject.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-insert.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-manual-edit-evidence.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-poll.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-resume.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-server.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-status.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-target.mjs delete mode 100644 .veto/skills/impeccable/scripts/live-wrap.mjs delete mode 100644 .veto/skills/impeccable/scripts/live.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/accept-css.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/accept-verify.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/browser-script-parts.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/completion.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/event-validation.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/astro.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/detect-utils.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/index.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/journal.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/nextjs.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/nuxt.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/script-src.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/static-html.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/sveltekit.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/frameworks/vite-generic.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/generation-preflight.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/insert-ui.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/instructions.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/manual-apply.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/manual-edit-routes.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/manual-edits-buffer.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/poll-lanes.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/project-ignores.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/roots.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/session-store.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/source-lock.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/source-search.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/svelte-ast.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/svelte-component.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/sveltekit-adapter.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/tanstack-adapter.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/ui-surfaces.mjs delete mode 100644 .veto/skills/impeccable/scripts/live/vocabulary.mjs delete mode 100644 .veto/skills/impeccable/scripts/palette.mjs delete mode 100644 .veto/skills/impeccable/scripts/pin.mjs delete mode 100644 .veto/skills/impeccable/scripts/serve-question.mjs delete mode 100644 .veto/skills/impeccable/scripts/surface-brief.mjs diff --git a/.agent/skills/impeccable/SKILL.md b/.agent/skills/impeccable/SKILL.md index 733586a93..0ec052154 100644 --- a/.agent/skills/impeccable/SKILL.md +++ b/.agent/skills/impeccable/SKILL.md @@ -5,7 +5,7 @@ version: 4.1.3 license: Apache 2.0 allowed-tools: - Bash(npx impeccable *) - - Bash(node .agent/skills/impeccable/scripts/*) + - Bash(.agent/skills/impeccable/scripts/impeccable *) --- This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft. @@ -17,7 +17,7 @@ Core principles: ## Setup -1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .agent/skills/impeccable/scripts/...` command in this skill and its references, and `.agent/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. +1. Run `/scripts/impeccable context` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `.agent/skills/impeccable/scripts/impeccable ` command in this skill and its references, and `.agent/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. On a Windows shell without `sh`, call `.agent/skills/impeccable/scripts/impeccable.cmd` instead. The launcher runs a self-contained binary that ships next to it or is downloaded once on first run; no Node or other runtime is required. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. 2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. @@ -70,12 +70,12 @@ Routing: - **No argument:** read [routing.md](reference/routing.md) and present its context-aware menu; never auto-run a command. - **Explicit or clearly implied command:** load its reference (native variant on native platforms) and follow it. Ask once if two commands fit. -- **Otherwise:** treat the request as general design work. Missing PRODUCT.md routes a new surface or replacement world through init, then new-work; a narrow refinement of existing code proceeds on the incumbent implementation as context.mjs directs, offering init afterward rather than blocking on it. +- **Otherwise:** treat the request as general design work. Missing PRODUCT.md routes a new surface or replacement world through init, then new-work; a narrow refinement of existing code proceeds on the incumbent implementation as `impeccable context` directs, offering init afterward rather than blocking on it. - `teach` aliases `init`. `craft` is a deprecated alias for ordinary new-work and adds nothing. `shape` owns task discovery, then enters new-work only for visual-world and surface-concept decisions. -After init writes PRODUCT.md, resume without rerunning `context.mjs`; init loads the native platform reference itself when the platform it recorded is `ios`, `android`, or `adaptive`. +After init writes PRODUCT.md, resume without rerunning `impeccable context`; init loads the native platform reference itself when the platform it recorded is `ios`, `android`, or `adaptive`. -**Pin / Unpin:** `node .agent/skills/impeccable/scripts/pin.mjs ` creates or removes a standalone `/` shortcut. Report the script's result concisely; relay stderr verbatim on error. +**Pin / Unpin:** `.agent/skills/impeccable/scripts/impeccable pin ` creates or removes a standalone `/` shortcut. Report the script's result concisely; relay stderr verbatim on error. **Hooks:** `/impeccable hooks ` manages the design detector hook for this project (auto-runs the detector after UI file edits and surfaces findings). Load [reference/hooks.md](reference/hooks.md) when the user invokes it with any argument. diff --git a/.agent/skills/impeccable/reference/audit.native.md b/.agent/skills/impeccable/reference/audit.native.md index 0126fa157..fbccb56f6 100644 --- a/.agent/skills/impeccable/reference/audit.native.md +++ b/.agent/skills/impeccable/reference/audit.native.md @@ -1,6 +1,6 @@ Run systematic **technical** quality checks on a native app (`ios` / `android` / `adaptive`) and generate a comprehensive report. Don't fix issues; document them for other commands to address. -This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `detect.mjs` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it. +This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `impeccable detect` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it. ## Diagnostic Scan diff --git a/.agent/skills/impeccable/reference/critique.md b/.agent/skills/impeccable/reference/critique.md index 1c4ae5b5c..2a72efcd1 100644 --- a/.agent/skills/impeccable/reference/critique.md +++ b/.agent/skills/impeccable/reference/critique.md @@ -8,7 +8,7 @@ Resolve one stable target, run two independent assessments, synthesize a design - Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask). - If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context ()`. A silent degraded critique is a failed critique. - Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment. -- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt. +- A skipped detector is a failed critique run unless `impeccable detect` is missing or crashes after a real attempt. - Viewable targets require browser inspection when available. - Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it. - Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page. @@ -23,7 +23,7 @@ Resolve one stable target, run two independent assessments, synthesize a design - "this page" -> the current URL or source file 2. **Confirm the target slugs cleanly**: ```bash - node .agent/skills/impeccable/scripts/critique-storage.mjs slug "" + .agent/skills/impeccable/scripts/impeccable critique-storage slug "" ``` Every later command also accepts the resolved target directly and derives the same slug internally; never hand-write a slug. If this exits non-zero, skip persistence and trend for this run, but continue the critique. 3. **Read `.impeccable/critique/ignore.md`** if it exists. Drop matching findings silently; it is the only prior-run input critique consumes. @@ -59,7 +59,7 @@ Run the bundled detector and browser visualization evidence. Assessment B is man CLI scan: ```bash -node .agent/skills/impeccable/scripts/detect.mjs --json [target] +.agent/skills/impeccable/scripts/impeccable detect --json [target] ``` - Pass markup files/directories as `[target]`; do not pass CSS-only files. @@ -73,12 +73,12 @@ Browser visualization is required for a viewable target when browser automation 1. Create a fresh tab and navigate. Prefer the harness's native/browser-canvas screenshot path before hand-rolling a Playwright/Puppeteer script; only fall back to a custom script when no native browser tool is exposed. 2. Preflight mutable injection by setting `document.title` and appending a `\n' + - open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' - ); -} - -function detectLineEnding(content) { - if (content.includes('\r\n')) return '\r\n'; - if (content.includes('\r')) return '\r'; - return '\n'; -} - -function normalizeLineEndings(content, lineEnding) { - return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); -} - -function readLineEndingAt(content, index) { - if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; - if (content[index] === '\n') return '\n'; - if (content[index] === '\r') return '\r'; - return ''; -} - -export function insertTag(content, config, port, token, scriptAttrs = '') { - const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, token, scriptAttrs), lineEnding); - // insertBefore: match the LAST occurrence. Anchors like `` naturally - // belong at the end, and the same literal can appear earlier in code blocks - // within rendered documentation pages. - if (config.insertBefore) { - const idx = content.lastIndexOf(config.insertBefore); - if (idx === -1) return content; - return content.slice(0, idx) + block + content.slice(idx); - } - // insertAfter: match the FIRST occurrence — typical anchors like `` or - // `` open near the top of the document. - const idx = content.indexOf(config.insertAfter); - if (idx === -1) return content; - const after = idx + config.insertAfter.length; - // Preserve an existing trailing newline if the anchor already has one. - // Slice the remainder from the original anchor offset, not prefix.length: - // in the no-newline case prefix is one char longer than the anchor (the - // appended '\n'), so slicing by prefix.length would drop the first real - // character after the anchor (#227). - const existingNewline = readLineEndingAt(content, after); - const prefix = content.slice(0, after) + (existingNewline || lineEnding); - const rest = content.slice(after + existingNewline.length); - return prefix + block + rest; -} - -/** - * Remove the live script block. Matches either HTML or JSX comment markers - * regardless of config (so stale tags from a wrong config can still be cleaned). - * - * Indent-preserving: captures any whitespace immediately preceding the opener - * marker and re-emits it in place of the removed block. `insertTag` inserted - * the block *after* the original line's indent and *before* the anchor (e.g. - * ``), which moved the indent onto the opener line and left the anchor - * unindented. Replacing the whole block (plus its trailing newline) with just - * the captured indent hands the indent back to the anchor that follows. - */ -export function removeTag(content, _syntax) { - const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, - ]; - for (const pat of patterns) { - let changed = false; - let next = content; - do { - content = next; - next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (/[\r\n]/.test(trailing)) return leadingIndent; - return leadingIndent || trailing || ''; - }); - if (next !== content) changed = true; - } while (next !== content); - if (changed) return next; - } - return content; -} - -// --------------------------------------------------------------------------- -// Content-Security-Policy meta-tag patcher -// -// When the user's HTML carries ``, -// the cross-origin load of /live.js (and the SSE/POST connection back to -// localhost:PORT) is blocked unless the CSP explicitly allows that origin. -// -// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`, -// and stash the original `content` value in a `data-impeccable-csp-original` -// attribute (base64) so revert is exact. -// -// On remove: detect the marker attribute, decode it, restore the original -// content value verbatim, drop the marker. -// -// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp, -// shared helpers) is NOT patched here — those need framework-specific config -// edits and are handled via the existing detect-csp.mjs reference output. -// Only the in-source meta-tag form gets the auto-patch. -// --------------------------------------------------------------------------- - -const CSP_MARKER_ATTR = 'data-impeccable-csp-original'; - -function findCspMetaTags(content) { - const out = []; - const tagRe = /]*?)\/?>/gis; - let m; - while ((m = tagRe.exec(content)) !== null) { - const attrs = m[1]; - if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue; - out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs }); - } - return out; -} - -function getAttr(attrs, name) { - const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'); - const m = attrs.match(re); - return m ? { quote: m[1], value: m[2], full: m[0] } : null; -} - -function appendOriginToDirective(csp, directive, origin) { - const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i'); - const m = csp.match(re); - if (m) { - const tokens = m[4].trim().split(/\s+/); - if (tokens.includes(origin)) return csp; - return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`); - } - // Directive missing — add it. Use 'self' + origin so we don't inadvertently - // narrow the policy compared to the default-src fallback (most users with - // an explicit CSP have 'self' there). - return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`; -} - -export function patchCspMeta(content, port) { - const tags = findCspMetaTags(content); - if (tags.length === 0) return content; - const origin = `http://localhost:${port}`; - - // Walk last-to-first so prior splices don't invalidate later indices. - let result = content; - for (let i = tags.length - 1; i >= 0; i--) { - const tag = tags[i]; - const attrs = tag.attrs; - if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched - const contentAttr = getAttr(attrs, 'content'); - if (!contentAttr) continue; - - const original = contentAttr.value; - let patched = original; - patched = appendOriginToDirective(patched, 'script-src', origin); - patched = appendOriginToDirective(patched, 'connect-src', origin); - // The shader overlay during 'generating' creates a screenshot via - // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects - // those. Add `blob:` so the overlay doesn't throw a CSP violation. - patched = appendOriginToDirective(patched, 'img-src', 'blob:'); - if (patched === original) continue; - - const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`; - const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`; - // The tagRe captures any whitespace between the last attribute and the - // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after - // a replace would land it BEFORE that trailing space, leaving a double - // space inside attrs and clobbering the space before `/>`. Split off - // the trailing whitespace, splice the marker into the attribute body, - // and re-append the original trailing whitespace so a self-closing - // `` round-trips byte-for-byte. - const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0]; - const attrsBody = attrs.slice(0, attrs.length - trailingWs.length); - const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs; - const newTag = tag.full.replace(attrs, newAttrs); - - result = result.slice(0, tag.start) + newTag + result.slice(tag.end); - } - return result; -} - -export function revertCspMeta(content) { - const tags = findCspMetaTags(content); - if (tags.length === 0) return content; - - let result = content; - for (let i = tags.length - 1; i >= 0; i--) { - const tag = tags[i]; - const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR); - if (!origAttr) continue; - const contentAttr = getAttr(tag.attrs, 'content'); - if (!contentAttr) continue; - - let originalValue; - try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); } - catch { continue; } - - const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`; - let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr); - // Drop the marker attribute and any single space immediately preceding it. - newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), ''); - const newTag = tag.full.replace(tag.attrs, newAttrs); - - result = result.slice(0, tag.start) + newTag + result.slice(tag.end); - } - return result; -} - -/** The journal's undo for a tag-strategy patch: drop the block, restore CSP. */ -export function unpatchTagFile(content) { - return revertCspMeta(removeTag(content)); -} diff --git a/.agent/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs b/.agent/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs deleted file mode 100644 index 9bfb3db4a..000000000 --- a/.agent/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs +++ /dev/null @@ -1,70 +0,0 @@ -/** - * TanStack Start registry entry. - * - * Detection and the apply/remove pair are the existing adapter's - * (`../tanstack-adapter.mjs`); this file only declares them to the registry - * and names the artifacts the journal has to be able to heal. - */ - -import { - TANSTACK_MARKER_OPEN, - applyTanStackLiveAdapter, - detectTanStackStartProject, - removeTanStackLiveAdapter, - unpatchTanStackRoot, -} from '../tanstack-adapter.mjs'; - -export const tanstackStart = { - name: 'tanstack-start', - - detect(cwd) { - return detectTanStackStartProject(cwd); - }, - - inject: { - kind: 'adapter', - - apply({ cwd, port, token, project }) { - return applyTanStackLiveAdapter({ cwd, port, token, project }); - }, - - remove({ cwd, project }) { - return removeTanStackLiveAdapter({ cwd, project }); - }, - - // The mount component's extension follows the root route's, so the path - // cannot live in the static ignore list. - ignorePatterns(project) { - return project?.componentFile ? [project.componentFile] : []; - }, - - artifacts({ project }) { - if (!project) return []; - return [ - { - kind: 'created', - path: project.componentFile, - marker: 'impeccable-live-tanstack', - pruneTo: 'src', - }, - { - kind: 'patched', - path: project.rootRoute, - patch: 'tanstack-root', - markers: [TANSTACK_MARKER_OPEN], - }, - ]; - }, - - unpatch: { - 'tanstack-root': unpatchTanStackRoot, - }, - }, - - source: { - extensions: ['.tsx', '.jsx'], - preview: 'source', - styleMode: 'scoped', - commentSyntax: 'jsx', - }, -}; diff --git a/.agent/skills/impeccable/scripts/live/frameworks/vite-generic.mjs b/.agent/skills/impeccable/scripts/live/frameworks/vite-generic.mjs deleted file mode 100644 index 4713670f4..000000000 --- a/.agent/skills/impeccable/scripts/live/frameworks/vite-generic.mjs +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Generic Vite registry entry: a bundled app with a real `index.html` entry - * and no framework-specific document ownership. React, Vue, Solid, Preact and - * a plain TanStack Router SPA all land here — the marker-wrapped script block - * goes straight into the HTML entry. - * - * This is the entry that catches everything with a bundler config; only - * static-html sits below it. - */ - -import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs'; - -const VITE_CONFIG_RE = /^vite\.config\.(?:js|mjs|cjs|ts|mts|cts)$/; - -export function detectViteProject(cwd = process.cwd()) { - const configFile = findConfigFile(cwd, VITE_CONFIG_RE); - if (configFile) return { configFile, via: 'config' }; - if (hasAnyDependency(cwd, ['vite'])) return { configFile: null, via: 'package' }; - // A zero-config Vite app is index.html + package.json, the same pair - // roots.mjs treats as an app root. - if (fileExists(cwd, 'index.html') && fileExists(cwd, 'package.json')) { - return { configFile: null, via: 'zero-config' }; - } - return null; -} - -export const viteGeneric = { - name: 'vite-generic', - - detect(cwd) { - return detectViteProject(cwd); - }, - - inject: { kind: 'tag' }, - - source: { - extensions: ['.tsx', '.jsx'], - preview: 'source', - styleMode: 'scoped', - commentSyntax: 'jsx', - }, -}; diff --git a/.agent/skills/impeccable/scripts/live/generation-preflight.mjs b/.agent/skills/impeccable/scripts/live/generation-preflight.mjs deleted file mode 100644 index bfe81b32f..000000000 --- a/.agent/skills/impeccable/scripts/live/generation-preflight.mjs +++ /dev/null @@ -1,149 +0,0 @@ -import { execFile } from 'node:child_process'; -import path from 'node:path'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); -const PREFLIGHT_TIMEOUT_MS = 15_000; - -// Per-target cache of the resolved source file. The wrap search walks the whole -// project tree and was measured at ~7.6s on a large repo; it re-ran on every -// generate for the same picked element (re-rolls, param passes). Keyed by the -// target signature (locator + route), so it invalidates automatically when the -// element or route changes; a failed resolution evicts its entry (see below). -const sourceResolutionCache = new Map(); - -/** Test/lifecycle hook: drop all cached source resolutions. */ -export function clearSourceResolutionCache() { - sourceResolutionCache.clear(); -} - -function targetSignature(event) { - const isInsert = event.mode === 'insert'; - const target = isInsert ? insertTarget(event) : replaceTarget(event); - return JSON.stringify({ - mode: isInsert ? 'insert' : 'replace', - position: isInsert ? target.position : null, - elementId: target.elementId || null, - classes: target.classes || null, - tag: target.tag || null, - pageUrl: event.pageUrl || null, - }); -} - -export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) { - if (!event || event.type !== 'generate' || !event.id) return null; - - const isInsert = event.mode === 'insert'; - const target = isInsert ? insertTarget(event) : replaceTarget(event); - if (!target.elementId && !target.classes) return null; - - const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs'); - const args = [script, '--id', event.id, '--count', String(event.count || 3)]; - // Compute the scaffold but do not write it into source for source-preview - // targets. The agent writes wrapper + variants atomically; a premature - // server-side write reloads the framework and strands the browser at 0/N. - // No-op on the svelte-component path, which never writes the route source. - args.push('--defer-source-write'); - if (isInsert) args.push('--position', target.position); - if (target.elementId) args.push('--element-id', target.elementId); - if (target.classes) args.push('--classes', target.classes); - if (target.tag) args.push('--tag', target.tag); - if (target.text) args.push('--text', target.text); - if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl); - const signature = targetSignature(event); - // A cached resolution points the helper straight at the file, skipping the - // tree search. The helper still reads current content, so line ranges stay - // fresh; only discovery is cached. - const cachedFile = cache ? cache.get(signature) : null; - if (cachedFile) args.push('--file', cachedFile); - return { script, args, mode: isInsert ? 'insert' : 'replace', signature }; -} - -/** - * Scaffold the source for a generate event before handing it to an agent. - * - * Async on purpose. This spawns `live-wrap.mjs`, which walks the project's - * source tree and can take seconds (measured at ~7.6s on a large repo when the - * element is not found, with a 15s ceiling). The live server is single-threaded - * and calls this while leasing a poll, so a synchronous spawn froze the whole - * server for that entire window: Accept and Discard POSTs, SSE progress - * broadcasts, and every other poll stalled behind it. - */ -export async function runGenerationPreflight(event, { - cwd = process.cwd(), - scriptsDir, - execFileImpl = execFileAsync, - timeoutMs = PREFLIGHT_TIMEOUT_MS, - cache = sourceResolutionCache, -} = {}) { - const command = buildGenerationPreflight(event, scriptsDir, { cache }); - if (!command) { - return { ok: false, skipped: true, reason: 'insufficient_locator' }; - } - - const startedAt = performance.now(); - try { - const { stdout } = await execFileImpl(process.execPath, command.args, { - cwd, - encoding: 'utf-8', - timeout: timeoutMs, - }); - const line = String(stdout).trim().split('\n').filter(Boolean).pop(); - if (!line) throw new Error('preflight returned no scaffold metadata'); - const scaffold = JSON.parse(line); - // Cache the resolved SOURCE file (route source, not the svelte manifest) so - // the next generate on this target skips the tree search. - const resolvedSource = scaffold.sourceFile || scaffold.file; - if (cache && command.signature && typeof resolvedSource === 'string') { - cache.set(command.signature, resolvedSource); - } - return { - ok: true, - mode: command.mode, - durationMs: performance.now() - startedAt, - scaffold, - }; - } catch (error) { - // Evict a stale/failed resolution so the next attempt does a full search - // (the element may have moved out of the previously cached file). - if (cache && command.signature) cache.delete(command.signature); - return { - ok: false, - mode: command.mode, - durationMs: performance.now() - startedAt, - error: compactError(error), - }; - } -} - -function replaceTarget(event) { - return normalizeTarget(event.element || {}); -} - -function insertTarget(event) { - return { - ...normalizeTarget(event.insert?.anchor || {}), - position: event.insert?.position === 'before' ? 'before' : 'after', - }; -} - -function normalizeTarget(target) { - const classes = Array.isArray(target.classes) - ? target.classes.join(' ') - : String(target.classes || '').trim(); - const text = typeof target.textContent === 'string' - ? target.textContent.trim().slice(0, 80) - : ''; - return { - elementId: target.id || target.elementId || undefined, - classes: classes || undefined, - tag: target.tagName || target.tag || undefined, - text: text || undefined, - }; -} - -function compactError(error) { - const stderr = error?.stderr ? String(error.stderr).trim() : ''; - const message = stderr.split('\n').filter(Boolean).pop() || error?.message || 'preflight failed'; - return String(message).slice(0, 500); -} diff --git a/.agent/skills/impeccable/scripts/live/insert-ui.mjs b/.agent/skills/impeccable/scripts/live/insert-ui.mjs deleted file mode 100644 index ae54f6f93..000000000 --- a/.agent/skills/impeccable/scripts/live/insert-ui.mjs +++ /dev/null @@ -1,458 +0,0 @@ -/** - * Pure helpers for live-mode insert UI (browser + tests). - * Kept separate from live-browser.js so insert logic is unit-testable. - */ - -export const PLACEHOLDER_DEFAULT_HEIGHT = 80; -export const PLACEHOLDER_MIN_HEIGHT = 48; -export const PLACEHOLDER_MIN_WIDTH = 120; - -/** @typedef {'before' | 'after'} InsertPosition */ -/** @typedef {'row' | 'column'} InsertAxis */ - -/** - * Infer sibling flow axis from a container's computed layout styles. - * @param {{ display?: string, flexDirection?: string, gridTemplateColumns?: string, gridAutoFlow?: string }} style - * @returns {InsertAxis} - */ -export function detectInsertAxisFromStyle(style) { - const display = style?.display || 'block'; - if (display.includes('flex')) { - const dir = style.flexDirection || 'row'; - return dir.startsWith('row') ? 'row' : 'column'; - } - if (display === 'grid' || display === 'inline-grid') { - const flow = style.gridAutoFlow || 'row'; - if (flow.includes('column')) return 'column'; - const cols = (style.gridTemplateColumns || '').trim(); - if (cols && cols !== 'none') { - const colCount = cols.split(/\s+/).filter(Boolean).length; - if (colCount > 1) return 'row'; - } - return 'row'; - } - return 'column'; -} - -/** - * Pick insertion side from pointer position against an anchor element box. - * @param {number} clientX - * @param {number} clientY - * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect - * @param {InsertAxis} [axis] - * @returns {InsertPosition} - */ -export function computeInsertPosition(clientX, clientY, rect, axis = 'column') { - if (!rect) return 'after'; - if (axis === 'row') { - if (!Number.isFinite(rect.left) || !Number.isFinite(rect.width) || rect.width <= 0) return 'after'; - const mid = rect.left + rect.width / 2; - return clientX < mid ? 'before' : 'after'; - } - if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) return 'after'; - const mid = rect.top + rect.height / 2; - return clientY < mid ? 'before' : 'after'; -} - -/** - * Whether Create is allowed for an insert session. - * Requires a non-empty prompt OR at least one annotation. - */ -export function canCreateInsert({ prompt, comments, strokes }) { - const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0; - const hasComments = Array.isArray(comments) && comments.length > 0; - const hasStrokes = Array.isArray(strokes) && strokes.some( - (s) => Array.isArray(s?.points) && s.points.length >= 2, - ); - return hasPrompt || hasComments || hasStrokes; -} - -/** Tooltip/title when Create is disabled. */ -export function insertCreateDisabledReason({ prompt, comments, strokes }) { - if (canCreateInsert({ prompt, comments, strokes })) return null; - return 'Add a prompt or annotate the placeholder to create'; -} - -/** - * Fixed-position insert line coordinates (viewport px). - * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect - * @param {InsertPosition} position - * @param {InsertAxis} [axis] - */ -export function insertLineCoords(rect, position, axis = 'column') { - if (axis === 'row') { - const right = rect.right ?? rect.left + rect.width; - const x = position === 'before' ? rect.left - 2 : right + 2; - return { axis: 'row', top: rect.top, left: x, width: 0, height: rect.height }; - } - const bottom = rect.bottom ?? rect.top + rect.height; - const y = position === 'before' ? rect.top - 2 : bottom + 2; - return { axis: 'column', top: y, left: rect.left, width: rect.width, height: 0 }; -} - -/** Cursor while hovering an insert boundary. */ -export function cursorForInsertAxis(axis) { - return axis === 'row' ? 'ew-resize' : 'ns-resize'; -} - -function groupSiblingRows(siblings, rowThreshold = 8) { - const sorted = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left); - const rows = []; - for (const entry of sorted) { - let placed = false; - for (const row of rows) { - if (Math.abs(entry.rect.top - row[0].rect.top) <= rowThreshold) { - row.push(entry); - placed = true; - break; - } - } - if (!placed) rows.push([entry]); - } - return rows; -} - -function horizontalOverlap(a, b) { - const left = Math.max(a.left, b.left); - const right = Math.min(a.right ?? a.left + a.width, b.right ?? b.left + b.width); - return Math.max(0, right - left); -} - -/** - * Hit-test the gap between adjacent siblings (flex rows, grid columns, stacked blocks). - * @param {number} clientX - * @param {number} clientY - * @param {Array<{ el: unknown, rect: { top: number, left: number, width: number, height: number, bottom?: number, right?: number } }>} siblings - * @param {{ slop?: number, minOverlap?: number }} [opts] - */ -export function hitSiblingInsertGap(clientX, clientY, siblings, opts = {}) { - if (!Array.isArray(siblings) || siblings.length < 2) return null; - const slop = opts.slop ?? 12; - const minOverlap = opts.minOverlap ?? 0.25; - - for (const row of groupSiblingRows(siblings)) { - if (row.length < 2) continue; - const sorted = [...row].sort((a, b) => a.rect.left - b.rect.left); - for (let i = 0; i < sorted.length - 1; i++) { - const a = sorted[i]; - const b = sorted[i + 1]; - const aRight = a.rect.right ?? a.rect.left + a.rect.width; - const bLeft = b.rect.left; - if (bLeft <= aRight) continue; - const top = Math.max(a.rect.top, b.rect.top); - const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height; - const bBottom = b.rect.bottom ?? b.rect.top + b.rect.height; - const bottom = Math.min(aBottom, bBottom); - const span = bottom - top; - const minH = Math.min(a.rect.height, b.rect.height); - if (span < minH * minOverlap) continue; - - const inX = clientX >= aRight - slop && clientX <= bLeft + slop; - const inY = clientY >= top - slop && clientY <= bottom + slop; - if (!inX || !inY) continue; - - const midX = (aRight + bLeft) / 2; - return { - anchor: b.el, - position: 'before', - axis: 'row', - line: { axis: 'row', left: midX, top, width: 0, height: span }, - }; - } - } - - const sortedCol = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left); - for (let i = 0; i < sortedCol.length - 1; i++) { - const a = sortedCol[i]; - const b = sortedCol[i + 1]; - const overlap = horizontalOverlap(a.rect, b.rect); - const minW = Math.min(a.rect.width, b.rect.width); - if (overlap < minW * minOverlap) continue; - - const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height; - const gapTop = aBottom; - const gapBottom = b.rect.top; - if (gapBottom <= gapTop) continue; - - const overlapLeft = Math.max(a.rect.left, b.rect.left); - const overlapRight = Math.min( - a.rect.right ?? a.rect.left + a.rect.width, - b.rect.right ?? b.rect.left + b.rect.width, - ); - const inY = clientY >= gapTop - slop && clientY <= gapBottom + slop; - const inX = clientX >= overlapLeft - slop && clientX <= overlapRight + slop; - if (!inY || !inX) continue; - - const midY = (gapTop + gapBottom) / 2; - return { - anchor: b.el, - position: 'before', - axis: 'column', - line: { axis: 'column', top: midY, left: overlapLeft, width: overlap, height: 0 }, - }; - } - - return null; -} - -/** - * Resolve insert hover target, side, axis, and indicator line for the pointer. - */ -export function resolveInsertHover({ clientX, clientY, target, rect, axis, siblings }) { - const gap = hitSiblingInsertGap(clientX, clientY, siblings); - if (gap) return gap; - - const position = computeInsertPosition(clientX, clientY, rect, axis); - const line = insertLineCoords(rect, position, axis); - return { anchor: target, position, axis, line }; -} - -/** - * How the in-flow placeholder should participate in layout. - * Prefer implicit sizing (flex / %) so row inserts don't inherit the full parent width in px. - * @returns {{ kind: 'flex', flex: string, minWidth: number } | { kind: 'percent' } | { kind: 'auto' } | { kind: 'explicit', width: number }} - */ -export function placeholderSizing({ axis, parentDisplay, parentWidth, anchorFlex }) { - const display = parentDisplay || 'block'; - const w = Number.isFinite(parentWidth) ? parentWidth : 0; - - if (axis === 'row') { - if (display.includes('flex')) { - const flex = anchorFlex && anchorFlex !== 'none' && anchorFlex !== '0 1 auto' - ? anchorFlex - : '1 1 0'; - return { kind: 'flex', flex, minWidth: 0 }; - } - if (display === 'grid' || display === 'inline-grid') { - return { kind: 'auto' }; - } - } - - if (w >= PLACEHOLDER_MIN_WIDTH) { - return { kind: 'percent' }; - } - - return { - kind: 'explicit', - width: Math.max(PLACEHOLDER_MIN_WIDTH, w || PLACEHOLDER_MIN_WIDTH), - }; -} - -/** Width kinds that need materializing to px before edge-resize. */ -export function placeholderWidthIsImplicit(kind) { - return kind === 'flex' || kind === 'percent' || kind === 'auto'; -} - -/** - * Clamp user-resized placeholder dimensions. - */ -export function clampPlaceholderSize(width, height, parentWidth, opts = {}) { - const minW = opts.minWidth ?? PLACEHOLDER_MIN_WIDTH; - const minH = opts.minHeight ?? PLACEHOLDER_MIN_HEIGHT; - const maxW = opts.maxWidth ?? Math.max(minW, parentWidth || minW); - return { - width: Math.min(maxW, Math.max(minW, Math.round(width))), - height: Math.max(minH, Math.round(height)), - }; -} - -/** CSS cursor for a placeholder edge resize handle. */ -export function cursorForPlaceholderEdge(edge) { - if (edge === 'n' || edge === 's') return 'ns-resize'; - if (edge === 'e' || edge === 'w') return 'ew-resize'; - return 'default'; -} - -/** - * Compute placeholder box after dragging one edge (in-flow margins shift for n/w). - * @param {{ width: number, height: number, marginLeft?: number, marginTop?: number }} start - * @param {'n'|'e'|'s'|'w'} edge - * @param {number} dx pointer delta X since drag start - * @param {number} dy pointer delta Y since drag start - * @param {number} parentWidth - */ -export function resizePlaceholderFromEdge(start, edge, dx, dy, parentWidth, opts = {}) { - const base = { - width: start.width, - height: start.height, - marginLeft: start.marginLeft ?? 0, - marginTop: start.marginTop ?? 0, - }; - if (edge === 'e') base.width = start.width + dx; - else if (edge === 'w') { - base.width = start.width - dx; - base.marginLeft = start.marginLeft + dx; - } else if (edge === 's') base.height = start.height + dy; - else if (edge === 'n') { - base.height = start.height - dy; - base.marginTop = start.marginTop + dy; - } - - const clamped = clampPlaceholderSize(base.width, base.height, parentWidth, opts); - if (edge === 'w') { - base.marginLeft = start.marginLeft + start.width - clamped.width; - } else if (edge === 'n') { - base.marginTop = start.marginTop + start.height - clamped.height; - } - - return { - width: clamped.width, - height: clamped.height, - marginLeft: Math.round(base.marginLeft), - marginTop: Math.round(base.marginTop), - }; -} - -/** Pick and insert toggles are independent but turning one ON turns the other OFF. */ -export function applyPickToggle(pickActive, insertActive) { - const nextPick = !pickActive; - return { - pickActive: nextPick, - insertActive: nextPick ? false : insertActive, - }; -} - -export function applyInsertToggle(pickActive, insertActive) { - const nextInsert = !insertActive; - return { - pickActive: nextInsert ? false : pickActive, - insertActive: nextInsert, - }; -} - -/** - * Build the browser generate payload for insert mode. - */ -export function buildInsertGeneratePayload({ - id, - count, - pageUrl, - anchorContext, - position, - placeholder, - freeformPrompt, - comments, - strokes, - screenshotPath, -}) { - const payload = { - type: 'generate', - mode: 'insert', - id, - count, - pageUrl, - insert: { - position, - anchor: anchorContext, - }, - placeholder, - freeformPrompt: freeformPrompt?.trim() || undefined, - }; - if (comments?.length) payload.comments = comments; - if (strokes?.length) payload.strokes = strokes; - if (screenshotPath) payload.screenshotPath = screenshotPath; - return payload; -} - -/** - * Whether a variant wrapper is currently shown (handles `hidden` and display:none). - * @param {{ hidden?: boolean, style?: { display?: string } } | null | undefined} el - */ -export function isVariantShown(el) { - if (!el) return false; - if (el.hidden) return false; - if (el.style?.display === 'none') return false; - return true; -} - -/** - * Show or hide a variant wrapper for cycling. - * @param {{ hidden?: boolean, style?: { display?: string }, removeAttribute?: (name: string) => void, setAttribute?: (name: string, value?: string) => void } | null | undefined} el - * @param {boolean} shown - */ -export function setVariantShown(el, shown) { - if (!el) return; - if (shown) { - el.removeAttribute?.('hidden'); - if (el.style) el.style.display = ''; - } else { - el.setAttribute?.('hidden', ''); - if (el.style) el.style.display = 'none'; - } -} - -/** - * Pick the best live anchor during an insert session (placeholder until variants land). - * @param {{ - * wrapper?: unknown, - * variantCount?: number, - * visibleVariant?: number, - * placeholder?: unknown, - * insertAnchor?: unknown, - * pickVariantContent?: (wrapper: unknown, index: number) => unknown, - * }} opts - */ -export function resolveInsertSessionAnchor(opts) { - const { - wrapper, - variantCount = 0, - visibleVariant = 0, - placeholder, - insertAnchor, - pickVariantContent, - } = opts || {}; - if (wrapper && variantCount > 0 && visibleVariant > 0 && pickVariantContent) { - const vis = pickVariantContent(wrapper, visibleVariant); - if (vis) return vis; - } - return placeholder || insertAnchor || null; -} - -/** - * Snapshot placeholder geometry + anchor fingerprint so HMR can recreate the box. - * @param {{ - * tagName?: string, - * className?: string, - * textContent?: string, - * }} anchor - * @param {{ - * offsetWidth?: number, - * offsetHeight?: number, - * style?: { marginLeft?: string, marginTop?: string }, - * }} placeholder - * @param {{ position: 'before' | 'after', layoutAxis?: 'row' | 'column' }} meta - */ -export function buildInsertPlaceholderSnapshot(anchor, placeholder, { position, layoutAxis }) { - return { - width: Math.round(placeholder.offsetWidth || 0), - height: Math.round(placeholder.offsetHeight || PLACEHOLDER_DEFAULT_HEIGHT), - marginLeft: parseFloat(placeholder.style?.marginLeft || '') || 0, - marginTop: parseFloat(placeholder.style?.marginTop || '') || 0, - position, - layoutAxis: layoutAxis || 'column', - anchorTag: anchor.tagName || 'DIV', - anchorClasses: anchor.className || '', - anchorText: (anchor.textContent || '').trim().slice(0, 120), - }; -} - -/** - * Re-find an insert anchor after framework HMR replaced the live DOM node. - * @param {Pick} doc - * @param {ReturnType | null | undefined} snapshot - * @param {Element | null | undefined} liveAnchor - */ -export function findInsertAnchorInDom(doc, snapshot, liveAnchor = null) { - if (liveAnchor && doc.body.contains(liveAnchor)) return liveAnchor; - if (!snapshot) return null; - const tag = (snapshot.anchorTag || 'div').toLowerCase(); - const cls = (snapshot.anchorClasses || '').split(/\s+/).filter(Boolean)[0]; - const needle = snapshot.anchorText || ''; - const sel = cls ? `${tag}.${cls}` : tag; - const candidates = doc.querySelectorAll(sel); - for (const candidate of candidates) { - if (needle && !(candidate.textContent || '').includes(needle.slice(0, 40))) continue; - return candidate; - } - return null; -} diff --git a/.agent/skills/impeccable/scripts/live/instructions.mjs b/.agent/skills/impeccable/scripts/live/instructions.mjs deleted file mode 100644 index 19f6a1ae3..000000000 --- a/.agent/skills/impeccable/scripts/live/instructions.mjs +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Just-in-time agent instructions for live mode. - * - * The live scripts, not the reference doc, own situational plumbing: every - * event printed by live-poll carries an `_instructions` string describing - * exactly what to do NEXT, with real ids, paths, and line numbers already - * substituted and only the active path's rules included (a svelte-component - * session never sees JSX guidance, and vice versa). live.md stays lean: the - * session contract, harness policy, and design-quality guidance that is not - * situational (identity lock, variation axes, parameter budgets). - * - * Keep these strings imperative, concrete, and short. They are read by an - * agent mid-session; every sentence must earn its tokens. Instructions are - * versioned with the scripts, so they cannot drift from behavior the way a - * hand-maintained doc can. - */ - -const PLAN_POINTER = 'Plan per live.md section 4: extract the identity lock, pick default vs departure mode, commit each variant to a DIFFERENT primary axis, squint-test the trio. Size parameter knobs per section 7 budgets.'; - -function pollCmd(scriptsPath) { - return `node ${scriptsPath}/live-poll.mjs`; -} - -function replyCmd(scriptsPath, id, rest) { - return `${pollCmd(scriptsPath)} --reply ${id} ${rest}`; -} - -export function instructionsForEvent(event, { scriptsPath = '{{scripts_path}}' } = {}) { - if (!event || typeof event !== 'object') return undefined; - switch (event.type) { - case 'generate': - return generateInstructions(event, scriptsPath); - case 'steer': - return `Do what the message asks (page edits, navigation help, or a short answer). Then reply exactly once: ${replyCmd(scriptsPath, event.id, 'steer_done ["optional short toast"]')} (on failure: --reply ${event.id} error "Short reason"). No pickup ack; poll again immediately after.`; - case 'prefetch': - return `Speculative pre-read, no reply owed: resolve ${JSON.stringify(event.pageUrl || '/')} to its source file (root "/" is usually the boot's pageFile; multi-page sites map /foo to public/foo/index.html; SPAs map all routes to one entry), read it into context, then poll again. Skip if you cannot resolve it confidently.`; - case 'variant_mount_failed': - return `The browser could NOT render variant ${event.variant}${event.url ? ` (module: ${event.url})` : ''}${event.error ? `: ${String(event.error).slice(0, 200)}` : ''}. The user sees a persistent error card, not variants. Fix the variant source files, then reply ${replyCmd(scriptsPath, event.id, 'done --file ')}; the browser retries on its own. Poll again after the reply.`; - case 'accept': - return acceptInstructions(event, scriptsPath); - case 'discard': - return event?._completionAck?.ok === true - ? 'Original restored and durable completion acknowledged; nothing to do. Poll again.' - : `Completion was not acknowledged: run node ${scriptsPath}/live-complete.mjs --id ${event.id} --discarded, then poll again.`; - case 'manual_edit_apply': - return `The user already clicked Apply; never ask, discard, or redirect. Delegate the source edits to the impeccable_manual_edit_applier subagent when available (pass cwd, scripts path, event id, page URL, chunk/deadline, batch, evidencePath); it must not poll or reply. ${event.repair ? 'A `repair` payload is present: the previous Apply changed source but validation failed; fix the CURRENT source, never roll back yourself. ' : ''}Reply exactly once: ${replyCmd(scriptsPath, event.id, `done --data '{"status":"done","appliedEntryIds":[...],"failed":[],"files":[...],"notes":[]}'`)} (status "partial"/"error" with failed[] when not every entry applied). Then poll again.`; - case 'timeout': - return 'No event arrived; poll again immediately.'; - case 'exit': - return `Session over: kill any background poll, then node ${scriptsPath}/live-server.mjs stop (removes the injected script tag). Sweep leftover impeccable-variants-start / impeccable-carbonize-start markers from source.`; - default: - return undefined; - } -} - -function generateInstructions(event, scriptsPath) { - const id = event.id; - const scaffold = event.scaffold; - const steps = []; - - if (event.screenshotPath) { - steps.push(`Read the annotated screenshot first: ${event.screenshotPath}. Comment {x,y} positions bind text to the child under that point; strokes read by shape (loop = emphasis on this thing, arrow = direction, cross = delete).`); - } else { - steps.push('No screenshot was sent (the user did not annotate); do not ask for one and do not screenshot the page. Work from element.outerHTML, the computed styles, and the prompt.'); - } - - if (event.mode === 'insert') { - steps.push(insertScaffoldInstructions(event, scriptsPath)); - } else if (scaffold?.previewMode === 'svelte-component') { - steps.push(svelteComponentInstructions(event, scaffold, scriptsPath)); - } else if (scaffold && scaffold.sourceWritten === false) { - steps.push(deferredWrapperInstructions(event, scaffold, scriptsPath)); - } else if (scaffold) { - steps.push(`The wrapper is already written into ${scaffold.file}. Splice preview CSS plus all ${event.count} variants at line ${scaffold.insertLine} in ONE edit, following the returned cssAuthoring contract (styleTag, selector strategy, forbidden patterns). Each variant div holds exactly ONE top-level element (same tag as the original); first visible, others display: none.`); - } else { - steps.push(`Preflight could not scaffold${event.scaffoldError ? ` (${event.scaffoldError})` : ''}. Run node ${scriptsPath}/live-wrap.mjs --id ${id} --count ${event.count} --element-id "${event.element?.id || ''}" --classes "${(event.element?.classes || []).join(',')}" --tag "${event.element?.tagName || ''}" --text "". Keep the flags separate; --text disambiguates repeated siblings. On a fallback error, follow live.md's Handle fallback.`); - } - - steps.push(event.action && event.action !== 'impeccable' - ? `Action is "${event.action}": read reference/${event.action}.md before planning; its MUST params are non-negotiable. ${PLAN_POINTER}` - : `Freeform action: work from SKILL.md rules plus craft-floor.md; no sub-command file. ${PLAN_POINTER}`); - - steps.push(`When all ${event.count} variants are delivered: ${replyCmd(scriptsPath, id, 'done --file ')}. Then poll again. If generation fails after the browser flipped to GENERATING, reply --reply ${id} error "Short reason" so the bar resets (never live-accept --discard for this).`); - - return steps.map((s, i) => `${i + 1}. ${s}`).join('\n'); -} - -function svelteComponentInstructions(event, scaffold, scriptsPath) { - const dir = scaffold.componentDir; - const count = event.count; - return `Svelte component preview. EDIT the existing stubs ${dir}/v1.svelte ... v${count}.svelte in place; never delete or recreate them; do not read them back (the prop-substituted markup is in scaffold.componentStubMarkup). Keep the stub's control flow ({#each}, {#if}) and propContract prop names exactly; never flatten a loop into literal items. The stub \n`; -} - -function buildInsertVariantStub(variantNum) { - return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; -} - -/** - * Scaffold a component-preview session. The scaffold is AST-based: the app's - * own svelte compiler parses the selected markup, control-flow blocks are - * preserved (an each collection crosses the prop contract as ONE structured - * prop, its loop body verbatim), and constructs a detached preview cannot - * support return `{ fallback: 'source-preview', reason }` so the caller keeps - * the markup inside the route file instead of shipping a wrong preview. - */ -export function scaffoldSvelteComponentSession({ - id, - count, - sourceFile, - sourceStartLine, - sourceEndLine, - originalLines, - cwd = process.cwd(), -}) { - const originalMarkup = originalLines.join('\n'); - - const compiler = loadSvelteCompiler(cwd); - if (!compiler) { - return { fallback: 'source-preview', reason: 'svelte 5 compiler not resolvable from the app root' }; - } - const analysis = analyzeSvelteMarkup(originalMarkup, compiler.parse); - if (!analysis.ok) { - return { fallback: 'source-preview', reason: analysis.reason }; - } - - ensureRuntimeHelper(cwd); - const dir = componentSessionDir(id, cwd); - fs.mkdirSync(dir, { recursive: true }); - - const contract = analysis.contract; - const seeded = extractMatchingSourceCss( - safeReadSource(path.resolve(cwd, sourceFile)), - originalMarkup, - ); - const seededCss = seeded.css; - // The preview compiles in isolation, so NONE of these source rules applied - // to what the user approved. Accept enforces that preview truth: any of - // them the variant does not re-declare is superseded and removed, instead - // of re-attaching to the accepted markup through kept class names (the - // ".decisions grid grabs the new board" failure). Only the CLASS-matched - // selectors are candidates; tag rules style shared route elements. - const seededSelectors = [...seeded.supersedable]; - - const manifest = { - id, - previewMode: 'svelte-component', - contractVersion: 2, - sourceFile: sourceFile.split(path.sep).join('/'), - sourceStartLine, - sourceEndLine, - count, - propContract: contract, - originalMarkup, - seededSelectors, - componentDir: path.relative(cwd, dir).split(path.sep).join('/'), - // Absolute paths let the browser fall back to /@fs/ imports when the dev - // server's base or root makes root-relative URLs miss, and probe whether - // the preview tree is reachable at all before blaming a variant. - componentDirAbs: dir.split(path.sep).join('/'), - runtimeModule: `/${SVELTE_RUNTIME_FILE}`, - runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'), - probeModule: `/${SVELTE_PROBE_FILE}`, - probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'), - }; - - fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); - - for (let n = 1; n <= count; n++) { - const variantFile = path.join(dir, `v${n}.svelte`); - if (!fs.existsSync(variantFile)) { - fs.writeFileSync(variantFile, buildVariantStubV2(n, analysis.markupWithProps, contract, seededCss), 'utf-8'); - } - } - - return { - manifest, - manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), - componentDir: manifest.componentDir, - propContract: contract, - // Inlined so the generate event's scaffold payload carries the stub - // shape; the agent edits vN.svelte in place instead of spending reads on - // the manifest and stub files (or deleting and recreating them). - stubMarkup: analysis.markupWithProps, - seededCss, - }; -} - -function safeReadSource(filePath) { - try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; } -} - -function escapeSelectorToken(token) { - return String(token).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Seed variant stubs with the source component's rules that already style the - * selected markup, so variants start from the real cascade (a detached - * preview inherits none of the route's compile-scoped CSS) instead of - * reimplementing it blind. - * - * Returns { css, supersedable }. `css` is every matching rule (class OR tag - * matched). `supersedable` holds only the CLASS-matched selectors: those are - * the accept-time removal candidates. Tag selectors (h1, a, p) style shared - * elements across the whole route, so they seed the preview but are never - * candidates for removal. - */ -export function extractMatchingSourceCss(routeSource, originalMarkup) { - const empty = { css: '', supersedable: new Set() }; - const styleMatch = String(routeSource || '').match(/]*>([\s\S]*?)<\/style\s*>/i); - if (!styleMatch) return empty; - const classNames = new Set(); - const classRe = /class\s*=\s*(["'])(.*?)\1/g; - let m; - while ((m = classRe.exec(originalMarkup))) { - for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); - } - const tagRe = /<([a-z][a-z0-9-]*)/gi; - const tags = new Set(); - while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); - if (classNames.size === 0 && tags.size === 0) return empty; - - // Token-boundary matching, never substring: `.btn` must not match - // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit - // seeds a rule that never styled the pick, and a falsely seeded selector - // becomes an accept-time DELETION of a hand-written rule. - const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); - const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); - const classMatches = (selector) => classRes.some((re) => re.test(selector)); - const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); - - const supersedable = new Set(); - const ruleMatches = (prelude) => { - let matched = false; - for (const selector of splitSelectorList(prelude)) { - if (classMatches(selector)) { - matched = true; - supersedable.add(normalizeSelector(selector)); - } else if (tagMatches(selector)) { - matched = true; - } - } - return matched; - }; - - const pick = (nodes) => { - const kept = []; - for (const node of nodes) { - if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); - else if (node.type === 'at' && node.children) { - const children = pick(node.children); - if (children.length) kept.push({ ...node, children }); - } - } - return kept; - }; - return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; -} - -function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { - const propsComment = contract.length > 0 - ? `\n\n` - : ''; - // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\n` - : `\n\n`; - return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`; -} - -export function scaffoldSvelteComponentInsertSession({ - id, - count, - sourceFile, - insertLine, - position, - anchorStartLine, - anchorEndLine, - anchorLines, - cwd = process.cwd(), -}) { - ensureRuntimeHelper(cwd); - const dir = componentSessionDir(id, cwd); - fs.mkdirSync(dir, { recursive: true }); - - const anchorMarkup = (anchorLines || []).join('\n'); - const manifest = { - id, - mode: 'insert', - previewMode: 'svelte-component', - sourceFile: sourceFile.split(path.sep).join('/'), - insertLine, - position, - anchorStartLine, - anchorEndLine, - originalMarkup: anchorMarkup, - anchorMarkup, - count, - propContract: [], - componentDir: path.relative(cwd, dir).split(path.sep).join('/'), - componentDirAbs: dir.split(path.sep).join('/'), - runtimeModule: `/${SVELTE_RUNTIME_FILE}`, - runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'), - probeModule: `/${SVELTE_PROBE_FILE}`, - probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'), - }; - - fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); - - for (let n = 1; n <= count; n++) { - const variantFile = path.join(dir, `v${n}.svelte`); - if (!fs.existsSync(variantFile)) { - fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); - } - } - - return { - manifest, - manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), - componentDir: manifest.componentDir, - propContract: [], - }; -} - -export function findSvelteComponentManifest(id, cwd = process.cwd()) { - const direct = manifestPathForSession(id, cwd); - if (fs.existsSync(direct)) { - return readManifest(direct); - } - // Legacy location: a session scaffolded by an older version can still be - // accepted after an upgrade. - const legacyDirect = path.join(cwd, LEGACY_SVELTE_COMPONENT_ROOT, id, 'manifest.json'); - if (fs.existsSync(legacyDirect)) { - return readManifest(legacyDirect); - } - for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) { - const root = path.join(cwd, rootRel); - if (!fs.existsSync(root)) continue; - for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const candidate = path.join(root, entry.name, 'manifest.json'); - if (!fs.existsSync(candidate)) continue; - try { - const manifest = readManifest(candidate); - if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; - } catch { /* skip */ } - } - } - return null; -} - -export function readManifest(manifestPath) { - const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); - return { - ...data, - manifestPath, - }; -} - -export function resolveSourceFile(sourceFile, cwd = process.cwd()) { - if (!sourceFile || path.isAbsolute(sourceFile)) { - throw new Error('Invalid svelte-component source file'); - } - const full = path.resolve(cwd, sourceFile); - const rel = path.relative(cwd, full); - if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { - throw new Error('Svelte-component source file escapes project root'); - } - if (!fs.existsSync(full)) { - throw new Error('Svelte-component source file not found: ' + sourceFile); - } - return full; -} - -function appendCssToSvelteStyle(lines, cssLines) { - const closeIdx = findLastStyleCloseLine(lines); - const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; - if (closeIdx === -1) { - return [...lines, '', '']; - } - return [ - ...lines.slice(0, closeIdx), - ...prepared, - ...lines.slice(closeIdx), - ]; -} - -function findLastStyleCloseLine(lines) { - for (let i = lines.length - 1; i >= 0; i--) { - if (/<\/style\s*>/.test(lines[i])) return i; - } - return -1; -} - -function bakeParamValuesInCss(cssLines, paramValues) { - if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; - return cssLines.map((line) => { - let out = line; - for (const [key, value] of Object.entries(paramValues)) { - const varName = `--p-${key}`; - out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); - } - return out; - }); -} - -function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { - const css = String((cssLines || []).join('\n')); - if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; - - const rules = parseCssRules(css); - const output = []; - for (const rule of rules) { - appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); - } - return output.join('\n') - .split('\n') - .map((line) => line.trimEnd()) - .filter((line) => line.trim() !== ''); -} - -function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { - const prelude = rule.prelude.trim(); - const body = rule.body.trim(); - if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; - - if (/^@scope\b/i.test(prelude)) { - if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; - const inner = parseCssRules(body); - for (const innerRule of inner) { - const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); - if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; - output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); - } - return; - } - - const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); - if (!rewrittenPrelude) return; - output.push(formatCssRule(rewrittenPrelude, body)); -} - -function parseCssRules(css) { - const rules = []; - const text = String(css || ''); - let i = 0; - while (i < text.length) { - while (i < text.length && /\s/.test(text[i])) i++; - const preludeStart = i; - while (i < text.length && text[i] !== '{') i++; - if (i >= text.length) break; - const prelude = text.slice(preludeStart, i).trim(); - i++; - const bodyStart = i; - let depth = 1; - let quote = null; - let comment = false; - while (i < text.length && depth > 0) { - const ch = text[i]; - const next = text[i + 1]; - if (comment) { - if (ch === '*' && next === '/') { - comment = false; - i += 2; - continue; - } - i++; - continue; - } - if (quote) { - if (ch === '\\') { - i += 2; - continue; - } - if (ch === quote) quote = null; - i++; - continue; - } - if (ch === '/' && next === '*') { - comment = true; - i += 2; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - i++; - continue; - } - if (ch === '{') depth++; - else if (ch === '}') depth--; - i++; - } - const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); - if (prelude) rules.push({ prelude, body }); - } - return rules; -} - -function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { - const selectors = splitSelectorList(prelude); - const rewritten = []; - for (const selector of selectors) { - const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); - if (next) rewritten.push(next); - } - return rewritten.join(', '); -} - -function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { - let out = selector.trim(); - const hasVariant = /data-impeccable-variant/.test(out); - if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; - if (hasVariant) { - out = out.replace(variantSelectorRegex(variantNum), ''); - out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); - } - - const paramResult = rewriteParamSelectors(out, paramValues); - if (!paramResult.keep) return ''; - out = paramResult.selector; - - out = out - .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') - .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') - .replace(/\s+/g, ' ') - .trim(); - - out = out.replace(/^[>+~]\s*/, '').trim(); - if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; - return out; -} - -function rewriteParamSelectors(selector, paramValues) { - let keep = true; - const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { - if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; - const actual = paramValues[key]; - if (expected != null && String(actual) !== String(expected)) { - keep = false; - return ''; - } - if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { - keep = false; - return ''; - } - return ''; - }); - return { keep, selector: next }; -} - - -function selectorHasVariant(selector, variantNum) { - return variantSelectorRegex(variantNum).test(selector); -} - -function variantSelectorRegex(variantNum) { - return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); -} - -function formatCssRule(selector, body) { - return `${selector} { ${body.trim()} }`; -} - -function escapeRegExp(value) { - return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { - const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); - const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); - const resultBase = { - file: manifest.sourceFile, - sourceFile: manifest.sourceFile, - previewMode: 'svelte-component', - componentDir: manifest.componentDir, - carbonize: false, - }; - if (!fs.existsSync(variantPath)) { - return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; - } - - const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); - if (manifest.mode === 'insert') { - return inlineSvelteComponentInsertAccept({ - manifest, - markup, - cssLines, - variantNum, - paramValues, - sourceFile, - resultBase, - cwd, - }); - } - - const rootTag = matchOpeningTag(markup)?.tag || 'div'; - const contract = manifest.propContract || []; - const compiler = loadSvelteCompiler(cwd); - const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); - - // Restore props back to route expressions. Contract v2 restores through the - // AST so a prop used without braces (each headers, attribute positions) - // still maps back to its original expression; v1 falls back to the textual - // placeholder swap. - let restoredText; - if (Number(manifest.contractVersion) === 2 && compiler) { - const restored = restoreSvelteMarkup(mergedMarkup, contract, compiler.parse); - if (!restored.ok) { - return { handled: false, error: 'Accepted variant does not parse: ' + restored.reason, ...resultBase }; - } - restoredText = restored.markup; - } else { - restoredText = substitutePropsWithExprs(mergedMarkup, contract); - } - const restoredMarkup = restoredText.split('\n').map((line) => line.trimEnd()); - - const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); - const sourceLines = sourceContent.split('\n'); - const start = Number(manifest.sourceStartLine) - 1; - const end = Number(manifest.sourceEndLine) - 1; - if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { - return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; - } - - const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; - const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent); - - let newLines = [ - ...sourceLines.slice(0, start), - ...indentedMarkup, - ...sourceLines.slice(end + 1), - ]; - - // Selectors that were already unused before this accept are the user's - // pre-existing code; the pruning pass must not touch them. - const preUnused = compiler ? collectUnusedSelectors(sourceContent, compiler.compile) : new Set(); - - // Bake params (declared kinds from params.json drive branch pruning), then - // MERGE into the component's existing style block: matching selectors are - // replaced, new ones appended. Appending alone is how superseded rules used - // to survive their own replacement. - const declaredParams = readDeclaredParams(manifest, variantNum, cwd); - let variantCss = cssLines.join('\n'); - if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) { - // Defensive: strip preview-wrapper selectors that authoring rules forbid - // on this path but an off-spec agent may still emit. - variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n'); - } - const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {}); - const cssStats = { replaced: 0, appended: 0, pruned: [], superseded: [] }; - if (bakedCss.trim()) { - const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss); - newLines = merged.text.split('\n'); - cssStats.replaced = merged.replaced; - cssStats.appended = merged.appended; - } - - let finalText = newLines.join('\n'); - - // Preview truth: the detached preview never applied the source rules that - // styled the replaced selection, so the user approved a design without - // them. Any seeded selector the variant did not re-declare is superseded; - // left in place it re-attaches through kept class names (the accepted root - // keeps its original classes) and re-layouts markup it no longer owns. - // - // Removal is bounded by ownership: a selector whose classes are still used - // by route markup OUTSIDE the replaced region does not belong to the pick - // alone, and removing it would strip styling from markup this accept never - // touched. Keeping it risks a visible re-attachment quirk on the accepted - // region; deleting it breaks the rest of the route. Keep it. - const outsideMarkup = [...sourceLines.slice(0, start), ...sourceLines.slice(end + 1)] - .join('\n') - .replace(/]*>[\s\S]*?<\/style\s*>/gi, ''); - const outsideClasses = new Set(); - { - const attrRe = /class\s*=\s*(["'])(.*?)\1/g; - let cm; - while ((cm = attrRe.exec(outsideMarkup))) { - for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); - } - const directiveRe = /class:([A-Za-z0-9_-]+)/g; - while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); - } - const usedOutsideReplacedRegion = (selector) => { - const classTokenRe = /\.([A-Za-z0-9_-]+)/g; - let tm; - while ((tm = classTokenRe.exec(selector))) { - if (outsideClasses.has(tm[1])) return true; - } - return false; - }; - const incomingSelectors = collectAllSelectors(bakedCss); - const superseded = (manifest.seededSelectors || []) - .map((selector) => normalizeSelector(selector)) - .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); - if (superseded.length > 0) { - const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); - finalText = scrubbed.text; - cssStats.superseded = scrubbed.removed; - } - - if (compiler) { - const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); - finalText = pruned.source; - cssStats.pruned = pruned.removed; - } - - // Postcondition: no selector from the user's pre-accept CSS may vanish - // unless the compiler-driven prune or the preview-truth supersession - // deliberately removed it. This turns any parser or reconciler defect into - // a loud refusal instead of silent damage to a hand-written style block. - const lostSelectors = findLostSelectors(sourceContent, finalText, [ - ...cssStats.pruned, - ...cssStats.superseded, - ]); - if (lostSelectors.length > 0) { - return { - handled: false, - error: 'CSS reconciliation would lose selectors from the existing style block: ' - + lostSelectors.join(', ') - + '. Source not modified; accept the variant manually.', - mode: 'error', - ...resultBase, - }; - } - - try { - fs.writeFileSync(sourceFile, finalText, 'utf-8'); - } catch (err) { - return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; - } - removeSvelteComponentSession(manifest.id, cwd); - - const verify = verifyAcceptedSource(finalText); - return { - handled: true, - css: cssStats, - verify, - ...resultBase, - }; -} - -/** Re-indent a block onto `indent` while preserving its internal structure. */ -export function reindentPreservingStructure(lines, indent) { - const nonEmpty = lines.filter((line) => line.trim() !== ''); - if (nonEmpty.length === 0) return lines.map(() => ''); - const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); - return lines.map((line) => { - if (line.trim() === '') return ''; - const current = (line.match(/^\s*/) || [''])[0].length; - return indent + line.slice(Math.min(minIndent, current)); - }); -} - -function styleBlockText(sourceText) { - const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); - return match ? match[1] : ''; -} - -/** - * Remove every rule whose (normalized) selector list is fully contained in - * `selectors` from the component's style block, at any at-rule nesting depth. - * Rules that mix doomed and surviving selectors keep the survivors. - */ -export function removeSelectorsFromSvelteSource(sourceText, selectors) { - const text = String(sourceText || ''); - const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; - let lastMatch = null; - let m; - while ((m = styleRe.exec(text))) lastMatch = m; - if (!lastMatch) return { text, removed: [] }; - - const removed = []; - const transform = (nodes) => { - const kept = []; - for (const node of nodes) { - if (node.type === 'rule') { - const survivors = []; - for (const selector of splitSelectorList(node.prelude)) { - if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); - else survivors.push(selector); - } - if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); - } else if (node.type === 'at' && node.children) { - const children = transform(node.children); - if (children.length > 0) kept.push({ ...node, children }); - } else { - kept.push(node); - } - } - return kept; - }; - - const nodes = transform(parseStylesheet(lastMatch[1])); - if (removed.length === 0) return { text, removed }; - const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); - const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; - return { - text: text.slice(0, lastMatch.index) + rebuilt + text.slice(lastMatch.index + lastMatch[0].length), - removed, - }; -} - -export function findLostSelectors(beforeSource, afterSource, prunedSelectors = []) { - const before = collectAllSelectors(styleBlockText(beforeSource)); - const after = collectAllSelectors(styleBlockText(afterSource)); - const pruned = new Set((prunedSelectors || []).map((s) => normalizeSelector(s))); - const lost = []; - for (const selector of before) { - if (!after.has(selector) && !pruned.has(selector)) lost.push(selector); - } - return lost; -} - -function readDeclaredParams(manifest, variantNum, cwd) { - try { - const raw = JSON.parse(fs.readFileSync(path.join(cwd, manifest.componentDir, 'params.json'), 'utf-8')); - const list = raw?.[String(variantNum)]; - return Array.isArray(list) ? list : []; - } catch { - return []; - } -} - -/** - * Merge CSS into a svelte component's top-level style block (created when - * absent), replacing rules whose selectors match and appending the rest. - */ -export function mergeCssIntoSvelteSource(sourceText, incomingCss) { - const text = String(sourceText || ''); - const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; - let lastMatch = null; - let m; - while ((m = styleRe.exec(text))) lastMatch = m; - - if (!lastMatch) { - const { css, replaced, appended } = reconcileCss('', incomingCss); - return { - text: `${text.replace(/\s*$/, '')}\n\n\n`, - replaced, - appended, - }; - } - - const inner = lastMatch[1]; - const { css, replaced, appended } = reconcileCss(inner, incomingCss); - const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); - const replacedBlock = `${openTag}\n${indentCssBlock(css)}\n`; - return { - text: text.slice(0, lastMatch.index) + replacedBlock + text.slice(lastMatch.index + lastMatch[0].length), - replaced, - appended, - }; -} - -function indentCssBlock(css) { - return String(css || '') - .split('\n') - .map((line) => (line.trim() === '' ? '' : ' ' + line)) - .join('\n'); -} - -function inlineSvelteComponentInsertAccept({ - manifest, - markup, - cssLines, - variantNum, - paramValues, - sourceFile, - resultBase, - cwd, -}) { - if (!svelteMarkupHasVisibleContent(markup)) { - return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; - } - if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { - return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; - } - - const rootTag = matchOpeningTag(markup)?.tag || 'div'; - const restoredMarkup = String(markup || '') - .split('\n') - .map((line) => line.trimEnd()); - const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); - const sourceLines = sourceContent.split('\n'); - const insertIndex = Number(manifest.insertLine) - 1; - if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { - return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; - } - - const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; - const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; - const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent); - - let newLines = [ - ...sourceLines.slice(0, insertIndex), - ...indentedMarkup, - ...sourceLines.slice(insertIndex), - ]; - - let variantCss = cssLines.join('\n'); - if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) { - variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n'); - } - const declaredParams = readDeclaredParams(manifest, variantNum, cwd); - const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {}); - if (bakedCss.trim()) { - const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss); - newLines = merged.text.split('\n'); - } - - try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); - } catch (err) { - return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; - } - removeSvelteComponentSession(manifest.id, cwd); - - const verify = verifyAcceptedSource(newLines.join('\n')); - return { - handled: true, - verify, - ...resultBase, - }; -} - -function svelteMarkupHasVisibleContent(markup) { - const text = String(markup || '') - .replace(//gi, '') - .replace(//gi, '') - .replace(//g, '') - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (text.length > 0) return true; - return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); -} - -function mergeOriginalTopLevelAttrs(markup, originalMarkup) { - const variantOpen = matchOpeningTag(markup); - const originalOpen = matchOpeningTag(originalMarkup); - if (!variantOpen || !originalOpen) return markup; - if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; - - const variantAttrs = parseAttrSegments(variantOpen.attrs); - const originalAttrs = parseAttrSegments(originalOpen.attrs); - const additions = []; - let attrs = variantOpen.attrs; - - const originalClass = originalAttrs.get('class'); - const variantClass = variantAttrs.get('class'); - if (originalClass && variantClass) { - const merged = mergeStaticClassAttr(originalClass, variantClass); - if (merged) { - attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); - variantAttrs.set('class', { ...variantClass, raw: merged }); - } - } else if (originalClass && !variantClass) { - additions.push(originalClass.raw); - } - - for (const [name, attr] of originalAttrs) { - if (name === 'class') continue; - if (!variantAttrs.has(name)) additions.push(attr.raw); - } - - if (additions.length === 0 && attrs === variantOpen.attrs) return markup; - const nextOpen = variantOpen.prefix - + variantOpen.tag - + attrs - + additions.map((attr) => ' ' + attr.trim()).join('') - + variantOpen.close; - return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); -} - -function matchOpeningTag(markup) { - const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); - if (!match) return null; - return { - raw: match[0], - prefix: match[1], - tag: match[2], - attrs: match[3] || '', - close: match[4], - index: match.index || 0, - }; -} - -function parseAttrSegments(attrs) { - const out = new Map(); - const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; - let match; - while ((match = re.exec(attrs))) { - const raw = match[0]; - const name = match[1]; - out.set(name, { - name, - raw, - start: match.index, - end: match.index + raw.length, - }); - } - return out; -} - -function mergeStaticClassAttr(originalClass, variantClass) { - const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); - const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); - if (!originalValue || !variantValue) return null; - const quote = variantValue[1]; - const classes = [ - ...variantValue[2].split(/\s+/), - ...originalValue[2].split(/\s+/), - ].filter(Boolean); - return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; -} - -export function removeSvelteComponentSession(id, cwd = process.cwd()) { - const dir = componentSessionDir(id, cwd); - try { - fs.rmSync(dir, { recursive: true, force: true }); - } catch { /* non-fatal */ } -} - -/** - * Compile-check every variant component of a session with the app's own - * compiler, BEFORE the browser ever imports them. A variant that does not - * compile (the classic: a second top-level - - - - -${buildPath?.toggle ? `` : ''} -
-
- - Impeccable -
-
-
-
-
- -

${esc(payload.title || 'Choose a direction')}

- ${buildPath?.toggle ? `
-
- - -
-

-
` : ''} -
- ${payload.question ? `

${esc(payload.question)}

` : ''} -
-
${cards}
- - - - -
-
-
-
- ${payload.steer ? '' : ''} - ${(() => { - if (!payload.reroll) return ''; - const die = ''; - const registers = Array.isArray(payload.reroll.registers) ? payload.reroll.registers.filter((r) => r === 'safer' || r === 'bolder') : []; - // The registers are the user's steering wheel on the familiar-to-bold - // axis; the plain re-roll sits between them so the spatial order matches - // the axis it names. - const safer = registers.includes('safer') ? '' : ''; - const bolder = registers.includes('bolder') ? '' : ''; - return `${safer}${bolder}`; - })()} - ${payload.canon && !payload.canonCard ? '' : ''} -
-`; -} - -// Browsers omit the :80 suffix on the default HTTP port, so a server on -// --port 80 sees bare loopback hosts and origins. -function allowedHost(host, port) { - if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true; - return port === 80 && (host === '127.0.0.1' || host === 'localhost'); -} - -function allowedOrigin(origin, port) { - if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true; - return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost'); -} - -function rejectDetachedPost(req, res, url, port) { - if (detachedKey && url.searchParams.get('key') !== detachedKey) { - res.writeHead(401); res.end(); return true; - } - const origin = req.headers.origin; - if (origin && !allowedOrigin(origin, port)) { - res.writeHead(403); res.end(); return true; - } - return false; -} - -const server = http.createServer((req, res) => { - const { port } = server.address(); - if (!allowedHost(req.headers.host, port)) { - res.writeHead(403); res.end(); return; - } - let url; - try { url = new URL(req.url, 'http://127.0.0.1'); } - catch { res.writeHead(400); res.end(); return; } - const pathname = url.pathname; - if (req.method === 'GET' && pathname === '/') { - const pending = nextFile(); - if (pending && fs.existsSync(pending)) { - // A next file the round cannot load has to leave the disk either way: - // kept, /next-status stays ready:true and the waiting page reloads - // into the same failure without bound. - try { loadRound(fs.readFileSync(pending, 'utf8')); } catch { /* keep current round */ } - try { fs.rmSync(pending); } catch { /* already gone */ } - // The claim consumes the file the idle-exit hold reads, and the - // reloading page cannot beat until it has parsed: stamp the claim so - // the same bounded grace covers the gap between them. Persisted too, - // because --wait watches the same gap from outside this process and - // would otherwise read the stale beat as a closed page. - server.lastClaimAt = Date.now(); - if (detachedKey) { - try { - const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8')); - state.claimedAt = server.lastClaimAt; - fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state)); - } catch { /* state file recreated on next beat */ } - } - } - res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); - res.end(page(awaitingNext)); - return; - } - if (req.method === 'POST' && pathname === '/heartbeat') { - if (rejectDetachedPost(req, res, url, port)) return; - res.writeHead(204); res.end(); - server.lastBeatSeen = Date.now(); - if (detachedKey) { - const now = Date.now(); - if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) { - server.lastBeatWrite = now; - try { - const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8')); - state.lastBeat = now; - fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state)); - } catch { /* state file recreated on next beat */ } - } - } - return; - } - if (req.method === 'GET' && pathname === '/next-status') { - const pending = nextFile(); - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) })); - return; - } - const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/); - if (imageMatch) { - const abs = localImages[Number(imageMatch[1])]; - if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; } - const type = abs.endsWith('.webp') ? 'image/webp' - : abs.endsWith('.png') ? 'image/png' - : abs.endsWith('.svg') ? 'image/svg+xml' - : abs.endsWith('.gif') ? 'image/gif' - : 'image/jpeg'; - res.writeHead(200, { 'content-type': type }); - fs.createReadStream(abs).pipe(res); - return; - } - if (req.method === 'POST' && pathname === '/build-path') { - if (rejectDetachedPost(req, res, url, port)) return; - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let value = null; - try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value === 'comp' || value === 'code') { - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); - } - } - // Answer only once the flip is on disk. Responding first raced the - // caller: the 200 reached the client (a separate process) while this - // one could still be preempted before the write landed, so a poller - // that trusted the 200 could look for the flip file and miss it. - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); - }); - return; - } - if (req.method === 'POST' && pathname === '/answer') { - if (rejectDetachedPost(req, res, url, port)) return; - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); - let parsed = {}; - try { parsed = JSON.parse(body); } catch { /* empty steer */ } - const chosen = options.find((o) => o.id === parsed.optionId); - const isReroll = parsed.optionId === 'reroll'; - // A followup round's pick is not terminal: the table stays open for the - // next round (--update), exactly like a re-roll. Detached mode only; - // the blocking mode has no update channel, so its picks stay terminal. - const followupOpen = Boolean(detachedKey) && payload.followup === true && !isReroll; - const answer = JSON.stringify({ - optionId: parsed.optionId ?? null, - steer: parsed.steer ?? '', - ...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}), - ...(followupOpen ? { followup: true } : {}), - ...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}), - ...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}), - ...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}), - }); - // The delivery deadline is single-issue: a duplicate answer racing the - // page's disable must not restamp the allowance already inherited. - const wasAwaiting = awaitingNext; - awaitingNext = (isReroll || followupOpen) && Boolean(detachedKey); - if (awaitingNext && !wasAwaiting) awaitingNextSince = Date.now(); - if (detachedKey) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(answerFile(detachedKey), answer + '\n'); - } else { - printAnswer(answer); - } - // A re-roll or followup pick in detached mode keeps the table open: the - // client shows a loading hand and reloads when --update delivers the - // next round. - if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150); - }); - return; - } - res.writeHead(404); res.end(); -}); - -server.listen(portArg, '127.0.0.1', () => { - const { port } = server.address(); - const url = `http://127.0.0.1:${port}/`; - if (hasFlag('detached-serve')) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(stateFile(arg('key')), JSON.stringify({ pid: process.pid, port, url })); - } else { - console.log(`QUESTION URL: ${url}`); - console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...'); - } - if (!hasFlag('no-open')) { - openSystemBrowser(url); - } - // The timeout bounds the wait for a page, never the user's decision: an - // absolute guillotine counted from start used to kill the server under a - // still-open tab (a slow re-rolled round easily outlived it), leaving the - // page polling skeletons that could never resolve. Once the page beats, - // the server's lifetime tracks the beats, and it exits only after the idle - // grace passes with none, long enough to survive a closed laptop lid. - // --timeout 0 waits for a page forever, but the idle grace still applies - // once one has beat: a page that arrived and went silent is a closed tab, - // and no timeout setting should let that daemon leak. - const startedAt = Date.now(); - const lifetime = setInterval(() => { - if (!server.lastBeatSeen) { - if (timeoutSec > 0 && Date.now() - startedAt > timeoutSec * 1000) { - console.log('serve-question: timed out with no answer'); - process.exit(2); - } - } else if (Date.now() - server.lastBeatSeen > idleGraceMs) { - // A hand delivered moments before this deadline still gets its claim - // window: the stalled page's watch reloads into it and beats again - // within seconds, while a file unclaimed past the grace means no page - // is coming back (the same verdict --wait reads from its age). The - // claim itself holds the daemon too: GET / deletes the file before the - // reloaded page can beat, so a tick in that gap must not exit under - // the hand just claimed. - const pending = nextFile(); - let deliveredAt = 0; - if (pending) { try { deliveredAt = fs.statSync(pending).mtimeMs; } catch { /* nothing delivered */ } } - if (Date.now() - Math.max(deliveredAt, server.lastClaimAt || 0) > NEXT_CLAIM_GRACE_MS) { - console.log('serve-question: the page stopped beating and never came back; exiting'); - process.exit(2); - } - } - }, 2000); - lifetime.unref?.(); -}); diff --git a/.agent/skills/impeccable/scripts/surface-brief.mjs b/.agent/skills/impeccable/scripts/surface-brief.mjs deleted file mode 100644 index 723f7c1b4..000000000 --- a/.agent/skills/impeccable/scripts/surface-brief.mjs +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { resolveProjectRoot } from './context.mjs'; -import { - listSurfaceBriefs, - resolveSurfaceBrief, - surfaceBriefPathForTarget, - writeSurfaceBrief, -} from './lib/surface-briefs.mjs'; - -function summary(brief, projectRoot) { - return { - slug: brief.slug, - path: path.relative(projectRoot, brief.path).split(path.sep).join('/'), - primaryTarget: brief.primaryTarget, - relatedTargets: brief.relatedTargets, - }; -} - -function main(argv) { - const [command, target, bodyFile, ...relatedTargets] = argv; - const projectRoot = resolveProjectRoot(process.cwd(), target ? { targetPath: target } : {}); - if (command === 'path') { - const filePath = surfaceBriefPathForTarget(target, { projectRoot }); - if (!filePath) throw new Error('surface brief path requires a concrete target'); - process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`); - return; - } - if (command === 'list') { - process.stdout.write(`${JSON.stringify(listSurfaceBriefs(projectRoot).map((brief) => summary(brief, projectRoot)), null, 2)}\n`); - return; - } - if (command === 'read') { - const result = resolveSurfaceBrief(projectRoot, target || null); - if (result.brief) { - process.stdout.write(result.brief.text); - return; - } - if (result.candidates.length) process.stderr.write(`${JSON.stringify(result.candidates.map((brief) => summary(brief, projectRoot)), null, 2)}\n`); - process.exit(2); - } - if (command === 'write') { - if (!target || !bodyFile) throw new Error('usage: surface-brief.mjs write '); - const filePath = writeSurfaceBrief({ - projectRoot, - primaryTarget: target, - relatedTargets, - body: fs.readFileSync(bodyFile, 'utf-8'), - }); - process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`); - return; - } - throw new Error('usage: surface-brief.mjs [target] [body-file] [related-target ...]'); -} - -function isMainModule() { - if (!process.argv[1]) return false; - try { - return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]); - } catch { - return import.meta.url === pathToFileURL(process.argv[1]).href; - } -} - -if (isMainModule()) { - try { - main(process.argv.slice(2)); - } catch (error) { - process.stderr.write(`${error?.message || error}\n`); - process.exit(1); - } -} diff --git a/.codex/hooks.json b/.codex/hooks.json index f91937cd6..319a31126 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -6,7 +6,8 @@ "hooks": [ { "type": "command", - "command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"", + "command": "[ ! -f \".codex/skills/impeccable/scripts/impeccable\" ] || \".codex/skills/impeccable/scripts/impeccable\" hook", + "commandWindows": "if exist \".codex/skills/impeccable/scripts/impeccable.cmd\" (\".codex/skills/impeccable/scripts/impeccable.cmd\" hook & exit /b)", "timeout": 5, "statusMessage": "Checking UI changes" } @@ -18,7 +19,8 @@ "hooks": [ { "type": "command", - "command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"", + "command": "[ ! -f \".codex/skills/impeccable/scripts/impeccable\" ] || \".codex/skills/impeccable/scripts/impeccable\" hook", + "commandWindows": "if exist \".codex/skills/impeccable/scripts/impeccable.cmd\" (\".codex/skills/impeccable/scripts/impeccable.cmd\" hook & exit /b)", "timeout": 30, "statusMessage": "Design deep pass" } diff --git a/.github/agents/impeccable-asset-producer.agent.md b/.github/agents/impeccable-asset-producer.agent.md index 76d090d4c..84392f387 100644 --- a/.github/agents/impeccable-asset-producer.agent.md +++ b/.github/agents/impeccable-asset-producer.agent.md @@ -16,9 +16,9 @@ When the parent hands you a decision card packet instead of an approved mock, th ## Input Contract -Expect the measured spec (`.impeccable/build/spec.json`, written by `comp-spec.mjs` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. +Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. -If there is no spec, stop and return one line asking the parent to run `comp-spec.mjs` first. You do not inventory the comp yourself; the spec is the inventory, and a second inventory disagrees with the first. +If there is no spec, stop and return one line asking the parent to run `impeccable comp-spec` first. You do not inventory the comp yourself; the spec is the inventory, and a second inventory disagrees with the first. ## The job @@ -26,8 +26,8 @@ Every region with `medium: raster` in the spec ships as a plate at its `plate` p Per region, in the spec's order: -1. `node .github/skills/impeccable/scripts/comp-spec.mjs --crop ` writes the reference crop under `.impeccable/build/crops/`. -2. Produce the plate. With the API fallback: `node .github/skills/impeccable/scripts/generate-image.mjs --plate --quality high` does the whole step (crop as reference, the spec's plate prompt, output size chosen from the region's aspect, the file written to its plate path, prompt embedded, and the plate scored against the crop). With a harness-native image tool: use the crop as the input image and `node .github/skills/impeccable/scripts/comp-spec.mjs --plate-prompt ` as the prompt, write the result to the plate path, then run `node .github/skills/impeccable/scripts/embed-prompt.mjs --prompt ""`. +1. `.github/skills/impeccable/scripts/impeccable comp-spec --crop ` writes the reference crop under `.impeccable/build/crops/`. +2. Produce the plate. With the API fallback: `.github/skills/impeccable/scripts/impeccable generate-image --plate --quality high` does the whole step (crop as reference, the spec's plate prompt, output size chosen from the region's aspect, the file written to its plate path, prompt embedded, and the plate scored against the crop). With a harness-native image tool: use the crop as the input image and `.github/skills/impeccable/scripts/impeccable comp-spec --plate-prompt ` as the prompt, write the result to the plate path, then run `.github/skills/impeccable/scripts/impeccable embed-prompt --prompt ""`. 3. Read the score line. `PLATE-SCORE` under 50%, or a `PLATE-WARN`, means the plate does not read as the region: open the plate beside the crop, name what drifted (subject, framing, palette, style), tighten the prompt with that, and regenerate once. Two misses on one region: keep the better plate, mark it `needs_parent_review`, and say why in one line. 4. Transparent cutouts (a figure or object on the page ground): generate on a flat chroma color absent from the subject and key it to alpha before writing the PNG; never ship the keyed background. @@ -35,4 +35,4 @@ Do not redesign. Do not add objects, restyle, or reinterpret; the comp was appro ## Output Contract -Return one line per raster region: ` % `. Then `blockers` (missing spec, missing comp, no image capability, exhausted key) and `assumptions`, each global and minimal. Nothing else: no summary, no praise, no implementation advice. The parent runs `build-phase.mjs advance` to verify the plates against the same spec; your line and its line must agree. +Return one line per raster region: ` % `. Then `blockers` (missing spec, missing comp, no image capability, exhausted key) and `assumptions`, each global and minimal. Nothing else: no summary, no praise, no implementation advice. The parent runs `impeccable build-phase advance` to verify the plates against the same spec; your line and its line must agree. diff --git a/.github/agents/impeccable-finish-reviewer.agent.md b/.github/agents/impeccable-finish-reviewer.agent.md index 76b96543b..04e5102e9 100644 --- a/.github/agents/impeccable-finish-reviewer.agent.md +++ b/.github/agents/impeccable-finish-reviewer.agent.md @@ -12,7 +12,7 @@ A hard turn ceiling ends the run without warning; a run that ends before its con ## Input Contract -Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); on a comp-led build the build state (`.impeccable/build/state.json`), the measured spec (`.impeccable/build/spec.json`), and the diff directories `.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/` (each holds `side-by-side.png`, `heatmap.png`, `regions/.png` paired crops, and `report.json` with per-region scores and verdicts from `comp-diff.mjs`); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped. +Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); on a comp-led build the build state (`.impeccable/build/state.json`), the measured spec (`.impeccable/build/spec.json`), and the diff directories `.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/` (each holds `side-by-side.png`, `heatmap.png`, `regions/.png` paired crops, and `report.json` with per-region scores and verdicts from `impeccable comp-diff`); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped. ## Checks, in order diff --git a/.github/agents/impeccable-manual-edit-applier.agent.md b/.github/agents/impeccable-manual-edit-applier.agent.md index 681f950eb..3133d0d3d 100644 --- a/.github/agents/impeccable-manual-edit-applier.agent.md +++ b/.github/agents/impeccable-manual-edit-applier.agent.md @@ -22,7 +22,7 @@ Expect a self-contained handoff with: - The current event `batch`. - Optional `evidencePath`. -The user already clicked Apply. Do not ask what to do. Do not discard edits. Do not run `live-poll.mjs`, `live-commit-manual-edits.mjs`, or any live server endpoint. Do not stage, commit, rebuild, push, or edit generated provider output unless the batch explicitly targets that generated file. +The user already clicked Apply. Do not ask what to do. Do not discard edits. Do not run `impeccable live-poll`, `impeccable live-commit-manual-edits`, or any live server endpoint. Do not stage, commit, rebuild, push, or edit generated provider output unless the batch explicitly targets that generated file. ## Workflow diff --git a/.github/hooks/impeccable.json b/.github/hooks/impeccable.json index 7d4e5b6e7..8932694c7 100644 --- a/.github/hooks/impeccable.json +++ b/.github/hooks/impeccable.json @@ -5,7 +5,7 @@ { "type": "command", "matcher": "edit|create|apply_patch", - "bash": "[ ! -f \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs\" ] || ! node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || node \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs\"", + "bash": "[ ! -f \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/impeccable\" ] || \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/impeccable\" hook", "timeoutSec": 5 } ] diff --git a/.github/workflows/sync-generated-output.yml b/.github/workflows/sync-generated-output.yml index b3c764f49..1c2d17908 100644 --- a/.github/workflows/sync-generated-output.yml +++ b/.github/workflows/sync-generated-output.yml @@ -21,10 +21,14 @@ concurrency: env: GENERATED_PATHS: >- + .agent .agents + .codex .claude .cursor .gemini + .github/agents + .github/hooks .github/skills .grok .hermes @@ -35,6 +39,7 @@ env: .rovodev .trae .trae-cn + .veto .vibe plugin diff --git a/.veto/skills/impeccable/SKILL.md b/.veto/skills/impeccable/SKILL.md index e8a5cf19c..4df056db2 100644 --- a/.veto/skills/impeccable/SKILL.md +++ b/.veto/skills/impeccable/SKILL.md @@ -14,7 +14,7 @@ Core principles: ## Setup -1. Run `node /scripts/context.mjs` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .veto/skills/impeccable/scripts/...` command in this skill and its references, and `.veto/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. +1. Run `/scripts/impeccable context` once per session, where `` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `.veto/skills/impeccable/scripts/impeccable ` command in this skill and its references, and `.veto/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. On a Windows shell without `sh`, call `.veto/skills/impeccable/scripts/impeccable.cmd` instead. The launcher runs a self-contained binary that ships next to it or is downloaded once on first run; no Node or other runtime is required. Pass a named source file or route as `--target `. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. 2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. 3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. @@ -67,12 +67,12 @@ Routing: - **No argument:** read [routing.md](reference/routing.md) and present its context-aware menu; never auto-run a command. - **Explicit or clearly implied command:** load its reference (native variant on native platforms) and follow it. Ask once if two commands fit. -- **Otherwise:** treat the request as general design work. Missing PRODUCT.md routes a new surface or replacement world through init, then new-work; a narrow refinement of existing code proceeds on the incumbent implementation as context.mjs directs, offering init afterward rather than blocking on it. +- **Otherwise:** treat the request as general design work. Missing PRODUCT.md routes a new surface or replacement world through init, then new-work; a narrow refinement of existing code proceeds on the incumbent implementation as `impeccable context` directs, offering init afterward rather than blocking on it. - `teach` aliases `init`. `craft` is a deprecated alias for ordinary new-work and adds nothing. `shape` owns task discovery, then enters new-work only for visual-world and surface-concept decisions. -After init writes PRODUCT.md, resume without rerunning `context.mjs`; init loads the native platform reference itself when the platform it recorded is `ios`, `android`, or `adaptive`. +After init writes PRODUCT.md, resume without rerunning `impeccable context`; init loads the native platform reference itself when the platform it recorded is `ios`, `android`, or `adaptive`. -**Pin / Unpin:** `node .veto/skills/impeccable/scripts/pin.mjs ` creates or removes a standalone `/` shortcut. Report the script's result concisely; relay stderr verbatim on error. +**Pin / Unpin:** `.veto/skills/impeccable/scripts/impeccable pin ` creates or removes a standalone `/` shortcut. Report the script's result concisely; relay stderr verbatim on error. **Hooks:** `/impeccable hooks ` manages the design detector hook for this project (auto-runs the detector after UI file edits and surfaces findings). Load [reference/hooks.md](reference/hooks.md) when the user invokes it with any argument. diff --git a/.veto/skills/impeccable/reference/audit.native.md b/.veto/skills/impeccable/reference/audit.native.md index 0126fa157..fbccb56f6 100644 --- a/.veto/skills/impeccable/reference/audit.native.md +++ b/.veto/skills/impeccable/reference/audit.native.md @@ -1,6 +1,6 @@ Run systematic **technical** quality checks on a native app (`ios` / `android` / `adaptive`) and generate a comprehensive report. Don't fix issues; document them for other commands to address. -This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `detect.mjs` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it. +This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `impeccable detect` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it. ## Diagnostic Scan diff --git a/.veto/skills/impeccable/reference/critique.md b/.veto/skills/impeccable/reference/critique.md index 92a40f3f5..c3213cdcd 100644 --- a/.veto/skills/impeccable/reference/critique.md +++ b/.veto/skills/impeccable/reference/critique.md @@ -8,7 +8,7 @@ Resolve one stable target, run two independent assessments, synthesize a design - Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask). - If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context ()`. A silent degraded critique is a failed critique. - Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment. -- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt. +- A skipped detector is a failed critique run unless `impeccable detect` is missing or crashes after a real attempt. - Viewable targets require browser inspection when available. - Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it. - Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page. @@ -23,7 +23,7 @@ Resolve one stable target, run two independent assessments, synthesize a design - "this page" -> the current URL or source file 2. **Confirm the target slugs cleanly**: ```bash - node .veto/skills/impeccable/scripts/critique-storage.mjs slug "" + .veto/skills/impeccable/scripts/impeccable critique-storage slug "" ``` Every later command also accepts the resolved target directly and derives the same slug internally; never hand-write a slug. If this exits non-zero, skip persistence and trend for this run, but continue the critique. 3. **Read `.impeccable/critique/ignore.md`** if it exists. Drop matching findings silently; it is the only prior-run input critique consumes. @@ -59,7 +59,7 @@ Run the bundled detector and browser visualization evidence. Assessment B is man CLI scan: ```bash -node .veto/skills/impeccable/scripts/detect.mjs --json [target] +.veto/skills/impeccable/scripts/impeccable detect --json [target] ``` - Pass markup files/directories as `[target]`; do not pass CSS-only files. @@ -73,12 +73,12 @@ Browser visualization is required for a viewable target when browser automation 1. Create a fresh tab and navigate. Prefer the harness's native/browser-canvas screenshot path before hand-rolling a Playwright/Puppeteer script; only fall back to a custom script when no native browser tool is exposed. 2. Preflight mutable injection by setting `document.title` and appending a `\n' + - open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' - ); -} - -function detectLineEnding(content) { - if (content.includes('\r\n')) return '\r\n'; - if (content.includes('\r')) return '\r'; - return '\n'; -} - -function normalizeLineEndings(content, lineEnding) { - return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); -} - -function readLineEndingAt(content, index) { - if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; - if (content[index] === '\n') return '\n'; - if (content[index] === '\r') return '\r'; - return ''; -} - -export function insertTag(content, config, port, token, scriptAttrs = '') { - const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, token, scriptAttrs), lineEnding); - // insertBefore: match the LAST occurrence. Anchors like `` naturally - // belong at the end, and the same literal can appear earlier in code blocks - // within rendered documentation pages. - if (config.insertBefore) { - const idx = content.lastIndexOf(config.insertBefore); - if (idx === -1) return content; - return content.slice(0, idx) + block + content.slice(idx); - } - // insertAfter: match the FIRST occurrence — typical anchors like `` or - // `` open near the top of the document. - const idx = content.indexOf(config.insertAfter); - if (idx === -1) return content; - const after = idx + config.insertAfter.length; - // Preserve an existing trailing newline if the anchor already has one. - // Slice the remainder from the original anchor offset, not prefix.length: - // in the no-newline case prefix is one char longer than the anchor (the - // appended '\n'), so slicing by prefix.length would drop the first real - // character after the anchor (#227). - const existingNewline = readLineEndingAt(content, after); - const prefix = content.slice(0, after) + (existingNewline || lineEnding); - const rest = content.slice(after + existingNewline.length); - return prefix + block + rest; -} - -/** - * Remove the live script block. Matches either HTML or JSX comment markers - * regardless of config (so stale tags from a wrong config can still be cleaned). - * - * Indent-preserving: captures any whitespace immediately preceding the opener - * marker and re-emits it in place of the removed block. `insertTag` inserted - * the block *after* the original line's indent and *before* the anchor (e.g. - * ``), which moved the indent onto the opener line and left the anchor - * unindented. Replacing the whole block (plus its trailing newline) with just - * the captured indent hands the indent back to the anchor that follows. - */ -export function removeTag(content, _syntax) { - const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, - ]; - for (const pat of patterns) { - let changed = false; - let next = content; - do { - content = next; - next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (/[\r\n]/.test(trailing)) return leadingIndent; - return leadingIndent || trailing || ''; - }); - if (next !== content) changed = true; - } while (next !== content); - if (changed) return next; - } - return content; -} - -// --------------------------------------------------------------------------- -// Content-Security-Policy meta-tag patcher -// -// When the user's HTML carries ``, -// the cross-origin load of /live.js (and the SSE/POST connection back to -// localhost:PORT) is blocked unless the CSP explicitly allows that origin. -// -// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`, -// and stash the original `content` value in a `data-impeccable-csp-original` -// attribute (base64) so revert is exact. -// -// On remove: detect the marker attribute, decode it, restore the original -// content value verbatim, drop the marker. -// -// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp, -// shared helpers) is NOT patched here — those need framework-specific config -// edits and are handled via the existing detect-csp.mjs reference output. -// Only the in-source meta-tag form gets the auto-patch. -// --------------------------------------------------------------------------- - -const CSP_MARKER_ATTR = 'data-impeccable-csp-original'; - -function findCspMetaTags(content) { - const out = []; - const tagRe = /]*?)\/?>/gis; - let m; - while ((m = tagRe.exec(content)) !== null) { - const attrs = m[1]; - if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue; - out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs }); - } - return out; -} - -function getAttr(attrs, name) { - const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'); - const m = attrs.match(re); - return m ? { quote: m[1], value: m[2], full: m[0] } : null; -} - -function appendOriginToDirective(csp, directive, origin) { - const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i'); - const m = csp.match(re); - if (m) { - const tokens = m[4].trim().split(/\s+/); - if (tokens.includes(origin)) return csp; - return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`); - } - // Directive missing — add it. Use 'self' + origin so we don't inadvertently - // narrow the policy compared to the default-src fallback (most users with - // an explicit CSP have 'self' there). - return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`; -} - -export function patchCspMeta(content, port) { - const tags = findCspMetaTags(content); - if (tags.length === 0) return content; - const origin = `http://localhost:${port}`; - - // Walk last-to-first so prior splices don't invalidate later indices. - let result = content; - for (let i = tags.length - 1; i >= 0; i--) { - const tag = tags[i]; - const attrs = tag.attrs; - if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched - const contentAttr = getAttr(attrs, 'content'); - if (!contentAttr) continue; - - const original = contentAttr.value; - let patched = original; - patched = appendOriginToDirective(patched, 'script-src', origin); - patched = appendOriginToDirective(patched, 'connect-src', origin); - // The shader overlay during 'generating' creates a screenshot via - // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects - // those. Add `blob:` so the overlay doesn't throw a CSP violation. - patched = appendOriginToDirective(patched, 'img-src', 'blob:'); - if (patched === original) continue; - - const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`; - const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`; - // The tagRe captures any whitespace between the last attribute and the - // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after - // a replace would land it BEFORE that trailing space, leaving a double - // space inside attrs and clobbering the space before `/>`. Split off - // the trailing whitespace, splice the marker into the attribute body, - // and re-append the original trailing whitespace so a self-closing - // `` round-trips byte-for-byte. - const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0]; - const attrsBody = attrs.slice(0, attrs.length - trailingWs.length); - const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs; - const newTag = tag.full.replace(attrs, newAttrs); - - result = result.slice(0, tag.start) + newTag + result.slice(tag.end); - } - return result; -} - -export function revertCspMeta(content) { - const tags = findCspMetaTags(content); - if (tags.length === 0) return content; - - let result = content; - for (let i = tags.length - 1; i >= 0; i--) { - const tag = tags[i]; - const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR); - if (!origAttr) continue; - const contentAttr = getAttr(tag.attrs, 'content'); - if (!contentAttr) continue; - - let originalValue; - try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); } - catch { continue; } - - const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`; - let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr); - // Drop the marker attribute and any single space immediately preceding it. - newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), ''); - const newTag = tag.full.replace(tag.attrs, newAttrs); - - result = result.slice(0, tag.start) + newTag + result.slice(tag.end); - } - return result; -} - -/** The journal's undo for a tag-strategy patch: drop the block, restore CSP. */ -export function unpatchTagFile(content) { - return revertCspMeta(removeTag(content)); -} diff --git a/.veto/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs b/.veto/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs deleted file mode 100644 index 9bfb3db4a..000000000 --- a/.veto/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs +++ /dev/null @@ -1,70 +0,0 @@ -/** - * TanStack Start registry entry. - * - * Detection and the apply/remove pair are the existing adapter's - * (`../tanstack-adapter.mjs`); this file only declares them to the registry - * and names the artifacts the journal has to be able to heal. - */ - -import { - TANSTACK_MARKER_OPEN, - applyTanStackLiveAdapter, - detectTanStackStartProject, - removeTanStackLiveAdapter, - unpatchTanStackRoot, -} from '../tanstack-adapter.mjs'; - -export const tanstackStart = { - name: 'tanstack-start', - - detect(cwd) { - return detectTanStackStartProject(cwd); - }, - - inject: { - kind: 'adapter', - - apply({ cwd, port, token, project }) { - return applyTanStackLiveAdapter({ cwd, port, token, project }); - }, - - remove({ cwd, project }) { - return removeTanStackLiveAdapter({ cwd, project }); - }, - - // The mount component's extension follows the root route's, so the path - // cannot live in the static ignore list. - ignorePatterns(project) { - return project?.componentFile ? [project.componentFile] : []; - }, - - artifacts({ project }) { - if (!project) return []; - return [ - { - kind: 'created', - path: project.componentFile, - marker: 'impeccable-live-tanstack', - pruneTo: 'src', - }, - { - kind: 'patched', - path: project.rootRoute, - patch: 'tanstack-root', - markers: [TANSTACK_MARKER_OPEN], - }, - ]; - }, - - unpatch: { - 'tanstack-root': unpatchTanStackRoot, - }, - }, - - source: { - extensions: ['.tsx', '.jsx'], - preview: 'source', - styleMode: 'scoped', - commentSyntax: 'jsx', - }, -}; diff --git a/.veto/skills/impeccable/scripts/live/frameworks/vite-generic.mjs b/.veto/skills/impeccable/scripts/live/frameworks/vite-generic.mjs deleted file mode 100644 index 4713670f4..000000000 --- a/.veto/skills/impeccable/scripts/live/frameworks/vite-generic.mjs +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Generic Vite registry entry: a bundled app with a real `index.html` entry - * and no framework-specific document ownership. React, Vue, Solid, Preact and - * a plain TanStack Router SPA all land here — the marker-wrapped script block - * goes straight into the HTML entry. - * - * This is the entry that catches everything with a bundler config; only - * static-html sits below it. - */ - -import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs'; - -const VITE_CONFIG_RE = /^vite\.config\.(?:js|mjs|cjs|ts|mts|cts)$/; - -export function detectViteProject(cwd = process.cwd()) { - const configFile = findConfigFile(cwd, VITE_CONFIG_RE); - if (configFile) return { configFile, via: 'config' }; - if (hasAnyDependency(cwd, ['vite'])) return { configFile: null, via: 'package' }; - // A zero-config Vite app is index.html + package.json, the same pair - // roots.mjs treats as an app root. - if (fileExists(cwd, 'index.html') && fileExists(cwd, 'package.json')) { - return { configFile: null, via: 'zero-config' }; - } - return null; -} - -export const viteGeneric = { - name: 'vite-generic', - - detect(cwd) { - return detectViteProject(cwd); - }, - - inject: { kind: 'tag' }, - - source: { - extensions: ['.tsx', '.jsx'], - preview: 'source', - styleMode: 'scoped', - commentSyntax: 'jsx', - }, -}; diff --git a/.veto/skills/impeccable/scripts/live/generation-preflight.mjs b/.veto/skills/impeccable/scripts/live/generation-preflight.mjs deleted file mode 100644 index bfe81b32f..000000000 --- a/.veto/skills/impeccable/scripts/live/generation-preflight.mjs +++ /dev/null @@ -1,149 +0,0 @@ -import { execFile } from 'node:child_process'; -import path from 'node:path'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); -const PREFLIGHT_TIMEOUT_MS = 15_000; - -// Per-target cache of the resolved source file. The wrap search walks the whole -// project tree and was measured at ~7.6s on a large repo; it re-ran on every -// generate for the same picked element (re-rolls, param passes). Keyed by the -// target signature (locator + route), so it invalidates automatically when the -// element or route changes; a failed resolution evicts its entry (see below). -const sourceResolutionCache = new Map(); - -/** Test/lifecycle hook: drop all cached source resolutions. */ -export function clearSourceResolutionCache() { - sourceResolutionCache.clear(); -} - -function targetSignature(event) { - const isInsert = event.mode === 'insert'; - const target = isInsert ? insertTarget(event) : replaceTarget(event); - return JSON.stringify({ - mode: isInsert ? 'insert' : 'replace', - position: isInsert ? target.position : null, - elementId: target.elementId || null, - classes: target.classes || null, - tag: target.tag || null, - pageUrl: event.pageUrl || null, - }); -} - -export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) { - if (!event || event.type !== 'generate' || !event.id) return null; - - const isInsert = event.mode === 'insert'; - const target = isInsert ? insertTarget(event) : replaceTarget(event); - if (!target.elementId && !target.classes) return null; - - const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs'); - const args = [script, '--id', event.id, '--count', String(event.count || 3)]; - // Compute the scaffold but do not write it into source for source-preview - // targets. The agent writes wrapper + variants atomically; a premature - // server-side write reloads the framework and strands the browser at 0/N. - // No-op on the svelte-component path, which never writes the route source. - args.push('--defer-source-write'); - if (isInsert) args.push('--position', target.position); - if (target.elementId) args.push('--element-id', target.elementId); - if (target.classes) args.push('--classes', target.classes); - if (target.tag) args.push('--tag', target.tag); - if (target.text) args.push('--text', target.text); - if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl); - const signature = targetSignature(event); - // A cached resolution points the helper straight at the file, skipping the - // tree search. The helper still reads current content, so line ranges stay - // fresh; only discovery is cached. - const cachedFile = cache ? cache.get(signature) : null; - if (cachedFile) args.push('--file', cachedFile); - return { script, args, mode: isInsert ? 'insert' : 'replace', signature }; -} - -/** - * Scaffold the source for a generate event before handing it to an agent. - * - * Async on purpose. This spawns `live-wrap.mjs`, which walks the project's - * source tree and can take seconds (measured at ~7.6s on a large repo when the - * element is not found, with a 15s ceiling). The live server is single-threaded - * and calls this while leasing a poll, so a synchronous spawn froze the whole - * server for that entire window: Accept and Discard POSTs, SSE progress - * broadcasts, and every other poll stalled behind it. - */ -export async function runGenerationPreflight(event, { - cwd = process.cwd(), - scriptsDir, - execFileImpl = execFileAsync, - timeoutMs = PREFLIGHT_TIMEOUT_MS, - cache = sourceResolutionCache, -} = {}) { - const command = buildGenerationPreflight(event, scriptsDir, { cache }); - if (!command) { - return { ok: false, skipped: true, reason: 'insufficient_locator' }; - } - - const startedAt = performance.now(); - try { - const { stdout } = await execFileImpl(process.execPath, command.args, { - cwd, - encoding: 'utf-8', - timeout: timeoutMs, - }); - const line = String(stdout).trim().split('\n').filter(Boolean).pop(); - if (!line) throw new Error('preflight returned no scaffold metadata'); - const scaffold = JSON.parse(line); - // Cache the resolved SOURCE file (route source, not the svelte manifest) so - // the next generate on this target skips the tree search. - const resolvedSource = scaffold.sourceFile || scaffold.file; - if (cache && command.signature && typeof resolvedSource === 'string') { - cache.set(command.signature, resolvedSource); - } - return { - ok: true, - mode: command.mode, - durationMs: performance.now() - startedAt, - scaffold, - }; - } catch (error) { - // Evict a stale/failed resolution so the next attempt does a full search - // (the element may have moved out of the previously cached file). - if (cache && command.signature) cache.delete(command.signature); - return { - ok: false, - mode: command.mode, - durationMs: performance.now() - startedAt, - error: compactError(error), - }; - } -} - -function replaceTarget(event) { - return normalizeTarget(event.element || {}); -} - -function insertTarget(event) { - return { - ...normalizeTarget(event.insert?.anchor || {}), - position: event.insert?.position === 'before' ? 'before' : 'after', - }; -} - -function normalizeTarget(target) { - const classes = Array.isArray(target.classes) - ? target.classes.join(' ') - : String(target.classes || '').trim(); - const text = typeof target.textContent === 'string' - ? target.textContent.trim().slice(0, 80) - : ''; - return { - elementId: target.id || target.elementId || undefined, - classes: classes || undefined, - tag: target.tagName || target.tag || undefined, - text: text || undefined, - }; -} - -function compactError(error) { - const stderr = error?.stderr ? String(error.stderr).trim() : ''; - const message = stderr.split('\n').filter(Boolean).pop() || error?.message || 'preflight failed'; - return String(message).slice(0, 500); -} diff --git a/.veto/skills/impeccable/scripts/live/insert-ui.mjs b/.veto/skills/impeccable/scripts/live/insert-ui.mjs deleted file mode 100644 index ae54f6f93..000000000 --- a/.veto/skills/impeccable/scripts/live/insert-ui.mjs +++ /dev/null @@ -1,458 +0,0 @@ -/** - * Pure helpers for live-mode insert UI (browser + tests). - * Kept separate from live-browser.js so insert logic is unit-testable. - */ - -export const PLACEHOLDER_DEFAULT_HEIGHT = 80; -export const PLACEHOLDER_MIN_HEIGHT = 48; -export const PLACEHOLDER_MIN_WIDTH = 120; - -/** @typedef {'before' | 'after'} InsertPosition */ -/** @typedef {'row' | 'column'} InsertAxis */ - -/** - * Infer sibling flow axis from a container's computed layout styles. - * @param {{ display?: string, flexDirection?: string, gridTemplateColumns?: string, gridAutoFlow?: string }} style - * @returns {InsertAxis} - */ -export function detectInsertAxisFromStyle(style) { - const display = style?.display || 'block'; - if (display.includes('flex')) { - const dir = style.flexDirection || 'row'; - return dir.startsWith('row') ? 'row' : 'column'; - } - if (display === 'grid' || display === 'inline-grid') { - const flow = style.gridAutoFlow || 'row'; - if (flow.includes('column')) return 'column'; - const cols = (style.gridTemplateColumns || '').trim(); - if (cols && cols !== 'none') { - const colCount = cols.split(/\s+/).filter(Boolean).length; - if (colCount > 1) return 'row'; - } - return 'row'; - } - return 'column'; -} - -/** - * Pick insertion side from pointer position against an anchor element box. - * @param {number} clientX - * @param {number} clientY - * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect - * @param {InsertAxis} [axis] - * @returns {InsertPosition} - */ -export function computeInsertPosition(clientX, clientY, rect, axis = 'column') { - if (!rect) return 'after'; - if (axis === 'row') { - if (!Number.isFinite(rect.left) || !Number.isFinite(rect.width) || rect.width <= 0) return 'after'; - const mid = rect.left + rect.width / 2; - return clientX < mid ? 'before' : 'after'; - } - if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) return 'after'; - const mid = rect.top + rect.height / 2; - return clientY < mid ? 'before' : 'after'; -} - -/** - * Whether Create is allowed for an insert session. - * Requires a non-empty prompt OR at least one annotation. - */ -export function canCreateInsert({ prompt, comments, strokes }) { - const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0; - const hasComments = Array.isArray(comments) && comments.length > 0; - const hasStrokes = Array.isArray(strokes) && strokes.some( - (s) => Array.isArray(s?.points) && s.points.length >= 2, - ); - return hasPrompt || hasComments || hasStrokes; -} - -/** Tooltip/title when Create is disabled. */ -export function insertCreateDisabledReason({ prompt, comments, strokes }) { - if (canCreateInsert({ prompt, comments, strokes })) return null; - return 'Add a prompt or annotate the placeholder to create'; -} - -/** - * Fixed-position insert line coordinates (viewport px). - * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect - * @param {InsertPosition} position - * @param {InsertAxis} [axis] - */ -export function insertLineCoords(rect, position, axis = 'column') { - if (axis === 'row') { - const right = rect.right ?? rect.left + rect.width; - const x = position === 'before' ? rect.left - 2 : right + 2; - return { axis: 'row', top: rect.top, left: x, width: 0, height: rect.height }; - } - const bottom = rect.bottom ?? rect.top + rect.height; - const y = position === 'before' ? rect.top - 2 : bottom + 2; - return { axis: 'column', top: y, left: rect.left, width: rect.width, height: 0 }; -} - -/** Cursor while hovering an insert boundary. */ -export function cursorForInsertAxis(axis) { - return axis === 'row' ? 'ew-resize' : 'ns-resize'; -} - -function groupSiblingRows(siblings, rowThreshold = 8) { - const sorted = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left); - const rows = []; - for (const entry of sorted) { - let placed = false; - for (const row of rows) { - if (Math.abs(entry.rect.top - row[0].rect.top) <= rowThreshold) { - row.push(entry); - placed = true; - break; - } - } - if (!placed) rows.push([entry]); - } - return rows; -} - -function horizontalOverlap(a, b) { - const left = Math.max(a.left, b.left); - const right = Math.min(a.right ?? a.left + a.width, b.right ?? b.left + b.width); - return Math.max(0, right - left); -} - -/** - * Hit-test the gap between adjacent siblings (flex rows, grid columns, stacked blocks). - * @param {number} clientX - * @param {number} clientY - * @param {Array<{ el: unknown, rect: { top: number, left: number, width: number, height: number, bottom?: number, right?: number } }>} siblings - * @param {{ slop?: number, minOverlap?: number }} [opts] - */ -export function hitSiblingInsertGap(clientX, clientY, siblings, opts = {}) { - if (!Array.isArray(siblings) || siblings.length < 2) return null; - const slop = opts.slop ?? 12; - const minOverlap = opts.minOverlap ?? 0.25; - - for (const row of groupSiblingRows(siblings)) { - if (row.length < 2) continue; - const sorted = [...row].sort((a, b) => a.rect.left - b.rect.left); - for (let i = 0; i < sorted.length - 1; i++) { - const a = sorted[i]; - const b = sorted[i + 1]; - const aRight = a.rect.right ?? a.rect.left + a.rect.width; - const bLeft = b.rect.left; - if (bLeft <= aRight) continue; - const top = Math.max(a.rect.top, b.rect.top); - const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height; - const bBottom = b.rect.bottom ?? b.rect.top + b.rect.height; - const bottom = Math.min(aBottom, bBottom); - const span = bottom - top; - const minH = Math.min(a.rect.height, b.rect.height); - if (span < minH * minOverlap) continue; - - const inX = clientX >= aRight - slop && clientX <= bLeft + slop; - const inY = clientY >= top - slop && clientY <= bottom + slop; - if (!inX || !inY) continue; - - const midX = (aRight + bLeft) / 2; - return { - anchor: b.el, - position: 'before', - axis: 'row', - line: { axis: 'row', left: midX, top, width: 0, height: span }, - }; - } - } - - const sortedCol = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left); - for (let i = 0; i < sortedCol.length - 1; i++) { - const a = sortedCol[i]; - const b = sortedCol[i + 1]; - const overlap = horizontalOverlap(a.rect, b.rect); - const minW = Math.min(a.rect.width, b.rect.width); - if (overlap < minW * minOverlap) continue; - - const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height; - const gapTop = aBottom; - const gapBottom = b.rect.top; - if (gapBottom <= gapTop) continue; - - const overlapLeft = Math.max(a.rect.left, b.rect.left); - const overlapRight = Math.min( - a.rect.right ?? a.rect.left + a.rect.width, - b.rect.right ?? b.rect.left + b.rect.width, - ); - const inY = clientY >= gapTop - slop && clientY <= gapBottom + slop; - const inX = clientX >= overlapLeft - slop && clientX <= overlapRight + slop; - if (!inY || !inX) continue; - - const midY = (gapTop + gapBottom) / 2; - return { - anchor: b.el, - position: 'before', - axis: 'column', - line: { axis: 'column', top: midY, left: overlapLeft, width: overlap, height: 0 }, - }; - } - - return null; -} - -/** - * Resolve insert hover target, side, axis, and indicator line for the pointer. - */ -export function resolveInsertHover({ clientX, clientY, target, rect, axis, siblings }) { - const gap = hitSiblingInsertGap(clientX, clientY, siblings); - if (gap) return gap; - - const position = computeInsertPosition(clientX, clientY, rect, axis); - const line = insertLineCoords(rect, position, axis); - return { anchor: target, position, axis, line }; -} - -/** - * How the in-flow placeholder should participate in layout. - * Prefer implicit sizing (flex / %) so row inserts don't inherit the full parent width in px. - * @returns {{ kind: 'flex', flex: string, minWidth: number } | { kind: 'percent' } | { kind: 'auto' } | { kind: 'explicit', width: number }} - */ -export function placeholderSizing({ axis, parentDisplay, parentWidth, anchorFlex }) { - const display = parentDisplay || 'block'; - const w = Number.isFinite(parentWidth) ? parentWidth : 0; - - if (axis === 'row') { - if (display.includes('flex')) { - const flex = anchorFlex && anchorFlex !== 'none' && anchorFlex !== '0 1 auto' - ? anchorFlex - : '1 1 0'; - return { kind: 'flex', flex, minWidth: 0 }; - } - if (display === 'grid' || display === 'inline-grid') { - return { kind: 'auto' }; - } - } - - if (w >= PLACEHOLDER_MIN_WIDTH) { - return { kind: 'percent' }; - } - - return { - kind: 'explicit', - width: Math.max(PLACEHOLDER_MIN_WIDTH, w || PLACEHOLDER_MIN_WIDTH), - }; -} - -/** Width kinds that need materializing to px before edge-resize. */ -export function placeholderWidthIsImplicit(kind) { - return kind === 'flex' || kind === 'percent' || kind === 'auto'; -} - -/** - * Clamp user-resized placeholder dimensions. - */ -export function clampPlaceholderSize(width, height, parentWidth, opts = {}) { - const minW = opts.minWidth ?? PLACEHOLDER_MIN_WIDTH; - const minH = opts.minHeight ?? PLACEHOLDER_MIN_HEIGHT; - const maxW = opts.maxWidth ?? Math.max(minW, parentWidth || minW); - return { - width: Math.min(maxW, Math.max(minW, Math.round(width))), - height: Math.max(minH, Math.round(height)), - }; -} - -/** CSS cursor for a placeholder edge resize handle. */ -export function cursorForPlaceholderEdge(edge) { - if (edge === 'n' || edge === 's') return 'ns-resize'; - if (edge === 'e' || edge === 'w') return 'ew-resize'; - return 'default'; -} - -/** - * Compute placeholder box after dragging one edge (in-flow margins shift for n/w). - * @param {{ width: number, height: number, marginLeft?: number, marginTop?: number }} start - * @param {'n'|'e'|'s'|'w'} edge - * @param {number} dx pointer delta X since drag start - * @param {number} dy pointer delta Y since drag start - * @param {number} parentWidth - */ -export function resizePlaceholderFromEdge(start, edge, dx, dy, parentWidth, opts = {}) { - const base = { - width: start.width, - height: start.height, - marginLeft: start.marginLeft ?? 0, - marginTop: start.marginTop ?? 0, - }; - if (edge === 'e') base.width = start.width + dx; - else if (edge === 'w') { - base.width = start.width - dx; - base.marginLeft = start.marginLeft + dx; - } else if (edge === 's') base.height = start.height + dy; - else if (edge === 'n') { - base.height = start.height - dy; - base.marginTop = start.marginTop + dy; - } - - const clamped = clampPlaceholderSize(base.width, base.height, parentWidth, opts); - if (edge === 'w') { - base.marginLeft = start.marginLeft + start.width - clamped.width; - } else if (edge === 'n') { - base.marginTop = start.marginTop + start.height - clamped.height; - } - - return { - width: clamped.width, - height: clamped.height, - marginLeft: Math.round(base.marginLeft), - marginTop: Math.round(base.marginTop), - }; -} - -/** Pick and insert toggles are independent but turning one ON turns the other OFF. */ -export function applyPickToggle(pickActive, insertActive) { - const nextPick = !pickActive; - return { - pickActive: nextPick, - insertActive: nextPick ? false : insertActive, - }; -} - -export function applyInsertToggle(pickActive, insertActive) { - const nextInsert = !insertActive; - return { - pickActive: nextInsert ? false : pickActive, - insertActive: nextInsert, - }; -} - -/** - * Build the browser generate payload for insert mode. - */ -export function buildInsertGeneratePayload({ - id, - count, - pageUrl, - anchorContext, - position, - placeholder, - freeformPrompt, - comments, - strokes, - screenshotPath, -}) { - const payload = { - type: 'generate', - mode: 'insert', - id, - count, - pageUrl, - insert: { - position, - anchor: anchorContext, - }, - placeholder, - freeformPrompt: freeformPrompt?.trim() || undefined, - }; - if (comments?.length) payload.comments = comments; - if (strokes?.length) payload.strokes = strokes; - if (screenshotPath) payload.screenshotPath = screenshotPath; - return payload; -} - -/** - * Whether a variant wrapper is currently shown (handles `hidden` and display:none). - * @param {{ hidden?: boolean, style?: { display?: string } } | null | undefined} el - */ -export function isVariantShown(el) { - if (!el) return false; - if (el.hidden) return false; - if (el.style?.display === 'none') return false; - return true; -} - -/** - * Show or hide a variant wrapper for cycling. - * @param {{ hidden?: boolean, style?: { display?: string }, removeAttribute?: (name: string) => void, setAttribute?: (name: string, value?: string) => void } | null | undefined} el - * @param {boolean} shown - */ -export function setVariantShown(el, shown) { - if (!el) return; - if (shown) { - el.removeAttribute?.('hidden'); - if (el.style) el.style.display = ''; - } else { - el.setAttribute?.('hidden', ''); - if (el.style) el.style.display = 'none'; - } -} - -/** - * Pick the best live anchor during an insert session (placeholder until variants land). - * @param {{ - * wrapper?: unknown, - * variantCount?: number, - * visibleVariant?: number, - * placeholder?: unknown, - * insertAnchor?: unknown, - * pickVariantContent?: (wrapper: unknown, index: number) => unknown, - * }} opts - */ -export function resolveInsertSessionAnchor(opts) { - const { - wrapper, - variantCount = 0, - visibleVariant = 0, - placeholder, - insertAnchor, - pickVariantContent, - } = opts || {}; - if (wrapper && variantCount > 0 && visibleVariant > 0 && pickVariantContent) { - const vis = pickVariantContent(wrapper, visibleVariant); - if (vis) return vis; - } - return placeholder || insertAnchor || null; -} - -/** - * Snapshot placeholder geometry + anchor fingerprint so HMR can recreate the box. - * @param {{ - * tagName?: string, - * className?: string, - * textContent?: string, - * }} anchor - * @param {{ - * offsetWidth?: number, - * offsetHeight?: number, - * style?: { marginLeft?: string, marginTop?: string }, - * }} placeholder - * @param {{ position: 'before' | 'after', layoutAxis?: 'row' | 'column' }} meta - */ -export function buildInsertPlaceholderSnapshot(anchor, placeholder, { position, layoutAxis }) { - return { - width: Math.round(placeholder.offsetWidth || 0), - height: Math.round(placeholder.offsetHeight || PLACEHOLDER_DEFAULT_HEIGHT), - marginLeft: parseFloat(placeholder.style?.marginLeft || '') || 0, - marginTop: parseFloat(placeholder.style?.marginTop || '') || 0, - position, - layoutAxis: layoutAxis || 'column', - anchorTag: anchor.tagName || 'DIV', - anchorClasses: anchor.className || '', - anchorText: (anchor.textContent || '').trim().slice(0, 120), - }; -} - -/** - * Re-find an insert anchor after framework HMR replaced the live DOM node. - * @param {Pick} doc - * @param {ReturnType | null | undefined} snapshot - * @param {Element | null | undefined} liveAnchor - */ -export function findInsertAnchorInDom(doc, snapshot, liveAnchor = null) { - if (liveAnchor && doc.body.contains(liveAnchor)) return liveAnchor; - if (!snapshot) return null; - const tag = (snapshot.anchorTag || 'div').toLowerCase(); - const cls = (snapshot.anchorClasses || '').split(/\s+/).filter(Boolean)[0]; - const needle = snapshot.anchorText || ''; - const sel = cls ? `${tag}.${cls}` : tag; - const candidates = doc.querySelectorAll(sel); - for (const candidate of candidates) { - if (needle && !(candidate.textContent || '').includes(needle.slice(0, 40))) continue; - return candidate; - } - return null; -} diff --git a/.veto/skills/impeccable/scripts/live/instructions.mjs b/.veto/skills/impeccable/scripts/live/instructions.mjs deleted file mode 100644 index 19f6a1ae3..000000000 --- a/.veto/skills/impeccable/scripts/live/instructions.mjs +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Just-in-time agent instructions for live mode. - * - * The live scripts, not the reference doc, own situational plumbing: every - * event printed by live-poll carries an `_instructions` string describing - * exactly what to do NEXT, with real ids, paths, and line numbers already - * substituted and only the active path's rules included (a svelte-component - * session never sees JSX guidance, and vice versa). live.md stays lean: the - * session contract, harness policy, and design-quality guidance that is not - * situational (identity lock, variation axes, parameter budgets). - * - * Keep these strings imperative, concrete, and short. They are read by an - * agent mid-session; every sentence must earn its tokens. Instructions are - * versioned with the scripts, so they cannot drift from behavior the way a - * hand-maintained doc can. - */ - -const PLAN_POINTER = 'Plan per live.md section 4: extract the identity lock, pick default vs departure mode, commit each variant to a DIFFERENT primary axis, squint-test the trio. Size parameter knobs per section 7 budgets.'; - -function pollCmd(scriptsPath) { - return `node ${scriptsPath}/live-poll.mjs`; -} - -function replyCmd(scriptsPath, id, rest) { - return `${pollCmd(scriptsPath)} --reply ${id} ${rest}`; -} - -export function instructionsForEvent(event, { scriptsPath = '{{scripts_path}}' } = {}) { - if (!event || typeof event !== 'object') return undefined; - switch (event.type) { - case 'generate': - return generateInstructions(event, scriptsPath); - case 'steer': - return `Do what the message asks (page edits, navigation help, or a short answer). Then reply exactly once: ${replyCmd(scriptsPath, event.id, 'steer_done ["optional short toast"]')} (on failure: --reply ${event.id} error "Short reason"). No pickup ack; poll again immediately after.`; - case 'prefetch': - return `Speculative pre-read, no reply owed: resolve ${JSON.stringify(event.pageUrl || '/')} to its source file (root "/" is usually the boot's pageFile; multi-page sites map /foo to public/foo/index.html; SPAs map all routes to one entry), read it into context, then poll again. Skip if you cannot resolve it confidently.`; - case 'variant_mount_failed': - return `The browser could NOT render variant ${event.variant}${event.url ? ` (module: ${event.url})` : ''}${event.error ? `: ${String(event.error).slice(0, 200)}` : ''}. The user sees a persistent error card, not variants. Fix the variant source files, then reply ${replyCmd(scriptsPath, event.id, 'done --file ')}; the browser retries on its own. Poll again after the reply.`; - case 'accept': - return acceptInstructions(event, scriptsPath); - case 'discard': - return event?._completionAck?.ok === true - ? 'Original restored and durable completion acknowledged; nothing to do. Poll again.' - : `Completion was not acknowledged: run node ${scriptsPath}/live-complete.mjs --id ${event.id} --discarded, then poll again.`; - case 'manual_edit_apply': - return `The user already clicked Apply; never ask, discard, or redirect. Delegate the source edits to the impeccable_manual_edit_applier subagent when available (pass cwd, scripts path, event id, page URL, chunk/deadline, batch, evidencePath); it must not poll or reply. ${event.repair ? 'A `repair` payload is present: the previous Apply changed source but validation failed; fix the CURRENT source, never roll back yourself. ' : ''}Reply exactly once: ${replyCmd(scriptsPath, event.id, `done --data '{"status":"done","appliedEntryIds":[...],"failed":[],"files":[...],"notes":[]}'`)} (status "partial"/"error" with failed[] when not every entry applied). Then poll again.`; - case 'timeout': - return 'No event arrived; poll again immediately.'; - case 'exit': - return `Session over: kill any background poll, then node ${scriptsPath}/live-server.mjs stop (removes the injected script tag). Sweep leftover impeccable-variants-start / impeccable-carbonize-start markers from source.`; - default: - return undefined; - } -} - -function generateInstructions(event, scriptsPath) { - const id = event.id; - const scaffold = event.scaffold; - const steps = []; - - if (event.screenshotPath) { - steps.push(`Read the annotated screenshot first: ${event.screenshotPath}. Comment {x,y} positions bind text to the child under that point; strokes read by shape (loop = emphasis on this thing, arrow = direction, cross = delete).`); - } else { - steps.push('No screenshot was sent (the user did not annotate); do not ask for one and do not screenshot the page. Work from element.outerHTML, the computed styles, and the prompt.'); - } - - if (event.mode === 'insert') { - steps.push(insertScaffoldInstructions(event, scriptsPath)); - } else if (scaffold?.previewMode === 'svelte-component') { - steps.push(svelteComponentInstructions(event, scaffold, scriptsPath)); - } else if (scaffold && scaffold.sourceWritten === false) { - steps.push(deferredWrapperInstructions(event, scaffold, scriptsPath)); - } else if (scaffold) { - steps.push(`The wrapper is already written into ${scaffold.file}. Splice preview CSS plus all ${event.count} variants at line ${scaffold.insertLine} in ONE edit, following the returned cssAuthoring contract (styleTag, selector strategy, forbidden patterns). Each variant div holds exactly ONE top-level element (same tag as the original); first visible, others display: none.`); - } else { - steps.push(`Preflight could not scaffold${event.scaffoldError ? ` (${event.scaffoldError})` : ''}. Run node ${scriptsPath}/live-wrap.mjs --id ${id} --count ${event.count} --element-id "${event.element?.id || ''}" --classes "${(event.element?.classes || []).join(',')}" --tag "${event.element?.tagName || ''}" --text "". Keep the flags separate; --text disambiguates repeated siblings. On a fallback error, follow live.md's Handle fallback.`); - } - - steps.push(event.action && event.action !== 'impeccable' - ? `Action is "${event.action}": read reference/${event.action}.md before planning; its MUST params are non-negotiable. ${PLAN_POINTER}` - : `Freeform action: work from SKILL.md rules plus craft-floor.md; no sub-command file. ${PLAN_POINTER}`); - - steps.push(`When all ${event.count} variants are delivered: ${replyCmd(scriptsPath, id, 'done --file ')}. Then poll again. If generation fails after the browser flipped to GENERATING, reply --reply ${id} error "Short reason" so the bar resets (never live-accept --discard for this).`); - - return steps.map((s, i) => `${i + 1}. ${s}`).join('\n'); -} - -function svelteComponentInstructions(event, scaffold, scriptsPath) { - const dir = scaffold.componentDir; - const count = event.count; - return `Svelte component preview. EDIT the existing stubs ${dir}/v1.svelte ... v${count}.svelte in place; never delete or recreate them; do not read them back (the prop-substituted markup is in scaffold.componentStubMarkup). Keep the stub's control flow ({#each}, {#if}) and propContract prop names exactly; never flatten a loop into literal items. The stub \n`; -} - -function buildInsertVariantStub(variantNum) { - return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; -} - -/** - * Scaffold a component-preview session. The scaffold is AST-based: the app's - * own svelte compiler parses the selected markup, control-flow blocks are - * preserved (an each collection crosses the prop contract as ONE structured - * prop, its loop body verbatim), and constructs a detached preview cannot - * support return `{ fallback: 'source-preview', reason }` so the caller keeps - * the markup inside the route file instead of shipping a wrong preview. - */ -export function scaffoldSvelteComponentSession({ - id, - count, - sourceFile, - sourceStartLine, - sourceEndLine, - originalLines, - cwd = process.cwd(), -}) { - const originalMarkup = originalLines.join('\n'); - - const compiler = loadSvelteCompiler(cwd); - if (!compiler) { - return { fallback: 'source-preview', reason: 'svelte 5 compiler not resolvable from the app root' }; - } - const analysis = analyzeSvelteMarkup(originalMarkup, compiler.parse); - if (!analysis.ok) { - return { fallback: 'source-preview', reason: analysis.reason }; - } - - ensureRuntimeHelper(cwd); - const dir = componentSessionDir(id, cwd); - fs.mkdirSync(dir, { recursive: true }); - - const contract = analysis.contract; - const seeded = extractMatchingSourceCss( - safeReadSource(path.resolve(cwd, sourceFile)), - originalMarkup, - ); - const seededCss = seeded.css; - // The preview compiles in isolation, so NONE of these source rules applied - // to what the user approved. Accept enforces that preview truth: any of - // them the variant does not re-declare is superseded and removed, instead - // of re-attaching to the accepted markup through kept class names (the - // ".decisions grid grabs the new board" failure). Only the CLASS-matched - // selectors are candidates; tag rules style shared route elements. - const seededSelectors = [...seeded.supersedable]; - - const manifest = { - id, - previewMode: 'svelte-component', - contractVersion: 2, - sourceFile: sourceFile.split(path.sep).join('/'), - sourceStartLine, - sourceEndLine, - count, - propContract: contract, - originalMarkup, - seededSelectors, - componentDir: path.relative(cwd, dir).split(path.sep).join('/'), - // Absolute paths let the browser fall back to /@fs/ imports when the dev - // server's base or root makes root-relative URLs miss, and probe whether - // the preview tree is reachable at all before blaming a variant. - componentDirAbs: dir.split(path.sep).join('/'), - runtimeModule: `/${SVELTE_RUNTIME_FILE}`, - runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'), - probeModule: `/${SVELTE_PROBE_FILE}`, - probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'), - }; - - fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); - - for (let n = 1; n <= count; n++) { - const variantFile = path.join(dir, `v${n}.svelte`); - if (!fs.existsSync(variantFile)) { - fs.writeFileSync(variantFile, buildVariantStubV2(n, analysis.markupWithProps, contract, seededCss), 'utf-8'); - } - } - - return { - manifest, - manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), - componentDir: manifest.componentDir, - propContract: contract, - // Inlined so the generate event's scaffold payload carries the stub - // shape; the agent edits vN.svelte in place instead of spending reads on - // the manifest and stub files (or deleting and recreating them). - stubMarkup: analysis.markupWithProps, - seededCss, - }; -} - -function safeReadSource(filePath) { - try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; } -} - -function escapeSelectorToken(token) { - return String(token).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Seed variant stubs with the source component's rules that already style the - * selected markup, so variants start from the real cascade (a detached - * preview inherits none of the route's compile-scoped CSS) instead of - * reimplementing it blind. - * - * Returns { css, supersedable }. `css` is every matching rule (class OR tag - * matched). `supersedable` holds only the CLASS-matched selectors: those are - * the accept-time removal candidates. Tag selectors (h1, a, p) style shared - * elements across the whole route, so they seed the preview but are never - * candidates for removal. - */ -export function extractMatchingSourceCss(routeSource, originalMarkup) { - const empty = { css: '', supersedable: new Set() }; - const styleMatch = String(routeSource || '').match(/]*>([\s\S]*?)<\/style\s*>/i); - if (!styleMatch) return empty; - const classNames = new Set(); - const classRe = /class\s*=\s*(["'])(.*?)\1/g; - let m; - while ((m = classRe.exec(originalMarkup))) { - for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); - } - const tagRe = /<([a-z][a-z0-9-]*)/gi; - const tags = new Set(); - while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); - if (classNames.size === 0 && tags.size === 0) return empty; - - // Token-boundary matching, never substring: `.btn` must not match - // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit - // seeds a rule that never styled the pick, and a falsely seeded selector - // becomes an accept-time DELETION of a hand-written rule. - const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); - const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); - const classMatches = (selector) => classRes.some((re) => re.test(selector)); - const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); - - const supersedable = new Set(); - const ruleMatches = (prelude) => { - let matched = false; - for (const selector of splitSelectorList(prelude)) { - if (classMatches(selector)) { - matched = true; - supersedable.add(normalizeSelector(selector)); - } else if (tagMatches(selector)) { - matched = true; - } - } - return matched; - }; - - const pick = (nodes) => { - const kept = []; - for (const node of nodes) { - if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); - else if (node.type === 'at' && node.children) { - const children = pick(node.children); - if (children.length) kept.push({ ...node, children }); - } - } - return kept; - }; - return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; -} - -function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { - const propsComment = contract.length > 0 - ? `\n\n` - : ''; - // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\n` - : `\n\n`; - return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`; -} - -export function scaffoldSvelteComponentInsertSession({ - id, - count, - sourceFile, - insertLine, - position, - anchorStartLine, - anchorEndLine, - anchorLines, - cwd = process.cwd(), -}) { - ensureRuntimeHelper(cwd); - const dir = componentSessionDir(id, cwd); - fs.mkdirSync(dir, { recursive: true }); - - const anchorMarkup = (anchorLines || []).join('\n'); - const manifest = { - id, - mode: 'insert', - previewMode: 'svelte-component', - sourceFile: sourceFile.split(path.sep).join('/'), - insertLine, - position, - anchorStartLine, - anchorEndLine, - originalMarkup: anchorMarkup, - anchorMarkup, - count, - propContract: [], - componentDir: path.relative(cwd, dir).split(path.sep).join('/'), - componentDirAbs: dir.split(path.sep).join('/'), - runtimeModule: `/${SVELTE_RUNTIME_FILE}`, - runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'), - probeModule: `/${SVELTE_PROBE_FILE}`, - probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'), - }; - - fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); - - for (let n = 1; n <= count; n++) { - const variantFile = path.join(dir, `v${n}.svelte`); - if (!fs.existsSync(variantFile)) { - fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); - } - } - - return { - manifest, - manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), - componentDir: manifest.componentDir, - propContract: [], - }; -} - -export function findSvelteComponentManifest(id, cwd = process.cwd()) { - const direct = manifestPathForSession(id, cwd); - if (fs.existsSync(direct)) { - return readManifest(direct); - } - // Legacy location: a session scaffolded by an older version can still be - // accepted after an upgrade. - const legacyDirect = path.join(cwd, LEGACY_SVELTE_COMPONENT_ROOT, id, 'manifest.json'); - if (fs.existsSync(legacyDirect)) { - return readManifest(legacyDirect); - } - for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) { - const root = path.join(cwd, rootRel); - if (!fs.existsSync(root)) continue; - for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const candidate = path.join(root, entry.name, 'manifest.json'); - if (!fs.existsSync(candidate)) continue; - try { - const manifest = readManifest(candidate); - if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; - } catch { /* skip */ } - } - } - return null; -} - -export function readManifest(manifestPath) { - const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); - return { - ...data, - manifestPath, - }; -} - -export function resolveSourceFile(sourceFile, cwd = process.cwd()) { - if (!sourceFile || path.isAbsolute(sourceFile)) { - throw new Error('Invalid svelte-component source file'); - } - const full = path.resolve(cwd, sourceFile); - const rel = path.relative(cwd, full); - if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { - throw new Error('Svelte-component source file escapes project root'); - } - if (!fs.existsSync(full)) { - throw new Error('Svelte-component source file not found: ' + sourceFile); - } - return full; -} - -function appendCssToSvelteStyle(lines, cssLines) { - const closeIdx = findLastStyleCloseLine(lines); - const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; - if (closeIdx === -1) { - return [...lines, '', '']; - } - return [ - ...lines.slice(0, closeIdx), - ...prepared, - ...lines.slice(closeIdx), - ]; -} - -function findLastStyleCloseLine(lines) { - for (let i = lines.length - 1; i >= 0; i--) { - if (/<\/style\s*>/.test(lines[i])) return i; - } - return -1; -} - -function bakeParamValuesInCss(cssLines, paramValues) { - if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; - return cssLines.map((line) => { - let out = line; - for (const [key, value] of Object.entries(paramValues)) { - const varName = `--p-${key}`; - out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); - } - return out; - }); -} - -function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { - const css = String((cssLines || []).join('\n')); - if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; - - const rules = parseCssRules(css); - const output = []; - for (const rule of rules) { - appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); - } - return output.join('\n') - .split('\n') - .map((line) => line.trimEnd()) - .filter((line) => line.trim() !== ''); -} - -function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { - const prelude = rule.prelude.trim(); - const body = rule.body.trim(); - if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; - - if (/^@scope\b/i.test(prelude)) { - if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; - const inner = parseCssRules(body); - for (const innerRule of inner) { - const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); - if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; - output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); - } - return; - } - - const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); - if (!rewrittenPrelude) return; - output.push(formatCssRule(rewrittenPrelude, body)); -} - -function parseCssRules(css) { - const rules = []; - const text = String(css || ''); - let i = 0; - while (i < text.length) { - while (i < text.length && /\s/.test(text[i])) i++; - const preludeStart = i; - while (i < text.length && text[i] !== '{') i++; - if (i >= text.length) break; - const prelude = text.slice(preludeStart, i).trim(); - i++; - const bodyStart = i; - let depth = 1; - let quote = null; - let comment = false; - while (i < text.length && depth > 0) { - const ch = text[i]; - const next = text[i + 1]; - if (comment) { - if (ch === '*' && next === '/') { - comment = false; - i += 2; - continue; - } - i++; - continue; - } - if (quote) { - if (ch === '\\') { - i += 2; - continue; - } - if (ch === quote) quote = null; - i++; - continue; - } - if (ch === '/' && next === '*') { - comment = true; - i += 2; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - i++; - continue; - } - if (ch === '{') depth++; - else if (ch === '}') depth--; - i++; - } - const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); - if (prelude) rules.push({ prelude, body }); - } - return rules; -} - -function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { - const selectors = splitSelectorList(prelude); - const rewritten = []; - for (const selector of selectors) { - const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); - if (next) rewritten.push(next); - } - return rewritten.join(', '); -} - -function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { - let out = selector.trim(); - const hasVariant = /data-impeccable-variant/.test(out); - if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; - if (hasVariant) { - out = out.replace(variantSelectorRegex(variantNum), ''); - out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); - } - - const paramResult = rewriteParamSelectors(out, paramValues); - if (!paramResult.keep) return ''; - out = paramResult.selector; - - out = out - .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') - .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') - .replace(/\s+/g, ' ') - .trim(); - - out = out.replace(/^[>+~]\s*/, '').trim(); - if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; - return out; -} - -function rewriteParamSelectors(selector, paramValues) { - let keep = true; - const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { - if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; - const actual = paramValues[key]; - if (expected != null && String(actual) !== String(expected)) { - keep = false; - return ''; - } - if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { - keep = false; - return ''; - } - return ''; - }); - return { keep, selector: next }; -} - - -function selectorHasVariant(selector, variantNum) { - return variantSelectorRegex(variantNum).test(selector); -} - -function variantSelectorRegex(variantNum) { - return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); -} - -function formatCssRule(selector, body) { - return `${selector} { ${body.trim()} }`; -} - -function escapeRegExp(value) { - return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { - const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); - const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); - const resultBase = { - file: manifest.sourceFile, - sourceFile: manifest.sourceFile, - previewMode: 'svelte-component', - componentDir: manifest.componentDir, - carbonize: false, - }; - if (!fs.existsSync(variantPath)) { - return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; - } - - const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); - if (manifest.mode === 'insert') { - return inlineSvelteComponentInsertAccept({ - manifest, - markup, - cssLines, - variantNum, - paramValues, - sourceFile, - resultBase, - cwd, - }); - } - - const rootTag = matchOpeningTag(markup)?.tag || 'div'; - const contract = manifest.propContract || []; - const compiler = loadSvelteCompiler(cwd); - const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); - - // Restore props back to route expressions. Contract v2 restores through the - // AST so a prop used without braces (each headers, attribute positions) - // still maps back to its original expression; v1 falls back to the textual - // placeholder swap. - let restoredText; - if (Number(manifest.contractVersion) === 2 && compiler) { - const restored = restoreSvelteMarkup(mergedMarkup, contract, compiler.parse); - if (!restored.ok) { - return { handled: false, error: 'Accepted variant does not parse: ' + restored.reason, ...resultBase }; - } - restoredText = restored.markup; - } else { - restoredText = substitutePropsWithExprs(mergedMarkup, contract); - } - const restoredMarkup = restoredText.split('\n').map((line) => line.trimEnd()); - - const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); - const sourceLines = sourceContent.split('\n'); - const start = Number(manifest.sourceStartLine) - 1; - const end = Number(manifest.sourceEndLine) - 1; - if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { - return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; - } - - const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; - const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent); - - let newLines = [ - ...sourceLines.slice(0, start), - ...indentedMarkup, - ...sourceLines.slice(end + 1), - ]; - - // Selectors that were already unused before this accept are the user's - // pre-existing code; the pruning pass must not touch them. - const preUnused = compiler ? collectUnusedSelectors(sourceContent, compiler.compile) : new Set(); - - // Bake params (declared kinds from params.json drive branch pruning), then - // MERGE into the component's existing style block: matching selectors are - // replaced, new ones appended. Appending alone is how superseded rules used - // to survive their own replacement. - const declaredParams = readDeclaredParams(manifest, variantNum, cwd); - let variantCss = cssLines.join('\n'); - if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) { - // Defensive: strip preview-wrapper selectors that authoring rules forbid - // on this path but an off-spec agent may still emit. - variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n'); - } - const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {}); - const cssStats = { replaced: 0, appended: 0, pruned: [], superseded: [] }; - if (bakedCss.trim()) { - const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss); - newLines = merged.text.split('\n'); - cssStats.replaced = merged.replaced; - cssStats.appended = merged.appended; - } - - let finalText = newLines.join('\n'); - - // Preview truth: the detached preview never applied the source rules that - // styled the replaced selection, so the user approved a design without - // them. Any seeded selector the variant did not re-declare is superseded; - // left in place it re-attaches through kept class names (the accepted root - // keeps its original classes) and re-layouts markup it no longer owns. - // - // Removal is bounded by ownership: a selector whose classes are still used - // by route markup OUTSIDE the replaced region does not belong to the pick - // alone, and removing it would strip styling from markup this accept never - // touched. Keeping it risks a visible re-attachment quirk on the accepted - // region; deleting it breaks the rest of the route. Keep it. - const outsideMarkup = [...sourceLines.slice(0, start), ...sourceLines.slice(end + 1)] - .join('\n') - .replace(/]*>[\s\S]*?<\/style\s*>/gi, ''); - const outsideClasses = new Set(); - { - const attrRe = /class\s*=\s*(["'])(.*?)\1/g; - let cm; - while ((cm = attrRe.exec(outsideMarkup))) { - for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); - } - const directiveRe = /class:([A-Za-z0-9_-]+)/g; - while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); - } - const usedOutsideReplacedRegion = (selector) => { - const classTokenRe = /\.([A-Za-z0-9_-]+)/g; - let tm; - while ((tm = classTokenRe.exec(selector))) { - if (outsideClasses.has(tm[1])) return true; - } - return false; - }; - const incomingSelectors = collectAllSelectors(bakedCss); - const superseded = (manifest.seededSelectors || []) - .map((selector) => normalizeSelector(selector)) - .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); - if (superseded.length > 0) { - const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); - finalText = scrubbed.text; - cssStats.superseded = scrubbed.removed; - } - - if (compiler) { - const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); - finalText = pruned.source; - cssStats.pruned = pruned.removed; - } - - // Postcondition: no selector from the user's pre-accept CSS may vanish - // unless the compiler-driven prune or the preview-truth supersession - // deliberately removed it. This turns any parser or reconciler defect into - // a loud refusal instead of silent damage to a hand-written style block. - const lostSelectors = findLostSelectors(sourceContent, finalText, [ - ...cssStats.pruned, - ...cssStats.superseded, - ]); - if (lostSelectors.length > 0) { - return { - handled: false, - error: 'CSS reconciliation would lose selectors from the existing style block: ' - + lostSelectors.join(', ') - + '. Source not modified; accept the variant manually.', - mode: 'error', - ...resultBase, - }; - } - - try { - fs.writeFileSync(sourceFile, finalText, 'utf-8'); - } catch (err) { - return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; - } - removeSvelteComponentSession(manifest.id, cwd); - - const verify = verifyAcceptedSource(finalText); - return { - handled: true, - css: cssStats, - verify, - ...resultBase, - }; -} - -/** Re-indent a block onto `indent` while preserving its internal structure. */ -export function reindentPreservingStructure(lines, indent) { - const nonEmpty = lines.filter((line) => line.trim() !== ''); - if (nonEmpty.length === 0) return lines.map(() => ''); - const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); - return lines.map((line) => { - if (line.trim() === '') return ''; - const current = (line.match(/^\s*/) || [''])[0].length; - return indent + line.slice(Math.min(minIndent, current)); - }); -} - -function styleBlockText(sourceText) { - const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); - return match ? match[1] : ''; -} - -/** - * Remove every rule whose (normalized) selector list is fully contained in - * `selectors` from the component's style block, at any at-rule nesting depth. - * Rules that mix doomed and surviving selectors keep the survivors. - */ -export function removeSelectorsFromSvelteSource(sourceText, selectors) { - const text = String(sourceText || ''); - const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; - let lastMatch = null; - let m; - while ((m = styleRe.exec(text))) lastMatch = m; - if (!lastMatch) return { text, removed: [] }; - - const removed = []; - const transform = (nodes) => { - const kept = []; - for (const node of nodes) { - if (node.type === 'rule') { - const survivors = []; - for (const selector of splitSelectorList(node.prelude)) { - if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); - else survivors.push(selector); - } - if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); - } else if (node.type === 'at' && node.children) { - const children = transform(node.children); - if (children.length > 0) kept.push({ ...node, children }); - } else { - kept.push(node); - } - } - return kept; - }; - - const nodes = transform(parseStylesheet(lastMatch[1])); - if (removed.length === 0) return { text, removed }; - const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); - const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; - return { - text: text.slice(0, lastMatch.index) + rebuilt + text.slice(lastMatch.index + lastMatch[0].length), - removed, - }; -} - -export function findLostSelectors(beforeSource, afterSource, prunedSelectors = []) { - const before = collectAllSelectors(styleBlockText(beforeSource)); - const after = collectAllSelectors(styleBlockText(afterSource)); - const pruned = new Set((prunedSelectors || []).map((s) => normalizeSelector(s))); - const lost = []; - for (const selector of before) { - if (!after.has(selector) && !pruned.has(selector)) lost.push(selector); - } - return lost; -} - -function readDeclaredParams(manifest, variantNum, cwd) { - try { - const raw = JSON.parse(fs.readFileSync(path.join(cwd, manifest.componentDir, 'params.json'), 'utf-8')); - const list = raw?.[String(variantNum)]; - return Array.isArray(list) ? list : []; - } catch { - return []; - } -} - -/** - * Merge CSS into a svelte component's top-level style block (created when - * absent), replacing rules whose selectors match and appending the rest. - */ -export function mergeCssIntoSvelteSource(sourceText, incomingCss) { - const text = String(sourceText || ''); - const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; - let lastMatch = null; - let m; - while ((m = styleRe.exec(text))) lastMatch = m; - - if (!lastMatch) { - const { css, replaced, appended } = reconcileCss('', incomingCss); - return { - text: `${text.replace(/\s*$/, '')}\n\n\n`, - replaced, - appended, - }; - } - - const inner = lastMatch[1]; - const { css, replaced, appended } = reconcileCss(inner, incomingCss); - const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); - const replacedBlock = `${openTag}\n${indentCssBlock(css)}\n`; - return { - text: text.slice(0, lastMatch.index) + replacedBlock + text.slice(lastMatch.index + lastMatch[0].length), - replaced, - appended, - }; -} - -function indentCssBlock(css) { - return String(css || '') - .split('\n') - .map((line) => (line.trim() === '' ? '' : ' ' + line)) - .join('\n'); -} - -function inlineSvelteComponentInsertAccept({ - manifest, - markup, - cssLines, - variantNum, - paramValues, - sourceFile, - resultBase, - cwd, -}) { - if (!svelteMarkupHasVisibleContent(markup)) { - return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; - } - if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { - return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; - } - - const rootTag = matchOpeningTag(markup)?.tag || 'div'; - const restoredMarkup = String(markup || '') - .split('\n') - .map((line) => line.trimEnd()); - const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); - const sourceLines = sourceContent.split('\n'); - const insertIndex = Number(manifest.insertLine) - 1; - if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { - return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; - } - - const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; - const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; - const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent); - - let newLines = [ - ...sourceLines.slice(0, insertIndex), - ...indentedMarkup, - ...sourceLines.slice(insertIndex), - ]; - - let variantCss = cssLines.join('\n'); - if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) { - variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n'); - } - const declaredParams = readDeclaredParams(manifest, variantNum, cwd); - const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {}); - if (bakedCss.trim()) { - const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss); - newLines = merged.text.split('\n'); - } - - try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); - } catch (err) { - return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; - } - removeSvelteComponentSession(manifest.id, cwd); - - const verify = verifyAcceptedSource(newLines.join('\n')); - return { - handled: true, - verify, - ...resultBase, - }; -} - -function svelteMarkupHasVisibleContent(markup) { - const text = String(markup || '') - .replace(//gi, '') - .replace(//gi, '') - .replace(//g, '') - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (text.length > 0) return true; - return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); -} - -function mergeOriginalTopLevelAttrs(markup, originalMarkup) { - const variantOpen = matchOpeningTag(markup); - const originalOpen = matchOpeningTag(originalMarkup); - if (!variantOpen || !originalOpen) return markup; - if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; - - const variantAttrs = parseAttrSegments(variantOpen.attrs); - const originalAttrs = parseAttrSegments(originalOpen.attrs); - const additions = []; - let attrs = variantOpen.attrs; - - const originalClass = originalAttrs.get('class'); - const variantClass = variantAttrs.get('class'); - if (originalClass && variantClass) { - const merged = mergeStaticClassAttr(originalClass, variantClass); - if (merged) { - attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); - variantAttrs.set('class', { ...variantClass, raw: merged }); - } - } else if (originalClass && !variantClass) { - additions.push(originalClass.raw); - } - - for (const [name, attr] of originalAttrs) { - if (name === 'class') continue; - if (!variantAttrs.has(name)) additions.push(attr.raw); - } - - if (additions.length === 0 && attrs === variantOpen.attrs) return markup; - const nextOpen = variantOpen.prefix - + variantOpen.tag - + attrs - + additions.map((attr) => ' ' + attr.trim()).join('') - + variantOpen.close; - return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); -} - -function matchOpeningTag(markup) { - const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); - if (!match) return null; - return { - raw: match[0], - prefix: match[1], - tag: match[2], - attrs: match[3] || '', - close: match[4], - index: match.index || 0, - }; -} - -function parseAttrSegments(attrs) { - const out = new Map(); - const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; - let match; - while ((match = re.exec(attrs))) { - const raw = match[0]; - const name = match[1]; - out.set(name, { - name, - raw, - start: match.index, - end: match.index + raw.length, - }); - } - return out; -} - -function mergeStaticClassAttr(originalClass, variantClass) { - const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); - const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); - if (!originalValue || !variantValue) return null; - const quote = variantValue[1]; - const classes = [ - ...variantValue[2].split(/\s+/), - ...originalValue[2].split(/\s+/), - ].filter(Boolean); - return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; -} - -export function removeSvelteComponentSession(id, cwd = process.cwd()) { - const dir = componentSessionDir(id, cwd); - try { - fs.rmSync(dir, { recursive: true, force: true }); - } catch { /* non-fatal */ } -} - -/** - * Compile-check every variant component of a session with the app's own - * compiler, BEFORE the browser ever imports them. A variant that does not - * compile (the classic: a second top-level - - - - -${buildPath?.toggle ? `` : ''} -
-
- - Impeccable -
-
-
-
-
- -

${esc(payload.title || 'Choose a direction')}

- ${buildPath?.toggle ? `
-
- - -
-

-
` : ''} -
- ${payload.question ? `

${esc(payload.question)}

` : ''} -
-
${cards}
- - - - -
-
-
-
- ${payload.steer ? '' : ''} - ${(() => { - if (!payload.reroll) return ''; - const die = ''; - const registers = Array.isArray(payload.reroll.registers) ? payload.reroll.registers.filter((r) => r === 'safer' || r === 'bolder') : []; - // The registers are the user's steering wheel on the familiar-to-bold - // axis; the plain re-roll sits between them so the spatial order matches - // the axis it names. - const safer = registers.includes('safer') ? '' : ''; - const bolder = registers.includes('bolder') ? '' : ''; - return `${safer}${bolder}`; - })()} - ${payload.canon && !payload.canonCard ? '' : ''} -
-`; -} - -// Browsers omit the :80 suffix on the default HTTP port, so a server on -// --port 80 sees bare loopback hosts and origins. -function allowedHost(host, port) { - if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true; - return port === 80 && (host === '127.0.0.1' || host === 'localhost'); -} - -function allowedOrigin(origin, port) { - if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true; - return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost'); -} - -function rejectDetachedPost(req, res, url, port) { - if (detachedKey && url.searchParams.get('key') !== detachedKey) { - res.writeHead(401); res.end(); return true; - } - const origin = req.headers.origin; - if (origin && !allowedOrigin(origin, port)) { - res.writeHead(403); res.end(); return true; - } - return false; -} - -const server = http.createServer((req, res) => { - const { port } = server.address(); - if (!allowedHost(req.headers.host, port)) { - res.writeHead(403); res.end(); return; - } - let url; - try { url = new URL(req.url, 'http://127.0.0.1'); } - catch { res.writeHead(400); res.end(); return; } - const pathname = url.pathname; - if (req.method === 'GET' && pathname === '/') { - const pending = nextFile(); - if (pending && fs.existsSync(pending)) { - // A next file the round cannot load has to leave the disk either way: - // kept, /next-status stays ready:true and the waiting page reloads - // into the same failure without bound. - try { loadRound(fs.readFileSync(pending, 'utf8')); } catch { /* keep current round */ } - try { fs.rmSync(pending); } catch { /* already gone */ } - // The claim consumes the file the idle-exit hold reads, and the - // reloading page cannot beat until it has parsed: stamp the claim so - // the same bounded grace covers the gap between them. Persisted too, - // because --wait watches the same gap from outside this process and - // would otherwise read the stale beat as a closed page. - server.lastClaimAt = Date.now(); - if (detachedKey) { - try { - const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8')); - state.claimedAt = server.lastClaimAt; - fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state)); - } catch { /* state file recreated on next beat */ } - } - } - res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); - res.end(page(awaitingNext)); - return; - } - if (req.method === 'POST' && pathname === '/heartbeat') { - if (rejectDetachedPost(req, res, url, port)) return; - res.writeHead(204); res.end(); - server.lastBeatSeen = Date.now(); - if (detachedKey) { - const now = Date.now(); - if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) { - server.lastBeatWrite = now; - try { - const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8')); - state.lastBeat = now; - fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state)); - } catch { /* state file recreated on next beat */ } - } - } - return; - } - if (req.method === 'GET' && pathname === '/next-status') { - const pending = nextFile(); - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) })); - return; - } - const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/); - if (imageMatch) { - const abs = localImages[Number(imageMatch[1])]; - if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; } - const type = abs.endsWith('.webp') ? 'image/webp' - : abs.endsWith('.png') ? 'image/png' - : abs.endsWith('.svg') ? 'image/svg+xml' - : abs.endsWith('.gif') ? 'image/gif' - : 'image/jpeg'; - res.writeHead(200, { 'content-type': type }); - fs.createReadStream(abs).pipe(res); - return; - } - if (req.method === 'POST' && pathname === '/build-path') { - if (rejectDetachedPost(req, res, url, port)) return; - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - let value = null; - try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value === 'comp' || value === 'code') { - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); - } - } - // Answer only once the flip is on disk. Responding first raced the - // caller: the 200 reached the client (a separate process) while this - // one could still be preempted before the write landed, so a poller - // that trusted the 200 could look for the flip file and miss it. - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); - }); - return; - } - if (req.method === 'POST' && pathname === '/answer') { - if (rejectDetachedPost(req, res, url, port)) return; - let body = ''; - req.on('data', (chunk) => { body += chunk; }); - req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); - let parsed = {}; - try { parsed = JSON.parse(body); } catch { /* empty steer */ } - const chosen = options.find((o) => o.id === parsed.optionId); - const isReroll = parsed.optionId === 'reroll'; - // A followup round's pick is not terminal: the table stays open for the - // next round (--update), exactly like a re-roll. Detached mode only; - // the blocking mode has no update channel, so its picks stay terminal. - const followupOpen = Boolean(detachedKey) && payload.followup === true && !isReroll; - const answer = JSON.stringify({ - optionId: parsed.optionId ?? null, - steer: parsed.steer ?? '', - ...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}), - ...(followupOpen ? { followup: true } : {}), - ...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}), - ...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}), - ...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}), - }); - // The delivery deadline is single-issue: a duplicate answer racing the - // page's disable must not restamp the allowance already inherited. - const wasAwaiting = awaitingNext; - awaitingNext = (isReroll || followupOpen) && Boolean(detachedKey); - if (awaitingNext && !wasAwaiting) awaitingNextSince = Date.now(); - if (detachedKey) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(answerFile(detachedKey), answer + '\n'); - } else { - printAnswer(answer); - } - // A re-roll or followup pick in detached mode keeps the table open: the - // client shows a loading hand and reloads when --update delivers the - // next round. - if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150); - }); - return; - } - res.writeHead(404); res.end(); -}); - -server.listen(portArg, '127.0.0.1', () => { - const { port } = server.address(); - const url = `http://127.0.0.1:${port}/`; - if (hasFlag('detached-serve')) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(stateFile(arg('key')), JSON.stringify({ pid: process.pid, port, url })); - } else { - console.log(`QUESTION URL: ${url}`); - console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...'); - } - if (!hasFlag('no-open')) { - openSystemBrowser(url); - } - // The timeout bounds the wait for a page, never the user's decision: an - // absolute guillotine counted from start used to kill the server under a - // still-open tab (a slow re-rolled round easily outlived it), leaving the - // page polling skeletons that could never resolve. Once the page beats, - // the server's lifetime tracks the beats, and it exits only after the idle - // grace passes with none, long enough to survive a closed laptop lid. - // --timeout 0 waits for a page forever, but the idle grace still applies - // once one has beat: a page that arrived and went silent is a closed tab, - // and no timeout setting should let that daemon leak. - const startedAt = Date.now(); - const lifetime = setInterval(() => { - if (!server.lastBeatSeen) { - if (timeoutSec > 0 && Date.now() - startedAt > timeoutSec * 1000) { - console.log('serve-question: timed out with no answer'); - process.exit(2); - } - } else if (Date.now() - server.lastBeatSeen > idleGraceMs) { - // A hand delivered moments before this deadline still gets its claim - // window: the stalled page's watch reloads into it and beats again - // within seconds, while a file unclaimed past the grace means no page - // is coming back (the same verdict --wait reads from its age). The - // claim itself holds the daemon too: GET / deletes the file before the - // reloaded page can beat, so a tick in that gap must not exit under - // the hand just claimed. - const pending = nextFile(); - let deliveredAt = 0; - if (pending) { try { deliveredAt = fs.statSync(pending).mtimeMs; } catch { /* nothing delivered */ } } - if (Date.now() - Math.max(deliveredAt, server.lastClaimAt || 0) > NEXT_CLAIM_GRACE_MS) { - console.log('serve-question: the page stopped beating and never came back; exiting'); - process.exit(2); - } - } - }, 2000); - lifetime.unref?.(); -}); diff --git a/.veto/skills/impeccable/scripts/surface-brief.mjs b/.veto/skills/impeccable/scripts/surface-brief.mjs deleted file mode 100644 index 723f7c1b4..000000000 --- a/.veto/skills/impeccable/scripts/surface-brief.mjs +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { resolveProjectRoot } from './context.mjs'; -import { - listSurfaceBriefs, - resolveSurfaceBrief, - surfaceBriefPathForTarget, - writeSurfaceBrief, -} from './lib/surface-briefs.mjs'; - -function summary(brief, projectRoot) { - return { - slug: brief.slug, - path: path.relative(projectRoot, brief.path).split(path.sep).join('/'), - primaryTarget: brief.primaryTarget, - relatedTargets: brief.relatedTargets, - }; -} - -function main(argv) { - const [command, target, bodyFile, ...relatedTargets] = argv; - const projectRoot = resolveProjectRoot(process.cwd(), target ? { targetPath: target } : {}); - if (command === 'path') { - const filePath = surfaceBriefPathForTarget(target, { projectRoot }); - if (!filePath) throw new Error('surface brief path requires a concrete target'); - process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`); - return; - } - if (command === 'list') { - process.stdout.write(`${JSON.stringify(listSurfaceBriefs(projectRoot).map((brief) => summary(brief, projectRoot)), null, 2)}\n`); - return; - } - if (command === 'read') { - const result = resolveSurfaceBrief(projectRoot, target || null); - if (result.brief) { - process.stdout.write(result.brief.text); - return; - } - if (result.candidates.length) process.stderr.write(`${JSON.stringify(result.candidates.map((brief) => summary(brief, projectRoot)), null, 2)}\n`); - process.exit(2); - } - if (command === 'write') { - if (!target || !bodyFile) throw new Error('usage: surface-brief.mjs write '); - const filePath = writeSurfaceBrief({ - projectRoot, - primaryTarget: target, - relatedTargets, - body: fs.readFileSync(bodyFile, 'utf-8'), - }); - process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`); - return; - } - throw new Error('usage: surface-brief.mjs [target] [body-file] [related-target ...]'); -} - -function isMainModule() { - if (!process.argv[1]) return false; - try { - return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]); - } catch { - return import.meta.url === pathToFileURL(process.argv[1]).href; - } -} - -if (isMainModule()) { - try { - main(process.argv.slice(2)); - } catch (error) { - process.stderr.write(`${error?.message || error}\n`); - process.exit(1); - } -}