CSV Config Pipeline Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace hardcoded TypeScript constants and YAML-in-markdown control files with a CSV → JSON pipeline, pre-populated with all existing values, with round-trip verification.
Architecture: Individual sync scripts per domain (categories, models, challenge, taxonomy, seed-controls, app-controls) share a common CSV parsing/validation/joining library. Existing TypeScript files become thin wrappers importing from generated JSON. Export scripts extract current constants to CSV. Verification proves the round-trip is lossless.
Tech Stack: Bun, TypeScript, csv-parse, Zod, csv-stringify (for export)
Task 1: Shared Library — CSV Reader + Validator
Files:
- Create:
scripts/config/lib/csv-reader.ts - Create:
scripts/config/lib/validate.ts - Test:
scripts/config/lib/__tests__/csv-reader.test.ts - Test:
scripts/config/lib/__tests__/validate.test.ts
Step 1: Write failing test for csv-reader
// scripts/config/lib/__tests__/csv-reader.test.ts
import { describe, test, expect } from 'vitest'
import { readCsv } from '../csv-reader'
import { writeFileSync, mkdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
const TMP = join(import.meta.dirname, '.tmp-test')
describe('readCsv', () => {
beforeEach(() => mkdirSync(TMP, { recursive: true }))
afterEach(() => rmSync(TMP, { recursive: true, force: true }))
test('parses CSV with headers into typed rows', () => {
const csv = 'name,count\nalpha,10\nbeta,20\n'
writeFileSync(join(TMP, 'test.csv'), csv)
const rows = readCsv(join(TMP, 'test.csv'))
expect(rows).toEqual([
{ name: 'alpha', count: '10', __source: { file: expect.stringContaining('test.csv'), line: 2 } },
{ name: 'beta', count: '20', __source: { file: expect.stringContaining('test.csv'), line: 3 } },
])
})
test('handles quoted fields with commas and newlines', () => {
const csv = 'slug,prompt\nhistory,"Ancient civilizations, empires"\n'
writeFileSync(join(TMP, 'quoted.csv'), csv)
const rows = readCsv(join(TMP, 'quoted.csv'))
expect(rows[0].prompt).toBe('Ancient civilizations, empires')
})
test('resolves @file: references', () => {
writeFileSync(join(TMP, 'long.txt'), 'Multi-line\ntext content')
const csv = 'key,value\ntone,@file:' + join(TMP, 'long.txt') + '\n'
writeFileSync(join(TMP, 'refs.csv'), csv)
const rows = readCsv(join(TMP, 'refs.csv'))
expect(rows[0].value).toBe('Multi-line\ntext content')
})
test('throws on missing file', () => {
expect(() => readCsv(join(TMP, 'nope.csv'))).toThrow()
})
})
Step 2: Run test to verify it fails
Run: bunx vitest run scripts/config/lib/__tests__/csv-reader.test.ts
Expected: FAIL — module not found
Step 3: Implement csv-reader
// scripts/config/lib/csv-reader.ts
import { readFileSync, existsSync } from 'node:fs'
import { parse } from 'csv-parse/sync'
import { resolve, dirname } from 'node:path'
export interface CsvRow extends Record<string, string> {
__source: { file: string; line: number }
}
/**
* Parse a CSV file into typed rows with source tracking.
* Supports @file: references in cell values — resolves relative to CSV file location.
*/
export function readCsv(filePath: string): CsvRow[] {
const absPath = resolve(filePath)
if (!existsSync(absPath)) {
throw new Error(`CSV file not found: ${absPath}`)
}
const content = readFileSync(absPath, 'utf-8')
const records: Record<string, string>[] = parse(content, {
columns: true,
skip_empty_lines: true,
trim: true,
relax_quotes: true,
})
const csvDir = dirname(absPath)
return records.map((record, index) => {
const resolved: Record<string, string> = {}
for (const [key, value] of Object.entries(record)) {
if (typeof value === 'string' && value.startsWith('@file:')) {
const refPath = resolve(csvDir, value.slice(6))
if (!existsSync(refPath)) {
throw new Error(`@file: reference not found: ${refPath} (in ${absPath}:${index + 2}:${key})`)
}
resolved[key] = readFileSync(refPath, 'utf-8')
} else {
resolved[key] = value
}
}
return {
...resolved,
__source: { file: absPath, line: index + 2 }, // +2 for header + 0-index
} as CsvRow
})
}
Step 4: Run tests
Run: bunx vitest run scripts/config/lib/__tests__/csv-reader.test.ts
Expected: PASS
Step 5: Write failing test for validate
// scripts/config/lib/__tests__/validate.test.ts
import { describe, test, expect } from 'vitest'
import { validateRows } from '../validate'
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1),
count: z.coerce.number().int().positive(),
})
describe('validateRows', () => {
test('passes valid rows', () => {
const rows = [
{ name: 'alpha', count: '10', __source: { file: 'test.csv', line: 2 } },
]
const result = validateRows(rows, schema, 'test.csv')
expect(result.valid).toBe(true)
expect(result.parsed).toHaveLength(1)
expect(result.parsed[0].count).toBe(10) // coerced to number
})
test('reports errors with file:line:column', () => {
const rows = [
{ name: '', count: '10', __source: { file: 'test.csv', line: 2 } },
{ name: 'beta', count: '-5', __source: { file: 'test.csv', line: 3 } },
]
const result = validateRows(rows, schema, 'test.csv')
expect(result.valid).toBe(false)
expect(result.errors).toHaveLength(2)
expect(result.errors[0]).toContain('test.csv:2')
expect(result.errors[1]).toContain('test.csv:3')
})
})
Step 6: Implement validate
// scripts/config/lib/validate.ts
import type { z } from 'zod'
import type { CsvRow } from './csv-reader'
export interface ValidationResult<T> {
valid: boolean
parsed: T[]
errors: string[]
}
/**
* Validate CSV rows against a Zod schema. Returns parsed rows (with coercion)
* and any validation errors with file:line precision.
*/
export function validateRows<T>(
rows: CsvRow[],
schema: z.ZodType<T>,
fileName: string,
): ValidationResult<T> {
const parsed: T[] = []
const errors: string[] = []
for (const row of rows) {
const { __source, ...data } = row
const result = schema.safeParse(data)
if (result.success) {
parsed.push(result.data)
} else {
for (const issue of result.error.issues) {
const col = issue.path.join('.')
errors.push(`${fileName}:${__source.line}:${col} — ${issue.message}`)
}
}
}
return { valid: errors.length === 0, parsed, errors }
}
Step 7: Run all tests
Run: bunx vitest run scripts/config/lib/__tests__/
Expected: ALL PASS
Step 8: Commit
git add scripts/config/lib/
git commit -m "feat(config): add CSV reader and Zod validator with source tracking"
Task 2: Shared Library — Companion CSV Joiner
Files:
- Create:
scripts/config/lib/join.ts - Test:
scripts/config/lib/__tests__/join.test.ts
Step 1: Write failing test
// scripts/config/lib/__tests__/join.test.ts
import { describe, test, expect } from 'vitest'
import { joinCompanions } from '../join'
describe('joinCompanions', () => {
test('nests companion rows by FK into primary records', () => {
const primary = [
{ slug: 'sports', guidance: 'Lead with scores' },
{ slug: 'history', guidance: 'Use era names' },
]
const companion = [
{ slug: 'sports', term: 'roster depth', use: 'player count' },
{ slug: 'sports', term: 'walk-off', use: 'game-ending play' },
{ slug: 'history', term: 'primary source', use: 'contemporary document' },
]
const result = joinCompanions(primary, 'slug', [
{ rows: companion, nestAs: 'domain_terms', joinKey: 'slug' },
])
expect(result.sports.domain_terms).toHaveLength(2)
expect(result.sports.domain_terms[0]).toEqual({ term: 'roster depth', use: 'player count' })
expect(result.history.domain_terms).toHaveLength(1)
})
test('supports flatten option for single-value arrays', () => {
const primary = [{ slug: 'sports', guidance: 'text' }]
const companion = [
{ slug: 'sports', pattern: 'Listing raw stats' },
{ slug: 'sports', pattern: 'Using passive voice' },
]
const result = joinCompanions(primary, 'slug', [
{ rows: companion, nestAs: 'avoid', joinKey: 'slug', flatten: 'pattern' },
])
expect(result.sports.avoid).toEqual(['Listing raw stats', 'Using passive voice'])
})
test('supports dot-notation nesting', () => {
const primary = [{ slug: 'sports', guidance: 'text' }]
const companion = [
{ slug: 'sports', term: 'walk-off', use: 'game ender' },
]
const result = joinCompanions(primary, 'slug', [
{ rows: companion, nestAs: 'vocabulary.domain_terms', joinKey: 'slug' },
])
expect(result.sports.vocabulary.domain_terms).toEqual([
{ term: 'walk-off', use: 'game ender' },
])
})
test('returns empty arrays for primary records with no companion matches', () => {
const primary = [{ slug: 'math', guidance: 'text' }]
const result = joinCompanions(primary, 'slug', [
{ rows: [], nestAs: 'terms', joinKey: 'slug' },
])
expect(result.math.terms).toEqual([])
})
})
Step 2: Run test to verify it fails
Run: bunx vitest run scripts/config/lib/__tests__/join.test.ts
Expected: FAIL
Step 3: Implement join
// scripts/config/lib/join.ts
export interface CompanionDef {
rows: Record<string, unknown>[]
nestAs: string // dot-notation path, e.g. 'vocabulary.domain_terms'
joinKey: string // FK column name in companion
flatten?: string // if set, extract this single column as string[] instead of object[]
}
/**
* Join companion CSV rows into primary records by foreign key.
* Supports dot-notation nesting and single-column flattening.
*/
export function joinCompanions<T extends Record<string, unknown>>(
primaryRows: T[],
primaryKey: string,
companions: CompanionDef[],
): Record<string, T & Record<string, unknown>> {
const result: Record<string, T & Record<string, unknown>> = {}
// Index primary rows by key
for (const row of primaryRows) {
const key = String(row[primaryKey])
result[key] = { ...row }
}
// Group and nest each companion
for (const companion of companions) {
// Group companion rows by FK
const grouped = new Map<string, Record<string, unknown>[]>()
for (const row of companion.rows) {
const fk = String(row[companion.joinKey])
if (!grouped.has(fk)) grouped.set(fk, [])
// Remove the FK column from the nested object
const { [companion.joinKey]: _, __source: _s, ...rest } = row
grouped.get(fk)!.push(rest)
}
// Nest into primary records
for (const [key, primary] of Object.entries(result)) {
const companionRows = grouped.get(key) ?? []
const value = companion.flatten
? companionRows.map(r => r[companion.flatten!])
: companionRows
setNestedPath(primary, companion.nestAs, value)
}
}
return result
}
/** Set a value at a dot-notation path, creating intermediate objects */
function setNestedPath(obj: Record<string, unknown>, path: string, value: unknown): void {
const parts = path.split('.')
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
if (!(parts[i] in current) || typeof current[parts[i]] !== 'object') {
current[parts[i]] = {}
}
current = current[parts[i]] as Record<string, unknown>
}
current[parts[parts.length - 1]] = value
}
Step 4: Run tests
Run: bunx vitest run scripts/config/lib/__tests__/join.test.ts
Expected: ALL PASS
Step 5: Commit
git add scripts/config/lib/join.ts scripts/config/lib/__tests__/join.test.ts
git commit -m "feat(config): add companion CSV joiner with dot-notation nesting"
Task 3: Shared Library — Checksum + JSON Writer
Files:
- Create:
scripts/config/lib/checksum.ts - Create:
scripts/config/lib/writer.ts - Test:
scripts/config/lib/__tests__/checksum.test.ts
Step 1: Write failing test
// scripts/config/lib/__tests__/checksum.test.ts
import { describe, test, expect } from 'vitest'
import { computeChecksum, isStale } from '../checksum'
import { writeFileSync, mkdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
const TMP = join(import.meta.dirname, '.tmp-checksum')
describe('checksum', () => {
beforeEach(() => mkdirSync(TMP, { recursive: true }))
afterEach(() => rmSync(TMP, { recursive: true, force: true }))
test('computes SHA-256 of file contents', () => {
writeFileSync(join(TMP, 'a.csv'), 'hello')
const hash = computeChecksum([join(TMP, 'a.csv')])
expect(hash).toMatch(/^[a-f0-9]{64}$/)
})
test('detects staleness when CSV changed after JSON', () => {
const csvPath = join(TMP, 'data.csv')
const jsonPath = join(TMP, 'data.json')
writeFileSync(csvPath, 'v1')
writeFileSync(jsonPath, '{}')
// Modify CSV content (checksum changes)
writeFileSync(csvPath, 'v2')
// isStale compares stored checksum in JSON vs current CSV
expect(isStale([csvPath], jsonPath)).toBe(true)
})
})
Step 2: Implement checksum + writer
// scripts/config/lib/checksum.ts
import { readFileSync, existsSync } from 'node:fs'
import { createHash } from 'node:crypto'
/** Compute SHA-256 checksum of concatenated file contents */
export function computeChecksum(filePaths: string[]): string {
const hash = createHash('sha256')
for (const p of filePaths.sort()) {
hash.update(readFileSync(p))
}
return hash.digest('hex')
}
/** Check if CSV sources have changed since JSON was generated */
export function isStale(csvPaths: string[], jsonPath: string): boolean {
if (!existsSync(jsonPath)) return true
const json = JSON.parse(readFileSync(jsonPath, 'utf-8'))
const storedChecksum = json.__checksum
if (!storedChecksum) return true
return computeChecksum(csvPaths) !== storedChecksum
}
// scripts/config/lib/writer.ts
import { writeFileSync } from 'node:fs'
import { computeChecksum } from './checksum'
/** Write JSON with embedded checksum for staleness detection */
export function writeJson(outputPath: string, data: unknown, csvPaths: string[]): void {
const output = {
__generated: new Date().toISOString(),
__checksum: computeChecksum(csvPaths),
...data as Record<string, unknown>,
}
writeFileSync(outputPath, JSON.stringify(output, null, 2) + '\n')
}
Step 3: Run tests, commit
Run: bunx vitest run scripts/config/lib/__tests__/checksum.test.ts
git add scripts/config/lib/checksum.ts scripts/config/lib/writer.ts scripts/config/lib/__tests__/checksum.test.ts
git commit -m "feat(config): add checksum staleness detection and JSON writer"
Task 4: Install csv-parse dependency
Step 1: Install
bun add -D csv-parse csv-stringify
Step 2: Commit
git add package.json bun.lock
git commit -m "chore: add csv-parse and csv-stringify dependencies"
Task 5: Export Script — Categories
Extract CATEGORY_SPECS from scripts/seed/generate-curated-entries.ts:56-744 to CSV.
Files:
- Create:
scripts/config/export-categories.ts - Create:
config/csv/categories.csv(generated output)
Step 1: Write export script
The script imports CATEGORY_SPECS directly, iterates all slugs + subcategories, writes CSV with columns: slug,subcategory,count,prompt
// scripts/config/export-categories.ts
import { stringify } from 'csv-stringify/sync'
import { writeFileSync, mkdirSync } from 'node:fs'
// Direct import from the source file
import { CATEGORY_SPECS } from '../seed/generate-curated-entries'
mkdirSync('config/csv', { recursive: true })
const rows: Record<string, string | number>[] = []
for (const spec of CATEGORY_SPECS) {
for (const sub of spec.subcategories) {
rows.push({
slug: spec.slug,
subcategory: sub.name,
count: sub.count,
prompt: sub.prompt,
})
}
}
const csv = stringify(rows, { header: true, columns: ['slug', 'subcategory', 'count', 'prompt'] })
writeFileSync('config/csv/categories.csv', csv)
console.log(`Exported ${rows.length} category rows to config/csv/categories.csv`)
Step 2: Run export
Run: bun scripts/config/export-categories.ts
Expected: Exported ~156 category rows to config/csv/categories.csv
Step 3: Verify CSV content
Run: head -5 config/csv/categories.csv
Expected: Header row + first 4 data rows matching history subcategories.
Step 4: Commit
git add scripts/config/export-categories.ts config/csv/categories.csv
git commit -m "feat(config): export CATEGORY_SPECS to categories.csv"
Task 6: Sync Script — Categories
Files:
- Create:
scripts/config/sync-categories.ts - Create:
scripts/config/schemas/categories.ts
Step 1: Write Zod schema
// scripts/config/schemas/categories.ts
import { z } from 'zod'
export const categoryRowSchema = z.object({
slug: z.string().min(1),
subcategory: z.string().min(1),
count: z.coerce.number().int().positive(),
prompt: z.string().min(10),
})
export type CategoryRow = z.infer<typeof categoryRowSchema>
Step 2: Write sync script
// scripts/config/sync-categories.ts
import { mkdirSync } from 'node:fs'
import { readCsv } from './lib/csv-reader'
import { validateRows } from './lib/validate'
import { writeJson } from './lib/writer'
import { categoryRowSchema } from './schemas/categories'
export function syncCategories(dryRun = false): { valid: boolean; errors: string[] } {
const csvPath = 'config/csv/categories.csv'
const outputPath = 'config/generated/categories.json'
const rows = readCsv(csvPath)
const { valid, parsed, errors } = validateRows(rows, categoryRowSchema, 'categories.csv')
if (!valid) return { valid, errors }
// Group by slug into CategorySpec shape
const specs: Record<string, { slug: string; subcategories: Array<{ name: string; count: number; prompt: string }> }> = {}
for (const row of parsed) {
if (!specs[row.slug]) {
specs[row.slug] = { slug: row.slug, subcategories: [] }
}
specs[row.slug].subcategories.push({
name: row.subcategory,
count: row.count,
prompt: row.prompt,
})
}
if (!dryRun) {
mkdirSync('config/generated', { recursive: true })
writeJson(outputPath, { categories: Object.values(specs) }, [csvPath])
console.log(`✓ categories.json — ${parsed.length} rows, ${Object.keys(specs).length} topics`)
}
return { valid: true, errors: [] }
}
// CLI entry point
if (import.meta.main) {
const dryRun = process.argv.includes('--dry-run')
const result = syncCategories(dryRun)
if (!result.valid) {
console.error('Validation errors:')
for (const e of result.errors) console.error(` ✗ ${e}`)
process.exit(1)
}
}
Step 3: Run sync
Run: bun scripts/config/sync-categories.ts
Expected: ✓ categories.json — 156 rows, 30 topics
Step 4: Commit
git add scripts/config/sync-categories.ts scripts/config/schemas/categories.ts config/generated/categories.json
git commit -m "feat(config): add categories sync script with Zod validation"
Task 7: Export + Sync — Models
Files:
- Create:
scripts/config/export-models.ts - Create:
scripts/config/sync-models.ts - Create:
scripts/config/schemas/models.ts - Create:
config/csv/models.csv - Create:
config/csv/model-tiers.csv
Export MODEL_REGISTRY (31 models) and DEFAULT_TIER_CONFIG (3 tiers) from packages/config/src/model-registry.ts:37-189.
CSV columns for models.csv:
modelId,provider,status,input_price,output_price,deprecation_note
CSV columns for model-tiers.csv:
tier,provider,model
Follow the same pattern as Task 5-6: export script reads constants → writes CSV, sync script reads CSV → validates → writes JSON.
Commit message: feat(config): export and sync model registry to CSV
Task 8: Export + Sync — Challenge Rules
The largest domain. Extract from packages/ai/src/challenge-content-rules.ts.
Files:
- Create:
scripts/config/export-challenge.ts - Create:
scripts/config/sync-challenge.ts - Create:
scripts/config/schemas/challenge.ts - Create:
config/csv/format-voices.csv(8 rows, 8 columns) - Create:
config/csv/format-rules.csv(8 rows, 5 columns) - Create:
config/csv/style-rules.csv(6 rows, 7 columns) - Create:
config/csv/style-voices.csv(6 rows — with nested challenge_phrasing and reveal_tone) - Create:
config/csv/difficulty-levels.csv(5 rows, 4 columns) - Create:
config/csv/banned-patterns.csv(~12 rows, 2 columns) - Create:
config/csv/challenge-voices.csv(key-value for tone_prefix, voice_constitution, super_fact_rules) - Create:
config/csv/long-text/voice-constitution.txt - Create:
config/csv/long-text/super-fact-rules.txt - Create:
config/csv/cq002-prefixes.csv(6 styles x 3 prefixes = 18 rows)
Key complexity:
-
STYLE_RULES contains composed strings (
SETUP_ARCHITECTURE + '\n\n...'). The export must emit the resolved concatenated strings, not the template parts. The style-rules CSV cells will contain the full combined text. -
STYLE_VOICE has nested
challenge_phrasing: string[]andreveal_tone: { correct, wrong }andemphasis: { lean_into, pull_back_from }. Use companion CSVs:style-voice-phrasings.csv—style, phrasing(FK → style-voices)- Or flatten into pipe-delimited in main CSV (simpler for 3-4 items per cell)
-
FORMAT_VOICE has nested
reveal_tone: { correct, wrong }andemphasis: { lean_into, pull_back_from }. Flatten into columns:reveal_tone_correct, reveal_tone_wrong, emphasis_lean_into, emphasis_pull_back_from. -
BANNED_PATTERNS are RegExp objects. Export as pattern source strings. Sync reconstructs
new RegExp(source, flags). -
CQ002_PREFIXES → companion CSV:
style, prefix(3 per style = 18 rows).
Commit message: feat(config): export and sync challenge content rules to CSV
Task 9: Export + Sync — Taxonomy Rules
Extract from packages/ai/src/taxonomy-content-rules.ts. 32 taxonomies with nested vocabulary.
Files:
- Create:
scripts/config/export-taxonomy.ts - Create:
scripts/config/sync-taxonomy.ts - Create:
scripts/config/schemas/taxonomy.ts - Create:
config/csv/taxonomy-rules.csv(32 rows: slug, extraction_guidance, challenge_guidance) - Create:
config/csv/taxonomy-avoid.csv(~160 rows: slug, pattern) - Create:
config/csv/taxonomy-domain-terms.csv(~200 rows: slug, term, use) - Create:
config/csv/taxonomy-expert-phrases.csv(~130 rows: slug, phrase) - Create:
config/csv/taxonomy-prefer-over.csv(~130 rows: slug, instead_of, use) - Create:
config/csv/taxonomy-voices.csv(32 rows: slug, register, energy, excitement_driver) - Create:
config/csv/taxonomy-voice-pitfalls.csv(~100 rows: slug, pitfall)
Key: The TAXONOMY_CONTENT_RULES and TAXONOMY_VOICE objects at lines 62+ and 1689+ respectively. Some taxonomies have vocabulary, some don't. Export must handle optional fields gracefully.
Commit message: feat(config): export and sync taxonomy content rules to CSV
Task 10: Export + Sync — Seed Controls
Parse YAML blocks from docs/projects/seeding/SEED.md into key-value CSV.
Files:
- Create:
scripts/config/export-seed-controls.ts - Create:
scripts/config/sync-seed-controls.ts - Create:
scripts/config/schemas/seed-controls.ts - Create:
config/csv/seed-controls.csv
CSV columns: section,key,value,description
Example rows:
mode,mode,curated-seed,Seeding mode selection
topics,science.enabled,true,Enable science topic
topics,science.priority,high,Processing priority
topics,science.Physics & Space.count,100,Entities to generate
volume,richness_tier,medium,Default tier for all topics
volume,max_entities,500,Max entities per run
quality,notability_threshold,0.6,Minimum notability score
execution,concurrency,5,Parallel AI calls
execution,model,gpt-5-mini,AI model preference
news,max_results,20,Articles per provider per category
Commit message: feat(config): export and sync seed controls to CSV
Task 11: Export + Sync — App Controls
Parse YAML blocks from docs/APP-CONTROL.md into multiple CSVs.
Files:
- Create:
scripts/config/export-app-controls.ts - Create:
scripts/config/sync-app-controls.ts - Create:
scripts/config/schemas/app-controls.ts - Create:
config/csv/app-controls/crons.csv - Create:
config/csv/app-controls/workers.csv - Create:
config/csv/app-controls/worker-queues.csv - Create:
config/csv/app-controls/queues.csv - Create:
config/csv/app-controls/env-controls.csv
Commit message: feat(config): export and sync app control manifest to CSV
Task 12: Orchestrator + CLI Registration
Files:
- Create:
scripts/config/sync.ts(orchestrator) - Create:
scripts/config/check.ts(CI staleness check) - Modify:
package.json(add scripts)
Step 1: Create orchestrator
// scripts/config/sync.ts
import { syncCategories } from './sync-categories'
import { syncModels } from './sync-models'
import { syncChallenge } from './sync-challenge'
import { syncTaxonomy } from './sync-taxonomy'
import { syncSeedControls } from './sync-seed-controls'
import { syncAppControls } from './sync-app-controls'
const SYNCS = {
categories: syncCategories,
models: syncModels,
challenge: syncChallenge,
taxonomy: syncTaxonomy,
'seed-controls': syncSeedControls,
'app-controls': syncAppControls,
}
const args = process.argv.slice(2)
const dryRun = args.includes('--dry-run')
const verbose = args.includes('--verbose')
const onlyIdx = args.indexOf('--only')
const only = onlyIdx >= 0 ? args[onlyIdx + 1] : undefined
const toRun = only ? { [only]: SYNCS[only as keyof typeof SYNCS] } : SYNCS
let hasErrors = false
for (const [name, syncFn] of Object.entries(toRun)) {
if (!syncFn) {
console.error(`Unknown domain: ${name}`)
process.exit(1)
}
if (verbose) console.log(`Syncing ${name}...`)
const result = syncFn(dryRun)
if (!result.valid) {
hasErrors = true
console.error(`✗ ${name}:`)
for (const e of result.errors) console.error(` ${e}`)
}
}
if (hasErrors) process.exit(1)
if (dryRun) console.log('Dry run complete — no files written')
Step 2: Register in package.json
Add to scripts in root package.json:
{
"config:sync": "bun scripts/config/sync.ts",
"config:check": "bun scripts/config/check.ts",
"config:export": "bun scripts/config/export.ts"
}
Step 3: Commit
git add scripts/config/sync.ts scripts/config/check.ts package.json
git commit -m "feat(config): add orchestrator, CI check, and package.json scripts"
Task 13: Round-Trip Verification
Files:
- Create:
scripts/config/verify.ts
The verify script:
- Imports original TypeScript constants directly
- Imports generated JSON
- Deep-equality compares each field
- Reports any mismatches with exact paths
bun run config:export && bun run config:sync && bun scripts/config/verify.ts
If all pass, the migration is proven lossless.
Commit message: feat(config): add round-trip verification script
Task 14: Thin Wrapper Migration — Categories
Files:
- Modify:
scripts/seed/generate-curated-entries.ts:30-744(removeCATEGORY_SPECS, import from JSON)
Step 1: Replace hardcoded CATEGORY_SPECS
Remove lines 30-744 (the interface + entire CATEGORY_SPECS array). Replace with:
import categoriesJson from '../../config/generated/categories.json'
interface CategorySpec {
slug: string
subcategories: Array<{ name: string; count: number; prompt: string }>
}
const CATEGORY_SPECS: CategorySpec[] = (categoriesJson as { categories: CategorySpec[] }).categories
Step 2: Run existing tests
Run: bun run test
Expected: All tests pass (no behavior change)
Step 3: Commit
git add scripts/seed/generate-curated-entries.ts
git commit -m "refactor(seed): import CATEGORY_SPECS from generated JSON"
Task 15: Thin Wrapper Migration — Models
Files:
- Modify:
packages/config/src/model-registry.ts:37-189(remove hardcoded registry, import from JSON)
Keep: types (ModelRegistryEntry, TierConfig, AIProviderName, ModelTier, ModelStatus), functions (logDeprecationWarnings), the deprecationCheckDone flag logic.
Remove: MODEL_REGISTRY object literal, DEFAULT_TIER_CONFIG object literal.
Replace with JSON imports + Zod validation at module load.
Commit message: refactor(config): import MODEL_REGISTRY from generated JSON
Task 16: Thin Wrapper Migration — Challenge Rules
Files:
- Modify:
packages/ai/src/challenge-content-rules.ts
Keep: All types/interfaces, CQ002_REGEX, patchCq002(), validateChallengeContent(), PRE_GENERATED_STYLES, MIN_LENGTHS.
Remove: CHALLENGE_TONE_PREFIX, CHALLENGE_VOICE_CONSTITUTION, FORMAT_VOICE, FORMAT_RULES, STYLE_RULES, STYLE_VOICE, DIFFICULTY_LEVELS, BANNED_PATTERNS, CQ002_PREFIXES, SUPER_FACT_RULES, and all the intermediate constants (SETUP_ARCHITECTURE, CHALLENGE_TECHNIQUE, etc.).
Replace with JSON imports. Note: BANNED_PATTERNS must reconstruct RegExp[] from string patterns in JSON.
Commit message: refactor(ai): import challenge content rules from generated JSON
Task 17: Thin Wrapper Migration — Taxonomy Rules
Files:
- Modify:
packages/ai/src/taxonomy-content-rules.ts
Keep: types (TaxonomyContentRule, TaxonomyVocabulary, TaxonomyVoice).
Remove: TAXONOMY_CONTENT_RULES and TAXONOMY_VOICE objects (~800 lines each).
Replace with JSON imports + Zod validation.
Commit message: refactor(ai): import taxonomy content rules from generated JSON
Task 18: Update SEED.md and APP-CONTROL.md
Files:
- Modify:
docs/projects/seeding/SEED.md(replace YAML blocks with JSON references) - Modify:
docs/APP-CONTROL.md(replace YAML blocks with JSON references)
Keep all prose/documentation. Replace YAML code blocks with notes pointing to the CSV/JSON sources.
Commit message: docs: update SEED.md and APP-CONTROL.md to reference CSV config
Task 19: CI Integration
Files:
- Modify: Root CI config (add
bun run config:checkstep) - Modify:
package.json(ensureciscript includes config check)
Add bun run config:check to the ci script, alongside migrations:check and other validation steps.
Commit message: ci: add config:check to validate CSV/JSON sync
Task 20: Update Changelog + Script Index
Files:
- Modify:
docs/changelog/02-2026.md - Run:
bun run scripts:indexto regenerate script index
Commit message: docs: add CSV config pipeline to changelog and script index
Execution Order Summary
| Phase | Tasks | Description |
|---|---|---|
| Foundation | 1-4 | Shared lib (reader, validator, joiner, checksum) + dependency |
| Export | 5, 7-11 | Extract all constants to CSV files |
| Sync | 6, 7-12 | Build sync scripts + orchestrator |
| Verify | 13 | Prove round-trip is lossless |
| Migrate | 14-17 | Replace hardcoded constants with JSON imports |
| Docs | 18-20 | Update references, CI, changelog |
Critical path: Tasks 1-4 → 5-6 (categories as proof of concept) → 13 (verify) → expand to remaining domains → thin wrapper migration.
Estimated scope: ~20 tasks, each 5-30 minutes. Foundation + categories (Tasks 1-6) should work end-to-end before expanding.