feat(studio): A11 切片三 M4 模块重生成执行(有界单文件 LLM 重写 game-logic.js)

两段式第二段的 regenerate-module 类:改玩法 = 据 intent 有界重写 game-logic.js,不动其余。

复用机制(参数化、不复制 resume/熔断/收口):
- cheap_studio.run_studio 加 4 个默认 None 可选参(system_prompt/initial_kick/write_whitelist/prepare)
  切到 modify 态,create 路零行为变化(params=None 等价原逻辑)
- cheap_toolkit.build_toolkit 加 write_whitelist(非白名单 basename 写直接拒)
- cheap_roles.build_modify_system_prompt(复用 create 红线契约块)

cheap_modify.execute_regenerate_modify:
- 取 intent(空→failed)→ materialize base 源 → 重写前后对非目标文件算 hash
- 有界重写(写边界收窄到只 game-logic.js)→ 九门
- status=succeeded ⟺ 九门过 ∧ 非目标稳;manifest{file,kind:behavior,intent,changed,untouchedStable}(断言②地基)

worker_service._process_regenerate_job(regen_fn):把 M3 的 regenerate-module 显式 failed 占位换成真执行;
deterministic/create/生成路一字未动。

测试:test_a11_m4_regenerate 10/10(全注入桩零 LLM)+ 全回归独立复跑 73 测试全绿。
一次真 LLM smoke(单跑):intent=连击递增 → status=succeeded、untouchedStable=True、九门 pass、
attempts=1、¥0.31、137s;独立 diff 佐证 5 非目标文件字节相同、仅 game-logic.js 变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-29 02:16:22 -07:00
parent 51230f1347
commit 14c79dbfdd
8 changed files with 640 additions and 23 deletions

View File

@ -22,12 +22,19 @@ best-effort 铁律:apply 失败 / 重建失败 → 返 failed + 诊断,绝不抛
不碰 L1 plumbing
"""
import hashlib
import json
import re
from pathlib import Path
import cheap_run
# A11 M4 模块重生成behavior 在便宜档映射到玩法文件KTD4——只重写它其余文件保持不变。
_BEHAVIOR_TARGET = "game-logic.js"
_TARGET_REL = "src/game-logic.js"
# 断言②非目标稳定game_dir 根下要 track 稳定性的 L1 入口/页面src/*.js 用 globbuild 衍生物 bundle.iife.js 不 track
_ROOT_STABLE = ("index.html", "entry-bundle.js", "entry.js")
# 调数值(config/level)寻址的 L3 文件优先序:核心数值集中在 core.js,找不到再搜其它 L3。
_CONFIG_FILE_ORDER = ("core.js", "game-logic.js", "render.js", "balance.js")
# 换资产寻址的 L3 文件:资产清单统一在 assets.js。
@ -324,3 +331,170 @@ def execute_deterministic_modify(game_id: str, source_project, modify_patch,
return {"status": status, "manifest": manifest, "verdict": verdict,
"stage": "play", "error": None if status == "succeeded" else "九门未通过",
"smokeOk": sm.get("ok"), "playOk": pr.get("ok")}
# ───────────────────────── ④ execute_regenerate_modify模块重生成 · 改玩法)─────────────────────────
def _hash_file(p: Path) -> str:
"""文件内容 sha256断言②的稳定性指纹"""
return hashlib.sha256(p.read_bytes()).hexdigest()
def _snapshot_nontarget_hashes(game_id: str) -> dict:
"""对 game_dir 内**非目标源文件**算 hash 快照(断言②地基)。返回 {rel_path: sha256}。
非目标 = game-logic.js 外的全部源文件src/ 下其余 .jscore/render/assets/balance + L1
host-config/game+ 根下 L1 入口/页面index.html/entry-bundle.js/entry.js** track build 衍生物**
bundle.iife.js 每次 build 重生成track 它会假性不稳重写前后各拍一次逐键比对即知非目标有没有被误伤
"""
gd = cheap_run.game_dir(game_id)
out = {}
src = gd / "src"
if src.is_dir():
for p in sorted(src.glob("*.js")):
if p.name == _BEHAVIOR_TARGET:
continue # 目标文件本身不算「非目标」
out[f"src/{p.name}"] = _hash_file(p)
for name in _ROOT_STABLE:
p = gd / name
if p.is_file():
out[name] = _hash_file(p)
return out
def _regen_manifest(intent: str, *, changed, stable) -> dict:
"""模块重生成改动清单(单条 dict区别确定性类的多条 list哪个文件 / 哪类 / 意图 / 变没变 / 非目标稳没稳。"""
return {"file": _TARGET_REL, "kind": "behavior", "intent": intent,
"changed": changed, "untouchedStable": stable}
def _regen_why(gates_pass: bool, untouched_stable: bool) -> str:
"""失败诊断文案:断言②泄漏优先(误伤非目标比九门不过更严重)。"""
if not untouched_stable:
return "断言②失败模块重生成误伤了非目标文件core/render/assets/host-config 等应保持不变)"
if not gates_pass:
return "九门未通过"
return "模块重生成失败"
def _noop_prepare(game_id: str) -> dict:
"""run_studio 的 prepare 注入位game_dir 已由 execute_regenerate_modify 预备scaffold+materialize base 源),
不要再 scaffold 覆盖掉 base 返回 scaffold 同形契约 {ok,output}"""
return {"ok": True, "output": "game_dir 已由 execute_regenerate_modify 预备scaffold+materialize base 源)"}
def _default_rewrite(game_id: str, intent: str, *, port: int = 4320, cdp_port: int = 9222) -> dict:
"""默认重写器:跑 cheap_studio.run_studio 的 modify 态(自定义 system prompt + kick + 写边界白名单 +
prepare=noop[game_dir 已就绪]复用同一套 ReAct + resume 熔断 + 三层校验收口返回 run-summary含九门 verdict
懒导入 cheap_studio/cheap_roles只在真运行时装配 tier2 框架 + key单测注入 rewrite_fn 不触发本函数
"""
import asyncio
import cheap_roles
import cheap_studio
sys_prompt = cheap_roles.build_modify_system_prompt(game_id, intent)
g = f"game-runtime/games/amgen-{game_id}"
kick = (f"{g}/src/game-logic.js 现在是一款能跑能玩的游戏。请**只重写它**来实现这个调整:「{intent}」。"
f"先 read_file 读当前的 {g}/src/game-logic.js 看现状,再据调整意图最小改写;"
"core/render/assets/host-config 等其余文件保持不变、别写它们(写会被拒)。"
"check 与 build 都绿了就立即 finish。")
# 复用既有 max_iters(40)/max_resumes(6) resume 熔断,不新造。
return asyncio.run(cheap_studio.run_studio(
game_id, intent, system_prompt=sys_prompt, initial_kick=kick,
write_whitelist={_BEHAVIOR_TARGET}, prepare=_noop_prepare,
port=port, cdp_port=cdp_port))
def execute_regenerate_modify(game_id: str, source_project, modify_patch,
*, rewrite_fn=None, port: int = 4320, cdp_port: int = 9222) -> dict:
"""模块重生成执行(改玩法):取 base 源 + intent → 有界单文件重写 game-logic.js → 九门 → 断言②非目标稳定 + 改动清单。
流程 intentfailed 不重写 base files无源failed scaffold-if-needed + materialize
base deterministic 口径 重写前对目标+非目标算 hash 有界重写 game-logic.js复用 cheap_studio
ReAct+resume+三层校验写边界收窄到只许写 game-logic.js 重写后再算 hash changed / untouchedStable
据九门 verdict + 断言②判 statusbest-effort任一步异常failed+诊断绝不抛崩 worker
Args:
game_id: run gameId
source_project: base 源工程dict 2.0 JSON files=路径文本
modify_patch: 改动意图形如 {"target":{"kind":"behavior"},"payload":{"intent":"<改写意图>"}}
rewrite_fn: (game_id, intent, *, port, cdp_port)->run_summary 的重写器注入位单测桩免真 LLM/esbuild/Chrome
默认 _default_rewrite真跑 cheap_studio modify
port/cdp_port: serve/CDP 端口透传 run_studio 收口
Returns:
{"status":"succeeded|failed", "stage":..., "manifest":{...}, "verdict":九门verdict|None,
"summary":run-summary|None, "error":str|None}status=succeeded 九门过 断言②非目标稳定
manifest = {"file":"src/game-logic.js","kind":"behavior","intent":intent,"changed":bool,"untouchedStable":bool}
"""
# 1. 取 intent改写意图空 → failed不重写省一切重活
patch = _as_dict(modify_patch)
payload = patch.get("payload") if isinstance(patch.get("payload"), dict) else {}
intent = payload.get("intent")
intent = intent.strip() if isinstance(intent, str) else ""
if not intent:
log(f"模块重生成 intent 为空 → failed 不重写 game_id={game_id}")
return {"status": "failed", "stage": "intent",
"manifest": _regen_manifest("", changed=False, stable=None),
"verdict": None, "summary": None,
"error": "modifyPatch.payload.intent 为空(改写意图缺失),不重写"}
# 2. 取 base 源 files无源 → failed无可改源
sp = _as_dict(source_project)
base_files = sp.get("files") if isinstance(sp.get("files"), dict) else None
if not base_files:
log(f"模块重生成无 base 源 files → failed game_id={game_id}")
return {"status": "failed", "stage": "intent",
"manifest": _regen_manifest(intent, changed=False, stable=None),
"verdict": None, "summary": None,
"error": "base 源工程无 files无可改源"}
try:
# 3. prep缺 plumbing 才 scaffold补 L1 entry-bundle/index.html→ materialize base 源覆盖 src/(同 deterministic 口径)。
gd = cheap_run.game_dir(game_id)
if not (gd / "entry-bundle.js").exists():
sc = cheap_run.scaffold(game_id)
if not sc["ok"]:
log(f"模块重生成 scaffold 失败 game_id={game_id}: {sc.get('output', '')[:300]}")
return {"status": "failed", "stage": "scaffold",
"manifest": _regen_manifest(intent, changed=False, stable=None),
"verdict": None, "summary": None,
"error": "scaffold 失败:" + sc.get("output", "")}
materialize_source_project(game_id, base_files)
# 4. 断言②地基:重写前对目标 + 非目标算 hash 快照。
target_p = gd / _TARGET_REL
target_before = _hash_file(target_p) if target_p.is_file() else None
nontarget_before = _snapshot_nontarget_hashes(game_id)
# 5. 有界单文件重写 game-logic.js默认 cheap_studio modify 态rewrite_fn 可注入桩)。
fn = rewrite_fn or _default_rewrite
run_summary = fn(game_id, intent, port=port, cdp_port=cdp_port)
run_summary = run_summary if isinstance(run_summary, dict) else {}
# 6. 断言②:重写后再算 hash判目标变没变 + 非目标稳没稳。
target_after = _hash_file(target_p) if target_p.is_file() else None
nontarget_after = _snapshot_nontarget_hashes(game_id)
changed = target_before != target_after
untouched_stable = nontarget_before == nontarget_after
manifest = _regen_manifest(intent, changed=changed, stable=untouched_stable)
except Exception as e: # noqa: BLE001 — 重写/落盘/hash 任一异常 best-effort 兜底,不抛崩 worker
log(f"模块重生成异常 game_id={game_id}: {type(e).__name__}: {e}")
return {"status": "failed", "stage": "code",
"manifest": _regen_manifest(intent, changed=False, stable=None),
"verdict": None, "summary": None,
"error": f"模块重生成异常:{type(e).__name__}: {e}"}
# 7. 据九门 verdict + 断言②判 status九门过 ∧ 非目标稳 才算成功(误伤非目标=边界泄漏,即便九门过也判失败)。
verdict_full = run_summary.get("verdictFull") if isinstance(run_summary.get("verdictFull"), dict) else None
vb = run_summary.get("verdict") if isinstance(run_summary.get("verdict"), dict) else None
gates_pass = bool(vb and vb.get("pass") is True)
status = "succeeded" if (gates_pass and untouched_stable) else "failed"
stage = run_summary.get("stage") or "code"
log(f"模块重生成完成 game_id={game_id} status={status} changed={changed} "
f"untouchedStable={untouched_stable} gatesPass={gates_pass} stage={stage}")
return {"status": status, "stage": stage, "manifest": manifest,
"verdict": verdict_full, "summary": run_summary,
"error": None if status == "succeeded" else _regen_why(gates_pass, untouched_stable)}

View File

@ -72,3 +72,46 @@ def build_system_prompt(game_id: str) -> str:
"""
g = f"game-runtime/games/amgen-{game_id}"
return _SYSTEM_PROMPT.replace("⟦G⟧", g)
def _contract_block() -> str:
"""从 create system prompt 运行时切出「红线 + 完成判据 + 现在开始」通用工程契约块。
红线入口契约五法 / 插件 API / 两条 footgun / 输入契约 / 完成判据 create modify 两条 prompt
完全通用是复利资产单一事实源modify prompt 复用它而非另抄一份运行时切片不改动 create 字面量
create 路一字不动 `红线` 标记到末尾找不到罕见被改坏兜底返全文
"""
idx = _SYSTEM_PROMPT.find("【红线")
return _SYSTEM_PROMPT[idx:] if idx >= 0 else _SYSTEM_PROMPT
def build_modify_system_prompt(game_id: str, intent: str) -> str:
"""A11 M4 模块重生成(改玩法)的 system prompt已能玩的游戏 → 只重写 game-logic.js 实现 intent。
create prompt 的两点差异 开头不是从零造而是在已能玩的游戏上最小改写 写边界收窄到
**只许写 game-logic.js**core/render/assets/host-config 等其余文件保持不变不要动写会被工具拒
红线契约块入口五法 / 插件 API / footgun / 输入 _contract_block() 复用 create 同一份不另抄
Args:
game_id: run 的游戏 id
intent: 用户的改写意图M2 判意图产出的 modifyPatch.payload.intent
Returns:
modify system prompt 字符串G 已据 game_id 替换
"""
head = f"""你是 A-model 游戏调整 agent。⟦G⟧/ 下已经是一款**能跑能玩**的便宜档 LittleJS 小游戏(多文件 src/ 工程)。
本次任务 = 只改玩法不是从零造
- **唯一任务只重写 G/src/game-logic.js**玩法本体来实现这个调整{intent}
- **其余文件一律保持不变绝不要写**src/core.js数值/ src/render.js画面/ src/assets.js资产/ src/host-config.js / src/game.js / index.html / entry-bundle.js 都不要动写它们会被工具直接拒绝**可以 read_file ** core/render/assets 取上下文**只能 write game-logic.js**
- 这是已能玩的游戏的**最小改写** read_file 读当前的 G/src/game-logic.js 看现在怎么实现再据上面的调整意图改写它别推倒重来别改 brief/调整没要求的东西
先读必须别凭记忆猜 API
1. read_file('{SKILL_PATH}') code 层作业手册结构/边界/红线/插件 API 速查
2. read_file('⟦G⟧/src/game-logic.js') 当前玩法实现你的改写起点
3. 用到某插件先 read_file 它的 api.d.ts 看精确签名 game-runtime/src/plugins/scene-fsm/api.d.ts
步骤
A 读手册 + 读当前 game-logic.js B 定位{intent}要改玩法的哪一处 C 读要用的插件 api.d.ts D write_file 改写 **game-logic.js只此一个文件** 实现调整 E check有错改到 PASS F build有错改到 PASS G check+build 都绿后调 finish"""
g = f"game-runtime/games/amgen-{game_id}"
return (head + "\n\n" + _contract_block()).replace("⟦G⟧", g)

View File

@ -109,24 +109,34 @@ def build_trace_source(verdict, driver_type, attempts, stage, model) -> dict:
async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=16000,
port=4320, cdp_port=9222, run_gates=True):
port=4320, cdp_port=9222, run_gates=True,
system_prompt=None, initial_kick=None, write_whitelist=None, prepare=None):
"""跑便宜档一局生成scaffold → ReAct 写 src/ → done 门 → 收口 stage+smoke+九门)。返回 run-summary dict。
run_gates=True默认spike 单局收口跑全套 stagesmokeensure_play_spec九门 play
run_gates=Falsegeneration-only compare_node 对照用收口只到 stage+smoke 为止****自动产 play-spec
****跑内部九门 play 由调用方在生成与 play 之间注入金标 spec 再单独 play把驱动器从对照变量里摘掉
Codex C1run_studio 内部已 play对照需把生成与 play 拆开
A11 M4 模块重生成复用本编排同一 ReAct + resume + 熔断 + 三层校验收口不另造靠四个可选参切到 modify
都默认 None=create 原行为create 路零改动
· system_promptNone=create build_system_promptmodify build_modify_system_prompt只改玩法
· initial_kickNone=create brief 造游戏首条 kickmodify 只重写 game-logic.js 实现 intent
· write_whitelistNone=create 不收窄modify {"game-logic.js"} 把写边界收窄到只许写玩法文件
· prepareNone=create scaffoldclone _templatemodify 已由上游 scaffold+materialize base
noopgame_dir 已就绪不要再 scaffold 覆盖掉 base 签名同 scaffold(game_id)->{ok,output}
"""
t0 = time.time()
_rec(f"model={_bootstrap.SPIKE_MODEL} id={game_id} brief=「{brief}」max_iters={max_iters} max_resumes={max_resumes}")
sc = cheap_run.scaffold(game_id)
prep = prepare or cheap_run.scaffold # create=scaffold clone _templatemodify=上游已预备的 noop
sc = prep(game_id)
if not sc["ok"]:
return {"ok": False, "gameId": game_id, "fail": "scaffold 失败:" + sc["output"]}
_rec(f"scaffold amgen-{game_id}clone _template + SAA 信封)")
return {"ok": False, "gameId": game_id, "fail": "准备失败:" + sc["output"]}
_rec(f"准备就绪 amgen-{game_id}{'scaffold clone _template + SAA 信封' if prepare is None else 'modify: base 源已预备'}")
session = CheapSession(game_id=game_id)
toolkit = build_toolkit(session)
toolkit = build_toolkit(session, write_whitelist=write_whitelist) # modify 收窄到只许写 game-logic.js
model = _bootstrap.build_cheap_model(max_tokens=max_tokens)
# 熔断:便宜档开 ¥ 累进硬闸M1 U4 / KTD5+ 设便宜档专属 ¥10 硬上限(区别 tier2 的 ¥3 genconfig 默认)。
# on_model_call 在每次裸模型调用前按「已花 + 本次预估」判、越线即 fail-closed 抛 Tier2CircuitBreak(budget)
@ -136,7 +146,7 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
writer = Agent(
name="cheap-writer",
system_prompt=build_system_prompt(game_id),
system_prompt=system_prompt or build_system_prompt(game_id), # modify 传 build_modify_system_prompt
model=model,
toolkit=toolkit,
middlewares=[tracer, breaker], # trace 外层、熔断内层(列表序=洋葱序)
@ -145,7 +155,8 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
context_config=config.build_context_config(), # 历史压缩
)
kick = (f"请按这个 brief 造一款游戏:「{brief}」。先 read_file 读手册(.agents/skills/littlejs-game-dev.md"
kick = initial_kick or (
f"请按这个 brief 造一款游戏:「{brief}」。先 read_file 读手册(.agents/skills/littlejs-game-dev.md"
"和你的起点 game-logic.js 再动手核心玩法实现完、check 与 build 都绿了就立即 finish。")
breaker_tripped = None
attempts = 0

View File

@ -29,8 +29,15 @@ class CheapSession:
finished: Optional[dict] = None # finish 组装的产物摘要None=未收敛,编排层据此判收敛)
def build_toolkit(session: CheapSession):
"""组装便宜档 Toolkit对 tier2 toolkit.py build_toolkit。返回 agentscope.tool.Toolkit。"""
def build_toolkit(session: CheapSession, *, write_whitelist=None):
"""组装便宜档 Toolkit对 tier2 toolkit.py build_toolkit。返回 agentscope.tool.Toolkit。
Args:
session: run 可变状态闭包
write_whitelist: 可选写边界白名单basename 集合None=create 路不收窄沿用 cheap_run.write_file
L3 边界 L1A11 M4 模块重生成传 {"game-logic.js"}把写边界收窄到只许写玩法文件
其余 L3core/render/assets连同 L1 一并禁写确保只改玩法不动别处
"""
from agentscope.tool import Toolkit, FunctionTool
async def read_file(path: str) -> str:
@ -63,6 +70,14 @@ def build_toolkit(session: CheapSession):
path: game-runtime/games/amgen-<id>/src/game-logic.js
content: 完整文件内容
"""
# M4 模块重生成写边界白名单生效时basename 不在白名单(即非 game-logic.js的写一律拒——
# 本次只重写玩法文件其余文件保持不变read_file 读它取上下文即可。create 路 white=None 不收窄。
if write_whitelist is not None:
base = path.rsplit("/", 1)[-1]
if base not in write_whitelist:
allowed = "/".join(sorted(write_whitelist))
return (f"ERROR: 本次是「只改玩法」的模块重生成,只许写 {allowed}"
f"{base} 属其余文件,要保持不变、不要写它(需要上下文就 read_file 读它)。")
r = cheap_run.write_file(session.game_id, path, content)
return f"OK {r['bytes']}B 已写" if r["ok"] else "ERROR: " + r["error"]

View File

@ -247,15 +247,20 @@ def test_worker_deterministic_route_success_carries_bundle_and_source():
assert "sourceProject" in p, "成功路 best-effort 带新版源(供下一次链式改)"
def test_worker_regenerate_module_explicit_failed():
"""modifyMode=regenerate-module(M4 未实现)→ 显式 failed + 发 failed 回调,绝不静默吞。"""
def test_worker_regenerate_module_routes_to_executor_no_llm():
"""modifyMode=regenerate-module(M4 已实现)→ 走 execute_regenerate_modify、不再占位 failed。
sourceProject/modifyPatch intent ,executor 早失败( LLM scaffold/不重写) 仍发 failed 回调
(任务不挂 RUNNING)+ 失败原因进 trace(不静默吞) regen 分流已接真执行器完整 regen 单测见 test_a11_m4
"""
captured = {}
state = W.WorkerState(send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""))
state = W.WorkerState(send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: None) # 默认 regen_fn → 真 execute_regenerate_modify(intent 空零 LLM)
job = {"job_id": "rm1", "traceId": "rm1", "modifyMode": "regenerate-module", "gameId": "g",
"brief": "x", "callback": {"target": "http://cb"}}
out = W.process_job(state, job)
assert out["status"] == "failed" and out["mode"] == "regenerate-module"
assert captured["payload"]["status"] == "failed", "应发 failed 回调(任务不挂 RUNNING)"
"brief": "x", "callback": {"target": "http://cb"}} # 无 sourceProject/modifyPatch
W.process_job(state, job)
assert captured["payload"]["status"] == "failed", "intent 空 → failed 回调(任务不挂 RUNNING)"
assert captured["payload"]["trace"]["modifyError"], "失败原因附进 trace(不静默吞)"
def _boom_modify(*a):

View File

@ -0,0 +1,275 @@
"""test_a11_m4_regenerate.py — A11 切片三 M4「模块重生成执行」(改玩法)单测(全注入桩、零真 LLM)。
守的不变量:
· execute_regenerate_modify:有界单文件重写 game-logic.js,复用 cheap_studio resume + 三层校验,
但写边界收窄到只许写 game-logic.js;断言②对非目标文件 hash 稳定性把关
- 注入假 rewrite( game-logic.js) manifest.changed=true非目标稳(untouchedStable=true)走到九门 verdict;
- intent failed 不重写(不调重写器);
- rewrite 篡改非目标 untouchedStable=false(断言②真能抓误伤)+ status failed;
- base / 重写异常 best-effort failed,不抛
· worker 接线:modifyMode=regenerate-module execute_regenerate_modify(不再占位 failed);
manifest( dict)附进 trace成功带新版 sourceProject + run-summary(cost/attempts)
:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m4_regenerate.py
"""
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_run # noqa: E402
import cheap_modify as M # noqa: E402
import worker_service as W # noqa: E402
import dedup # noqa: E402
# game_dir 重定向到临时目录:materialize / 接线落盘免污染真 games/(cheap_modify、worker_service 同引用一个
# cheap_run 模块对象 → patch 模块属性即对二者生效;与 test_a11_m3 同范式)。
_TMP_GAMES = Path(tempfile.mkdtemp(prefix="a11-m4-games-"))
cheap_run.game_dir = lambda gid: _TMP_GAMES / f"amgen-{gid}" # noqa: E731
# 防 D9 dedup 写真登记表(本测不走 create 路、稳妥起见仍重定向)。
dedup.DEDUP_REGISTRY = Path(tempfile.mkdtemp(prefix="a11m4-dedup-")) / "_dedup_registry.jsonl"
_GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();"
_BASE_LOGIC = "// base game-logic\nexport function createGame({ plugins, bundle, viewport }) { return {}; }\n"
def _setup_base(game_id: str):
"""预备一个「已能玩」的便宜档产物:写好 L1 根件(entry-bundle/index,让 scaffold 跳过)+ 返回 base 源 dict。
base files 含目标 game-logic.js + 两个非目标(core 数值 / host-config L1);execute_regenerate_modify
materialize 这些到 game_dir返回 (game_dir, source_project_dict)
"""
gd = cheap_run.game_dir(game_id)
(gd / "src").mkdir(parents=True, exist_ok=True)
# 预置 entry-bundle.js 让 execute 的 scaffold-if-needed 跳过(单测不 shell-out node scaffold)。
(gd / "entry-bundle.js").write_text("// L1 entry-bundle\n", encoding="utf-8")
(gd / "index.html").write_text("<html><body>g</body></html>", encoding="utf-8")
sp = {"files": {
"src/game-logic.js": _BASE_LOGIC,
"src/core.js": "export const SPEED = 5;\n", # 非目标(数值)
"src/host-config.js": "// L1 host-config plumbing\n", # 非目标(L1)
}}
return gd, sp
# ───────────────────────── ① execute_regenerate_modify ─────────────────────────
def _fake_rewrite_ok(game_id, intent, **kw):
"""假重写器:只改 game-logic.js(模拟 agent 有界重写),返回九门桩 run-summary(verdict pass)。"""
gd = cheap_run.game_dir(game_id)
(gd / "src" / "game-logic.js").write_text(
f"// rewritten for: {intent}\nexport function createGame({{ plugins, bundle, viewport }}) {{ return {{}}; }}\n",
encoding="utf-8")
return {"verdict": {"pass": True, "failedGates": []},
"verdictFull": {"pass": True, "guards": {}},
"attempts": 3, "stage": "play", "costRmb": 0.5,
"models": {"code": "MiniMax-M3"}, "gameId": game_id}
def _fake_rewrite_tamper(game_id, intent, **kw):
"""假重写器(误伤):改了 game-logic.js,**还动了非目标 core.js**——断言②应抓到。"""
gd = cheap_run.game_dir(game_id)
(gd / "src" / "game-logic.js").write_text("// rewritten\nexport function createGame(o){ return {}; }\n", encoding="utf-8")
(gd / "src" / "core.js").write_text("export const SPEED = 999;\n", encoding="utf-8") # 误伤非目标
return {"verdict": {"pass": True, "failedGates": []}, "verdictFull": {"pass": True, "guards": {}},
"attempts": 2, "stage": "play", "gameId": game_id}
def test_regenerate_changed_and_nontarget_stable():
"""假 rewrite 改 game-logic → changed=true、非目标稳(untouchedStable=true)、走到九门 verdict、status succeeded。"""
gid = "m4ok"
_, sp = _setup_base(gid)
patch = {"target": {"kind": "behavior"}, "payload": {"intent": "把命中加分从固定改成连击递增"}}
out = M.execute_regenerate_modify(gid, sp, patch, rewrite_fn=_fake_rewrite_ok)
assert out["status"] == "succeeded", out # 九门桩 pass ∧ 非目标稳 → succeeded
man = out["manifest"]
assert man["file"] == "src/game-logic.js" and man["kind"] == "behavior"
assert man["intent"] == "把命中加分从固定改成连击递增"
assert man["changed"] is True, "game-logic 被重写 → changed"
assert man["untouchedStable"] is True, "非目标(core/host-config/entry/index)未动 → stable"
assert out["verdict"] == {"pass": True, "guards": {}}, "九门 verdict 透传(走到 verdict)"
assert out["stage"] == "play"
assert out["summary"]["attempts"] == 3, "富 run-summary 透传(cost/attempts 在)"
# 非目标文件内容确实没变。
assert (cheap_run.game_dir(gid) / "src" / "core.js").read_text(encoding="utf-8") == "export const SPEED = 5;\n"
def test_regenerate_empty_intent_failed_no_rewrite():
"""intent 空(空串/缺 intent/缺 payload)→ failed 不重写(不调用重写器)。"""
gid = "m4empty"
_, sp = _setup_base(gid)
called = {}
def _spy(game_id, intent, **kw):
called["x"] = True
return {"verdict": {"pass": True}}
for patch in ({"target": {"kind": "behavior"}, "payload": {"intent": " "}},
{"target": {"kind": "behavior"}, "payload": {"intent": ""}},
{"target": {"kind": "behavior"}, "payload": {}},
{"target": {"kind": "behavior"}}):
out = M.execute_regenerate_modify(gid, sp, patch, rewrite_fn=_spy)
assert out["status"] == "failed", out
assert out["stage"] == "intent"
assert "intent" in (out["error"] or "")
assert out["manifest"]["untouchedStable"] is None, "没重写 → 稳定性未知=None"
assert "x" not in called, "intent 空绝不应调用重写器"
def test_regenerate_tamper_nontarget_caught_by_assert2():
"""假 rewrite 篡改非目标(core.js)→ untouchedStable=false(断言②真能抓误伤)+ 即便九门过也判 failed。"""
gid = "m4tamper"
_, sp = _setup_base(gid)
patch = {"target": {"kind": "behavior"}, "payload": {"intent": "改玩法但误伤了 core"}}
out = M.execute_regenerate_modify(gid, sp, patch, rewrite_fn=_fake_rewrite_tamper)
assert out["manifest"]["changed"] is True, "game-logic 仍被改"
assert out["manifest"]["untouchedStable"] is False, "断言②应抓到非目标 core.js 被改"
assert out["status"] == "failed", "误伤非目标=边界泄漏,即便九门桩 pass 也判失败"
assert "断言②" in (out["error"] or "") or "非目标" in (out["error"] or "")
def test_regenerate_no_source_failed():
"""无 base 源 files → failed(无可改源),不重写。"""
patch = {"target": {"kind": "behavior"}, "payload": {"intent": "随便改"}}
out = M.execute_regenerate_modify("m4nosrc", {"files": {}}, patch, rewrite_fn=_fake_rewrite_ok)
assert out["status"] == "failed" and "" in (out["error"] or "")
out2 = M.execute_regenerate_modify("m4nosrc2", "{bad json", patch, rewrite_fn=_fake_rewrite_ok)
assert out2["status"] == "failed", "坏 source_project 容错 failed,不抛"
def test_regenerate_rewrite_exception_best_effort():
"""重写器抛异常 → best-effort failed + 诊断,绝不抛崩。"""
gid = "m4boom"
_, sp = _setup_base(gid)
def _boom(game_id, intent, **kw):
raise RuntimeError("LLM exploded")
out = M.execute_regenerate_modify(gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "x"}},
rewrite_fn=_boom)
assert out["status"] == "failed" and "异常" in (out["error"] or "")
assert out["stage"] == "code"
def test_regenerate_gates_fail_is_plumbing_pass():
"""九门桩不过(verdict pass=false)但非目标稳 → status failed,但 changed/stable 正确(M4 验 plumbing,质量归 agent)。"""
gid = "m4gatefail"
_, sp = _setup_base(gid)
def _rewrite_gatefail(game_id, intent, **kw):
gd = cheap_run.game_dir(game_id)
(gd / "src" / "game-logic.js").write_text("// rewritten low quality\n", encoding="utf-8")
return {"verdict": {"pass": False, "failedGates": ["H_progress"]},
"verdictFull": {"pass": False, "guards": {"H_progress": {"pass": False}}},
"attempts": 6, "stage": "play", "gameId": game_id}
out = M.execute_regenerate_modify(gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "改玩法"}},
rewrite_fn=_rewrite_gatefail)
assert out["status"] == "failed" and out["error"] == "九门未通过"
assert out["manifest"]["changed"] is True and out["manifest"]["untouchedStable"] is True, "plumbing 仍通(改了+非目标稳)"
# ───────────────────────── ② worker 接线 ─────────────────────────
def test_worker_regenerate_route_success_carries_manifest_bundle_source():
"""regenerate-module 成功 → result-out 带 engineBundle + manifest(单 dict)进 trace + 新版 sourceProject + 富 trace。"""
gid = "m4wok"
gd = cheap_run.game_dir(gid)
(gd / "src").mkdir(parents=True, exist_ok=True)
(gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8") # 成功路需 engineBundle
(gd / "src" / "game-logic.js").write_text("// rewritten\n", encoding="utf-8")
captured = {}
fake = {"status": "succeeded", "stage": "play",
"manifest": {"file": "src/game-logic.js", "kind": "behavior", "intent": "连击递增",
"changed": True, "untouchedStable": True},
"verdict": {"pass": True, "guards": {}},
"summary": {"verdict": {"pass": True, "failedGates": []}, "verdictFull": {"pass": True, "guards": {}},
"attempts": 4, "stage": "play", "costRmb": 0.7, "models": {"code": "MiniMax-M3"}, "gameId": gid},
"error": None}
profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"}
state = W.WorkerState(regen_fn=lambda *a: fake,
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: profile)
job = {"job_id": "rmok", "traceId": "rmok", "modifyMode": "regenerate-module", "gameId": gid,
"sourceProject": "{}", "modifyPatch": "{}", "brief": "连击小游戏", "callback": {"target": "http://cb"}}
W.process_job(state, job)
p = captured["payload"]
assert p["status"] == "succeeded"
assert "__GameBundle" in p["engineBundle"], "成功路携重建后的 bundle"
man = p["trace"]["modifyManifest"]
assert man["untouchedStable"] is True and man["changed"] is True, "manifest(单 dict)进 trace"
assert man["kind"] == "behavior"
assert "sourceProject" in p, "成功路 best-effort 带新版源(供链式改)"
assert p["trace"]["attempts"] == 4, "富 run-summary → trace.attempts"
assert p["trace"]["cost"]["totalRmb"] == 0.7, "富 run-summary → trace.cost"
def test_worker_regenerate_route_failed_attaches_error_and_manifest():
"""regenerate-module 失败(误伤非目标)→ failed 回调 + manifest/error 进 trace(不静默吞)。"""
gid = "m4wfail"
captured = {}
fake = {"status": "failed", "stage": "play",
"manifest": {"file": "src/game-logic.js", "kind": "behavior", "intent": "x",
"changed": True, "untouchedStable": False},
"verdict": {"pass": True, "guards": {}}, "summary": None,
"error": "断言②失败:模块重生成误伤了非目标文件"}
state = W.WorkerState(regen_fn=lambda *a: fake,
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: None)
job = {"job_id": "rmf", "traceId": "rmf", "modifyMode": "regenerate-module", "gameId": gid,
"sourceProject": "{}", "modifyPatch": "{}", "brief": "x", "callback": {"target": "http://cb"}}
out = W.process_job(state, job)
assert out["status"] == "failed", "无 bundle + 执行器 failed → failed"
assert captured["payload"]["trace"]["modifyManifest"]["untouchedStable"] is False
assert captured["payload"]["trace"]["modifyError"], "失败原因附进 trace"
assert "sourceProject" not in captured["payload"], "失败路不带新版源"
def _boom_regen(*a):
raise AssertionError("deterministic/create 路不应调用 regen_fn")
def test_worker_deterministic_unaffected_by_regen_wiring():
"""modifyMode=deterministic 仍走 modify_fn、不触 regen_fn(regen 接线对 deterministic 零副作用)。"""
gid = "m4det"
gd = cheap_run.game_dir(gid)
(gd / "src").mkdir(parents=True, exist_ok=True)
(gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8")
captured = {}
det = {"status": "succeeded",
"manifest": [{"file": "src/core.js", "kind": "config", "key": "SPEED", "old": "5", "new": "8", "found": True}],
"verdict": {"pass": True, "guards": {}}, "error": None, "stage": "play"}
state = W.WorkerState(modify_fn=lambda *a: det, regen_fn=_boom_regen,
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: None)
job = {"job_id": "det1", "traceId": "det1", "modifyMode": "deterministic", "gameId": gid,
"sourceProject": "{}", "modifyPatch": "{}", "brief": "x", "callback": {"target": "http://cb"}}
W.process_job(state, job)
assert captured["payload"]["status"] == "succeeded"
assert isinstance(captured["payload"]["trace"]["modifyManifest"], list), "deterministic manifest 仍是 list"
def test_worker_unknown_mode_still_failed():
"""未知 modifyMode → 显式 failed 兜底回调(不静默吞;regen 接线后未知 mode 行为不变)。"""
captured = {}
state = W.WorkerState(send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""))
job = {"job_id": "unk1", "traceId": "unk1", "modifyMode": "teleport", "gameId": "g",
"brief": "x", "callback": {"target": "http://cb"}}
out = W.process_job(state, job)
assert out["status"] == "failed" and out["mode"] == "teleport"
assert captured["payload"]["status"] == "failed"
if __name__ == "__main__":
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
_failed = 0
for _fn in _fns:
try:
_fn()
print(f" PASS {_fn.__name__}")
except Exception as e: # noqa: BLE001
_failed += 1
print(f" FAIL {_fn.__name__}: {type(e).__name__}: {e}")
print(f"\n[test_a11_m4_regenerate] {len(_fns) - _failed}/{len(_fns)} passed")
sys.exit(1 if _failed else 0)

View File

@ -149,7 +149,7 @@ class WorkerState:
"""进程级状态:配置 + 有界队列 + 可注入依赖 + job_id 去重。"""
def __init__(self, *, callback_secret: str = "", queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE,
run_fn=None, send_fn=None, profile_fn=None, classify_fn=None, modify_fn=None):
run_fn=None, send_fn=None, profile_fn=None, classify_fn=None, modify_fn=None, regen_fn=None):
self.callback_secret = callback_secret
self.queue: queue.Queue = queue.Queue(maxsize=queue_maxsize)
self.run_fn = run_fn or _default_run_fn # job -> (summary, game_dir)
@ -159,6 +159,9 @@ class WorkerState:
# A11 切片三 M3:确定性类执行器(game_id, sourceProject, modifyPatch) -> 执行结果。
# None=懒绑 cheap_modify.execute_deterministic_modify(单测注入桩免真 esbuild/Chrome)。
self.modify_fn = modify_fn
# A11 切片三 M4:模块重生成执行器(game_id, sourceProject, modifyPatch) -> 执行结果。
# None=懒绑 cheap_modify.execute_regenerate_modify(单测注入桩免真 LLM/esbuild/Chrome)。
self.regen_fn = regen_fn
self._seen: set = set() # job_id 去重(防同 job 重投重跑)
self._lock = threading.Lock()
@ -245,18 +248,23 @@ def _modify_summary(result: dict, game_id: str) -> dict:
def _process_modify_job(state: WorkerState, job: dict, modify: dict) -> dict:
"""A11 调整路:deterministic 走确定性执行(改→重建→九门);regenerate-module 留 M4;未知 mode → failed。
"""A11 调整路:deterministic 走确定性执行(改→重建→九门);regenerate-module 走模块重生成(M4);未知 mode → failed。
deterministic:execute_deterministic_modify(注入 modify_fn 可桩) result-out(manifest 附进 trace
best-effort 带新版 sourceProject 供链式改) 回调regenerate-module/未知:显式 failed + 日志(不静默吞)
best-effort 带新版 sourceProject 供链式改) 回调regenerate-module:_process_regenerate_job(M4 有界重写
game-logic.js)未知 mode:显式 failed + 日志(不静默吞)
"""
trace_id = job.get("traceId") or job.get("job_id")
mode = modify.get("mode")
game_id = job.get("gameId") or job.get("job_id")
# A11 M4:模块重生成(改玩法)分流——有界单文件重写 game-logic.js,复用 cheap_studio resume + 三层校验。
if mode == "regenerate-module":
return _process_regenerate_job(state, job, modify)
if mode != "deterministic":
# regenerate-module 属 M4(模块重生成,要 LLM)、未知 mode 都显式 failed,绝不静默吞(错误路红线)。
why = "regenerate-module 属 M4、未实现" if mode == "regenerate-module" else f"未知 modifyMode={mode}"
# 未知 mode 显式 failed,绝不静默吞(错误路红线)。
why = f"未知 modifyMode={mode}"
log(f"调整路不支持的 mode → 发兜底 failed trace_id={trace_id}: {why}")
_send_failed(state, job, why)
return {"status": "failed", "mode": mode, "error": why}
@ -301,6 +309,83 @@ def _process_modify_job(state: WorkerState, job: dict, modify: dict) -> dict:
return payload
def _regen_summary(result: dict, game_id: str) -> dict:
"""把 execute_regenerate_modify 结果转成 build_result_out 吃的 run-summary。
复用重写产出的富 run-summary(cost/models/attempts/wallSec/stage/driverType, trace 完整);**
verdict.pass 以执行器最终判定为准**(= 九门过 断言②非目标稳定)否则九门过但误伤了非目标会被
build_result_out run-summary 原始 verdict.pass=True 误判成功,与执行器 status=failed 矛盾
早失败(无重写result.summary=None) 用最小 failed summary 兜底
"""
pass_ = result.get("status") == "succeeded"
run_summary = result.get("summary")
verdict_full = result.get("verdict") if isinstance(result.get("verdict"), dict) else None
failed = []
if verdict_full:
guards = verdict_full.get("guards") or {}
failed = [k for k, g in guards.items() if isinstance(g, dict) and g.get("pass") is False]
if isinstance(run_summary, dict):
s = dict(run_summary) # 浅拷贝富字段(cost/models/attempts/wallSec/stage/gameId/driverType)
else:
s = {"gameId": game_id, "attempts": 1, "stage": result.get("stage", "code")}
# verdict 以执行器最终判定为准(含断言②);verdictFull 透传供 trace.sevenGateVerdict(后端 D11)。
s["verdict"] = {"pass": pass_, "failedGates": failed}
s["verdictFull"] = verdict_full
s["gameId"] = game_id
return s
def _process_regenerate_job(state: WorkerState, job: dict, modify: dict) -> dict:
"""A11 M4 模块重生成(改玩法):execute_regenerate_modify 有界重写 game-logic.js → 九门 → 改动清单 +
断言②非目标稳定manifest tracebest-effort 带新版 sourceProject( deterministic 口径)
deterministic 路两点差异: 执行器是 regen_fn(LLM 重写 resume ),summary 取重写富 run-summary
(cost/models/attempts 完整)而非 attempts=1 _modify_summary; manifest 是单条 dict( list)
changed/untouchedStable
"""
trace_id = job.get("traceId") or job.get("job_id")
game_id = job.get("gameId") or job.get("job_id")
import cheap_run
regen_fn = state.regen_fn
if regen_fn is None:
import cheap_modify
regen_fn = cheap_modify.execute_regenerate_modify
result = regen_fn(game_id, modify.get("sourceProject"), modify.get("modifyPatch"))
summary = _regen_summary(result, game_id)
game_dir = cheap_run.game_dir(game_id)
# best-effort:重写成功则把新版源回传(供下一次链式改用 base);派生失败只记日志、不阻断。
source_project = None
if result.get("status") == "succeeded":
try:
profile = state.profile_fn(job, game_dir)
if profile:
source_project = result_out.build_source_project(game_dir, profile)
except Exception as e: # noqa: BLE001 — sourceProject 非承重,派生失败不阻断回调
log(f"模块重生成 sourceProject 派生失败(省略,不阻断)trace_id={trace_id}: {type(e).__name__}: {e}")
payload = result_out.build_result_out(job, summary, game_dir, source_project=source_project)
# 改动清单 manifest 附进 trace(后端 trace 是开放 Map、忽略未知键;供前端展示「改了玩法、非目标稳没稳」+ 排障)。
manifest = result.get("manifest")
if manifest is not None:
payload.setdefault("trace", {})["modifyManifest"] = manifest
if result.get("error"):
payload.setdefault("trace", {})["modifyError"] = result["error"]
callback_url = (job.get("callback") or {}).get("target")
if callback_url:
http_status, body = state.send_fn(callback_url, payload, state.callback_secret)
log(f"模块重生成回调完成 trace_id={trace_id}, status={payload['status']}, callbackHttp={http_status}, "
f"changed={(manifest or {}).get('changed')}, untouchedStable={(manifest or {}).get('untouchedStable')}, "
f"body={str(body)[:160]}")
else:
log(f"模块重生成 job 无 callback.target,跳过回调 trace_id={trace_id}, status={payload['status']}")
return payload
def _send_failed(state: WorkerState, job: dict, reason: str) -> None:
"""生成异常兜底:发 failed result-out,避免任务挂 RUNNING(对执行器 callbackFailed 语义)。"""
trace_id = job.get("traceId") or job.get("job_id")

View File

@ -1,6 +1,6 @@
---
title: A11 对话式调整回路(试玩后纠错)细化执行 plan
status: 已批(创始人 2026-06-29· 执行中M1✅ M2✅ M3✅ → M4
status: 已批(创始人 2026-06-29· 执行中M1✅ M2✅ M3✅ M4✅ → M5
date: 2026-06-28
topic: studio-A11-调整回路
canonical: false # 切片三统领在 plan①本档是「切片三·A11」的可执行细化不另立 SoT评审后把 A11 设计面蒸馏回 docs/architecture 生成引擎域(兑现 plan① 收口 TODO⑥
@ -297,6 +297,15 @@ stateDiagram-v2
- **九门 smoke主会话独立复跑本机 Chrome 免 LLM**:取 `amgen-bake-click-score-0` base 源 → config 改 `ROUND_MS 30000→19000`**status=succeeded、stage=play、verdictPass=true、manifest found=true、落盘 core.js=19000、运行时 remainMs=19000**(改的那一处真在游戏里生效并过九门)。坐实"改规范工程一处→重建→过九门"全链路。
- **M3 收口**:确定性类执行端到端通(零 LLM改动清单为 M5 断言①就位。
**M4 模块重生成执行完成、绿2026-06-29· 有界单文件 LLM 重写**
- **复用机制(参数化、不复制 resume/熔断/收口)**`cheap_studio.run_studio` 加 4 个默认 None 可选参system_prompt/initial_kick/write_whitelist/prepare切到 modify 态,**create 路零行为变化**(核 diff 确认 params=None 等价原逻辑、仅日志串变);`cheap_toolkit.build_toolkit` 加 write_whitelist非白名单 basename 写直接拒);`cheap_roles.build_modify_system_prompt`(复用 create 红线契约块)。
- **`execute_regenerate_modify`**:取 intent空→failed→ materialize base 源 → 重写前后对**非目标文件**算 hash → 有界重写(写边界收窄到只 `game-logic.js`agent 可读其余取上下文)→ 九门status=succeeded ⟺ 九门过 ∧ 非目标稳manifest `{file:game-logic.js, kind:behavior, intent, changed, untouchedStable}`(断言②地基)。
- **worker 接线**`_process_regenerate_job`regen_fn把 M3 的 regenerate-module 显式 failed 占位换成真执行deterministic/create/生成路一字未动。
- **测试**`test_a11_m4_regenerate` 10/10全注入桩零真 LLM含"篡改非目标→断言②抓+判 failed"+ 全回归m1/m2/m3/worker_service/toolkit/roles主会话独立复跑 **73 测试全绿**
- **一次真 LLM smoke子代理单跑本机 Chrome + new-api M3**base=`amgen-bake-click-score-0`、intent="把命中加分从固定改成连击递增" → **status=succeeded、untouchedStable=True、九门 verdict.pass=True、attempts=1、¥0.31、137s、无熔断**;独立 diff 佐证 5 个非目标文件字节相同、仅 game-logic.js 变且真实现连击递增。**M4 plumbing 通**(重写发生 + 写边界守住 + 非目标稳 + 九门跑到 verdict此次质量也恰好达标质量归 agent 层、不在 M4 判据)。
- **M4 收口**两档执行deterministic + regenerate-module全落地断言②地基非目标 hash 稳)就位。
## 状态
双评审已过(发现全处置)→ 待创始人批 → 执行M1→M5 顺序M1 为 plan① 所称"首个里程碑")。