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:

  1. 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.

  2. STYLE_VOICE has nested challenge_phrasing: string[] and reveal_tone: { correct, wrong } and emphasis: { lean_into, pull_back_from }. Use companion CSVs:

    • style-voice-phrasings.csvstyle, phrasing (FK → style-voices)
    • Or flatten into pipe-delimited in main CSV (simpler for 3-4 items per cell)
  3. FORMAT_VOICE has nested reveal_tone: { correct, wrong } and emphasis: { lean_into, pull_back_from }. Flatten into columns: reveal_tone_correct, reveal_tone_wrong, emphasis_lean_into, emphasis_pull_back_from.

  4. BANNED_PATTERNS are RegExp objects. Export as pattern source strings. Sync reconstructs new RegExp(source, flags).

  5. 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:

  1. Imports original TypeScript constants directly
  2. Imports generated JSON
  3. Deep-equality compares each field
  4. 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 (remove CATEGORY_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:check step)
  • Modify: package.json (ensure ci script 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:index to regenerate script index

Commit message: docs: add CSV config pipeline to changelog and script index


Execution Order Summary

PhaseTasksDescription
Foundation1-4Shared lib (reader, validator, joiner, checksum) + dependency
Export5, 7-11Extract all constants to CSV files
Sync6, 7-12Build sync scripts + orchestrator
Verify13Prove round-trip is lossless
Migrate14-17Replace hardcoded constants with JSON imports
Docs18-20Update 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.