Ctrl K
ComunidadGuías…

scripts/generate-skill-bodies.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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},`,
    `    ${atom.mcp_exposed ?? true},`,
    `    ${atom.reviewed_at ? sqlStr(atom.reviewed_at) : 'NULL'}`,
    '  )',
  ].join('\n')
}

export function buildMigrationSql(atoms: DiscoveredAtom[], versions: Record<string, number>, removed: string[] = [], developerOnly: string[] = []): 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).
-- The WHERE guard skips rows whose registry version is newer than this seed's:
-- two branches can each carry a full seed, and the one that applies last can
-- no longer downgrade atoms the other already bumped.
`

  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, mcp_exposed, reviewed_at)
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,
  mcp_exposed = CASE WHEN EXCLUDED.tier = 'community' THEN EXCLUDED.mcp_exposed ELSE public.agent_atom_registry.mcp_exposed END,
  reviewed_at = COALESCE(EXCLUDED.reviewed_at, public.agent_atom_registry.reviewed_at),
  updated_at = now()
WHERE public.agent_atom_registry.version <= EXCLUDED.version;
`

  const withdrawal = removed.filter((id) => id.startsWith('community/'))
  const disable = withdrawal.length ? `UPDATE public.agent_atom_registry SET is_active = false, mcp_exposed = false WHERE id IN (${withdrawal.map(sqlStr).join(', ')});\n` : ''
  // Developer-audience references never reach an agent: earlier seeds may have
  // shipped them, so switch those rows off by id (kept, never deleted). Moving a
  // file back to `agent` re-seeds its body but leaves is_active to a person.
  const developer = developerOnly.length ? `UPDATE public.agent_atom_registry SET is_active = false, mcp_exposed = false WHERE id IN (${developerOnly.map(sqlStr).join(', ')});\n` : ''
  return `${header}\n${insert}\n${disable}${developer}\nNOTIFY pgrst, 'reload schema';\n`
}

async function main() {
  const discovered = await discoverAtoms(ROOT)
  const atoms = discovered.filter((a) => a.audience === 'agent')
  const developerOnly = discovered.filter((a) => a.audience === 'developer').map((a) => a.id)
  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.tier === 'community' ? JSON.stringify([a.body, a.title, a.description, a.mcp_exposed, a.reviewed_at]) : 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, removed, developerOnly), '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(', ')}`)
  const withdrawn = removed.filter((id) => id.startsWith('community/'))
  if (withdrawn.length) console.log(`  removed (community rows disabled): ${withdrawn.join(', ')}`)
  if (developerOnly.length) console.log(`  developer-only (rows switched off): ${developerOnly.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)
  })
}