docs: add .agents workspace and the Astro refactor plan
Adds the agent-facing workspace and a 20-task plan for migrating the site to Astro. Nothing here implements the refactor; these are briefs, rules and templates that the task agents read. - .agents/ holds context, rules, checklists, skills, specialist agents, component/page/config templates and gate scripts. It is vendor-neutral so MiniMax, Gemini and Codex can all read it; CLAUDE.md just points at AGENTS.md. - .husky/ plus .lintstagedrc.json wire the three gate tiers. gate.sh locks on the shared git-common-dir so parallel worktrees serialise, and guards the assertion count in scripts/verify.mjs against a coverage drop. - plans/astro-refactor/ carries the phase graph, per-task briefs and the model-routing recommendation. These files must be tracked before fanning out: a worktree only checks out tracked files, so an untracked plan is invisible to every agent working in one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
// Fails when a raw colour, px font-size, or ad-hoc breakpoint appears outside
|
||||
// the token layer. A rule nobody checks is a suggestion — wire this into
|
||||
// `npm run verify`.
|
||||
//
|
||||
// Usage: node .agents/scripts/check-tokens.mjs [srcDir]
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
|
||||
// lint-staged appends staged file paths; a bare run sweeps `src`.
|
||||
const ARGS = process.argv.slice(2);
|
||||
const TOKEN_FILES = ['tokens.css', 'base.css'];
|
||||
const ALLOWED_BREAKPOINTS = ['560px', '800px', '1100px', '1600px', '2200px'];
|
||||
|
||||
const walk = (dir) =>
|
||||
readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
return statSync(path).isDirectory() ? walk(path) : [path];
|
||||
});
|
||||
|
||||
const targets = ARGS.length
|
||||
? ARGS.flatMap((arg) => (statSync(arg).isDirectory() ? walk(arg) : [arg]))
|
||||
: walk('src');
|
||||
|
||||
const findings = [];
|
||||
|
||||
for (const path of targets) {
|
||||
if (!['.astro', '.css'].includes(extname(path))) continue;
|
||||
if (TOKEN_FILES.some((allowed) => path.endsWith(allowed))) continue;
|
||||
|
||||
readFileSync(path, 'utf8').split('\n').forEach((line, index) => {
|
||||
const at = `${path}:${index + 1}`;
|
||||
|
||||
// Raw hex — the drifted-palette failure mode this whole layer exists to stop.
|
||||
const hex = line.match(/#[0-9a-fA-F]{3,8}\b/g);
|
||||
if (hex) findings.push(`${at}: raw hex ${hex.join(', ')} — use a token from tokens.css`);
|
||||
|
||||
// rgb()/hsl() literals are the same problem wearing a different hat.
|
||||
if (/\b(rgba?|hsla?)\(\s*\d/.test(line))
|
||||
findings.push(`${at}: raw colour function — use a token`);
|
||||
|
||||
// Hard-coded font sizes bypass the type scale.
|
||||
const fontSize = line.match(/font-size:\s*\d+(\.\d+)?px/);
|
||||
if (fontSize) findings.push(`${at}: hard-coded ${fontSize[0]} — use var(--step-*)`);
|
||||
|
||||
// Ad-hoc breakpoints are how sixteen of them accumulated last time.
|
||||
const media = line.match(/@media[^{]*?\(\s*(?:max|min)-width:\s*(\d+px)/);
|
||||
if (media && !ALLOWED_BREAKPOINTS.includes(media[1]))
|
||||
findings.push(`${at}: breakpoint ${media[1]} is not a named one (${ALLOWED_BREAKPOINTS.join(', ')})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (findings.length) {
|
||||
console.error(`token check failed — ${findings.length} finding(s):\n`);
|
||||
findings.forEach((finding) => console.error(` ${finding}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('token check passed');
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
// Mechanically extract every { en, pt } string pair from a file or directory,
|
||||
// sorted and normalised, so a content migration can be proven lossless:
|
||||
//
|
||||
// node .agents/scripts/extract-strings.mjs app.js > /tmp/before.json
|
||||
// node .agents/scripts/extract-strings.mjs src/content/ > /tmp/after.json
|
||||
// diff /tmp/before.json /tmp/after.json
|
||||
//
|
||||
// A non-empty diff means you altered content. These are hand-written
|
||||
// translations with deliberate tone — copy them, never retype them.
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const target = process.argv[2];
|
||||
if (!target) {
|
||||
console.error('usage: extract-strings.mjs <file|dir>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const walk = (dir) =>
|
||||
readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
return statSync(path).isDirectory() ? walk(path) : [path];
|
||||
});
|
||||
|
||||
const files = statSync(target).isDirectory() ? walk(target) : [target];
|
||||
|
||||
// Matches `en: '…'` / "en": "…" and the pt counterpart, single or double quoted,
|
||||
// tolerating escaped quotes inside.
|
||||
const PAIR = /["']?\b(en|pt)\b["']?\s*:\s*(['"])((?:\\.|(?!\2)[\s\S])*)\2/g;
|
||||
|
||||
const strings = [];
|
||||
for (const file of files) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(PAIR)) {
|
||||
strings.push({ lang: match[1], value: match[3] });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort so file ordering and structure changes do not show up as content changes.
|
||||
strings.sort((a, b) => (a.lang + a.value).localeCompare(b.lang + b.value));
|
||||
|
||||
console.log(JSON.stringify(strings, null, 2));
|
||||
console.error(`extracted ${strings.length} strings from ${files.length} file(s)`);
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tier 2 gate: types, build, content contracts, dependency audit, tokens.
|
||||
# Invoked by .husky/pre-push, and safe to run by hand at any time.
|
||||
#
|
||||
# Parallel-safe: takes a lock in the SHARED git dir, so ten agents pushing from
|
||||
# ten worktrees queue instead of running ten concurrent Astro builds and
|
||||
# thrashing the machine. Waiters block; they do not fail.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
cd "$root"
|
||||
|
||||
# --git-common-dir resolves to the ONE shared .git across all worktrees, which
|
||||
# is exactly the scope we want the lock to cover.
|
||||
common=$(git rev-parse --git-common-dir)
|
||||
lock="$common/af-gate.lock"
|
||||
|
||||
exec 9>"$lock"
|
||||
if ! flock -n 9; then
|
||||
echo "gate: another worktree is running the gate — waiting for it…"
|
||||
flock 9
|
||||
fi
|
||||
|
||||
started=$(date +%s)
|
||||
step() { printf '\n\033[1m▸ %s\033[0m\n' "$1"; }
|
||||
|
||||
# Fail loudly rather than passing vacuously when the toolchain is not installed.
|
||||
if [ ! -d node_modules ]; then
|
||||
echo "gate: node_modules missing — run 'npm ci --prefer-offline' first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
step "types"
|
||||
npx --no-install astro check
|
||||
|
||||
step "build"
|
||||
npm run build
|
||||
|
||||
step "content contracts"
|
||||
npm run verify
|
||||
|
||||
# The assertion count is the thing agents are most tempted to "fix" downward.
|
||||
# Compare against origin/main and refuse a silent reduction.
|
||||
step "assertion coverage"
|
||||
current=$(grep -c 'throw new Error' scripts/verify.mjs)
|
||||
baseline=$(git show origin/main:scripts/verify.mjs 2>/dev/null | grep -c 'throw new Error' || echo "$current")
|
||||
if [ "$current" -lt "$baseline" ]; then
|
||||
echo "gate: verify.mjs coverage fell from $baseline to $current assertions." >&2
|
||||
echo " Only verification-engineer may reduce it, with a reason per removal." >&2
|
||||
echo " See .agents/context/verification.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " $current assertions (baseline $baseline)"
|
||||
|
||||
step "runtime dependency audit"
|
||||
node scripts/audit-ui.mjs
|
||||
|
||||
step "design tokens"
|
||||
node .agents/scripts/check-tokens.mjs
|
||||
|
||||
printf '\n\033[32mgate passed\033[0m in %ss\n' "$(( $(date +%s) - started ))"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# `.agents/` is the vendor-neutral home for this project's rules, skills, and
|
||||
# agent definitions — MiniMax, Gemini, and Codex all read plain files from it.
|
||||
#
|
||||
# Claude Code, however, discovers subagents and skills at fixed paths. This
|
||||
# symlinks them so there is exactly ONE copy of every definition and no drift.
|
||||
#
|
||||
# .agents/scripts/install-claude-agents.sh
|
||||
set -euo pipefail
|
||||
|
||||
root="$(git rev-parse --show-toplevel)"
|
||||
cd "$root"
|
||||
|
||||
mkdir -p .claude
|
||||
|
||||
for target in agents skills; do
|
||||
link=".claude/$target"
|
||||
if [ -e "$link" ] && [ ! -L "$link" ]; then
|
||||
echo "refusing: $link exists and is not a symlink — move it aside first" >&2
|
||||
exit 1
|
||||
fi
|
||||
ln -sfn "../.agents/$target" "$link"
|
||||
echo "linked $link -> .agents/$target"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "Claude Code will now load:"
|
||||
ls -1 .agents/agents/*.md | sed 's|.*/| agent: |;s|\.md$||'
|
||||
ls -1d .agents/skills/*/ | sed 's|.*/skills/| skill: |;s|/$||'
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
// Rendered-text snapshot of one route. This is the migration's regression net:
|
||||
// token matching in verify.mjs cannot catch a dropped paragraph, this can.
|
||||
//
|
||||
// Usage:
|
||||
// node .agents/scripts/snapshot-route.mjs http://localhost:4173/models/
|
||||
// node .agents/scripts/snapshot-route.mjs dist/models/index.html
|
||||
//
|
||||
// Take a snapshot from the vanilla site BEFORE migrating, then diff the built
|
||||
// output against it. An empty diff is the proof that nothing was lost.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const target = process.argv[2];
|
||||
if (!target) {
|
||||
console.error('usage: snapshot-route.mjs <url|path>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const html = target.startsWith('http')
|
||||
? await (await fetch(target)).text()
|
||||
: readFileSync(target, 'utf8');
|
||||
|
||||
const text = html
|
||||
// Drop anything that is not user-visible prose.
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/<[^>]+>/g, '\n')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/�?39;/g, "'").replace(/ /g, ' ')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
console.log(text);
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Guards the silent-failure mode described in .agents/rules/gates.md.
|
||||
#
|
||||
# Husky points core.hooksPath at `.husky/_`, but that directory is GENERATED by
|
||||
# `npm install` and is NOT committed. A fresh `git worktree add` therefore has
|
||||
# hooks configured and the directory missing — so every hook silently does
|
||||
# nothing and every commit passes unchecked.
|
||||
#
|
||||
# Run this in any worktree you did not create with worktree.sh.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
fail=0
|
||||
|
||||
path=$(git config --get core.hooksPath || true)
|
||||
if [ -z "$path" ]; then
|
||||
echo "✗ core.hooksPath is unset — husky was never installed here"
|
||||
fail=1
|
||||
else
|
||||
echo "✓ core.hooksPath = $path"
|
||||
fi
|
||||
|
||||
if [ ! -d "${path:-.husky/_}" ]; then
|
||||
echo "✗ ${path:-.husky/_}/ does not exist — HOOKS ARE NOT RUNNING"
|
||||
fail=1
|
||||
else
|
||||
echo "✓ ${path} exists"
|
||||
fi
|
||||
|
||||
for hook in pre-commit commit-msg pre-push; do
|
||||
if [ -f ".husky/$hook" ]; then
|
||||
echo "✓ .husky/$hook present"
|
||||
else
|
||||
echo "✗ .husky/$hook missing"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -d node_modules ]; then
|
||||
echo "✗ node_modules missing — lint-staged and astro check cannot run"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo
|
||||
echo "Fix: npm ci --prefer-offline (its prepare script regenerates .husky/_)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "hooks are live in this worktree"
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Spin up (or tear down) an isolated worktree for one refactor task.
|
||||
#
|
||||
# .agents/scripts/worktree.sh start 07 route-cards
|
||||
# .agents/scripts/worktree.sh finish 07 route-cards
|
||||
#
|
||||
# One task, one worktree, one agent. See .agents/rules/git-worktrees.md
|
||||
set -euo pipefail
|
||||
|
||||
action=${1:?usage: worktree.sh <start|finish> <task-number> <slug>}
|
||||
number=${2:?task number, e.g. 07}
|
||||
slug=${3:?slug, e.g. route-cards}
|
||||
|
||||
dir="../af-task-${number}"
|
||||
branch="refactor/task-${number}-${slug}"
|
||||
plan="plans/astro-refactor/task-${number}-${slug}.md"
|
||||
|
||||
case "$action" in
|
||||
start)
|
||||
[ -f "$plan" ] || echo "warning: no plan at $plan — check the task number" >&2
|
||||
git fetch origin
|
||||
git worktree add "$dir" -b "$branch" origin/main
|
||||
|
||||
# Not optional. `.husky/_` is generated by install and is NOT committed, so
|
||||
# a fresh worktree has hooks configured but absent — every commit would pass
|
||||
# unchecked. --prefer-offline keeps ten parallel spin-ups off the registry.
|
||||
( cd "$dir" && npm ci --prefer-offline && .agents/scripts/verify-hooks.sh )
|
||||
|
||||
echo
|
||||
echo "worktree : $dir"
|
||||
echo "branch : $branch"
|
||||
echo "brief : $plan"
|
||||
echo
|
||||
echo "next: cd $dir && read the brief end to end before writing anything"
|
||||
;;
|
||||
finish)
|
||||
# Never remove a worktree with uncommitted work in it.
|
||||
if [ -n "$(git -C "$dir" status --porcelain)" ]; then
|
||||
echo "refusing: $dir has uncommitted changes" >&2
|
||||
git -C "$dir" status --short >&2
|
||||
exit 1
|
||||
fi
|
||||
git worktree remove "$dir"
|
||||
echo "removed $dir — branch $branch kept; delete it after the merge lands"
|
||||
;;
|
||||
*)
|
||||
echo "unknown action: $action (expected start|finish)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user