/** * tools.mjs — agentic A-model 生成 harness 的工具层 * owner:amodel-gen spike | 消费方:gen.mjs(ReAct 循环) / play.mjs / CLI * * 【两类东西】 * A) 给 agent 的 5 工具(L1 沙箱边界): * - read_file : repo 根只读(agent 自读 skill / api.d.ts / catch-fruit 范例);拒绝逃出 repo 根;超大截断。 * - write_file : 仅许写本 run 的 game 目录 games/amgen-/;拒绝越界。 * - list_dir : repo 根内列目录。 * - check : 循环内快反馈 = node --check 各 src/*.js + 五法/导出名/红线静态 lint。 * - build : esbuild(scripts/build.mjs 锁参)→ bundle;循环内快反馈。 * 不给 agent 任意 shell。 * B) 确定性 scaffold(克隆 _template → 本 run 目录)。 * * 【路径基准】本文件在 game-runtime/tools/amodel-gen/ → GAME_RUNTIME=../.. → REPO_ROOT=../../.. */ 'use strict'; import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, existsSync, rmSync, cpSync, renameSync, } from 'node:fs'; import { resolve, dirname, relative, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; const __dirname = dirname(fileURLToPath(import.meta.url)); // tools/amodel-gen → game-runtime → repo 根 export const GAME_RUNTIME = resolve(__dirname, '../..'); export const REPO_ROOT = resolve(__dirname, '../../..'); const GAMES_DIR = join(GAME_RUNTIME, 'games'); const TEMPLATE_DIR = join(GAMES_DIR, '_template'); const SHARED_DIR = join(GAMES_DIR, '_wg1-gen', '_shared'); // SAA 信封 plumbing 模板源 const WG1_DIR = join(GAMES_DIR, '_wg1-gen'); // SAA stage 目标根(play/emit 既有读盘契约) const MAX_READ_BYTES = 200 * 1024; // 单文件读上限,防撑爆 agent context /** 把 repo 相对路径解析为绝对路径,并校验不逃出 repo 根。 */ function resolveInRepo(p) { const abs = resolve(REPO_ROOT, p); if (abs !== REPO_ROOT && !abs.startsWith(REPO_ROOT + '/')) { throw new Error(`路径越界(必须在 repo 根内):${p}`); } return abs; } /** 本 run 的 game 目录(= 写边界)。 */ export function gameDir(id) { return join(GAMES_DIR, `amgen-${id}`); } /** 校验绝对路径落在本 run 的 game 目录内(写边界)。 */ function assertInGameDir(id, abs) { const gd = gameDir(id); if (abs !== gd && !abs.startsWith(gd + '/')) { throw new Error(`write 越界:只许写 ${relative(REPO_ROOT, gd)}/ 内(你给的是 ${relative(REPO_ROOT, abs)})`); } } // ───────────────────────── 给 agent 的 5 工具 ───────────────────────── /** read_file:repo 根只读。返回 {ok, content?, truncated?, error?}。 */ export function readFileTool(path) { try { const abs = resolveInRepo(path); const st = statSync(abs); if (st.isDirectory()) return { ok: false, error: `是目录,不是文件:${path}(请用 list_dir)` }; let content = readFileSync(abs, 'utf8'); let truncated = false; if (Buffer.byteLength(content, 'utf8') > MAX_READ_BYTES) { content = content.slice(0, MAX_READ_BYTES); truncated = true; // 截断:超大文件只给前 200KB,提示 agent 它被截了 } return { ok: true, content, truncated }; } catch (e) { return { ok: false, error: String((e && e.message) || e) }; } } /** write_file:仅许写本 run game 目录。返回 {ok, bytes?, error?}。 */ export function writeFileTool(id, path, content) { try { const abs = resolveInRepo(path); assertInGameDir(id, abs); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, content, 'utf8'); return { ok: true, bytes: Buffer.byteLength(content, 'utf8') }; } catch (e) { return { ok: false, error: String((e && e.message) || e) }; } } /** list_dir:repo 根内列目录(目录名带尾 /)。返回 {ok, entries?, error?}。 */ export function listDirTool(path) { try { const abs = resolveInRepo(path); const entries = readdirSync(abs, { withFileTypes: true }) .map((d) => (d.isDirectory() ? d.name + '/' : d.name)) .sort(); return { ok: true, entries }; } catch (e) { return { ok: false, error: String((e && e.message) || e) }; } } // ───────────────────────── scaffold / check / build ───────────────────────── /** scaffold:克隆 _template → games/amgen-/(去掉 dist;保留可玩骨架 + plumbing)。返回 game 目录。 */ export function scaffold(id) { const gd = gameDir(id); if (existsSync(gd)) rmSync(gd, { recursive: true, force: true }); cpSync(TEMPLATE_DIR, gd, { recursive: true }); rmSync(join(gd, 'dist'), { recursive: true, force: true }); // 旧产物(若有)清掉 rmSync(join(gd, 'test'), { recursive: true, force: true }); // 去 test/:spike 不让 agent 钻测试(gate=check+build+play),也去掉 README 的"跑测试"诱因 return gd; } // 游戏源红线:零裸时间/随机/DOM(时间随机经 boot.ctx,定时经 timer-scheduler)。只匹配"调用形态"。 const BANNED = [ /\bMath\.random\s*\(/, /\bDate\.now\s*\(/, /\bperformance\.now\s*\(/, /\bsetTimeout\s*\(/, /\bsetInterval\s*\(/, /\brequestAnimationFrame\s*\(/, /\bnew\s+AudioContext\b/, /\baddEventListener\s*\(/, ]; /** 去注释 + 字符串字面量,避免红线/结构正则误伤文档注释里的示例(如注释里写「禁用 Math.random()」)。 */ function stripCode(s) { return s .replace(/\/\*[\s\S]*?\*\//g, ' ') // 块注释 .replace(/\/\/[^\n]*/g, ' ') // 行注释 .replace(/'(?:[^'\\]|\\.)*'/g, "''") // 单引号串 .replace(/"(?:[^"\\]|\\.)*"/g, '""') // 双引号串 .replace(/`(?:[^`\\]|\\.)*`/g, '``'); // 模板串 } /** * check:循环内快反馈门(便宜模型自纠语法/契约错的头号手段)。 * 1) node --check 各 src/*.js + entry.js(语法); * 2) 结构契约:game.js 默认导出 + update 内调 bundle.tick;host-config.js 导出 buildTemplateHostConfig; * 3) 红线:游戏源(game/core/render)零裸时间/随机/DOM。 * 返回 {ok, errors[]}。 */ export function check(id) { const gd = gameDir(id); const errors = []; const srcDir = join(gd, 'src'); let srcFiles = []; try { srcFiles = readdirSync(srcDir).filter((f) => f.endsWith('.js')); } catch { return { ok: false, errors: ['src/ 目录不存在或不可读'] }; } // 1) 语法 for (const rel of [...srcFiles.map((f) => join('src', f)), 'entry.js']) { const abs = join(gd, rel); if (!existsSync(abs)) continue; const r = spawnSync('node', ['--check', abs], { encoding: 'utf8' }); if (r.status !== 0) { errors.push(`语法错误 ${rel}: ${(r.stderr || '').trim().split('\n').slice(0, 3).join(' ')}`); } } // 2) 结构契约 const gameAbs = join(gd, 'src', 'game.js'); if (existsSync(gameAbs)) { const g = stripCode(readFileSync(gameAbs, 'utf8')); if (!/export\s+default/.test(g)) errors.push('game.js 必须 export default(工厂默认导出)'); if (!/\bbundle\.tick\s*\(/.test(g)) { errors.push('game.js 的 update(dt) 内必须调 bundle.tick(dt)(否则插件 onFrame 不推进、timer 永不到期、一局不结束)'); } } else { errors.push('缺 src/game.js'); } const hcAbs = join(gd, 'src', 'host-config.js'); if (existsSync(hcAbs)) { const hc = stripCode(readFileSync(hcAbs, 'utf8')); if (!/export\s+function\s+buildTemplateHostConfig\b/.test(hc)) { errors.push('host-config.js 必须保留导出名 buildTemplateHostConfig(固定 plumbing main.js 据此 import,勿改名)'); } } else { errors.push('缺 src/host-config.js'); } // 2.5) game.js 必须真改写过(防 write 路径写错→没覆盖→拿 _template 原样蒙混过 check) try { const tmpl = readFileSync(join(TEMPLATE_DIR, 'src', 'game.js'), 'utf8'); if (existsSync(gameAbs) && readFileSync(gameAbs, 'utf8') === tmpl) { errors.push(`game.js 与 _template 完全相同:还没真正改写游戏(确认 write 路径写进了 ${relative(REPO_ROOT, gd)}/src/)`); } } catch { /* 模板缺失则跳过 */ } // 3) 红线(只查 agent 写的游戏文件) for (const f of ['game.js', 'core.js', 'render.js']) { const abs = join(gd, 'src', f); if (!existsSync(abs)) continue; const s = stripCode(readFileSync(abs, 'utf8')); for (const re of BANNED) { if (re.test(s)) errors.push(`红线 ${f}: 禁用 ${re.source}(时间/随机经 boot.ctx,定时经 timer-scheduler,输入经 ctx.getInput)`); } } return { ok: errors.length === 0, errors }; } /** build:esbuild 锁参打包(cwd=game-runtime)。返回 {ok, error?}。 */ export function build(id) { const entry = `games/amgen-${id}/entry.js`; const out = `games/amgen-${id}/dist/template-bundle.js`; const r = spawnSync( 'node', ['scripts/build.mjs', entry, out, '--global-name=TemplateGame'], { cwd: GAME_RUNTIME, encoding: 'utf8', timeout: 60000 }, ); if (r.status !== 0) { const err = ((r.stderr || '') + (r.stdout || '')).trim().split('\n').slice(-8).join('\n'); return { ok: false, error: err || `build 退出码 ${r.status}` }; } return { ok: true }; } // ───────────────────────── SAA 模式(批 A:信封对齐 __GameBundle + stage 到 _wg1-gen)───────────────────────── /** SAA stage 目标目录(_wg1-gen//;SAA play/emit 既有读盘契约,治 review B1)。 */ export function saaGameDir(gameId) { return join(WG1_DIR, gameId); } /** * scaffoldSaa:clone _template → amgen-/,换上 A-model SAA 信封 plumbing(治 review B2)。 * - entry-bundle.js = entry-bundle.amodel.template.js(导出 bootGameHost,经 --global-name=__GameBundle 挂 window.__GameBundle) * - index.html = _wg1-gen/_shared/index.template.html(调 __GameBundle.bootGameHost,置 __genBooted) * - 去 spike 入口(entry.js/src/main.js;SAA 不用)+ dist/test * agent 仍只写 src/{game,core,render,host-config,balance,assets}.js(与 spike 同;信封差异全在 plumbing)。 */ export function scaffoldSaa(gameId) { const gd = gameDir(gameId); if (existsSync(gd)) rmSync(gd, { recursive: true, force: true }); cpSync(TEMPLATE_DIR, gd, { recursive: true }); rmSync(join(gd, 'dist'), { recursive: true, force: true }); rmSync(join(gd, 'test'), { recursive: true, force: true }); // 换 SAA 信封 plumbing(覆盖 _template 的 spike 信封 entry.js/index.html) cpSync(join(SHARED_DIR, 'entry-bundle.amodel.template.js'), join(gd, 'entry-bundle.js')); cpSync(join(SHARED_DIR, 'index.template.html'), join(gd, 'index.html')); // 去 spike 入口(SAA 用 entry-bundle.js;entry.js/main.js 不参与构建,删之免混淆) rmSync(join(gd, 'entry.js'), { force: true }); rmSync(join(gd, 'src', 'main.js'), { force: true }); return gd; } /** buildSaa:esbuild 打 entry-bundle.js → bundle.iife.js(--global-name=__GameBundle)。 */ export function buildSaa(gameId) { const entry = `games/amgen-${gameId}/entry-bundle.js`; const out = `games/amgen-${gameId}/bundle.iife.js`; const r = spawnSync('node', ['scripts/build.mjs', entry, out, '--global-name=__GameBundle'], { cwd: GAME_RUNTIME, encoding: 'utf8', timeout: 60000 }); if (r.status !== 0) { const err = ((r.stderr || '') + (r.stdout || '')).trim().split('\n').slice(-8).join('\n'); return { ok: false, error: err || `buildSaa 退出码 ${r.status}` }; } return { ok: true }; } /** stage:原子(写 tmp→rename)把自包含 bundle + SAA index 拷到 _wg1-gen//(SAA play/emit 读这里;play-spec 由 SAA 产、本步不碰)。 */ export function stage(gameId) { const gd = gameDir(gameId); const dst = saaGameDir(gameId); mkdirSync(dst, { recursive: true }); for (const f of ['bundle.iife.js', 'index.html']) { const src = join(gd, f); if (!existsSync(src)) return { ok: false, error: `stage 缺源 ${f}(先 buildSaa)` }; const tmp = join(dst, f + '.tmp'); cpSync(src, tmp); renameSync(tmp, join(dst, f)); // 原子 rename,防 play 读半成品 } return { ok: true, dir: dst }; } // ───────────────────────── CLI(独立验证用)───────────────────────── // 用法:node tools.mjs scaffold | check | build if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const [, , cmd, id] = process.argv; if (!cmd || !id) { console.error('用法:node tools.mjs '); process.exit(2); } if (cmd === 'scaffold') { const gd = scaffold(id); console.log('[scaffold] →', relative(REPO_ROOT, gd)); } else if (cmd === 'check') { const r = check(id); console.log('[check]', r.ok ? 'PASS' : 'FAIL'); r.errors.forEach((e) => console.log(' -', e)); process.exit(r.ok ? 0 : 1); } else if (cmd === 'build') { const r = build(id); console.log('[build]', r.ok ? 'PASS' : 'FAIL'); if (!r.ok) { console.log(r.error); process.exit(1); } } else if (cmd === 'scaffold-saa') { const gd = scaffoldSaa(id); console.log('[scaffold-saa] →', relative(REPO_ROOT, gd), '(SAA 信封 plumbing)'); } else if (cmd === 'build-saa') { const r = buildSaa(id); console.log('[build-saa]', r.ok ? 'PASS' : 'FAIL'); if (!r.ok) { console.log(r.error); process.exit(1); } } else if (cmd === 'stage') { const r = stage(id); console.log('[stage]', r.ok ? 'PASS → ' + relative(REPO_ROOT, r.dir) : 'FAIL ' + r.error); if (!r.ok) process.exit(1); } else { console.error('未知命令:', cmd); process.exit(2); } }