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:
Marcos Paulo
2026-09-05 01:18:27 +00:00
parent aa85c1d0b7
commit aae4d42229
79 changed files with 3805 additions and 0 deletions
+59
View File
@@ -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');
+45
View File
@@ -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)`);
+61
View File
@@ -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 ))"
+29
View File
@@ -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|/$||'
+37
View File
@@ -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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#0?39;/g, "'").replace(/&nbsp;/g, ' ')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join('\n');
console.log(text);
+51
View File
@@ -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"
+50
View File
@@ -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