chore: consolidate the skill packages under .agents/

The repository had two skill directories. `skills/` held the four written
for this project; `.agents/skills/` held the ones the agent context refers
to. Nothing said which an agent should read, and `.agents/ORCHESTRATOR.md`
only ever pointed at the second.

Move the first four into `.agents/skills/` so there is one location, and
add the vendored packages this chapter work used: `animation-vocabulary`
and `improve-animations` (emilkowalski/skills), `teach` (mattpocock/skills),
plus a local `translation` skill and `audit-translations.mjs` for the EN/PT
pairs.

`skills-lock.json` pins the vendored three by source and content hash, so a
later re-vendor is a diff rather than a guess. `.claude/skills/` is
symlinks into `.agents/skills/`, which is what makes them loadable here
without a second copy on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-09-06 16:03:23 -03:00
parent 61de2f6270
commit ab2308441c
38 changed files with 2662 additions and 122 deletions
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
// Walk every entry in src/content/** and flag any localized field whose
// `en` and `pt` values are identical. Identical pairs are how a bilingual
// site quietly becomes monolingual — the schema accepts them, the build
// passes, and a Portuguese speaker sees English.
//
// node .agents/scripts/audit-translations.mjs # walks src/content
// node .agents/scripts/audit-translations.mjs src/content/ # explicit root
//
// Exits non-zero if any pair is identical. The output is grouped by
// collection, then by file, then by field path, so the report reads like
// a translation backlog rather than a wall of strings.
//
// This is the sibling of extract-strings.mjs: that one proves the
// migration moved every string; this one proves every string actually
// differs across locales.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const ROOT = process.argv[2] ?? 'src/content';
const walk = (dir) =>
readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
return statSync(path).isDirectory() ? walk(path) : [path];
});
const files = walk(ROOT).filter((path) => path.endsWith('.json'));
// Returns an array of [fieldPath, en, pt] tuples for a parsed document.
// Handles the two shapes in the project:
// 1. { ..., someKey: { en, pt }, ... } — the `localized` Zod helper
// 2. { en: { ...string IDs... }, pt: { ...string IDs... } } — rules/copy.json
const collect = (node, fieldPath = '', out = []) => {
if (!node || typeof node !== 'object') return out;
if ('en' in node && 'pt' in node) {
out.push([fieldPath || '(root)', node.en, node.pt]);
return out;
}
for (const [key, value] of Object.entries(node)) {
const next = fieldPath ? `${fieldPath}.${key}` : key;
// Top-level `{ en: {...}, pt: {...} }` — recurse into each side.
if (
(key === 'en' || key === 'pt') &&
value &&
typeof value === 'object' &&
!Array.isArray(value)
) {
collect(value, '', out);
} else {
collect(value, next, out);
}
}
return out;
};
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// Compute the collection name from the path relative to ROOT.
// src/content/chapters/landing.json -> chapters
// src/content/rules/copy.json -> rules
const collectionOf = (file, root) => {
const rel = relative(root, file);
const parts = rel.split('/');
// ['chapters', 'landing.json'] -> 'chapters'
return parts.length >= 2 ? parts[0] : '(root)';
};
const groups = new Map();
let totalLocalized = 0;
let totalIdentical = 0;
for (const file of files) {
let data;
try {
data = JSON.parse(readFileSync(file, 'utf8'));
} catch (err) {
console.error(`skip: ${file} is not valid JSON (${err.message})`);
continue;
}
const pairs = collect(data);
if (pairs.length === 0) continue;
const offenders = pairs.filter(([, en, pt]) => same(en, pt));
if (offenders.length === 0) continue;
totalLocalized += pairs.length;
totalIdentical += offenders.length;
const collection = collectionOf(file, ROOT);
const filename = relative(ROOT, file);
if (!groups.has(collection)) groups.set(collection, []);
groups.get(collection).push({ file: filename, pairs: offenders });
}
if (groups.size === 0) {
console.log('OK: no identical en/pt pairs found under', ROOT);
process.exit(0);
}
// Print grouped report.
for (const [collection, entries] of groups) {
console.log(`\n[${collection}]`);
for (const { file, pairs } of entries) {
console.log(` ${file}`);
for (const [field, en, pt] of pairs) {
const sample = typeof en === 'string' ? JSON.stringify(en).slice(0, 80) : '<non-string>';
console.log(` - ${field.padEnd(28)} ${sample}`);
}
}
}
console.log(
`\nFAIL: ${totalIdentical} identical localized field(s) across ${groups.size} collection(s) of ${ROOT}`,
);
console.log(
'A `pt` identical to `en` is how a bilingual site becomes monolingual. Translate, then re-run.',
);
process.exit(1);