#!/usr/bin/env node /** * Generate hybrid-codex piece variants from standard pieces. * * For each standard piece (not already -hybrid-codex, not in skip list): * 1. Parse the YAML * 2. Add `provider: codex` to all coder movements (including parallel sub-movements) * 3. Change name to {name}-hybrid-codex * 4. Write the hybrid-codex YAML file * 5. Update piece-categories.yaml to include generated hybrids * * Usage: * node tools/generate-hybrid-codex.mjs # Generate all * node tools/generate-hybrid-codex.mjs --dry-run # Preview only */ import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; import { join, basename, dirname } from 'node:path'; import { parse, stringify } from 'yaml'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const BUILTINS = join(ROOT, 'builtins'); const LANGUAGES = ['en', 'ja']; /** Pieces that should NOT get hybrid variants (no coder involvement or special purpose) */ const SKIP_PIECES = new Set(['magi', 'research', 'review-only']); const CODER_PERSONA = 'coder'; const dryRun = process.argv.includes('--dry-run'); // ───────────────────────────────────────── // Movement transformation // ───────────────────────────────────────── function hasCoderPersona(movement) { if (movement.persona === CODER_PERSONA) return true; if (movement.parallel) return movement.parallel.some(sub => sub.persona === CODER_PERSONA); return false; } /** * Insert a field into an object after specified anchor fields (preserves key order). * If anchor not found, appends at end. */ function insertFieldAfter(obj, key, value, anchorFields) { if (obj[key] === value) return obj; const result = {}; let inserted = false; for (const [k, v] of Object.entries(obj)) { if (k === key) continue; // Remove existing (will re-insert) result[k] = v; if (!inserted && anchorFields.includes(k)) { result[key] = value; inserted = true; } } if (!inserted) result[key] = value; return result; } /** * Add `provider: codex` to all coder movements (recursively handles parallel). */ function addCodexToCoders(movements) { return movements.map(m => { if (m.parallel) { return { ...m, parallel: addCodexToCoders(m.parallel) }; } if (m.persona === CODER_PERSONA) { return insertFieldAfter(m, 'provider', 'codex', ['knowledge', 'stance', 'persona']); } return m; }); } // ───────────────────────────────────────── // Hybrid piece builder // ───────────────────────────────────────── /** Top-level field order for readable output */ const TOP_FIELD_ORDER = [ 'name', 'description', 'max_iterations', 'stances', 'knowledge', 'personas', 'instructions', 'report_formats', 'initial_movement', 'loop_monitors', 'answer_agent', 'movements', ]; function buildHybrid(parsed) { const hybrid = {}; for (const field of TOP_FIELD_ORDER) { if (field === 'name') { hybrid.name = `${parsed.name}-hybrid-codex`; } else if (field === 'movements') { hybrid.movements = addCodexToCoders(parsed.movements); } else if (parsed[field] != null) { hybrid[field] = parsed[field]; } } // Carry over any extra top-level fields not in the order list for (const key of Object.keys(parsed)) { if (!(key in hybrid) && key !== 'name') { hybrid[key] = parsed[key]; } } return hybrid; } function generateHeader(sourceFile) { return [ `# Auto-generated from ${sourceFile} by tools/generate-hybrid-codex.mjs`, '# Do not edit manually. Edit the source piece and re-run the generator.', '', '', ].join('\n'); } // ───────────────────────────────────────── // Category handling // ───────────────────────────────────────── /** Recursively collect all piece names from a category tree */ function collectPieces(obj) { const pieces = []; if (!obj || typeof obj !== 'object') return pieces; if (Array.isArray(obj.pieces)) pieces.push(...obj.pieces); for (const [key, val] of Object.entries(obj)) { if (key === 'pieces') continue; if (typeof val === 'object' && val !== null && !Array.isArray(val)) { pieces.push(...collectPieces(val)); } } return pieces; } /** Find the key for the hybrid top-level category */ function findHybridTopKey(categories) { for (const key of Object.keys(categories)) { if (key.includes('Hybrid') || key.includes('ハイブリッド')) return key; } return null; } /** * Build mapping: standard piece name → top-level category key. * Excludes the hybrid category and "Others" category. */ function getTopLevelMapping(categories, hybridKey, othersKey) { const map = new Map(); for (const [key, val] of Object.entries(categories)) { if (key === hybridKey) continue; if (othersKey && key === othersKey) continue; if (typeof val !== 'object' || val === null) continue; const pieces = collectPieces(val); for (const p of pieces) map.set(p, key); } return map; } /** * Build the hybrid category section by mirroring standard categories. */ function buildHybridCategories(generatedNames, topMap) { // Group hybrids by their source piece's top-level category const grouped = new Map(); for (const hybridName of generatedNames) { const sourceName = hybridName.replace('-hybrid-codex', ''); const topCat = topMap.get(sourceName); if (!topCat) continue; if (!grouped.has(topCat)) grouped.set(topCat, []); grouped.get(topCat).push(hybridName); } const section = {}; for (const [catKey, hybrids] of grouped) { section[catKey] = { pieces: hybrids.sort() }; } return section; } // ───────────────────────────────────────── // Main // ───────────────────────────────────────── console.log('=== Generating hybrid-codex pieces ===\n'); for (const lang of LANGUAGES) { console.log(`[${lang}]`); const generatedNames = []; const piecesDir = join(BUILTINS, lang, 'pieces'); const files = readdirSync(piecesDir) .filter(f => f.endsWith('.yaml') && !f.includes('-hybrid-codex')) .sort(); for (const file of files) { const name = basename(file, '.yaml'); if (SKIP_PIECES.has(name)) { console.log(` Skip: ${name} (in skip list)`); continue; } const content = readFileSync(join(piecesDir, file), 'utf-8'); const parsed = parse(content); if (!parsed.movements?.some(hasCoderPersona)) { console.log(` Skip: ${name} (no coder movements)`); continue; } const hybrid = buildHybrid(parsed); const header = generateHeader(file); const yamlOutput = stringify(hybrid, { lineWidth: 120, indent: 2 }); const outputPath = join(piecesDir, `${name}-hybrid-codex.yaml`); if (dryRun) { console.log(` Would generate: ${name}-hybrid-codex.yaml`); } else { writeFileSync(outputPath, header + yamlOutput, 'utf-8'); console.log(` Generated: ${name}-hybrid-codex.yaml`); } generatedNames.push(`${name}-hybrid-codex`); } // ─── Update piece-categories.yaml ─── const catPath = join(BUILTINS, lang, 'piece-categories.yaml'); const catRaw = readFileSync(catPath, 'utf-8'); const catParsed = parse(catRaw); const cats = catParsed.piece_categories; if (cats) { const hybridKey = findHybridTopKey(cats); const othersKey = Object.keys(cats).find(k => k === 'Others' || k === 'その他' ); if (hybridKey) { const topMap = getTopLevelMapping(cats, hybridKey, othersKey); const newSection = buildHybridCategories(generatedNames, topMap); cats[hybridKey] = newSection; if (dryRun) { console.log(` Would update: piece-categories.yaml`); console.log(` Hybrid pieces: ${generatedNames.join(', ')}`); } else { const catOut = stringify(catParsed, { lineWidth: 120, indent: 2 }); writeFileSync(catPath, catOut, 'utf-8'); console.log(` Updated: piece-categories.yaml`); } } else { console.log(` Warning: No hybrid category found in piece-categories.yaml`); } } console.log(); } console.log('Done!'); if (dryRun) console.log('(dry-run mode, no files were written)');