Files
accounted/scripts/generate-skill-bodies.ts
T
Jakob WennbergandClaude Fable 5 678f2ccffd feat(mcp): P2 hygiene — honest category suggestions, skill-reference lint, cadence copy (#882)
* feat(mcp): counterparty-tied category suggestions + no_signal (P2-1)

suggest_categories padded every transaction with a company-wide
category-frequency fallback at <=0.5 confidence — an identical four-way
spread on 20+/24 items that agents correctly reported as pure noise
(agent.feedback). Real signal came from memory atoms and query_journal.

- History is now counterparty-keyed: buildMerchantHistory groups past
  categorized transactions by normalized merchant; the engine only
  surfaces history for THIS transaction's merchant, with provenance
  ('Bokförd N gånger tidigare för denna motpart') and occurrence-scaled
  confidence (0.56 at 1x, capped 0.85). No global padding — an empty
  list is the honest answer.
- The MCP tool returns no_signal_transaction_ids for transactions where
  NO source matched, steering agents to investigate (query_journal)
  instead of pattern-matching on unrelated rows.
- Both callers (REST suggest-categories route + MCP tool) share the new
  helpers, so web UI and agents improve together.

Part of dev_docs/mcp_optimization_plan.md (P2-1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): dangling-reference validation in skills:check + fix 10 dangling links (P2-2)

skills:generate/check now fail when an atom SKILL.md links a
references/*.md that does not exist on disk — a dangling pointer ships
a 404 to every agent that follows it (the weekly-booking-check
incident, agent.feedback).

The validator immediately caught 10 live dangling links in 4 atoms,
three distinct flavors:
- filename typo: swedish-asset-accounting/references/depreciaton.md
  renamed to depreciation.md (the link was right, the file misspelled)
- link mismatch: swedish-e-invoicing linked market-providers-pricing.md;
  the file is market-provider-pricing.md (link fixed)
- unauthored plans: single-shareholder-ab-fmb TODOs and reklambyra's
  'planerad utbyggnad' section used resolvable references/ paths for
  files that were never written — rephrased as plans without paths

Seed migration regenerated (4 atoms bumped, renamed reference child).

Part of dev_docs/mcp_optimization_plan.md (P2-2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(events): align agent-feedback review cadence copy (P2-4)

gnubok_feedback replies 'we aggregate signal weekly'; the event-log
handler comment said quarterly. One of them was lying — weekly wins
(the mcp_optimization_plan triage is the living example).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:48:29 +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)
})
}