From 2a1ad1797bcd4860cf9846c0ddb6b601ecdef20b Mon Sep 17 00:00:00 2001 From: bastien Date: Mon, 28 Sep 2026 01:40:01 +0200 Subject: [PATCH 1/5] feat(lib): vendor-skills helper, five MengTo scroll skills pinned lib/vendor-skills.sh: vendor_pinned_skills [refresh], list or dict lock shapes, lock read via python argv, traversal and charset guard on lock values, VENDOR_BASE_URL honoured only as file:// (hermetic suite), per-file tmp+mv, refresh skips a skill never installed. install-plugins.sh Step 8e and update-all.sh 7.3 call it for agent-skills and mengto-skills. Vendored at a965851: scroll-world-storytelling, build-threejs-scroll-worlds (+5 references), scroll-scrubbed-visual-sequence, scroll-scrubbed-word- reveal, scroll-progress-timeline; text files only. Registered in link.sh, .gitignore, toggle-external, profile.sh and the design/web/web-full/full profiles, which also list site-motion (personal). Suite: 8 cases. --- .gitignore | 17 +++ install-plugins.sh | 74 ++++-------- lib/profile.sh | 10 +- lib/profiles/design.profile | 10 ++ lib/profiles/full.profile | 8 ++ lib/profiles/web-full.profile | 8 ++ lib/profiles/web.profile | 10 ++ lib/tests/vendor-skills.test.sh | 144 ++++++++++++++++++++++ lib/toggle-external.sh | 20 +++- lib/vendor-skills.sh | 205 ++++++++++++++++++++++++++++++++ link.sh | 5 +- plugins.lock.json | 16 ++- update-all.sh | 38 ++---- 13 files changed, 479 insertions(+), 86 deletions(-) create mode 100755 lib/tests/vendor-skills.test.sh create mode 100644 lib/vendor-skills.sh diff --git a/.gitignore b/.gitignore index a8b9bf3..aa05165 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,11 @@ skills/frontend-design skills/ci-cd-and-automation skills/deprecation-and-migration skills/observability-and-instrumentation +skills/scroll-world-storytelling +skills/build-threejs-scroll-worlds +skills/scroll-scrubbed-visual-sequence +skills/scroll-scrubbed-word-reveal +skills/scroll-progress-timeline # Impeccable — NOT a symlink: `impeccable skills install --scope=global` # writes the skill dir (and its ~15 MB engine binary) straight in through the @@ -189,6 +194,18 @@ skills-external/observability-and-instrumentation/ skills-external/deprecation-and-migration/ skills-external/ci-cd-and-automation/ +# Mengto scroll-choreography skills (MengTo/Skills) — machine-owned, +# curl'd at the commit pinned in plugins.lock.json ("mengto-skills" entry) +# by install-plugins.sh Step 8e (when absent) and re-fetched at the SAME +# commit by update-all.sh, through the shared lib/vendor-skills.sh helper. +# Not vendored: this is a pin, not a tracked snapshot — bump the commit +# deliberately to pick up an upstream edit. +skills-external/scroll-world-storytelling/ +skills-external/build-threejs-scroll-worlds/ +skills-external/scroll-scrubbed-visual-sequence/ +skills-external/scroll-scrubbed-word-reveal/ +skills-external/scroll-progress-timeline/ + # 21st.dev skill pack — machine-owned: `21st skills install` output, staged by # install-plugins.sh Step 8.7 (the installer refuses to write through the # ~/.claude/skills symlink, so it runs under a throwaway HOME and the skills diff --git a/install-plugins.sh b/install-plugins.sh index f126ff8..4867d29 100644 --- a/install-plugins.sh +++ b/install-plugins.sh @@ -90,22 +90,6 @@ print(v) fi } -# Read a pinned commit sha from plugins.lock.json (agent-skills style entries -# — no "version", a "commit" field instead). Prints the sha, or "" if the -# entry or the field is absent. -# Usage: pinned_commit "agent-skills" → prints the commit sha or "" -pinned_commit() { - local key="$1" - if [ -f "$REPO/plugins.lock.json" ] && command -v python3 &>/dev/null; then - python3 -c " -import json, sys -with open(sys.argv[1]) as f: - d = json.load(f) -print(d.get(sys.argv[2], {}).get('commit', '')) -" "$REPO/plugins.lock.json" "$key" 2>/dev/null || true - fi -} - # ============================================================ # DETECT OS # ============================================================ @@ -915,41 +899,29 @@ else fi echo "" -# ── Step 8e: Agent Skills (addyosmani/agent-skills, pinned commit) ── -# Three dev-lifecycle skills vendored the emil-design-eng way (curl → -# skills-external//SKILL.md, symlinked by link.sh) but COMMIT-pinned -# instead of tracking main: the sha lives in plugins.lock.json ("agent-skills" -# entry), never hardcoded here. -echo "── Step 8e: Agent Skills (addyosmani/agent-skills) ─────────" +# ── Step 8e: Agent Skills (addyosmani/agent-skills) + Mengto scroll +# skills (MengTo/Skills) — both commit-pinned, vendored the emil-design-eng +# way (curl → skills-external//, symlinked by link.sh) through the +# shared lib/vendor-skills.sh helper. Shas/paths/file-lists live in +# plugins.lock.json ("agent-skills" / "mengto-skills" entries), never +# hardcoded here. +echo "── Step 8e: Agent Skills + Mengto scroll skills (pinned commit) ──" echo "" -AGENT_SKILLS_NAMES=(observability-and-instrumentation deprecation-and-migration ci-cd-and-automation) -AGENT_SKILLS_SHA=$(pinned_commit "agent-skills") -if [ -z "$AGENT_SKILLS_SHA" ]; then - err "agent-skills: no commit pinned in plugins.lock.json — add an \"agent-skills\" entry with a \"commit\" field" -else - for _as_skill in "${AGENT_SKILLS_NAMES[@]}"; do - _as_dir="$REPO/skills-external/$_as_skill" - _as_url="https://raw.githubusercontent.com/addyosmani/agent-skills/$AGENT_SKILLS_SHA/skills/$_as_skill/SKILL.md" - mkdir -p "$_as_dir" - if [ -f "$_as_dir/SKILL.md" ]; then - ok "$_as_skill already downloaded" - else - info "Downloading SKILL.md from addyosmani/agent-skills ($_as_skill)..." - if curl -fsSL "$_as_url" -o "$_as_dir/SKILL.md.tmp" \ - && mv "$_as_dir/SKILL.md.tmp" "$_as_dir/SKILL.md"; then - ok "$_as_skill installed" - else - rm -f "$_as_dir/SKILL.md.tmp" - err "$_as_skill download failed — try: curl -fsSL $_as_url -o $_as_dir/SKILL.md" - fi - fi - if [ -L "$HOME/.claude/skills/$_as_skill" ]; then - ok "$_as_skill symlink OK" - else - info "Symlinking $_as_skill — will be created by link.sh" - fi - done -fi +# shellcheck source=lib/vendor-skills.sh disable=SC1091 +source "$REPO/lib/vendor-skills.sh" +EXT_SKILL_NAMES=(observability-and-instrumentation deprecation-and-migration + ci-cd-and-automation scroll-world-storytelling build-threejs-scroll-worlds + scroll-scrubbed-visual-sequence scroll-scrubbed-word-reveal + scroll-progress-timeline) +vendor_pinned_skills agent-skills +vendor_pinned_skills mengto-skills +for _ext_skill in "${EXT_SKILL_NAMES[@]}"; do + if [ -L "$HOME/.claude/skills/$_ext_skill" ]; then + ok "$_ext_skill symlink OK" + else + info "Symlinking $_ext_skill — will be created by link.sh" + fi +done echo "" # ============================================================ @@ -1240,6 +1212,7 @@ echo " 🔄 frontend-design — distinctive frontend interfaces, anti-AI- echo " 🔄 impeccable — /impeccable design verbs + 45-rule deterministic detector (npx impeccable detect)" echo " 🔄 design-motion-principles — motion/animation design, 3-designer lens (kylezantos)" echo " 🔄 agent-skills trio — observability-and-instrumentation, deprecation-and-migration, ci-cd-and-automation (curl → symlink, pinned commit)" +echo " 🔄 mengto scroll skills — scroll-world-storytelling, build-threejs-scroll-worlds, scroll-scrubbed-visual-sequence, scroll-scrubbed-word-reveal, scroll-progress-timeline (curl → symlink, pinned commit)" echo " 🔄 darwin-skill — autonomous skill optimizer (npx skills, ~/.agents/skills/)" echo " 🔄 21st skill pack — 21st.dev CLI skills; design ones follow the profile (full by default), publishing ones on demand (toggle: lib/toggle-external.sh enable 21st)" echo "" @@ -1249,6 +1222,7 @@ echo " Emil Design Eng at: ~/.claude/skills/emil-design-eng/ (symlink → skill echo " Frontend Design at: ~/.claude/skills/frontend-design/ (symlink → skills-external)" echo " Design Motion Principles at: ~/.claude/skills/design-motion-principles/ (symlink → skills-external)" echo " Agent Skills trio at: ~/.claude/skills/{observability-and-instrumentation,deprecation-and-migration,ci-cd-and-automation}/ (symlink → skills-external)" +echo " Mengto scroll skills at: ~/.claude/skills/{scroll-world-storytelling,build-threejs-scroll-worlds,scroll-scrubbed-visual-sequence,scroll-scrubbed-word-reveal,scroll-progress-timeline}/ (symlink → skills-external)" echo " npx skills at: ~/.agents/skills/ (symlinked into ~/.claude/skills/)" echo "" echo " → Restart Claude Code — plugins load automatically" diff --git a/lib/profile.sh b/lib/profile.sh index a88292f..5cfa3d1 100755 --- a/lib/profile.sh +++ b/lib/profile.sh @@ -82,6 +82,11 @@ MANAGED_EXTERNALS=( observability-and-instrumentation deprecation-and-migration ci-cd-and-automation + scroll-world-storytelling + build-threejs-scroll-worlds + scroll-scrubbed-visual-sequence + scroll-scrubbed-word-reveal + scroll-progress-timeline ) # MCP servers that are toggle-managed by `set`, both ways (enable AND @@ -761,7 +766,10 @@ NOTE: (emil-design-eng, frontend-design, design-motion-principles, impeccable, the five 21st design skills, the agent-skills trio observability-and-instrumentation/deprecation-and-migration/ - ci-cd-and-automation). Anything outside those allowlists stays + ci-cd-and-automation, the five Mengto scroll skills + scroll-world-storytelling/build-threejs-scroll-worlds/ + scroll-scrubbed-visual-sequence/scroll-scrubbed-word-reveal/ + scroll-progress-timeline). Anything outside those allowlists stays advisory — run "claude plugin enable|disable" or "bash lib/toggle-external.sh enable|disable " yourself. EOF diff --git a/lib/profiles/design.profile b/lib/profiles/design.profile index 06c5e61..4354a3f 100644 --- a/lib/profiles/design.profile +++ b/lib/profiles/design.profile @@ -31,6 +31,16 @@ frontend-design external design-motion-principles external impeccable external +# External: Mengto scroll-choreography skills (curl, commit-pinned) +scroll-world-storytelling external +build-threejs-scroll-worlds external +scroll-scrubbed-visual-sequence external +scroll-scrubbed-word-reveal external +scroll-progress-timeline external + +# Personal: motion implementation companion (skills/site-motion) +site-motion personal + # External: 21st.dev pack — CLI-driven (no MCP, no API key). 21st-registry # and 21st-design-sync are publishing flows; installed but left parked. 21st-ui-build external diff --git a/lib/profiles/full.profile b/lib/profiles/full.profile index 942a1d2..62959d6 100644 --- a/lib/profiles/full.profile +++ b/lib/profiles/full.profile @@ -86,6 +86,11 @@ impeccable external observability-and-instrumentation external deprecation-and-migration external ci-cd-and-automation external +scroll-world-storytelling external +build-threejs-scroll-worlds external +scroll-scrubbed-visual-sequence external +scroll-scrubbed-word-reveal external +scroll-progress-timeline external ui-ux-pro-max plugin@ui-ux-pro-max-skill # pr-review-toolkit REMOVED from full (audit 2026-07-02 #12): heaviest # single plugin cost (~2.2k tokens of agent descriptions/session), useful @@ -99,6 +104,9 @@ ui-ux-pro-max plugin@ui-ux-pro-max-skill 21st-cli-use external 21st-ai external +# Personal: motion implementation companion (skills/site-motion) +site-motion personal + # === CLIs (advisory) ================================================= 21st cli ctx7 cli diff --git a/lib/profiles/web-full.profile b/lib/profiles/web-full.profile index 8ee1dbb..18da3ab 100644 --- a/lib/profiles/web-full.profile +++ b/lib/profiles/web-full.profile @@ -49,6 +49,11 @@ emil-design-eng external frontend-design external design-motion-principles external impeccable external +scroll-world-storytelling external +build-threejs-scroll-worlds external +scroll-scrubbed-visual-sequence external +scroll-scrubbed-word-reveal external +scroll-progress-timeline external 21st-ui-build external 21st-ui-explore external 21st-ui-review external @@ -56,6 +61,9 @@ impeccable external 21st-ai external ui-ux-pro-max plugin@ui-ux-pro-max-skill +# Personal: motion implementation companion (skills/site-motion) +site-motion personal + # === CLIs ============================================================ 21st cli ctx7 cli diff --git a/lib/profiles/web.profile b/lib/profiles/web.profile index 718c721..0eb19a0 100644 --- a/lib/profiles/web.profile +++ b/lib/profiles/web.profile @@ -38,6 +38,16 @@ frontend-design external design-motion-principles external impeccable external +# External: Mengto scroll-choreography skills (curl, commit-pinned) +scroll-world-storytelling external +build-threejs-scroll-worlds external +scroll-scrubbed-visual-sequence external +scroll-scrubbed-word-reveal external +scroll-progress-timeline external + +# Personal: motion implementation companion (skills/site-motion) +site-motion personal + # External: 21st.dev pack (publishing flows 21st-registry / -design-sync # stay parked) 21st-ui-build external diff --git a/lib/tests/vendor-skills.test.sh b/lib/tests/vendor-skills.test.sh new file mode 100755 index 0000000..f96f062 --- /dev/null +++ b/lib/tests/vendor-skills.test.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# lib/tests/vendor-skills.test.sh — lib/vendor-skills.sh's vendor_pinned_skills(): +# list-shape and dict-shape lock entries, a file:// VENDOR_BASE_URL fixture +# tree (VENDOR_SKILLS_REPO_OVERRIDE points skills-external/ + the lock at a +# throwaway repo), tmp+mv semantics (a missing upstream file leaves no dest +# and no tmp), skip-when-present, refresh overwriting a stale copy, a +# non-file:// VENDOR_BASE_URL override being ignored (warn, default URL), +# and a "../evil" lock file being rejected before any fetch. +set -u +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +LIB="$ROOT/lib/vendor-skills.sh" +pass=0; fail=0 + +check_bool() { + local name="$1" ok="$2" + if [ "$ok" = 1 ]; then pass=$((pass+1)); echo "PASS $name" + else fail=$((fail+1)); echo "FAIL $name"; fi +} + +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +FIXTURE_REPO="$WORK/repo" +UPSTREAM="$WORK/upstream" +mkdir -p "$FIXTURE_REPO/skills-external" + +# Lock: "list-key" mirrors the agent-skills bare-list shape (file defaults +# to SKILL.md, path defaults to "skills"). "dict-key" mirrors the +# mengto-skills explicit shape (a references/ file, an explicit path). +# "fail-key" names a file that is never placed in $UPSTREAM. "missing-key" +# names a skill never installed locally (no skills-external/ dir), to +# prove refresh skips it instead of installing it. "traversal-key" names +# a well-behaved SKILL.md alongside a "../evil" file, to prove the whole +# key is rejected before either one is fetched. +cat > "$FIXTURE_REPO/plugins.lock.json" <<'JSON' +{ + "list-key": { + "source": "https://github.com/acme/list-repo", + "commit": "abc123", + "skills": ["skill-list-a"] + }, + "dict-key": { + "source": "https://github.com/acme/dict-repo", + "commit": "def456", + "path": "somewhere/nested", + "skills": {"skill-dict-a": ["SKILL.md", "references/notes.md"]} + }, + "fail-key": { + "source": "https://github.com/acme/fail-repo", + "commit": "789fail", + "skills": ["skill-fail-a"] + }, + "missing-key": { + "source": "https://github.com/acme/missing-repo", + "commit": "111missing", + "skills": ["skill-missing-a"] + }, + "traversal-key": { + "source": "https://github.com/acme/traversal-repo", + "commit": "222trav", + "skills": {"skill-trav-a": ["SKILL.md", "../evil"]} + } +} +JSON + +mkdir -p "$UPSTREAM/abc123/skills/skill-list-a" +echo v1 > "$UPSTREAM/abc123/skills/skill-list-a/SKILL.md" +mkdir -p "$UPSTREAM/def456/somewhere/nested/skill-dict-a/references" +echo "dict skill" > "$UPSTREAM/def456/somewhere/nested/skill-dict-a/SKILL.md" +echo "dict notes" > "$UPSTREAM/def456/somewhere/nested/skill-dict-a/references/notes.md" +# fail-key: 789fail/skills/skill-fail-a/SKILL.md deliberately absent. +# missing-key: no $UPSTREAM tree at all — refresh must skip it on the +# missing skills-external/ dir alone, before ever reaching curl. +# traversal-key: no $UPSTREAM tree either — the "../evil" file must be +# rejected by the lock reader itself, before any URL is built. + +export VENDOR_SKILLS_REPO_OVERRIDE="$FIXTURE_REPO" +export VENDOR_BASE_URL="file://$UPSTREAM" +# shellcheck source=../vendor-skills.sh disable=SC1091 +source "$LIB" + +# ── LIST_SHAPE ──────────────────────────────────────────────────────────── +vendor_pinned_skills list-key >/dev/null 2>&1 +dest="$FIXTURE_REPO/skills-external/skill-list-a/SKILL.md" +check_bool LIST_SHAPE \ + "$([ -f "$dest" ] && [ "$(cat "$dest")" = v1 ] && echo 1 || echo 0)" + +# ── DICT_SHAPE ──────────────────────────────────────────────────────────── +vendor_pinned_skills dict-key >/dev/null 2>&1 +d1="$FIXTURE_REPO/skills-external/skill-dict-a/SKILL.md" +d2="$FIXTURE_REPO/skills-external/skill-dict-a/references/notes.md" +check_bool DICT_SHAPE \ + "$([ -f "$d1" ] && [ "$(cat "$d2")" = "dict notes" ] && echo 1 || echo 0)" + +# ── FAIL_LEAVES_NOTHING ─────────────────────────────────────────────────── +out="$(vendor_pinned_skills fail-key 2>&1)" +fdest="$FIXTURE_REPO/skills-external/skill-fail-a/SKILL.md" +check_bool FAIL_LEAVES_NOTHING "$([ ! -e "$fdest" ] && [ ! -e "$fdest.tmp" ] \ + && printf '%s' "$out" | grep -q 'not all files landed' && echo 1 || echo 0)" + +# ── SKIP_PRESENT — upstream changes, a plain re-run keeps the old copy ─── +echo v2-upstream-changed > "$UPSTREAM/abc123/skills/skill-list-a/SKILL.md" +vendor_pinned_skills list-key >/dev/null 2>&1 +check_bool SKIP_PRESENT "$([ "$(cat "$dest")" = v1 ] && echo 1 || echo 0)" + +# ── REFRESH_OVERWRITES — same changed upstream, refresh picks it up ───── +vendor_pinned_skills list-key refresh >/dev/null 2>&1 +check_bool REFRESH_OVERWRITES \ + "$([ "$(cat "$dest")" = v2-upstream-changed ] && echo 1 || echo 0)" + +# ── REFRESH_SKIPS_MISSING — refresh never installs a skill that has no +# skills-external/ dir yet; it prints the standard "not installed" +# skip line and never touches curl (no $UPSTREAM/111missing/ exists). +mdest="$FIXTURE_REPO/skills-external/skill-missing-a" +out="$(vendor_pinned_skills missing-key refresh 2>&1)" +check_bool REFRESH_SKIPS_MISSING "$([ ! -e "$mdest" ] \ + && printf '%s' "$out" | \ + grep -qF 'skill-missing-a not installed — skipping (run: make plugin)' \ + && echo 1 || echo 0)" + +# ── OVERRIDE_NON_FILE_IGNORED — a non-file:// VENDOR_BASE_URL is ignored: +# a warn names the variable and the default raw.githubusercontent.com +# prefix is used instead. list-key's SKILL.md is already vendored (v2, +# from REFRESH_OVERWRITES above), so this probe never touches curl +# either way — the assertion is the warn line, and that the file:// mode +# used everywhere else in this suite (asserted by the six cases above +# and below) keeps working. +out="$(VENDOR_BASE_URL="https://evil.example.com" \ + vendor_pinned_skills list-key 2>&1)" +check_bool OVERRIDE_NON_FILE_IGNORED \ + "$(printf '%s' "$out" | grep -q 'VENDOR_BASE_URL ignored' \ + && echo 1 || echo 0)" + +# ── REJECTS_TRAVERSAL — a lock entry naming a "../evil" file is rejected +# whole by the lock reader: nothing is fetched for the key, so not even +# its well-behaved SKILL.md lands, and nothing lands outside the skill's +# own directory either. +out="$(vendor_pinned_skills traversal-key 2>&1)" +rc=$? +tdir="$FIXTURE_REPO/skills-external/skill-trav-a" +outside="$FIXTURE_REPO/skills-external/evil" +check_bool REJECTS_TRAVERSAL "$([ "$rc" -ne 0 ] && [ ! -e "$tdir" ] \ + && [ ! -e "$outside" ] && echo 1 || echo 0)" + +echo "PASS=$pass FAIL=$fail" +[ "$fail" -eq 0 ] diff --git a/lib/toggle-external.sh b/lib/toggle-external.sh index 5c3d4c8..5e4b1ba 100755 --- a/lib/toggle-external.sh +++ b/lib/toggle-external.sh @@ -24,6 +24,9 @@ # observability-and-instrumentation, deprecation-and-migration, # ci-cd-and-automation — the agent-skills trio, same single-symlink shape # as emil-design-eng (commit-pinned instead of main-branch tracking) +# scroll-world-storytelling, build-threejs-scroll-worlds, +# scroll-scrubbed-visual-sequence, scroll-scrubbed-word-reveal, +# scroll-progress-timeline — the Mengto scroll skills, same shape # # For fine-grained activation (only design skills, only qa skills, only # audit skills, etc.) instead of all-or-nothing gstack toggling, use: @@ -44,7 +47,10 @@ err() { echo -e "${RED}✗${NC} $1"; } # All non-plugin tools this script can toggle. MANAGED_TOOLS=(gstack emil-design-eng darwin-skill 21st - observability-and-instrumentation deprecation-and-migration ci-cd-and-automation) + observability-and-instrumentation deprecation-and-migration ci-cd-and-automation + scroll-world-storytelling build-threejs-scroll-worlds + scroll-scrubbed-visual-sequence scroll-scrubbed-word-reveal + scroll-progress-timeline) # Prints the skill names that belong to the "21st" pack. Source of truth: # skills-external/21st-* — the `21st skills install` run in install-plugins.sh @@ -80,7 +86,9 @@ status_tool() { done < <(gstack_skills) echo "disabled" ;; - emil-design-eng|observability-and-instrumentation|deprecation-and-migration|ci-cd-and-automation) + emil-design-eng|observability-and-instrumentation|deprecation-and-migration|ci-cd-and-automation| \ + scroll-world-storytelling|build-threejs-scroll-worlds|scroll-scrubbed-visual-sequence| \ + scroll-scrubbed-word-reveal|scroll-progress-timeline) [ -d "$REPO/skills-external/$tool" ] || { echo "missing"; return; } [ -e "$SKILLS_DIR/$tool" ] && echo "enabled" || echo "disabled" ;; @@ -119,7 +127,9 @@ disable_tool() { done < <(gstack_skills) ok "gstack disabled ($moved symlinks moved)" ;; - emil-design-eng|darwin-skill|observability-and-instrumentation|deprecation-and-migration|ci-cd-and-automation) + emil-design-eng|darwin-skill|observability-and-instrumentation|deprecation-and-migration| \ + ci-cd-and-automation|scroll-world-storytelling|build-threejs-scroll-worlds| \ + scroll-scrubbed-visual-sequence|scroll-scrubbed-word-reveal|scroll-progress-timeline) if [ -e "$SKILLS_DIR/$tool" ]; then rm -rf "${DISABLED_DIR:?}/${tool:?}" mv "$SKILLS_DIR/$tool" "$DISABLED_DIR/$tool" @@ -169,7 +179,9 @@ enable_tool() { ok "gstack enabled ($moved symlinks restored)" fi ;; - emil-design-eng|darwin-skill|observability-and-instrumentation|deprecation-and-migration|ci-cd-and-automation) + emil-design-eng|darwin-skill|observability-and-instrumentation|deprecation-and-migration| \ + ci-cd-and-automation|scroll-world-storytelling|build-threejs-scroll-worlds| \ + scroll-scrubbed-visual-sequence|scroll-scrubbed-word-reveal|scroll-progress-timeline) local src case "$tool" in darwin-skill) src="$HOME/.agents/skills/$tool" ;; diff --git a/lib/vendor-skills.sh b/lib/vendor-skills.sh new file mode 100644 index 0000000..3a6ac47 --- /dev/null +++ b/lib/vendor-skills.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# ============================================================ +# lib/vendor-skills.sh — shared curl-vendoring for commit-pinned skills +# +# One helper, `vendor_pinned_skills [refresh]`, replaces the +# inline curl loops install-plugins.sh (Step 8e) and update-all.sh (7.3) +# used to carry separately for the addyosmani/agent-skills trio. Both +# scripts source this file and call it once per plugins.lock.json entry +# ("agent-skills", "mengto-skills", …) — no sha ever hardcoded here. +# +# Lock entry shape (plugins.lock.json): +# "": { +# "source": "https://github.com//", +# "commit": "", +# "path": "", # optional +# "skills": ["", ...] | {"": ["", ...], ...} +# } +# `skills` as a bare list defaults every named skill to `["SKILL.md"]` and +# `path` to "skills" (the agent-skills shape); `skills` as a dict carries an +# explicit per-skill file list (references/*, etc.) and `path` is required. +# +# Raw URL: https://raw.githubusercontent.com///// +# /. VENDOR_BASE_URL overrides the "https://…/" prefix +# (everything before "//…") ONLY when it starts with "file://" (the +# hermetic suite's fixture form); any other non-empty value is ignored — +# a warn names the variable and the default raw.githubusercontent.com +# prefix is used, so a stray value in the caller's environment can never +# silently redirect a real install/update run. +# +# Per file: tmp + mv, tmp removed on failure. A file already at its +# destination is skipped unless refresh="refresh". A skill counts as +# vendored only when every one of its listed files landed; otherwise err +# names the file and the manual curl to retry it. +# +# Every skill name and file path from the lock is rejected — before any +# URL is built or any file fetched — if it contains "..", starts with +# "/", or holds a character outside [A-Za-z0-9._/-]; this guards against +# a lock entry walking a fetch outside skills-external//. +# +# No `set -euo pipefail` here (mirrors lib/detect-plugins.sh): a sourced +# lib must not change the caller's shell options. +# ============================================================ + +VENDOR_REPO="${VENDOR_SKILLS_REPO_OVERRIDE:-$(cd -P \ + "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + +# Fallback color helpers when sourced standalone (e.g. the test suite) — +# skip anything the caller (install-plugins.sh / update-all.sh) already +# defines, so the same ok/warn/info/err instances keep being used. +if ! declare -F ok >/dev/null 2>&1; then + GREEN='\033[0;32m'; NC='\033[0m' + ok() { echo -e "${GREEN}✓${NC} $1"; } +fi +if ! declare -F info >/dev/null 2>&1; then + BLUE='\033[0;34m'; NC='\033[0m' + info() { echo -e "${BLUE}→${NC} $1"; } +fi +if ! declare -F warn >/dev/null 2>&1; then + YELLOW='\033[1;33m'; NC='\033[0m' + warn() { echo -e "${YELLOW}⚠${NC} $1"; } +fi +if ! declare -F err >/dev/null 2>&1; then + RED='\033[0;31m'; NC='\033[0m' + err() { echo -e "${RED}✗${NC} $1"; } +fi + +# _vendor_read_lock — prints, on +# success: line 1: "" (the raw-file URL up to and including +# , using when non-empty) line 2: "" then one +# "\t …" line per skill (sorted). +# is a plain argv string — the caller (vendor_pinned_skills) already +# checked it is either empty or a "file://" value, never the raw +# VENDOR_BASE_URL. +# rc 1 when the key, its commit or its skills are absent (nothing +# printed), or when a skill name or file path fails the traversal/ +# character check (prints one "INVALID\t" line, nothing +# else — no URL is built and no file is fetched for that lock key). +# Reads the lock path, key and base override via argv — never +# string-spliced into the script. +_vendor_read_lock() { + python3 - "$1" "$2" "$3" <<'PY' +import json, re, sys + +SAFE = re.compile(r'^[A-Za-z0-9._/-]+$') + + +def unsafe(value): + return ".." in value or value.startswith("/") or not SAFE.match(value) + + +lockfile, key, base_override = sys.argv[1], sys.argv[2], sys.argv[3] +with open(lockfile) as f: + data = json.load(f) +entry = data.get(key, {}) +sha = entry.get("commit", "") +owner_repo = entry.get("source", "").rstrip("/").rsplit("github.com/", 1)[-1] +path = entry.get("path", "skills") +skills = entry.get("skills", {}) +if isinstance(skills, list): + skills = {name: ["SKILL.md"] for name in skills} +if not sha or not owner_repo or not skills: + sys.exit(1) +for name, files in skills.items(): + if unsafe(name): + print(f"INVALID\t{name}") + sys.exit(1) + for file in files: + if unsafe(file): + print(f"INVALID\t{file}") + sys.exit(1) +base = base_override or f"https://raw.githubusercontent.com/{owner_repo}" +print(f"{base}/{sha}/{path}") +print(sha) +for name in sorted(skills): + print(f"{name}\t{' '.join(skills[name])}") +PY +} + +# _vendor_fetch_file — tmp + mv; tmp removed on +# failure. Skips an existing dest unless refresh="refresh". rc 0 on +# success or skip, rc 1 (with an err naming the manual curl) on failure. +_vendor_fetch_file() { + local url="$1" dest="$2" refresh="$3" + [ -f "$dest" ] && [ "$refresh" != "refresh" ] && return 0 + mkdir -p "$(dirname "$dest")" + local tmp="$dest.tmp" + if curl -fsSL "$url" -o "$tmp" 2>/dev/null && mv "$tmp" "$dest"; then + return 0 + fi + rm -f "$tmp" + err "$dest download failed — try: curl -fsSL $url -o $dest" + return 1 +} + +# _vendor_install_skill — +# fetches every listed file under //, from +# //. rc 0 only when all of them landed (existing +# or freshly fetched). +_vendor_install_skill() { + local url_base="$1" skill="$2" files="$3" dest_root="$4" refresh="$5" + local file landed=0 total=0 + for file in $files; do + total=$((total + 1)) + _vendor_fetch_file "$url_base/$skill/$file" "$dest_root/$skill/$file" \ + "$refresh" && landed=$((landed + 1)) + done + [ "$landed" -eq "$total" ] +} + +# _vendor_report_lock_error — turns a failed +# _vendor_read_lock into the right err line: a rejected name/path when +# carries the "INVALID\t" marker, the generic +# no-commit-pinned hint otherwise. +_vendor_report_lock_error() { + local lock_key="$1" lock_out="$2" msg + if [[ "$lock_out" == INVALID$'\t'* ]]; then + msg="$lock_key: rejected '${lock_out#INVALID$'\t'}'" + msg="$msg — path traversal or disallowed characters" + err "$msg" + return + fi + local hint="add an entry with a \"commit\" field" + err "$lock_key: no commit/skills pinned in plugins.lock.json — $hint" +} + +# vendor_pinned_skills [refresh] — vendors every skill listed +# under plugins.lock.json's entry into skills-external//. +# Pass "refresh" as the second arg to re-fetch files already present (at +# the same pinned commit — never advances the pin). In refresh mode, a +# skill whose skills-external// directory does not exist yet is +# skipped (the standard "not installed — skipping" info line, no fetch) — +# refresh keeps installed skills current, it never installs a new one; +# install mode (no refresh) still creates it. +vendor_pinned_skills() { + local lock_key="$1" refresh="${2:-}" + local lockfile="$VENDOR_REPO/plugins.lock.json" lock_out lock_rc + local base_override="${VENDOR_BASE_URL:-}" + if [ -n "$base_override" ] && [[ "$base_override" != file://* ]]; then + warn "VENDOR_BASE_URL ignored (must start with file://): $base_override" + base_override="" + fi + lock_out="$(_vendor_read_lock "$lockfile" "$lock_key" "$base_override")" + lock_rc=$? + if [ "$lock_rc" -ne 0 ]; then + _vendor_report_lock_error "$lock_key" "$lock_out" + return 1 + fi + local url_base sha dest_root="$VENDOR_REPO/skills-external" + url_base="$(sed -n '1p' <<<"$lock_out")" + sha="$(sed -n '2p' <<<"$lock_out")" + local skill files + while IFS=$'\t' read -r skill files; do + [ -n "$skill" ] || continue + if [ "$refresh" = "refresh" ] && [ ! -d "$dest_root/$skill" ]; then + info "$skill not installed — skipping (run: make plugin)" + continue + fi + if _vendor_install_skill "$url_base" "$skill" "$files" \ + "$dest_root" "$refresh"; then + ok "$skill vendored (pinned @ ${sha:0:7})" + else + err "$skill: not all files landed (see the download-failed lines above)" + fi + done < <(tail -n +3 <<<"$lock_out") +} diff --git a/link.sh b/link.sh index f9c19d4..470240b 100644 --- a/link.sh +++ b/link.sh @@ -94,7 +94,10 @@ fi # skills/ (and its agents into agents/) at --scope=global, so there is no # skills-external/ copy to symlink. See install-plugins.sh Step 8d. EXTERNAL_SKILLS=(emil-design-eng frontend-design design-motion-principles - observability-and-instrumentation deprecation-and-migration ci-cd-and-automation) + observability-and-instrumentation deprecation-and-migration ci-cd-and-automation + scroll-world-storytelling build-threejs-scroll-worlds + scroll-scrubbed-visual-sequence scroll-scrubbed-word-reveal + scroll-progress-timeline) for _ext_skill in "${EXTERNAL_SKILLS[@]}"; do if [ -d "$REPO/skills-external/$_ext_skill" ]; then if [ -L "$CLAUDE/skills/$_ext_skill" ] && [ "$(readlink "$CLAUDE/skills/$_ext_skill")" = "$REPO/skills-external/$_ext_skill" ]; then diff --git a/plugins.lock.json b/plugins.lock.json index 60d223c..07b34b4 100644 --- a/plugins.lock.json +++ b/plugins.lock.json @@ -48,7 +48,21 @@ "commit": "2686b620fc1fed2e8f60c704839c766b8594c6b6", "skills": ["observability-and-instrumentation", "deprecation-and-migration", "ci-cd-and-automation"], "managed_by": "curl", - "note": "Three dev-lifecycle skills from addyosmani/agent-skills, vendored the emil-design-eng way but COMMIT-pinned (not main-branch tracking): each lands in skills-external//SKILL.md (gitignored, symlinked by link.sh), install-plugins.sh Step 8e curls all three at this commit when absent, update-all.sh re-fetches at the SAME commit on every run (a pin, not an auto-advance). Bump the commit deliberately to pick up upstream changes; the scripts read it from here, never hardcode it." + "note": "Three dev-lifecycle skills from addyosmani/agent-skills, vendored the emil-design-eng way but COMMIT-pinned (not main-branch tracking): each lands in skills-external//SKILL.md (gitignored, symlinked by link.sh), install-plugins.sh Step 8e curls all three at this commit when absent, update-all.sh re-fetches at the SAME commit on every run (a pin, not an auto-advance). Bump the commit deliberately to pick up upstream changes; the scripts read it from here, never hardcode it. Both install-plugins.sh and update-all.sh vendor this entry through lib/vendor-skills.sh's vendor_pinned_skills() — the shared helper also used by the \"mengto-skills\" entry below." + }, + "mengto-skills": { + "source": "https://github.com/MengTo/Skills", + "commit": "a965851e27dc179e693fde1bee94457a64e1a7a5", + "path": "agent-skills/web-design", + "skills": { + "scroll-world-storytelling": ["SKILL.md", "REFERENCES.md"], + "build-threejs-scroll-worlds": ["SKILL.md", "references/kage-anatomy.md", "references/quality-and-qa.md", "references/realtime-architecture.md", "references/scroll-conductor.js", "references/world-bible.md"], + "scroll-scrubbed-visual-sequence": ["SKILL.md", "REFERENCES.md"], + "scroll-scrubbed-word-reveal": ["SKILL.md", "REFERENCES.md"], + "scroll-progress-timeline": ["SKILL.md", "REFERENCES.md"] + }, + "managed_by": "curl", + "note": "Five scroll-choreography skills from MengTo/Skills (agent-skills/web-design), vendored the agent-skills way but with an explicit per-skill file list (SKILL.md + REFERENCES.md, or references/*.md + references/scroll-conductor.js for build-threejs-scroll-worlds) instead of the SKILL.md-only default. Never vendored: demo/, agents/, or any binary asset upstream ships alongside each skill. Text-only, byte-for-byte copies (Codex-isms in the source text stay). Bump the commit deliberately to pick up an upstream edit; install-plugins.sh Step 8e and update-all.sh 7.3 both read it from here via lib/vendor-skills.sh's vendor_pinned_skills(), never hardcoded." }, "impeccable": { "source": "npm:impeccable", diff --git a/update-all.sh b/update-all.sh index f1384fc..03854ac 100644 --- a/update-all.sh +++ b/update-all.sh @@ -379,37 +379,17 @@ else info "design-motion-principles not installed — skipping" fi -# ── 7.3. Update Agent Skills (addyosmani/agent-skills, pinned commit) ── +# ── 7.3. Update Agent Skills + Mengto scroll skills (pinned commit) ── +# Both re-fetched at the SAME pinned commit (never advances the pin) via +# the shared lib/vendor-skills.sh helper — see install-plugins.sh Step 8e. echo "" echo "── Updating Agent Skills (addyosmani/agent-skills)..." -AGENT_SKILLS_SHA="" -if [ -f "$REPO/plugins.lock.json" ] && command -v python3 &>/dev/null; then - AGENT_SKILLS_SHA=$(python3 -c " -import json, sys -with open(sys.argv[1]) as f: - d = json.load(f) -print(d.get(sys.argv[2], {}).get('commit', '')) -" "$REPO/plugins.lock.json" "agent-skills" 2>/dev/null || true) -fi -AGENT_SKILLS_NAMES=(observability-and-instrumentation deprecation-and-migration ci-cd-and-automation) -if [ -z "$AGENT_SKILLS_SHA" ]; then - warn "agent-skills: no commit pinned in plugins.lock.json — skipping" -else - for _as_skill in "${AGENT_SKILLS_NAMES[@]}"; do - _as_dir="$REPO/skills-external/$_as_skill" - if [ ! -d "$_as_dir" ]; then - info "$_as_skill not installed — skipping (run: make plugin)" - continue - fi - _as_url="https://raw.githubusercontent.com/addyosmani/agent-skills/$AGENT_SKILLS_SHA/skills/$_as_skill/SKILL.md" - if curl -fsSL "$_as_url" -o "$_as_dir/SKILL.md.tmp" \ - && mv "$_as_dir/SKILL.md.tmp" "$_as_dir/SKILL.md"; then - ok "$_as_skill re-fetched at pinned commit" - else - warn "$_as_skill update failed" - fi - done -fi +# shellcheck source=lib/vendor-skills.sh disable=SC1091 +source "$REPO/lib/vendor-skills.sh" +vendor_pinned_skills agent-skills refresh +echo "" +echo "── Updating Mengto scroll skills (MengTo/Skills)..." +vendor_pinned_skills mengto-skills refresh # ── Impeccable (design detector + skill + subagents) ── # Global scope: the installer writes through the ~/.claude/{skills,agents} From ba14b5ea031821a64eaa5c12c121156a9ec20fc7 Mon Sep 17 00:00:00 2001 From: bastien Date: Mon, 28 Sep 2026 01:40:01 +0200 Subject: [PATCH 2/5] feat(skills): site-motion, site-level scroll and transition choreography Personal skill distilling the MengTo motion pack invariants (LRN-141): gates first (reduced motion renders final states, content visible without JS, compositor-only, offscreen pause), one smooth-scroll engine with the Lenis/ScrollTrigger sync, Astro ClientRouter lifecycle, numbered recipes (reveal, scrub, sticky stack, video and image scrub, TreeWalker split, progressive blur, marquee, WebGL budgets), upstream pitfalls, checklist. Routed into the Build UI chain of CLAUDE.global.md and lib/design-gate.md. --- CLAUDE.global.md | 3 +- lib/design-gate.md | 3 + skills/site-motion/SKILL.md | 185 +++++++++++++++++++++++++++ skills/site-motion/test-prompts.json | 6 + 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 skills/site-motion/SKILL.md create mode 100644 skills/site-motion/test-prompts.json diff --git a/CLAUDE.global.md b/CLAUDE.global.md index aa5bb4d..d2b8d97 100644 --- a/CLAUDE.global.md +++ b/CLAUDE.global.md @@ -269,7 +269,8 @@ design routing; the design-toolchain hook reinforces it. - Trivial (≤2 files, one cosmetic value) → /hotfix, no toolchain. - Build UI (component, page, redesign) → ui-ux-pro-max + frontend-design (anti-slop) + 21st-ui-build (catalog + generation) + emil-design-eng - (polish) + design-motion-principles (motion) + design-html (static). + (polish) + design-motion-principles (motion) + design-html (static) + + site-motion (site-level scroll/page choreography, personal skill). Post-build floor when impeccable is installed: `npx impeccable detect ` (45 deterministic anti-slop rules, exit 2 = findings). - Design system / brand → design-consultation first, then the build tools. diff --git a/lib/design-gate.md b/lib/design-gate.md index ed385af..e6dd5bd 100644 --- a/lib/design-gate.md +++ b/lib/design-gate.md @@ -47,6 +47,9 @@ browser/plan/shotgun tooling and graphify for convenience; those never trip the gate. Motion (`design-motion-principles`) and static-HTML (`design-html`) are already in the core set — checked regardless; their CLAUDE.md "+motion / +static" notes say which tool you'll lean on, not a separate activation step. +`site-motion` (personal skill, site-level scroll/page choreography) rides the +same Build chain but isn't on the GATE-BLOCK list: it ships with the repo, +nothing to install or verify. ### 2. State — run the deterministic check diff --git a/skills/site-motion/SKILL.md b/skills/site-motion/SKILL.md new file mode 100644 index 0000000..caa80b3 --- /dev/null +++ b/skills/site-motion/SKILL.md @@ -0,0 +1,185 @@ +--- +name: site-motion +description: | + Site-level motion choreography: scroll engine choice, page-transition + rules, and pin/scrub sequencing across a whole page or Astro route — + not one component's hover or enter/exit (that's design-motion-principles + or emil-design-eng). Distills the invariants behind smooth scroll, + scroll storytelling, sticky card stacks, video/image scrubbing, and + WebGL hero lanes into gates, numbers, and pitfalls. + Triggers: "site mouvementé", "scroll storytelling", "smooth scroll", + "hero WebGL", "transitions de page", "page transitions", "Lenis", + "ScrollTrigger", "Awwwards". +argument-hint: +allowed-tools: + - Read + - Edit + - Write + - Bash + - Grep + - Glob +--- + +# Site motion — page-level scroll and transition choreography + +Invariants, not machinery: numbers and gates that hold across whichever +scroll library the project already runs (LRN-141). Defaults follow +`rules/web-building.md`; this skill only adds the site-level layer on +top of it. + +## When to use this, not the component skills + +- One component's hover, tap feedback, or enter/exit → the target feels + small and self-contained → `skills-external/design-motion-principles/SKILL.md` + (component motion, frequency/duration framework) or + `skills-external/emil-design-eng/SKILL.md` (taste, polish). +- The choice is page-wide: which scroll engine, whether to pin a section, + how a route transition should morph, how a WebGL hero should degrade → + this skill. +- Linting already-shipped motion against anti-slop rules → + `skills/impeccable/reference/animate.md` (`impeccable detect`) as the + deterministic floor, or design-motion-principles' own audit workflow + (`skills-external/design-motion-principles/workflows/audit.md`). +- Non-motion defaults (fonts, color, spacing, the public-site checklist) + stay in `rules/web-building.md` — read it, don't restate it here. + +## Gates first (fail closed, not invisible) + +- Under `prefers-reduced-motion: reduce`, every animation renders its + FINAL state, never a shortened version of the same tween — jump, don't + rush. +- Content is visible with JavaScript disabled; no permanent + `opacity: 0` gated only by a script that might fail to run. +- Any `html.js` (or `.has-motion`) class that hides the pre-animation + state is added only AFTER `gsap.registerPlugin(...)` and the reveal + setup both succeed — never before. An error between the two otherwise + leaves real content stuck invisible with no JS path left to reveal it. +- Animate compositor-only properties: `transform`, `opacity`, short-lived + `filter`/`clip-path`. Never a layout property during scroll. +- `will-change` only while an element is actively animating; drop it + once the animation ends. +- Every RAF loop, CSS animation, and WebGL render loop pauses when its + section leaves the viewport and resumes on re-entry. +- The first-viewport CTA is never covered by a preloader. +- No preloader on a fixed timer — tie its exit to real load state. + +## Engine choice + +- Exactly one smooth-scroll engine per page. A second scroller (native + plus Lenis, or two libraries) fights the first over wheel/touch input + and desyncs from anything watching scroll position. +- Lenis feeds `ScrollTrigger` through `gsap.ticker`, with + `gsap.ticker.lagSmoothing(0)` — without it, a tab-switch catch-up jump + throws scrub position out of sync with the visuals. +- Reach for CSS `animation-timeline: scroll()` / `view()` first when the + effect is a plain progress mapping with no pin and no cross-timeline + coordination. GSAP/Lenis earn their cost on pin, scrub-linked + sequencing, or a timeline shared across sections (BDR-005: `motion` is + the default library; GSAP is allowed once the project already uses it + or the effect needs pin/scrub). + +## Astro lifecycle (ClientRouter) + +Route swaps replace the DOM without a full reload, so `DOMContentLoaded` +fires once and never again. + +- Init scroll engines, `ScrollTrigger` instances, and RAF loops on + `astro:page-load` — it fires after every swap, including the first. +- Teardown on `astro:before-swap`: kill ScrollTriggers, stop the + scroller, cancel RAF, disconnect observers, before the old DOM is + replaced — otherwise the previous route's loop keeps running detached. +- `transition:persist` on a WebGL canvas or renderer that should survive + the swap instead of losing its context every navigation; pair it with + hooks that update the scene, not rebuild it. +- `transition:name` for element morphs (hero image to detail image) + across routes; leave unrelated elements unnamed to avoid accidental + cross-fades. +- Test every scene with JavaScript on and off — without it, the + ClientRouter falls back to a normal navigation and the page still has + to make sense. + +## Recipes (the numbers) + +- Reveal: trigger at `top 82%`, once; ease the entrance tween itself, + never the trigger point. +- Scrub scenes: `scrub: 0.8` to `1.4`, `ease: "none"` on the + scroll-driven tween — ease the child tweens inside it instead, so the + outer timeline stays scroll-linear while the content still feels eased. +- Sticky card stack: the receding card scales to `0.92 + i * 0.015` + (`i` = card index), scrubbed from the next card crossing `top 78%` to + `top 24%`. +- Story pacing: budget `0.7` to `1.8` viewport heights of scroll per + story beat — below that it reads as a flicker, above it as a stall. +- Video scrub: encode with + `ffmpeg -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart` — a + keyframe every 8 frames so scrubbed `currentTime` seeks land on-frame. +- Image sequences: preload the current frame first, prefetch neighbors, + and cancel stale in-flight requests on a fast scroll so a slow response + can't paint an out-of-order frame. +- Word-level reveal on marked-up text (links, `em`, `strong` inside): + walk it with `TreeWalker`, wrap non-whitespace tokens in spans, keep + the original text and its inline markup intact. +- Progressive blur: stack `backdrop-filter` layers from `0.5px` to + `64px`, each masked to a `12.5%` band of the gradient; add the + `-webkit-backdrop-filter` prefix for Safari; cap the band at `12%` of + viewport height from the top edge, `65%` from the bottom. +- Marquee: duplicate the track, animate `translateX(-50%)` linear, mark + the duplicate `aria-hidden`, pause the track while its section is + offscreen. +- Magnetic and cursor motion: drive with `gsap.quickTo()` so a pointer + move updates the existing tween instead of creating a new one per + event. +- WebGL hero, one lane per page: + - A pricing or checkout page keeps the WebGL lane decorative only. + - Build the poster fallback first, enhance after — the poster is the + page when WebGL fails or the tab throttles. + - Handle `webglcontextlost`/`webglcontextrestored` explicitly. + - Mobile budget: DPR `1.25` to `1.5`, `150k` to `300k` visible + triangles, `50` to `90` draw calls. + - Track two progress values: exact scroll progress for navigation and + ARIA state, a damped one for the camera — the camera can lag, the + nav state cannot. +- Leak census: sample `document.getAnimations()` before and after a + route round-trip. A count that grows across `astro:page-load` cycles + means a teardown is missing, not that more is animating. + +## Upstream pitfalls + +- `clearProps` combined with a visibility override in the same + reduced-motion call clears that override before the CSS-hidden class + it was meant to defeat ever lifts — text stays hidden. Clear props or + drop the hiding class; don't do both in one step. +- `registerPlugin` running after the `html.js` gate is already set: see + Gates above, this is the concrete failure it prevents. +- A per-frame increment (`phi += 0.01`) with no delta-time factor ties + rotation speed to frame rate — the same globe spins at a different + real-world speed on a 30 Hz and a 120 Hz display. +- A WebGL context torn down and rebuilt on every `resize` event: expensive, + and rapid resizing (mobile keyboard, orientation flicker) can trigger a + real context loss. Resize the renderer/camera in place; debounce first. +- A preloader gated on a fixed timer exits early on a slow connection and + late on a fast one; gate it on real asset load state instead. +- `aria-label` set on a `

` after flattening it to plain text: any + inline link, `em`, or `strong` it contained is gone for assistive tech, + which now hears the label instead of the real content. Use `TreeWalker` + splitting on paragraphs with inline markup; `aria-label` is fine only on + a plain-text heading with nothing inside to lose. +- Critical CSS starting elements at `opacity: 0` with no fallback: if the + script errors, is blocked, or never loads, the section stays invisible + forever. Pair every such rule with the gates above. + +## Verification checklist + +- Reload each scene with reduced motion forced on: final states, no + smooth scroll, no pinning. +- Disable JavaScript: every section is present and readable in order. +- Scrub through fast and reversed: same scroll position always yields + the same visual state. +- Resize mid-scene, including orientation change on a real WebGL scene. +- Navigate the Astro route twice: `document.getAnimations()` count + returns to baseline, no duplicate ScrollTriggers, no orphaned RAF loop. +- Throttle to a slow connection: preloader and poster still make sense, + CTA is reachable immediately. +- Tab away mid-scrub, come back: no jump beyond what `lagSmoothing(0)` + already accounts for. +- Run `impeccable detect` on the touched files as the anti-slop floor. diff --git a/skills/site-motion/test-prompts.json b/skills/site-motion/test-prompts.json new file mode 100644 index 0000000..129c4ff --- /dev/null +++ b/skills/site-motion/test-prompts.json @@ -0,0 +1,6 @@ +[ + {"id": 1, "prompt": "Build a scroll storytelling landing page with Lenis smooth scroll and a sticky project card stack", "expected": "Route to site-motion: one smooth-scroll engine, Lenis->ScrollTrigger sync via gsap.ticker + lagSmoothing(0), sticky stack scale 0.92+i*0.015 recipe, reduced-motion gate"}, + {"id": 2, "prompt": "Ajoute des transitions de page fluides avec un hero WebGL qui doit survivre à la navigation", "expected": "Route to site-motion: Astro ClientRouter lifecycle, astro:page-load/astro:before-swap, transition:persist on the canvas, WebGL poster fallback + context-loss handling"}, + {"id": 3, "prompt": "Add a hover scale effect and a fade-in on this pricing card component", "expected": "Component-level, not site-motion: route to design-motion-principles or emil-design-eng instead"}, + {"id": 4, "prompt": "Make our site feel like an Awwwards ScrollTrigger showcase, with a scrubbed video sequence", "expected": "Route to site-motion: scrub 0.8-1.4 with ease none + eased children, ffmpeg -g 8 -keyint_min 8 encoding recipe, offscreen-pause and reduced-motion gates before any pin/scrub work"} +] From 8dcf8d36800b365a51e6effeaf9e9597b818a544 Mon Sep 17 00:00:00 2001 From: bastien Date: Mon, 28 Sep 2026 01:40:02 +0200 Subject: [PATCH 3/5] =?UTF-8?q?chore(memory):=20case=207=20registries,=20c?= =?UTF-8?q?ontracts,=20CHANGELOG=20=E2=80=94=20BDR-104=20LRN-174=20EVAL-03?= =?UTF-8?q?3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/memory/decisions.md | 10 +++ .claude/memory/evals.md | 8 +++ .claude/memory/journal.md | 3 + .claude/memory/learnings.md | 5 ++ .claude/tasks/TODO.md | 25 +++++++ .../2026-09-27-mengto-vendor-0002.md | 68 +++++++++++++++++++ .../2026-09-27-site-motion-skill-0002.md | 50 ++++++++++++++ CHANGELOG.md | 22 ++++++ 8 files changed, 191 insertions(+) create mode 100644 .claude/tasks/contracts/2026-09-27-mengto-vendor-0002.md create mode 100644 .claude/tasks/contracts/2026-09-27-site-motion-skill-0002.md diff --git a/.claude/memory/decisions.md b/.claude/memory/decisions.md index cf393df..6914473 100644 --- a/.claude/memory/decisions.md +++ b/.claude/memory/decisions.md @@ -125,6 +125,7 @@ rules: | BDR-101 | 2026-09-25 | `full` = default profile: no selection ⇒ full in force, `reset` applies it, install applies it | accepted | | BDR-102 | 2026-09-27 | agent-skills: no plugin, vendor 3 skills + build floor-guard + routing census + rest-api rule | accepted | | BDR-103 | 2026-09-27 | 6-repo review: 5 verdicts, 3 criteria (grep-verified coverage, per-session cost, doctrine conflict); stars decided nothing | accepted | +| BDR-104 | 2026-09-28 | MengTo motion pack: vendor 5 scroll skills pinned via shared lib/vendor-skills.sh + build personal skill site-motion; 17 skipped | accepted | --- @@ -1297,3 +1298,12 @@ Branch feature/user-writing-web-rules, UNMERGED (human gate). - **Alternatives rejected**: install-then-prune (sunk cost, [[BDR-047]] ECC lesson); one bulk verdict (user wanted one case per turn, each with a build-vs-install call); building reticle's engine (1 286 server files). - **Caveats**: borrowed prompts change upstream with no diff, the pin is the review point; do not re-audit these six expecting more; mengto motion pack from the ui-skills registry is the open follow-up if site-level choreography (GSAP/ScrollTrigger, WebGL hero, masked reveals) proves thin locally. - **Reference**: branches feature/yagni-ladder (9315c6c, de7371d), feature/agent-skills-borrow (d28c45e 2b25cb4 409db51 1a8e6de 7401383 6617889 d71f3a7), feature/web-building-microrules (a2e654d 5a27372), chore/six-repo-review-notes (da35cde 197225a). Links [[BDR-102]] [[LRN-172]] [[LRN-173]] [[EVAL-032]] [[BDR-047]] [[BDR-006]]. + +## BDR-104 — MengTo motion pack: vendor 5 scroll skills via a shared helper + build `site-motion`; 17 skipped +- **Date**: 2026-09-28 +- **Status**: accepted, feature/mengto-site-motion, UNMERGED (human gate) +- **Decision**: (1) `lib/vendor-skills.sh` = one `vendor_pinned_skills [refresh]` for every curl-vendored upstream (agent-skills moved onto it; lock `skills` as list or dict of file lists; python argv lock read; `..`/charset guard; `VENDOR_BASE_URL` only as `file://`; tmp+mv; refresh skips never-installed skills, update-all convention). (2) Vendored at a965851: scroll-world-storytelling, build-threejs-scroll-worlds (+5 refs incl. scroll-conductor.js), scroll-scrubbed-visual-sequence, scroll-scrubbed-word-reveal, scroll-progress-timeline; text only, never demo/agents/binaries; design/web/web-full/full profiles. (3) `skills/site-motion` personal: invariants of the other 17 (gates, engine choice, Lenis sync, Astro ClientRouter lifecycle, numbered recipes, pitfalls); routed in CLAUDE.global.md Build UI chain + design-gate (not GATE-BLOCK). +- **Why**: user asked whether the UI profiles carry motion knowledge for lively sites. Census: component polish deep (emil 27 KB, motion cookbook, impeccable animate), site choreography thin as workflows; ui-ux-pro-max CSVs cover terms but as search rows ([[LRN-174]]). Two analyzers read 22 skills: 5 clean workflows/recipes absent locally; 17 covered, generic, buggy (reduced-motion `clearProps`, gate before `registerPlugin`, no-JS opacity 0, refresh-rate `phi`) or Codex/Xcode machinery → distil per [[LRN-141]]. Registry sample (11) ≠ catalog (88): always list the tree. +- **Alternatives rejected**: vendor all 22 (bugs + duplicates + 17×~100 tok descriptions); distil only (loses the two deep workflows whose value is their full text); second inline curl loop (duplication; helper instead); `VENDOR_BASE_URL` gated by a companion var (file:// prefix check suffices); grouped toggle pack (per-name like emil). +- **Caveats**: vendored prompts change upstream with no diff, pin = review point; GSAP-first content vs [[BDR-005]] `motion` default, site-motion states the allowance; LOW hardening open: `re.match` `$` accepts a trailing newline, `source`/`path`/`sha` lock fields not charset-checked; `make link` + `bash lib/profile.sh apply full` after merge (user); sub-agent leftovers `/tmp/mengto-verify` (user removes). +- **Reference**: commits 2a1ad17 (helper + vendoring), ba14b5e (site-motion); contracts `.claude/tasks/contracts/2026-09-27-{mengto-vendor,site-motion-skill}-0002.md`; gates MET, verifiers CONFORME after 3 re-dispatches (frontmatter shape, refresh convention, security env override + traversal), security PASS ×2, `make test` 36 suites green minus 2 pre-existing T16a. Links [[BDR-103]] [[BDR-102]] [[LRN-141]] [[LRN-174]] [[EVAL-033]]. diff --git a/.claude/memory/evals.md b/.claude/memory/evals.md index 17d1edf..8baa7c7 100644 --- a/.claude/memory/evals.md +++ b/.claude/memory/evals.md @@ -53,6 +53,7 @@ rules: | EVAL-030 | 2026-09-24 | 2026-09-24 self-audit: two regressions and one guardrail bypass came from my own process, not from the tools | BDR-100 mechanisms shipped; re-run census at next doctrine wave | | EVAL-031 | 2026-09-25 | /feat run for BDR-101: challenge round earned its cost, two blockers sat in my own premises | keep challenge round on state-detection plans; check live state before planning; pin grep in oracles | | EVAL-032 | 2026-09-27 | 4 parallel feater executors, one tree, gate loop: verifier caught a vacuous test, security caught a partial-write; my oracles wrong twice | keep same-tree parallel dispatch with disjoint FILE SCOPE + orchestrator-owned shared files; blind verifier stays; measure oracles on precedents | +| EVAL-033 | 2026-09-28 | case 7: 2 analyzers + 2 executors + 3 re-dispatches; verifiers caught shape, convention and my wrong count; security caught an env override | brief names the scratchpad path explicitly (3 /tmp leftovers); keep blind verifiers; count claims get an artifact | --- @@ -314,3 +315,10 @@ Dogfood: 3 blind lenses attacked the v1 plan for the plan-challenge feature itse - **Result**: 4/4 CONFORME after 2 re-dispatches; security PASS ×2. Verifier A3 caught a vacuous distinct-pair test the executor had self-justified ([[LRN-172]]). Security caught first-download without tmp+mv (partial file accepted forever) + python source splicing → fixed by fresh executor, re-verified, re-audited. Executors never touched each other's files; A4 verifier counted the orchestrator-reserved CHANGELOG as ECARTS(1), correct by contract wording. - **Anomalies**: (1) my oracles wrong twice ([[LRN-173]]); (2) gates.sh ERROR(3) on first run, `EVIDENCE: pending` missing; (3) 3 guardrail denials on sub-agents (`export GIT_CONFIG_GLOBAL` inline ×2 incl. a verifier, `rm -rf /tmp/tmp.AAyJzvufO6` executor cleanup), all reported, none evaded — [[BDR-100]] live; (4) security-auditor miscounted the sha as 41 chars (it is 40) — verify sub-agent claims before acting; (5) `make test` rc 1 from the 2 pre-existing T16a, my first grep filter hid the totals. - **Action**: keep the pattern; add `EVIDENCE: pending` to the contract skeleton; floor-guard waiver policy → user decision; snippet framing for LLM-consumed output → follow-up. + +## EVAL-033 — case 7 execution: analyzers first, then two executors, three re-dispatches +- **Date**: 2026-09-28 +- **Method**: two read-only analyzers (22 skills, fixed per-skill format, grep overlap against local assets) → verdict → two contracts → two feater executors in parallel (disjoint scopes, profiles owned by one, CHANGELOG by me) → gates.sh → fresh verifiers → security ×2 → full `make test`. +- **Result**: both CONFORME after 3 re-dispatches: frontmatter shape (executor mirrored external peers instead of the named personal ones), update-all refresh convention (executor's "additive" install broke "not installed — skipping"), security MEDIUM env override + LOW traversal. Verifier also doubted my CHANGELOG "sixteen skipped" → it was seventeen. Byte-for-byte fidelity of 14 files confirmed twice. +- **Anomalies**: (1) three sub-agents wrote to `/tmp` outside the scratchpad then could not `rm -rf` (refused, correctly) — the brief must name the scratchpad path; (2) executors' self-justified deviations were plausible each time and wrong twice → blind verifier stays mandatory; (3) analyzer reports at ~120-180 words per skill were the right grain, two of them fit my context; (4) `make test` rc 1 is still the 2 pre-existing T16a, my filter now shows totals. +- **Action**: brief template line "scratch only under "; count claims in CHANGELOG/journal cite the list they count; keep the analyzer-first pattern for any pack > 5 skills. diff --git a/.claude/memory/journal.md b/.claude/memory/journal.md index 1587b74..4a7167b 100644 --- a/.claude/memory/journal.md +++ b/.claude/memory/journal.md @@ -528,3 +528,6 @@ rules: - User go "merge le tout": the four review branches merged into develop via `gitflow finish` → b3597eb (yagni-ladder), 04cb057 (agent-skills-borrow), 68fcdaf (web-building-microrules), 39d5b15 (six-repo-review-notes); 7 registry conflicts (TODO ×3, journal ×3, CHANGELOG, decisions ×2) resolved by a scratch resolver keeping both sides in order (TODO/CHANGELOG incoming first, registries HEAD first), merge commits by hand, finish re-run removed local + origin copies. develop == origin/develop, no review branch left. Post-merge: 0 conflict markers, BDR-101→103 in order, `make test` 35 suites green minus 2 pre-existing T16a, shellcheck clean. BDR-103 written on user go. Open for the user: `make link` + `bash lib/profile.sh apply full` (trio symlinks), `rm -rf /tmp/tmp.AAyJzvufO6`. - User asked whether the UI profiles already carry motion-design knowledge for lively modern sites. Census answer: micro-interaction + component polish deep (emil 27 KB, motion cookbook 14 sections incl. scroll-driven, impeccable animate + detect); site-level choreography thin (GSAP/ScrollTrigger storytelling, Lenis, WebGL hero, masked reveals, marquee as workflows) and Astro View Transitions at zero mentions despite Astro-first. Candidate: mengto motion pack from the ui-skills registry, same three criteria; user decides. - Correction to the motion census above: ui-ux-pro-max's data CSVs (motion.csv 17 rows: GSAP reveal/pin/scrub, SplitText, parallax, magnetic; stacks/threejs.csv 53 rows; stacks/astro.csv rows 28-31 ViewTransitions: ClientRouter, `transition:name`, no-JS fallback; landing.csv scrollytelling) cover what I called absent. My grep skipped the plugin's data files. They are search-DB rows reached through the skill's search tool, not build workflows. Case 7 (mengto pack, 22 skills read by two analyzers): verdict pending user decision. + +## 2026-09-28 +- Case 7 (MengTo motion pack), user go "l'hybride" → [[BDR-104]]: `lib/vendor-skills.sh` shared helper (agent-skills moved onto it), 5 scroll skills vendored at a965851 (2a1ad17), `skills/site-motion` personal skill + routing (ba14b5e). Two analyzers read 22 skills first; 17 skipped (bugs, duplicates, covered, Codex/Xcode machinery). Gates MET, verifiers CONFORME after 3 re-dispatches (frontmatter shape, update-all refresh convention, security env override + traversal), security PASS ×2, `make test` 36 suites green minus 2 pre-existing T16a, shellcheck clean. [[LRN-174]] [[EVAL-033]]. feature/mengto-site-motion UNMERGED. Open for the user: `make link` + `bash lib/profile.sh apply full`, `rm -rf /tmp/mengto-verify`, LOW hardening (regex trailing newline, source/path/sha charset). diff --git a/.claude/memory/learnings.md b/.claude/memory/learnings.md index 2e4553d..e5c2606 100644 --- a/.claude/memory/learnings.md +++ b/.claude/memory/learnings.md @@ -193,6 +193,7 @@ rules: | LRN-171 | 2026-09-25 | sub-agent sandbox: grep shim returns EMPTY inside `$(...)` for patterns holding literal `$VAR` — oracles pin `command grep` | contract CHECK lines, hermetic test greps, hooks parsing grep output | | LRN-172 | 2026-09-27 | TF-IDF cosine on a 2-doc corpus is identically 0: similarity self-tests need N ≥ 4, a same-corpus positive control and a sensitivity re-run | fixtures for any corpus-normalised statistic (idf, z-score, ranking), "distinct pair passes" tests | | LRN-173 | 2026-09-27 | contract oracles written from memory failed twice: run the CHECK on the precedent files first, census greps via `git grep` (tracked only), `EVIDENCE: pending` mandatory for gates.sh | contract CHECK lines, precedent-mirroring criteria, gates.sh ledgers | +| LRN-174 | 2026-09-28 | a coverage census must grep plugin DATA files (CSV/JSON search DBs), not only SKILL.md prose; and a registry sample is not the upstream catalog, list the tree | before claiming a gap in installed skills; before scoping an external-repo evaluation | --- @@ -1615,3 +1616,7 @@ Rule: when editing a doctrine file under structure locks, grep the test's lock s ## LRN-173 — contract oracles written from memory failed twice: run the CHECK on the precedent files first, census greps via `git grep`, `EVIDENCE: pending` mandatory - **Context**: same run, two orchestrator oracle bugs. (1) "≤ 80 chars" over the whole file: rules/web-building.md line 2 (`paths:` frontmatter) is already 110 chars, so the criterion contradicted the precedent it named; executor returned NEED-DECISION instead of bending. (2) emil-citers census with `grep -rl` hit gitignored `install-*.log` at the repo root; `git grep -l` (tracked only) is the right census tool. (3) gates.sh `run` errors `runnable but has no EVIDENCE: line` unless each criterion carries `EVIDENCE: pending`. - **Apply**: before shipping a CHECK, run it against the files it claims to mirror; census oracles = `git grep`; contract skeleton carries `EVIDENCE: pending` per criterion (check /feat's template writes it). Oracle fixes are orchestrator-owned, never a re-dispatch ([[BDR-102]]). + +## LRN-174 — a coverage census greps plugin data files too; a registry sample is not the catalog +- **Context**: I told the user Astro View Transitions had zero local mentions. ui-ux-pro-max's `data/stacks/astro.csv` rows 28-31 carry ClientRouter, `transition:name`, no-JS fallback; `motion.csv` carries GSAP pin/scrub, SplitText, parallax. My grep covered SKILL.md prose and archetypes, not the plugin's CSV search DB. Same day: the ui-skills registry showed 11 MengTo skills; the repo tree has 88 web-design skills, and the substantive ones were outside the sample. +- **Apply**: census = `grep -rl` over the plugin cache including data dirs, then say "row in a search DB" vs "workflow"; evaluating an upstream = `git/trees?recursive=1` first, sample never. Correct the user the moment the miss is found ([[BDR-104]]). diff --git a/.claude/tasks/TODO.md b/.claude/tasks/TODO.md index 09c4cb8..fe36570 100644 --- a/.claude/tasks/TODO.md +++ b/.claude/tasks/TODO.md @@ -1,5 +1,30 @@ # TODO +## 2026-09-27 — case 7: MengTo motion pack → vendor 5 + build site-motion (feature/mengto-site-motion) +User go "ok pour 1, l'hybride" after two analyzers read 22 skills. Contracts under +`.claude/tasks/contracts/2026-09-27-{mengto-vendor,site-motion-skill}-*`, two feater +executors in parallel, gates replayed (gates.sh → fresh verifier → security). +- [x] M1 2a1ad17 vendor scroll-world-storytelling, build-threejs-scroll-worlds (+5 refs), + scroll-scrubbed-visual-sequence, scroll-scrubbed-word-reveal, + scroll-progress-timeline at pinned a965851 via a shared `lib/vendor-skills.sh` + (agent-skills moves onto it), design profiles, hermetic suite. +- [x] M2 ba14b5e `skills/site-motion/SKILL.md` + test-prompts.json: distilled invariants + (gates, engine choice, Lenis sync, Astro ClientRouter lifecycle, numbered + recipes, upstream pitfalls), routing line in CLAUDE.global.md + design-gate. +- [x] M3 gates MET ×2, verifiers CONFORME ×2 after 3 re-dispatches, security PASS ×2, + make test 36 suites green minus 2 pre-existing T16a, CHANGELOG, BDR-104 LRN-174 + EVAL-033. UNMERGED — human gate. After merge: `make link` + `bash lib/profile.sh + apply full`; user removes `/tmp/mengto-verify`. +Follow-up LOW (security): `SAFE` regex `$` accepts a trailing newline (use `\Z`); +`source`/`path`/`commit` lock fields not charset-checked in lib/vendor-skills.sh. +Skipped on purpose (analysis 2026-09-27): cinematic-gsap-lenis (reduced-motion bug), +cinematic-scroll-storytelling (50 % duplicate), build-awwwards-quality-sites (0 code), +animation-systems, gsap, threejs (⊂ ui-ux-pro-max threejs.csv), cobejs, matterjs, +marquee-loop, masked-reveal (gate bug), animation-on-scroll (no-JS bug), +progressive-blur, webgl-landing-steering, staggered-word-reveal (covered), +gsap-scrolltrigger-storytelling (empty), optimize-web-animations (Codex machinery), +performance-profiling (Xcode). Their invariants live in site-motion. + ## 2026-09-27 — case 5 of the 6-repo review: OmniRoute rejected (chore/six-repo-review-notes) Gateway to 357 providers via `ANTHROPIC_BASE_URL` → localhost:20128; needs provider API keys, a Claude Pro/Max subscription cannot go through it. No gap here: Claude-only diff --git a/.claude/tasks/contracts/2026-09-27-mengto-vendor-0002.md b/.claude/tasks/contracts/2026-09-27-mengto-vendor-0002.md new file mode 100644 index 0000000..e19c02c --- /dev/null +++ b/.claude/tasks/contracts/2026-09-27-mengto-vendor-0002.md @@ -0,0 +1,68 @@ +# CONTRACT — mengto-vendor +- date: 2026-09-27 | flow: feat (ad-hoc dispatch, /feat gates replayed by the orchestrator) | branch: feature/mengto-site-motion +- status: active + +## REQUEST (verbatim — IMMUTABLE) +> Vendor five scroll-choreography skills from MengTo/Skills (`agent-skills/web-design`) as machine-owned copies at pinned commit `a965851e27dc179e693fde1bee94457a64e1a7a5` (main, 2026-09-23), text files only: scroll-world-storytelling (SKILL.md, REFERENCES.md); build-threejs-scroll-worlds (SKILL.md, references/kage-anatomy.md, references/quality-and-qa.md, references/realtime-architecture.md, references/scroll-conductor.js, references/world-bible.md); scroll-scrubbed-visual-sequence (SKILL.md, REFERENCES.md); scroll-scrubbed-word-reveal (SKILL.md, REFERENCES.md); scroll-progress-timeline (SKILL.md, REFERENCES.md). Never demo/, agents/, assets, images, video, fonts or minified js. Lock entry `mengto-skills` with per-skill file lists; install and update read the pin from the lock; the vendoring loop becomes ONE shared helper used by both the agent-skills entry and this one (agent-skills behaviour unchanged, re-verified). Registered like emil-design-eng in link.sh, .gitignore, lib/toggle-external.sh, lib/profile.sh MANAGED_EXTERNALS and the design-class profiles (design, web, web-full, full). User go 2026-09-27 ("ok pour 1, l'hybride", case 7 of the repo review). + +## CLARIFICATIONS +- Lock shape: `"mengto-skills": {"source": "https://github.com/MengTo/Skills", "commit": "", "path": "agent-skills/web-design", "skills": {"": ["SKILL.md", "..."]}, "managed_by": "curl", "note": "..."}`. The helper accepts both shapes: `skills` as a list of names (agent-skills: files = ["SKILL.md"], path default "skills") and as a dict name → file list. +- Helper: `lib/vendor-skills.sh`, function `vendor_pinned_skills [refresh]`, sourced by install-plugins.sh (Step 8e, replacing its inline loop) and update-all.sh (step 7.3, replacing its inline loop). Reads the lock with python3 via argv (never string-spliced). Raw URL = `https://raw.githubusercontent.com//////`; the prefix up to `` is overridable through `VENDOR_BASE_URL` so a hermetic test can serve `file://` fixtures. Per file: tmp + mv, tmp removed on failure; a skill counts as installed only when every listed file landed, otherwise `err` with the manual curl. Skip files already present unless `refresh`. Subdirectories (references/) created as needed. +- Byte-for-byte copies; Codex-isms in the text stay. +- Profiles: the five under the design/external section of design, web, web-full, full; ALSO add the line `site-motion` with the `personal` label to the same four profiles (a sibling contract writes skills/site-motion and must not touch profiles); mirror how personal design skills are listed there. Never backend/dev. +- Not mirrored on purpose (design-only or sibling-owned): CLAUDE.global.md, lib/design-gate.md, agents/plugin-advisor.md, agents/plugin-probe.md, lib/tests/fixtures/registry-index-drift.md, lib/profiles/backend.profile, lib/profiles/dev.profile. +- Executor materializes the files with the same curl (network read allowed); never runs link.sh, make link, make plugin, update-all.sh or install-plugins.sh. +- Routing census pre-checked 2026-09-27: no pair ≥ 0.50 among the five descriptions. + +## ACCEPTANCE CRITERIA +1. Every listed file present per skill, frontmatter name = dir name, nothing else vendored. + CHECK: ok=1; declare -A F=( [scroll-world-storytelling]="SKILL.md REFERENCES.md" [build-threejs-scroll-worlds]="SKILL.md references/kage-anatomy.md references/quality-and-qa.md references/realtime-architecture.md references/scroll-conductor.js references/world-bible.md" [scroll-scrubbed-visual-sequence]="SKILL.md REFERENCES.md" [scroll-scrubbed-word-reveal]="SKILL.md REFERENCES.md" [scroll-progress-timeline]="SKILL.md REFERENCES.md" ); for s in "${!F[@]}"; do for f in ${F[$s]}; do [ -f "skills-external/$s/$f" ] || { echo "missing $s/$f"; ok=0; }; done; grep -q "^name: $s\$" "skills-external/$s/SKILL.md" || { echo "name mismatch $s"; ok=0; }; n=$(find "skills-external/$s" -type f | wc -l); [ "$n" -eq "$(echo ${F[$s]} | wc -w)" ] || { echo "extra files in $s"; ok=0; }; done; [ "$ok" -eq 1 ] && echo VENDORED + EXPECT: VENDORED + EVIDENCE: MET exit=0 marker-found :: VENDORED +2. Pin and file lists recorded in the lock. + CHECK: python3 -c 'import json; d=json.load(open("plugins.lock.json"))["mengto-skills"]; assert d["commit"]=="a965851e27dc179e693fde1bee94457a64e1a7a5", d; assert d["path"]=="agent-skills/web-design"; s=d["skills"]; assert set(s)=={"scroll-world-storytelling","build-threejs-scroll-worlds","scroll-scrubbed-visual-sequence","scroll-scrubbed-word-reveal","scroll-progress-timeline"}, s; assert set(s["build-threejs-scroll-worlds"])=={"SKILL.md","references/kage-anatomy.md","references/quality-and-qa.md","references/realtime-architecture.md","references/scroll-conductor.js","references/world-bible.md"}; assert all(set(v)=={"SKILL.md","REFERENCES.md"} for k,v in s.items() if k!="build-threejs-scroll-worlds"); print("PINNED")' + EXPECT: PINNED + EVIDENCE: MET exit=0 marker-found :: PINNED +3. One shared helper, both scripts use it, no sha hardcoded. + CHECK: [ -f lib/vendor-skills.sh ] && grep -q '^vendor_pinned_skills()' lib/vendor-skills.sh && grep -q 'vendor-skills.sh' install-plugins.sh && grep -q 'vendor-skills.sh' update-all.sh && [ "$(grep -c 'vendor_pinned_skills' install-plugins.sh)" -ge 2 ] && [ "$(grep -c 'vendor_pinned_skills' update-all.sh)" -ge 2 ] && ! grep -qE 'a965851|2686b620' lib/vendor-skills.sh install-plugins.sh update-all.sh link.sh lib/toggle-external.sh && echo LOCK_DRIVEN + EXPECT: LOCK_DRIVEN + EVIDENCE: MET exit=0 marker-found :: LOCK_DRIVEN +4. Hermetic helper suite: list-shape and dict-shape entries, file:// base, tmp+mv, failure leaves nothing, refresh overwrites. + CHECK: out=$(make test suite=lib/tests/vendor-skills.test.sh 2>&1); echo "$out" | grep -qE "FAIL=[1-9]" && { echo "$out" | tail -15; exit 1; }; for k in LIST_SHAPE DICT_SHAPE FAIL_LEAVES_NOTHING REFRESH_OVERWRITES SKIP_PRESENT; do echo "$out" | grep -q "PASS $k" || { echo "missing PASS $k"; exit 1; }; done; echo HELPER_SUITE_GREEN + EXPECT: HELPER_SUITE_GREEN + EVIDENCE: MET exit=0 marker-found :: HELPER_SUITE_GREEN +5. Copies and symlink paths gitignored. + CHECK: ok=1; for s in scroll-world-storytelling build-threejs-scroll-worlds scroll-scrubbed-visual-sequence scroll-scrubbed-word-reveal scroll-progress-timeline; do git check-ignore -q "skills-external/$s" && git check-ignore -q "skills/$s" || { echo "not ignored $s"; ok=0; }; done; [ "$ok" -eq 1 ] && echo IGNORED + EXPECT: IGNORED + EVIDENCE: MET exit=0 marker-found :: IGNORED +6. link.sh, toggle-external, profile.sh and the four design profiles list the five; the four profiles also list `site-motion` as personal. + CHECK: ok=1; for s in scroll-world-storytelling build-threejs-scroll-worlds scroll-scrubbed-visual-sequence scroll-scrubbed-word-reveal scroll-progress-timeline; do grep -q "$s" link.sh && grep -q "$s" lib/toggle-external.sh && grep -q "$s" lib/profile.sh || { echo "not registered $s"; ok=0; }; for p in design web web-full full; do grep -q "^$s" "lib/profiles/$p.profile" || { echo "not in $p: $s"; ok=0; }; done; done; for p in design web web-full full; do grep -qE "^site-motion\s+personal" "lib/profiles/$p.profile" || { echo "site-motion missing in $p"; ok=0; }; done; for p in backend dev; do grep -qE "^(scroll-|site-motion)" "lib/profiles/$p.profile" && { echo "leak into $p"; ok=0; }; done; [ "$ok" -eq 1 ] && echo REGISTERED + EXPECT: REGISTERED + EVIDENCE: MET exit=0 marker-found :: REGISTERED +7. Profile, toggle-external, doctrine-citers and routing-census suites green. + CHECK: out=$(make test suite="lib/tests/profile-default.test.sh lib/tests/profile-set-managed.test.sh lib/tests/toggle-external-repo-resolution.test.sh lib/tests/doctrine-citers.test.sh lib/tests/skill-routing-census.test.sh" 2>&1); echo "$out" | grep -qE "FAIL=[1-9]" && { echo "$out" | tail -20; exit 1; }; echo SUITES_GREEN + EXPECT: SUITES_GREEN + EVIDENCE: MET exit=0 marker-found :: SUITES_GREEN +8. agent-skills behaviour intact through the shared helper. + CHECK: ok=1; for s in observability-and-instrumentation deprecation-and-migration ci-cd-and-automation; do [ -f "skills-external/$s/SKILL.md" ] || ok=0; done; python3 -c 'import json; d=json.load(open("plugins.lock.json"))["agent-skills"]; assert d["commit"]=="2686b620fc1fed2e8f60c704839c766b8594c6b6"; assert isinstance(d["skills"], list) and len(d["skills"])==3' && [ "$ok" -eq 1 ] && echo AGENT_SKILLS_INTACT + EXPECT: AGENT_SKILLS_INTACT + EVIDENCE: MET exit=0 marker-found :: AGENT_SKILLS_INTACT +9. shellcheck clean on every touched script. + CHECK: shellcheck install-plugins.sh update-all.sh link.sh lib/toggle-external.sh lib/profile.sh lib/vendor-skills.sh lib/tests/vendor-skills.test.sh && echo SHELLCHECK_OK + EXPECT: SHELLCHECK_OK + EVIDENCE: MET exit=0 marker-found :: SHELLCHECK_OK + +## FILE SCOPE +- plugins.lock.json, install-plugins.sh, update-all.sh, link.sh, .gitignore +- lib/vendor-skills.sh (new), lib/tests/vendor-skills.test.sh (new) +- lib/toggle-external.sh, lib/profile.sh, lib/profiles/{design,web,web-full,full}.profile +- lib/tests/profile-default.test.sh, lib/tests/profile-set-managed.test.sh, lib/tests/toggle-external-repo-resolution.test.sh (only if they enumerate externals) +- skills-external// materialized (gitignored) + +## PLAN +1. Read install-plugins.sh Step 8e + `pinned_commit`, update-all.sh 7.3, link.sh EXTERNAL_SKILLS, .gitignore blocks, lib/toggle-external.sh, lib/profile.sh, the four design profiles (how emil-design-eng and personal skills are listed). +2. `lib/vendor-skills.sh`: lock reader (python3 argv → source/commit/path/skills, normalising list → dict), URL builder honoring `VENDOR_BASE_URL`, per-file tmp+mv loop, skip/refresh, summary lines in the scripts' ok/info/err style (define fallbacks if sourced standalone). Functions ≤ 25 logic lines, 80-char lines. +3. install-plugins.sh Step 8e → source the lib, call `vendor_pinned_skills agent-skills` then `vendor_pinned_skills mengto-skills`; update-all.sh 7.3 → same with `refresh`. Remove the now-dead inline loops; keep or fold `pinned_commit`. +4. Lock entry; link.sh EXTERNAL_SKILLS += 5; .gitignore both patterns ×5 with a source comment; toggle-external MANAGED_TOOLS += 5; profile.sh MANAGED_EXTERNALS += 5; profiles (five + `site-motion personal`). +5. `lib/tests/vendor-skills.test.sh`: temp dir with a fake lock (one list-shape key, one dict-shape key with a references/ file), fixture tree served via `VENDOR_BASE_URL=file://…`, checks LIST_SHAPE, DICT_SHAPE, SKIP_PRESENT, REFRESH_OVERWRITES, FAIL_LEAVES_NOTHING (missing fixture file → no partial file, no tmp). `PASS=n FAIL=m` summary. +6. Materialize the five with the helper against the real lock (network); run criteria 1-9. diff --git a/.claude/tasks/contracts/2026-09-27-site-motion-skill-0002.md b/.claude/tasks/contracts/2026-09-27-site-motion-skill-0002.md new file mode 100644 index 0000000..4f140f5 --- /dev/null +++ b/.claude/tasks/contracts/2026-09-27-site-motion-skill-0002.md @@ -0,0 +1,50 @@ +# CONTRACT — site-motion-skill +- date: 2026-09-27 | flow: feat (ad-hoc dispatch, /feat gates replayed by the orchestrator) | branch: feature/mengto-site-motion +- status: active + +## REQUEST (verbatim — IMMUTABLE) +> Write the personal skill `skills/site-motion/SKILL.md` (+ `test-prompts.json` for darwin, same schema as the 32 existing ones): site-level motion choreography for lively modern sites, distilling the invariants of the MengTo motion pack (LRN-141: invariants, never machinery), aligned with rules/web-building.md, BDR-005 (`motion` is the default library; GSAP allowed for scroll choreography when the project already uses it or the effect needs pin/scrub) and the design toolchain. Sections: when to use versus the component-level skills (emil-design-eng, design-motion-principles, impeccable animate) and the audits; gates first (reduced motion renders final states, never shortened animations; content visible without JS, `html.js` gate set only after plugin registration; compositor-only properties, `will-change` only during an animation, offscreen pause, first-viewport CTA never covered by a preloader, no preloader on a timer); engine choice (exactly one smooth-scroll engine; Lenis ↔ ScrollTrigger sync through `gsap.ticker` with `lagSmoothing(0)`; CSS `animation-timeline` first when the effect is plain progress); Astro lifecycle with ClientRouter (init on `astro:page-load`, teardown on `astro:before-swap`, `transition:persist` for canvases, `transition:name` for morphs, test with and without JS); recipes as numbers (reveal at `top 82%`, once; scrub 0.8-1.4 with `ease: "none"` and eased children; sticky card stack scale `0.92 + i*0.015` from the next card `top 78%` → `top 24%`; 0.7-1.8 viewport heights of scroll per story beat; video scrub encoded with `ffmpeg -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart`; image sequences cancel stale requests; word split through `TreeWalker` with the original text kept readable and no `aria-label` on paragraphs; progressive blur = stacked `backdrop-filter` layers 0.5 → 64 px in 12.5 % mask bands with the `-webkit-` prefix, top ≤ 12 %, bottom ≤ 65 %; marquee = duplicated track, `translateX(-50%)` linear, `aria-hidden` clone, paused offscreen; magnetic and cursor effects through `gsap.quickTo`; WebGL: one lane per page, pricing decorative only, poster fallback built first, context loss handled, DPR 1.25-1.5 on mobile with 150-300k triangles and 50-90 draw calls, exact progress for navigation and ARIA versus damped progress for the camera; offscreen census through `document.getAnimations()` and route-cycle leak sampling); an upstream-pitfalls list; a verification checklist. Routing: one line in CLAUDE.global.md § Design work, "Build UI" chain, and in lib/design-gate.md's toolchain list, doctrine budget ≤ 320 lines. User go 2026-09-27 ("ok pour 1, l'hybride", case 7). + +## CLARIFICATIONS +- Length 140-220 lines, English, frontmatter `name: site-motion` and a `description:` that states what it does then FR+EN triggers ("site mouvementé", "scroll storytelling", "smooth scroll", "hero WebGL", "transitions de page", "Lenis", "ScrollTrigger", "Awwwards"), plus the frontmatter keys the sibling personal skills carry (read skills/feat/SKILL.md and one design-side skill for the shape). +- Sources: the upstream texts are already on disk, read them, never re-fetch: /tmp/claude-1000/-home-bchanot-Documents-claude/977f1703-f01d-497a-b794-5b69fafcd35f/scratchpad/mengto/ (11 small skills) and .../scratchpad/mengto-heavy/ (11 heavy ones, extras under x/). Distil; never copy paragraphs. +- Never prescribe: glow or gradient hover, reveal on every section, permanent `will-change`, Inter or Geist, preloaders on timers, entrances from `scale(0)`, ease-in exits. Point to rules/web-building.md by path instead of restating it. +- Pitfalls to list (found upstream by reading): `clearProps` under reduced motion leaving text hidden behind a visibility gate; `registerPlugin` after the `html.js` gate; per-frame `phi += 0.01` without delta time; WebGL context recreated on every resize; preloader on a fixed timer; `aria-label` on `

`; content left at opacity 0 without JS. +- Citations of doctrine use the exact heading: `CLAUDE.md § Design work — full toolchain (tiered by scope)`; any other citation must resolve for lib/tests/doctrine-citers.test.sh. +- No profile edits (the sibling contract adds `site-motion personal` to the design profiles); no CHANGELOG; no README. +- test-prompts.json: ≥ 4 prompts, at least one French, following the existing schema. + +## ACCEPTANCE CRITERIA +1. Shape: frontmatter, name, description, length, valid test prompts. + CHECK: f=skills/site-motion/SKILL.md; [ -f "$f" ] && [ "$(head -1 "$f")" = "---" ] && grep -q "^name: site-motion$" "$f" && grep -qE "^description:" "$f" && n=$(wc -l < "$f") && [ "$n" -ge 140 ] && [ "$n" -le 220 ] && python3 -c 'import json; d=json.load(open("skills/site-motion/test-prompts.json")); p=d if isinstance(d,list) else next(v for v in d.values() if isinstance(v,list)); assert len(p)>=4' && echo SHAPE + EXPECT: SHAPE + EVIDENCE: MET exit=0 marker-found :: SHAPE +2. Content markers present. + CHECK: f=skills/site-motion/SKILL.md; ok=1; for k in "prefers-reduced-motion" "astro:page-load" "astro:before-swap" "transition:persist" "transition:name" "lagSmoothing" "animation-timeline" "TreeWalker" "-webkit-backdrop-filter" "getAnimations" "quickTo" "keyint_min" "top 82%" "0.015" "draw call" "poster" "html.js" "emil-design-eng" "design-motion-principles" "web-building.md"; do grep -qi -- "$k" "$f" || { echo "missing $k"; ok=0; }; done; [ "$ok" -eq 1 ] && echo CONTENT + EXPECT: CONTENT + EVIDENCE: MET exit=0 marker-found :: CONTENT +3. No default-reflex prescriptions. + CHECK: f=skills/site-motion/SKILL.md; ! grep -qE "\b(Inter|Geist)\b" "$f" && ! grep -qiE "scale\(0\)[^)]*(entrance|enter|in\b)" "$f" && echo NO_DEFAULT_REFLEXES + EXPECT: NO_DEFAULT_REFLEXES + EVIDENCE: MET exit=0 marker-found :: NO_DEFAULT_REFLEXES +4. Routing wired within budget. + CHECK: grep -q "site-motion" CLAUDE.global.md && grep -q "site-motion" lib/design-gate.md && [ "$(wc -l < CLAUDE.global.md)" -le 320 ] && echo ROUTED + EXPECT: ROUTED + EVIDENCE: MET exit=0 marker-found :: ROUTED +5. Citations resolve and the skill routes without collision. + CHECK: out=$(make test suite="lib/tests/doctrine-citers.test.sh lib/tests/skill-routing-census.test.sh" 2>&1); echo "$out" | grep -qE "FAIL=[1-9]" && { echo "$out" | tail -15; exit 1; }; echo "$out" | grep -E "^(WARN|FAIL) " | grep -q "site-motion" && { echo "site-motion collides"; echo "$out" | grep -E "site-motion"; exit 1; }; echo CITED_AND_ROUTABLE + EXPECT: CITED_AND_ROUTABLE + EVIDENCE: MET exit=0 marker-found :: CITED_AND_ROUTABLE +6. Every local path the skill names exists. + CHECK: ok=1; for p in $(grep -oE "\b(skills-external|skills|rules|lib)/[A-Za-z0-9_./-]+" skills/site-motion/SKILL.md | sed 's/[.,;:)]*$//' | sort -u); do [ -e "$p" ] || { echo "missing $p"; ok=0; }; done; [ "$ok" -eq 1 ] && echo LINKS_OK + EXPECT: LINKS_OK + EVIDENCE: MET exit=0 marker-found :: LINKS_OK + +## FILE SCOPE +- skills/site-motion/SKILL.md (new), skills/site-motion/test-prompts.json (new) +- CLAUDE.global.md (one line, § Design work, "Build UI" chain), lib/design-gate.md (toolchain list lines) + +## PLAN +1. Read the shape precedents (skills/feat/SKILL.md frontmatter, one design-side personal skill, skills/feat/test-prompts.json), rules/web-building.md, the "Design work" section of CLAUDE.global.md, lib/design-gate.md lines 40-50 and 130-140, and the upstream scratch copies. +2. Draft the skill: When to use / not; Gates first; Engine choice; Astro lifecycle; Recipes (numbers); Upstream pitfalls; Verification. Link to the local skills by path. +3. test-prompts.json; routing line + design-gate list; run criteria 1-6. diff --git a/CHANGELOG.md b/CHANGELOG.md index b85b2c7..3cbd974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- **`skills/site-motion`** — personal skill for site-level motion + choreography (scroll engine choice and Lenis/ScrollTrigger sync, Astro + ClientRouter lifecycle, pin/scrub numbers, sticky stacks, video and image + scrubbing, WebGL hero lanes and budgets, upstream pitfalls, verification + checklist), distilled from the MengTo motion pack (invariants only, + LRN-141). Routed into the Build UI toolchain of CLAUDE.global.md and + lib/design-gate.md (not on the GATE-BLOCK set). Case 7 of the repo review. +- **Five MengTo scroll skills vendored** (`scroll-world-storytelling`, + `build-threejs-scroll-worlds` with its five references, + `scroll-scrubbed-visual-sequence`, `scroll-scrubbed-word-reveal`, + `scroll-progress-timeline`) at a pinned commit (`mengto-skills` entry in + plugins.lock.json, text files only, never demos or binaries). The + agent-skills curl loop became the shared `lib/vendor-skills.sh` + (`vendor_pinned_skills [refresh]`, list or dict lock shapes, + `VENDOR_BASE_URL` for the hermetic suite `lib/tests/vendor-skills.test.sh`), + used by install-plugins.sh Step 8e and update-all.sh 7.3; refresh keeps + the file's convention and skips a skill that was never installed. + Registered in link.sh, .gitignore, + toggle-external, profile.sh and the design/web/web-full/full profiles + (plus `site-motion personal`). Seventeen other MengTo skills were read and + skipped: covered locally, buggy (reduced-motion `clearProps`, gate before + `registerPlugin`, no-JS opacity 0) or off-domain. - **rules/web-building.md § Write-time reflexes** — stack-agnostic micro-rules borrowed from ibelick/ui-skills (baseline-ui + playbook): dvh and safe-area, paste never blocked, tabular-nums and text-wrap, one From 415b44ed25f6f27c56909615b086c4d6fa785c38 Mon Sep 17 00:00:00 2001 From: bastien Date: Mon, 28 Sep 2026 02:35:52 +0200 Subject: [PATCH 4/5] fix(lib): vendor-skills validates lock fields, fullmatch guard Two security-gate LOW notes closed on user ask: the SAFE guard uses re.fullmatch so a trailing newline is rejected; commit (40 hex), source (github.com owner/repo) and path (SAFE class, no traversal) are validated before any URL is built, INVALID marker names the field. Suite 12 cases. --- CHANGELOG.md | 4 +- lib/tests/vendor-skills.test.sh | 109 ++++++++++++++++++++++++++------ lib/vendor-skills.sh | 58 ++++++++++++----- 3 files changed, 137 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbd974..bdf2cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). plugins.lock.json, text files only, never demos or binaries). The agent-skills curl loop became the shared `lib/vendor-skills.sh` (`vendor_pinned_skills [refresh]`, list or dict lock shapes, - `VENDOR_BASE_URL` for the hermetic suite `lib/tests/vendor-skills.test.sh`), + `VENDOR_BASE_URL` honoured only as `file://` for the hermetic suite + `lib/tests/vendor-skills.test.sh`, lock values validated: 40-hex commit, + github.com source, traversal-free paths, no trailing newline), used by install-plugins.sh Step 8e and update-all.sh 7.3; refresh keeps the file's convention and skips a skill that was never installed. Registered in link.sh, .gitignore, diff --git a/lib/tests/vendor-skills.test.sh b/lib/tests/vendor-skills.test.sh index f96f062..9169ab5 100755 --- a/lib/tests/vendor-skills.test.sh +++ b/lib/tests/vendor-skills.test.sh @@ -5,7 +5,10 @@ # throwaway repo), tmp+mv semantics (a missing upstream file leaves no dest # and no tmp), skip-when-present, refresh overwriting a stale copy, a # non-file:// VENDOR_BASE_URL override being ignored (warn, default URL), -# and a "../evil" lock file being rejected before any fetch. +# a "../evil" lock file being rejected before any fetch, a lock value +# ending in a newline being rejected (the re.fullmatch fix), and a bad +# commit/source/path on the lock entry itself being rejected before the +# raw URL is ever built. set -u ROOT="$(cd "$(dirname "$0")/../.." && pwd)" LIB="$ROOT/lib/vendor-skills.sh" @@ -29,47 +32,78 @@ mkdir -p "$FIXTURE_REPO/skills-external" # names a skill never installed locally (no skills-external/ dir), to # prove refresh skips it instead of installing it. "traversal-key" names # a well-behaved SKILL.md alongside a "../evil" file, to prove the whole -# key is rejected before either one is fetched. +# key is rejected before either one is fetched. "newline-key" names a +# file whose value ends in a newline, to prove the SAFE-class check uses +# re.fullmatch (a plain re.match "$" would let it through). "bad-commit- +# key", "bad-source-key" and "bad-path-key" carry an otherwise-valid entry +# with exactly one malformed field, to prove each is checked before the +# raw URL is built. Every commit below is a real 40-hex sha1 (of the key's +# own name) — only the three "bad-*-key" entries break that on purpose. cat > "$FIXTURE_REPO/plugins.lock.json" <<'JSON' { "list-key": { "source": "https://github.com/acme/list-repo", - "commit": "abc123", + "commit": "0b29f58330afad53522f9045ef48ba153bb5ab81", "skills": ["skill-list-a"] }, "dict-key": { "source": "https://github.com/acme/dict-repo", - "commit": "def456", + "commit": "68ca98404892988c1cbe928dc12ef3ed144c8d70", "path": "somewhere/nested", "skills": {"skill-dict-a": ["SKILL.md", "references/notes.md"]} }, "fail-key": { "source": "https://github.com/acme/fail-repo", - "commit": "789fail", + "commit": "898733695eac132c05aac536e7f86cdd89bdfe09", "skills": ["skill-fail-a"] }, "missing-key": { "source": "https://github.com/acme/missing-repo", - "commit": "111missing", + "commit": "2e7a1c5865d4f2c9dc2e3354658f0e257f6110d8", "skills": ["skill-missing-a"] }, "traversal-key": { "source": "https://github.com/acme/traversal-repo", - "commit": "222trav", + "commit": "9704a3bf7b366acd318b0d12dacc78774ff2ade1", "skills": {"skill-trav-a": ["SKILL.md", "../evil"]} + }, + "newline-key": { + "source": "https://github.com/acme/newline-repo", + "commit": "12f27ef33f4cd777b9471b989a8e022e35c0ab74", + "skills": {"skill-nl-a": ["SKILL.md\n"]} + }, + "bad-commit-key": { + "source": "https://github.com/acme/badcommit-repo", + "commit": "main", + "skills": ["skill-badcommit-a"] + }, + "bad-source-key": { + "source": "https://evil.example.com/x/y", + "commit": "eeb5c78b15a6b1ffa3fb5d46d8c794bcd0446bdc", + "skills": ["skill-badsource-a"] + }, + "bad-path-key": { + "source": "https://github.com/acme/badpath-repo", + "commit": "e341601ba6f6255378268e9aaec304de93a13d50", + "path": "../x", + "skills": {"skill-badpath-a": ["SKILL.md"]} } } JSON -mkdir -p "$UPSTREAM/abc123/skills/skill-list-a" -echo v1 > "$UPSTREAM/abc123/skills/skill-list-a/SKILL.md" -mkdir -p "$UPSTREAM/def456/somewhere/nested/skill-dict-a/references" -echo "dict skill" > "$UPSTREAM/def456/somewhere/nested/skill-dict-a/SKILL.md" -echo "dict notes" > "$UPSTREAM/def456/somewhere/nested/skill-dict-a/references/notes.md" -# fail-key: 789fail/skills/skill-fail-a/SKILL.md deliberately absent. +LIST_SHA="0b29f58330afad53522f9045ef48ba153bb5ab81" +DICT_SHA="68ca98404892988c1cbe928dc12ef3ed144c8d70" +mkdir -p "$UPSTREAM/$LIST_SHA/skills/skill-list-a" +echo v1 > "$UPSTREAM/$LIST_SHA/skills/skill-list-a/SKILL.md" +mkdir -p "$UPSTREAM/$DICT_SHA/somewhere/nested/skill-dict-a/references" +echo "dict skill" > "$UPSTREAM/$DICT_SHA/somewhere/nested/skill-dict-a/SKILL.md" +echo "dict notes" \ + > "$UPSTREAM/$DICT_SHA/somewhere/nested/skill-dict-a/references/notes.md" +# fail-key: .../skills/skill-fail-a/SKILL.md deliberately absent. # missing-key: no $UPSTREAM tree at all — refresh must skip it on the # missing skills-external/ dir alone, before ever reaching curl. -# traversal-key: no $UPSTREAM tree either — the "../evil" file must be +# traversal-key, newline-key, bad-commit-key, bad-source-key and +# bad-path-key: no $UPSTREAM tree either — every one of them must be # rejected by the lock reader itself, before any URL is built. export VENDOR_SKILLS_REPO_OVERRIDE="$FIXTURE_REPO" @@ -97,7 +131,7 @@ check_bool FAIL_LEAVES_NOTHING "$([ ! -e "$fdest" ] && [ ! -e "$fdest.tmp" ] \ && printf '%s' "$out" | grep -q 'not all files landed' && echo 1 || echo 0)" # ── SKIP_PRESENT — upstream changes, a plain re-run keeps the old copy ─── -echo v2-upstream-changed > "$UPSTREAM/abc123/skills/skill-list-a/SKILL.md" +echo v2-upstream-changed > "$UPSTREAM/$LIST_SHA/skills/skill-list-a/SKILL.md" vendor_pinned_skills list-key >/dev/null 2>&1 check_bool SKIP_PRESENT "$([ "$(cat "$dest")" = v1 ] && echo 1 || echo 0)" @@ -108,7 +142,8 @@ check_bool REFRESH_OVERWRITES \ # ── REFRESH_SKIPS_MISSING — refresh never installs a skill that has no # skills-external/ dir yet; it prints the standard "not installed" -# skip line and never touches curl (no $UPSTREAM/111missing/ exists). +# skip line and never touches curl (missing-key's sha has no $UPSTREAM +# tree at all). mdest="$FIXTURE_REPO/skills-external/skill-missing-a" out="$(vendor_pinned_skills missing-key refresh 2>&1)" check_bool REFRESH_SKIPS_MISSING "$([ ! -e "$mdest" ] \ @@ -121,8 +156,8 @@ check_bool REFRESH_SKIPS_MISSING "$([ ! -e "$mdest" ] \ # prefix is used instead. list-key's SKILL.md is already vendored (v2, # from REFRESH_OVERWRITES above), so this probe never touches curl # either way — the assertion is the warn line, and that the file:// mode -# used everywhere else in this suite (asserted by the six cases above -# and below) keeps working. +# used everywhere else in this suite (asserted by the eleven other cases) +# keeps working. out="$(VENDOR_BASE_URL="https://evil.example.com" \ vendor_pinned_skills list-key 2>&1)" check_bool OVERRIDE_NON_FILE_IGNORED \ @@ -140,5 +175,43 @@ outside="$FIXTURE_REPO/skills-external/evil" check_bool REJECTS_TRAVERSAL "$([ "$rc" -ne 0 ] && [ ! -e "$tdir" ] \ && [ ! -e "$outside" ] && echo 1 || echo 0)" +# ── REJECTS_TRAILING_NEWLINE — a lock file value ending in a newline +# ("SKILL.md\n") is rejected by the SAFE-class check (re.fullmatch, not +# re.match): nothing is fetched for the key, and the err line names the +# lock key. +out="$(vendor_pinned_skills newline-key 2>&1)" +rc=$? +nldir="$FIXTURE_REPO/skills-external/skill-nl-a" +check_bool REJECTS_TRAILING_NEWLINE "$([ "$rc" -ne 0 ] && [ ! -e "$nldir" ] \ + && printf '%s' "$out" | grep -q 'newline-key' && echo 1 || echo 0)" + +# ── REJECTS_BAD_COMMIT — a lock entry whose "commit" is not 40 lowercase +# hex chars ("main") is rejected before any URL is built. +out="$(vendor_pinned_skills bad-commit-key 2>&1)" +rc=$? +bcdir="$FIXTURE_REPO/skills-external/skill-badcommit-a" +check_bool REJECTS_BAD_COMMIT "$([ "$rc" -ne 0 ] && [ ! -e "$bcdir" ] \ + && printf '%s' "$out" | grep -qF "rejected commit='main'" \ + && echo 1 || echo 0)" + +# ── REJECTS_BAD_SOURCE — a lock entry whose "source" is not a +# "https://github.com//" URL is rejected before any URL is +# built. +out="$(vendor_pinned_skills bad-source-key 2>&1)" +rc=$? +bsdir="$FIXTURE_REPO/skills-external/skill-badsource-a" +check_bool REJECTS_BAD_SOURCE "$([ "$rc" -ne 0 ] && [ ! -e "$bsdir" ] \ + && printf '%s' "$out" \ + | grep -qF "rejected source='https://evil.example.com/x/y'" \ + && echo 1 || echo 0)" + +# ── REJECTS_BAD_PATH — a lock entry whose "path" walks outside the repo +# ("../x") is rejected before any URL is built. +out="$(vendor_pinned_skills bad-path-key 2>&1)" +rc=$? +bpdir="$FIXTURE_REPO/skills-external/skill-badpath-a" +check_bool REJECTS_BAD_PATH "$([ "$rc" -ne 0 ] && [ ! -e "$bpdir" ] \ + && printf '%s' "$out" | grep -qF "rejected path='../x'" && echo 1 || echo 0)" + echo "PASS=$pass FAIL=$fail" [ "$fail" -eq 0 ] diff --git a/lib/vendor-skills.sh b/lib/vendor-skills.sh index 3a6ac47..8880703 100644 --- a/lib/vendor-skills.sh +++ b/lib/vendor-skills.sh @@ -34,8 +34,14 @@ # # Every skill name and file path from the lock is rejected — before any # URL is built or any file fetched — if it contains "..", starts with -# "/", or holds a character outside [A-Za-z0-9._/-]; this guards against -# a lock entry walking a fetch outside skills-external//. +# "/", or holds a character outside [A-Za-z0-9._/-] (checked with +# re.fullmatch, so a trailing newline or other stray character cannot +# slip past the "$" anchor the way it could under re.match); this guards +# against a lock entry walking a fetch outside skills-external//. +# The entry's own "commit" (must be 40 lowercase hex chars), "source" +# (must be "https://github.com//", trailing slash optional) +# and "path" (same SAFE class as a file, no traversal) are format-checked +# the same way, before either is ever spliced into the raw-file URL. # # No `set -euo pipefail` here (mirrors lib/detect-plugins.sh): a sourced # lib must not change the caller's shell options. @@ -71,10 +77,12 @@ fi # is a plain argv string — the caller (vendor_pinned_skills) already # checked it is either empty or a "file://" value, never the raw # VENDOR_BASE_URL. -# rc 1 when the key, its commit or its skills are absent (nothing -# printed), or when a skill name or file path fails the traversal/ -# character check (prints one "INVALID\t" line, nothing -# else — no URL is built and no file is fetched for that lock key). +# rc 1 when the key, its commit, source or skills are absent (nothing +# printed); when the commit, source or path fails its format check +# (prints one "INVALID\t=" line); or when a +# skill name or file path fails the traversal/character check (prints +# one "INVALID\t" line) — no URL is built and no file +# is fetched for that lock key either way. # Reads the lock path, key and base override via argv — never # string-spliced into the script. _vendor_read_lock() { @@ -82,10 +90,13 @@ _vendor_read_lock() { import json, re, sys SAFE = re.compile(r'^[A-Za-z0-9._/-]+$') +COMMIT_RE = re.compile(r'^[0-9a-f]{40}$') +SOURCE_RE = re.compile( + r'^https://github\.com/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+/?$') def unsafe(value): - return ".." in value or value.startswith("/") or not SAFE.match(value) + return ".." in value or value.startswith("/") or not SAFE.fullmatch(value) lockfile, key, base_override = sys.argv[1], sys.argv[2], sys.argv[3] @@ -93,13 +104,23 @@ with open(lockfile) as f: data = json.load(f) entry = data.get(key, {}) sha = entry.get("commit", "") -owner_repo = entry.get("source", "").rstrip("/").rsplit("github.com/", 1)[-1] +source = entry.get("source", "") path = entry.get("path", "skills") skills = entry.get("skills", {}) if isinstance(skills, list): skills = {name: ["SKILL.md"] for name in skills} -if not sha or not owner_repo or not skills: +if not sha or not source or not skills: sys.exit(1) +if not COMMIT_RE.fullmatch(sha): + print(f"INVALID\tcommit={sha}") + sys.exit(1) +if not SOURCE_RE.fullmatch(source): + print(f"INVALID\tsource={source}") + sys.exit(1) +if unsafe(path): + print(f"INVALID\tpath={path}") + sys.exit(1) +owner_repo = source.rstrip("/").rsplit("github.com/", 1)[-1] for name, files in skills.items(): if unsafe(name): print(f"INVALID\t{name}") @@ -148,14 +169,21 @@ _vendor_install_skill() { } # _vendor_report_lock_error — turns a failed -# _vendor_read_lock into the right err line: a rejected name/path when -# carries the "INVALID\t" marker, the generic -# no-commit-pinned hint otherwise. +# _vendor_read_lock into the right err line: a rejected commit/source/ +# path when carries the "INVALID\t=" marker (the +# field named in full), a rejected skill name/file when it carries the +# plain "INVALID\t" marker, the generic no-commit-pinned hint +# otherwise. _vendor_report_lock_error() { - local lock_key="$1" lock_out="$2" msg + local lock_key="$1" lock_out="$2" rest msg if [[ "$lock_out" == INVALID$'\t'* ]]; then - msg="$lock_key: rejected '${lock_out#INVALID$'\t'}'" - msg="$msg — path traversal or disallowed characters" + rest="${lock_out#INVALID$'\t'}" + if [[ "$rest" == *=* ]]; then + msg="$lock_key: rejected ${rest%%=*}='${rest#*=}' — invalid format" + else + msg="$lock_key: rejected '$rest'" + msg="$msg — path traversal or disallowed characters" + fi err "$msg" return fi From 36f94b23e8cbd2cb4d8cef348ff4b61064d1851d Mon Sep 17 00:00:00 2001 From: bastien Date: Mon, 28 Sep 2026 02:35:53 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore(memory):=20BDR-104=20amendment,=20jou?= =?UTF-8?q?rnal,=20TODO=20=E2=80=94=20LOW=20hardening=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/memory/decisions.md | 1 + .claude/memory/journal.md | 1 + .claude/tasks/TODO.md | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.claude/memory/decisions.md b/.claude/memory/decisions.md index 6914473..f77d609 100644 --- a/.claude/memory/decisions.md +++ b/.claude/memory/decisions.md @@ -1307,3 +1307,4 @@ Branch feature/user-writing-web-rules, UNMERGED (human gate). - **Alternatives rejected**: vendor all 22 (bugs + duplicates + 17×~100 tok descriptions); distil only (loses the two deep workflows whose value is their full text); second inline curl loop (duplication; helper instead); `VENDOR_BASE_URL` gated by a companion var (file:// prefix check suffices); grouped toggle pack (per-name like emil). - **Caveats**: vendored prompts change upstream with no diff, pin = review point; GSAP-first content vs [[BDR-005]] `motion` default, site-motion states the allowance; LOW hardening open: `re.match` `$` accepts a trailing newline, `source`/`path`/`sha` lock fields not charset-checked; `make link` + `bash lib/profile.sh apply full` after merge (user); sub-agent leftovers `/tmp/mengto-verify` (user removes). - **Reference**: commits 2a1ad17 (helper + vendoring), ba14b5e (site-motion); contracts `.claude/tasks/contracts/2026-09-27-{mengto-vendor,site-motion-skill}-0002.md`; gates MET, verifiers CONFORME after 3 re-dispatches (frontmatter shape, refresh convention, security env override + traversal), security PASS ×2, `make test` 36 suites green minus 2 pre-existing T16a. Links [[BDR-103]] [[BDR-102]] [[LRN-141]] [[LRN-174]] [[EVAL-033]]. +- **Amendment 2026-09-28**: the two LOW caveats closed on user ask (415b44e): `re.fullmatch` guard, `commit`/`source`/`path` validated before URL construction, 4 more hermetic cases (12). Security PASS, verifier CONFORME 9/9. diff --git a/.claude/memory/journal.md b/.claude/memory/journal.md index 4a7167b..be4c3bf 100644 --- a/.claude/memory/journal.md +++ b/.claude/memory/journal.md @@ -531,3 +531,4 @@ rules: ## 2026-09-28 - Case 7 (MengTo motion pack), user go "l'hybride" → [[BDR-104]]: `lib/vendor-skills.sh` shared helper (agent-skills moved onto it), 5 scroll skills vendored at a965851 (2a1ad17), `skills/site-motion` personal skill + routing (ba14b5e). Two analyzers read 22 skills first; 17 skipped (bugs, duplicates, covered, Codex/Xcode machinery). Gates MET, verifiers CONFORME after 3 re-dispatches (frontmatter shape, update-all refresh convention, security env override + traversal), security PASS ×2, `make test` 36 suites green minus 2 pre-existing T16a, shellcheck clean. [[LRN-174]] [[EVAL-033]]. feature/mengto-site-motion UNMERGED. Open for the user: `make link` + `bash lib/profile.sh apply full`, `rm -rf /tmp/mengto-verify`, LOW hardening (regex trailing newline, source/path/sha charset). +- User: "fais les deux low, et après on merge". Hardening by fresh executor (415b44e): fullmatch guard + lock field validation, 12-case suite; verifier CONFORME 9/9, security PASS 0 findings, full make test 36 suites green minus 2 pre-existing T16a. Merge of feature/mengto-site-motion into develop follows. diff --git a/.claude/tasks/TODO.md b/.claude/tasks/TODO.md index fe36570..b1295d4 100644 --- a/.claude/tasks/TODO.md +++ b/.claude/tasks/TODO.md @@ -15,8 +15,8 @@ executors in parallel, gates replayed (gates.sh → fresh verifier → security) make test 36 suites green minus 2 pre-existing T16a, CHANGELOG, BDR-104 LRN-174 EVAL-033. UNMERGED — human gate. After merge: `make link` + `bash lib/profile.sh apply full`; user removes `/tmp/mengto-verify`. -Follow-up LOW (security): `SAFE` regex `$` accepts a trailing newline (use `\Z`); -`source`/`path`/`commit` lock fields not charset-checked in lib/vendor-skills.sh. +- [x] M4 415b44e LOW hardening on user ask: `re.fullmatch` guard, `commit`/`source`/`path` + validated, 12-case suite; verifier CONFORME, security PASS. Skipped on purpose (analysis 2026-09-27): cinematic-gsap-lenis (reduced-motion bug), cinematic-scroll-storytelling (50 % duplicate), build-awwwards-quality-sites (0 code), animation-systems, gsap, threejs (⊂ ui-ux-pro-max threejs.csv), cobejs, matterjs,