Files
accounted/scripts/generate-skill-bodies.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

250 lines
9.3 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Generate a Supabase seed migration that inlines every atom SKILL.md body into
* agent_atom_registry.body.
*
* Why a generated migration (not a runtime/seed-script write):
* - Vercel's build has no DB, so prebuild seeding can't write.
* - Skill bodies must reach prod via the one deploy path the project trusts:
* supabase/migrations applied on deploy. The SQL is generated, never hand-authored.
* - It also fixes the "registry never seeded" case (the manual seed may never have
* run in prod): the migration populates the rows on deploy.
*
* Determinism:
* - Atoms are emitted sorted by id; bodies are dollar-quoted with a collision-proof
* tag; `version` is derived from a committed content-hash manifest
* (scripts/.skill-body-manifest.json) so it only bumps when a SKILL.md changes.
* - A no-change run emits NOTHING (byte-identical repo). A change emits ONE new
* timestamped migration (append-only: we never edit an existing migration).
*
* Usage:
* npx tsx scripts/generate-skill-bodies.ts # emit a migration if skills changed
* npx tsx scripts/generate-skill-bodies.ts --check # CI guard: exit 1 if a skill changed
* # without a regenerated migration
*/
import { createHash } from 'node:crypto'
import { readdir, writeFile } from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { discoverAtoms, type DiscoveredAtom } from './lib/atom-discovery'
const __filename = fileURLToPath(import.meta.url)
const ROOT = dirname(dirname(__filename))
const MIGRATIONS_DIR = join(ROOT, 'supabase', 'migrations')
const MANIFEST_PATH = join(ROOT, 'scripts', '.skill-body-manifest.json')
const checkOnly = process.argv.includes('--check')
interface ManifestEntry {
hash: string
version: number
}
type Manifest = Record<string, ManifestEntry>
function sha256(s: string): string {
return createHash('sha256').update(s, 'utf8').digest('hex')
}
function loadManifest(): Manifest {
if (!existsSync(MANIFEST_PATH)) return {}
try {
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Manifest
} catch {
return {}
}
}
async function saveManifest(manifest: Manifest): Promise<void> {
// Sorted keys for stable diffs.
const sorted: Manifest = {}
for (const id of Object.keys(manifest).sort()) sorted[id] = manifest[id]
await writeFile(MANIFEST_PATH, JSON.stringify(sorted, null, 2) + '\n', 'utf8')
}
/** Wrap a string in a dollar-quote tag guaranteed not to appear inside it. */
function dollarQuote(s: string): string {
let tag = '$gb$'
let n = 0
while (s.includes(tag)) {
tag = `$gb${n}$`
n++
}
return `${tag}${s}${tag}`
}
/** Single-quoted SQL text literal (apostrophes doubled). For short, simple values. */
function sqlStr(s: string): string {
return `'${s.replace(/'/g, "''")}'`
}
function sqlTextArray(arr: string[]): string {
if (arr.length === 0) return `'{}'::text[]`
return `ARRAY[${arr.map(sqlStr).join(', ')}]::text[]`
}
/** Next migration timestamp = max existing 14-digit prefix + 1 (guarantees ordering). */
async function nextMigrationTimestamp(): Promise<string> {
const files = await readdir(MIGRATIONS_DIR)
// 14-digit timestamps (max ~1e14) are well within Number.MAX_SAFE_INTEGER (~9e15).
let max = 0
for (const f of files) {
const m = /^(\d{14})_/.exec(f)
if (m) {
const n = Number(m[1])
if (n > max) max = n
}
}
return String(max + 1).padStart(14, '0')
}
function buildValuesRow(atom: DiscoveredAtom, version: number): string {
const triggerJson = JSON.stringify(atom.trigger_signals ?? {})
return [
' (',
` ${sqlStr(atom.id)},`,
` ${sqlStr(atom.tier)},`,
` ${sqlStr(atom.title)},`,
` ${dollarQuote(atom.description)},`,
` ${sqlTextArray(atom.sni_prefixes)},`,
` ${dollarQuote(triggerJson)}::jsonb,`,
` ${atom.estimated_tokens},`,
` ${sqlStr(atom.body_path)},`,
` ${dollarQuote(atom.body)},`,
` ${atom.parent_atom_id ? sqlStr(atom.parent_atom_id) : 'NULL'},`,
` ${version},`,
` ${atom.schema_version}`,
' )',
].join('\n')
}
export function buildMigrationSql(atoms: DiscoveredAtom[], versions: Record<string, number>): string {
const header = `-- AUTO-GENERATED by scripts/generate-skill-bodies.ts: DO NOT EDIT BY HAND.
-- Regenerate with \`npm run skills:generate\` after editing any .claude/skills/**/SKILL.md.
--
-- Seeds agent_atom_registry rows with their SKILL.md body so the MCP server and the
-- in-app agent read skill content from the DB (works on Vercel, Docker, self-hosted).
-- Idempotent: ON CONFLICT refreshes content fields but leaves is_active and
-- mcp_exposed under manual control (they take column defaults on first insert).
`
const values = atoms.map((a) => buildValuesRow(a, versions[a.id])).join(',\n')
const insert = `INSERT INTO public.agent_atom_registry
(id, tier, title, description, sni_prefixes, trigger_signals, estimated_tokens, body_path, body, parent_atom_id, version, schema_version)
VALUES
${values}
ON CONFLICT (id) DO UPDATE SET
tier = EXCLUDED.tier,
title = EXCLUDED.title,
description = EXCLUDED.description,
sni_prefixes = EXCLUDED.sni_prefixes,
trigger_signals = EXCLUDED.trigger_signals,
estimated_tokens = EXCLUDED.estimated_tokens,
body_path = EXCLUDED.body_path,
body = EXCLUDED.body,
parent_atom_id = EXCLUDED.parent_atom_id,
version = EXCLUDED.version,
schema_version = EXCLUDED.schema_version,
updated_at = now();
`
return `${header}\n${insert}\nNOTIFY pgrst, 'reload schema';\n`
}
async function main() {
const atoms = await discoverAtoms(ROOT)
if (atoms.length === 0) {
console.error('No atoms discovered under .claude/skills/: refusing to emit an empty seed.')
process.exit(1)
}
// Dangling references/ links (mcp_optimization_plan P2-2): an atom body
// pointing at a references/ file that does not exist ships a 404 to every
// agent that follows it. Fails BOTH modes so a dangling pointer can never
// reach the registry. Top-level atoms only: reference children live inside
// references/ themselves, so relative links would double-resolve.
const refLinkRe = /(?:\]\(|\b)\.?\/?(references\/[A-Za-z0-9._/-]+\.md)/g
const danglingRefs: string[] = []
for (const atom of atoms) {
if (atom.parent_atom_id) continue
const baseDir = dirname(join(ROOT, atom.body_path))
const seen = new Set<string>()
for (const m of atom.body.matchAll(refLinkRe)) {
const rel = m[1]
if (seen.has(rel)) continue
seen.add(rel)
if (!existsSync(join(baseDir, rel))) danglingRefs.push(`${atom.id}: ${rel}`)
}
}
if (danglingRefs.length > 0) {
console.error('✗ dangling references/ link(s) in atom bodies: create the file or remove the pointer:')
for (const d of danglingRefs) console.error(` ${d}`)
process.exit(1)
}
const manifest = loadManifest()
const onDisk = new Map(atoms.map((a) => [a.id, sha256(a.body)]))
// Drift = added, changed, or removed atoms vs. the committed manifest.
const added: string[] = []
const changed: string[] = []
for (const [id, hash] of onDisk) {
const prev = manifest[id]
if (!prev) added.push(id)
else if (prev.hash !== hash) changed.push(id)
}
const removed = Object.keys(manifest).filter((id) => !onDisk.has(id))
const hasDrift = added.length > 0 || changed.length > 0 || removed.length > 0
if (checkOnly) {
if (!hasDrift) {
console.log(`✓ skill bodies up to date (${atoms.length} atoms).`)
return
}
console.error('✗ skill bodies are STALE: a SKILL.md changed without regenerating the seed migration.')
if (added.length) console.error(` added: ${added.join(', ')}`)
if (changed.length) console.error(` changed: ${changed.join(', ')}`)
if (removed.length) console.error(` removed: ${removed.join(', ')}`)
console.error('\nRun `npm run skills:generate` and commit the emitted migration.')
process.exit(1)
}
if (!hasDrift) {
console.log(`✓ no skill changes (${atoms.length} atoms): nothing to generate.`)
return
}
// Compute versions: bump only changed atoms; new atoms start at 1.
const newManifest: Manifest = {}
const versions: Record<string, number> = {}
for (const atom of atoms) {
const hash = onDisk.get(atom.id)!
const prev = manifest[atom.id]
const version = !prev ? 1 : prev.hash === hash ? prev.version : prev.version + 1
versions[atom.id] = version
newManifest[atom.id] = { hash, version }
}
const ts = await nextMigrationTimestamp()
const fileName = `${ts}_seed_agent_atom_bodies.sql`
const filePath = join(MIGRATIONS_DIR, fileName)
await writeFile(filePath, buildMigrationSql(atoms, versions), 'utf8')
await saveManifest(newManifest)
console.log(`Wrote supabase/migrations/${fileName} (${atoms.length} atoms).`)
if (added.length) console.log(` added: ${added.join(', ')}`)
if (changed.length) console.log(` changed: ${changed.join(', ')}`)
if (removed.length) console.log(` removed (dropped from manifest, row left as-is in DB): ${removed.join(', ')}`)
}
// Only run when invoked directly (not when imported by tests).
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}