lili 8d851ea716
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(cheap-gen): W-AXIS-V2 波2——取证契约退役+提示全加载链清洗,四门∧测试员为唯一验收链
opus 执行位交付、主会话四步验收过(三套测试亲跑/红线 diff 亲读/冒烟截图亲眼):

- 验收消费:check 新增源码级插件调用存在断言(_pluginCallPresenceErrors 词法层,
  接替 F_wiring 真接线护栏);_forensicsView「必须实现」拦截摘除,降级为可选调试
  接口(handleTap 输入契约与 ctx.log 日志纪律保留);gate_judge 放行措辞对齐四门
- play-spec/驱动器退役(逐件裁定 10 件):gen.mjs done 门自动产 play-spec 摘除、
  cheap_gates/cheap_studio/cheap_modify/service_driver 调用摘除、_read_driver_type
  改读 evidence/playtest/playtest.json;golden-spec 三工具(auto_vs_golden/bake_off/
  compare_node)显式封存;ensure_play_spec 两实现保留一个版本窗口(只摘调用不删码)
- prompt 全加载链清洗:cheap-system.md v1.6.3→1.7.0(删 _forensicsView 自动验收
  契约红线与全部 targets/occupied 语义,五法句/品类指路同步),cheap_roles 内置
  回退逐字节同步(test_roles 12/12 证 parity),modify 切片经运行时切块自动同步;
  5 品类 skills+littlejs 手册+recipes×2+模板 8 件+README 6 件教学清洗,真设计
  价值(单击 casual/可解性质/双层反馈/latch)保留
- 批账:hard_genre_batch/xtheme 行加 playtest 段,_row_total_cost 修 shadow 下
  测试员成本漏算(v2 取 max 防双计)

验收(主会话亲验):pytest cheap-worker 512 绿 + tier2 141 绿 + node 49/49;
25 局基线新旧口径重放对照——真坏 5 局全拦(floor 拦 2/测试员拦 3)、无一例旧拦
新放,翻案 2 局均为破案坐实的仪器误杀;n=3 冒烟(mode=v2)全链绿:w2v2-sim
harness verdict.pass=True 而测试员 reject(机械放行测试员逮住,盘上双证)、
w2v2-sim-fix 现象反馈两轮回喂→fix2 收敛(配方显示修成,局内截图亲眼);
rg 零残留(模板残留均为「验收不读它」的降级调试接口合规形态);真跑 ¥8.4。

已知余项:repairFeedback 有产地、Service 续修环消费者未接(测试员 reject 不入
RepairMiddleware,续修判据仍四门)——是否接入随波3 校准数据一并裁;v1 配置级
回退窗口关闭为创始人知悉项,genconfig 默认 mode 仍 shadow、切 v2 归创始人。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:21:04 -07:00

722 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
cheap_run.py — 便宜档生成的工具底座实现对照源Node amodel-gen 的 tools.mjs + play.mjs
分工KTD3
· read_file / list_dir / write_file —— Python 直接实现(对齐 tools.mjs 的 readFileTool/listDirTool/writeFileTool
repo 根只读 + 200KB 截断 / L1_FIXED 拦截 + 写边界),逻辑简单、无引擎重活。
· check / build / scaffold / stage / play —— shell-out 现有 node/cjs不 Python 重写 esbuild/CDP/形状门:
check=`node tools.mjs check <id>`(含 node --check + 全部静态门 + 两条形状门、build=`node tools.mjs build-saa <id>`、
scaffold/stage 同 tools.mjs CLI、九门 play=`bash serve-and-play.sh <id>`U4 落地)。
Node 已验过不误伤的形状门,便宜档 shell-out 同一份代码自动继承,零口径漂移。
本文件 U2 先落 read/list/write/check/buildscaffold/stage/play/ensure_play_spec 由 U4 补全。
"""
import hashlib
import json
import os
import shutil
import subprocess
import time
from pathlib import Path
from typing import Optional
# 路径基准cheap-worker/cheap_run.py → parents[1]=repo 根。
_REPO_ROOT = Path(__file__).resolve().parents[1]
_GAME_RUNTIME = _REPO_ROOT / "game-runtime"
_GAMES_DIR = _GAME_RUNTIME / "games"
_AMODEL_GEN = _GAME_RUNTIME / "tools" / "amodel-gen"
_TEMPLATE_DIR = _GAMES_DIR / "_template"
_WG1_DIR = _GAMES_DIR / "_wg1-gen"
_SERVE_AND_PLAY = _WG1_DIR / "_shared" / "serve-and-play.sh" # 九门一键cwd=game-runtime
# 本机 Mac Chromeplay.mjs smokeBoot 默认 /usr/bin/google-chrome 在 Mac 不存在,须显式 Mac 路径。
# 只在该路径真实存在(=Mac)才注入;Linux(mini-desktop)上注入它会把 smoke 全打成 spawn ENOENT →
# state=None → 考卷全体回退 key-cycle(2026-07-04 生产实证:80007 族错配盲修 ¥14.9、80012 gatespec
# driver=key-cycle 的共同上游根因)——Linux 交 play.mjs:22 自身默认 /usr/bin/google-chrome。
_DEFAULT_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
_MAX_READ_BYTES = 200 * 1024 # 单文件读上限,对 tools.mjs MAX_READ_BYTES防撑爆 agent context
# L1 固定 plumbingboot 链/插件装配/viewport/工厂 wiringagent 不许写,对 tools.mjs:89 L1_FIXED。
_L1_FIXED = {"host-config.js", "game.js", "index.html", "entry-bundle.js", "entry.js", "main.js"}
def game_dir(game_id: str) -> Path:
"""本 run 的 game 目录(= 写边界),对 tools.mjs gameDirgames/amgen-<id>/。"""
return _GAMES_DIR / f"amgen-{game_id}"
def wg1_game_dir(game_id: str) -> Path:
"""SAA stage 目标目录,对 tools.mjs saaGameDirgames/_wg1-gen/<id>/。"""
return _WG1_DIR / game_id
# 便宜档 Service 会话注册表目录C2driver 按 session_id 写 session→后端 gameId 映射 + 本 session 配置,
# Service 两工厂读它把框架分配的 session_id 解析回后端 gameId。_ 前缀 = 基建目录(同 _template/_wg1-gen
# 非游戏目录,不会被 scaffold/list 当游戏,避免在 games/ 堆 amgen-<session uuid> 空壳。
_CHEAP_SESSIONS_DIR = _GAMES_DIR / "_cheap-sessions"
def session_cfg_path(session_id: str) -> Path:
"""本 session 的 cheap 配置 sidecar 路径(按 session_id 键driver 在 /chat 前写、Service 工厂读)。
内容 {external_game_id, write_whitelist, scaffold_template, restricted}:因 AgentScope 工厂签名固定
(user_id, agent_id, session_id)、拿不到后端 gameId 也拿不到 session 记录,故用同机共享 FS sidecar 把
「session_id → 后端 gameId + 本 session 配置」跨进程传给工厂C2 收口)。
"""
return _CHEAP_SESSIONS_DIR / f"{session_id}.json"
# ───────────────────────── W-AXIS 波1 真相层:per-run 归档 + evidence 清理清单化 ─────────────────────────
# 病根(诊断档 §2.3 F0-b/F0-c):
# F0-b「失败 run 被覆盖」——scaffoldSaa 每局起手 rmSync(amgen-<id>/)(tools.mjs:471)整目录删,重跑同 gid
# 直接抹掉上一 run 的 trace.jsonl / turns.jsonl / src / 收口采集,失败链路无从回放。
# F0-c「evidence 残留混杂」——scaffold 只删 amgen-<id>/、绝不碰 _wg1-gen/<id>/,于是九门/续修反馈/截图在
# _wg1-gen/<id>/evidence/ 逐 run 累积(实证:hard-heritage 的 verdict-feedback.json@07-08 与 verdict.json@07-09
# 并存=不同 run 残留);旧红线③只清 verdict.json 一个文件,反馈/日志/截图残留照喂下一 run。
# 本节两件对症:
# ① archive_prior_run:重跑同 gid 前把上一 run 的 amgen-<id>/ 与 _wg1-gen/<id>/ 整体【移】进带时间戳归档位,
# 永不同 gid 覆盖;返回归档指针(供批次账每 run 记一笔)。
# ② clean_stale_evidence:scaffold 后按【固定清单】清 _wg1-gen/<id>/evidence/ 残留(verdict/反馈/日志/截图全列),
# 不再只清一个文件——封「读回上一 run 反馈/绿 verdict → 误归因/假绿」。
# 归档根:归档位 = games/_amgen-archive/<gid>/<时间戳>/{amgen/, wg1/}。_ 前缀=基建目录,不被 scaffold/list 当游戏。
_ARCHIVE_DIR = _GAMES_DIR / "_amgen-archive"
# scaffold 后应清的 _wg1-gen/<id>/evidence/ 残留清单(F0-c:全列,不再只清 verdict.json)。
# verdict-feedback.json 是残留混杂的元凶(旧红线③没清);game-log/截图/快照/报告一并清,防跨 run 混读。
_STALE_EVIDENCE_WG1 = (
"verdict.json", # 九门裁决(旧红线③只清它)
"verdict-feedback.json", # C6 续修反馈(残留混杂元凶——旧代码不清,喂错下一 run 归因)
"game-log.json", # smoke 抓的运行时日志
"world-snapshot.json", # 世界快照(若产)
"play-report.json", # 真玩报告(若产)
"first-paint.png", # 首帧截图
"after-play.png", # 真玩后截图
)
# amgen-<id>/evidence/ 侧:scaffold 已 rmSync 整个 amgen-<id>/、这些本已随之清掉,列此仅作【不归档/不 scaffold 时】
# 的防御兜底(如归档关 + 未走 scaffold 的异常路);service-run-summary.json = Service 收口采集,brief.json 本次早落不清。
_STALE_EVIDENCE_AMGEN = (
"service-run-summary.json",
)
# 归档开关(默认开;env CHEAP_ARCHIVE_ENABLED ∈ {0,false,no,off} 关 → 退回旧行为=scaffold 直接覆盖)。
_ENV_ARCHIVE_ENABLED = "CHEAP_ARCHIVE_ENABLED"
def _archive_enabled() -> bool:
v = os.environ.get(_ENV_ARCHIVE_ENABLED)
if v is None:
return True
return v.strip().lower() not in ("0", "false", "no", "off", "")
def archive_prior_run(game_id: str) -> Optional[str]:
"""重跑同 gid 前把上一 run 的整套产物【移】进带时间戳归档位,永不同 gid 覆盖(F0-b)。
移 amgen-<id>/(trace.jsonl/turns.jsonl/src/收口采集)与 _wg1-gen/<id>/(staged bundle/play-spec/九门证据)
到 games/_amgen-archive/<gid>/<YYYYmmdd-HHMMSS[-n]>/{amgen/,wg1/};两目录皆不存在(首跑)→ 返回 None、no-op。
移走后 scaffold 重建 amgen-<id>/、stage 重建 _wg1-gen/<id>/,旧 run 完整留档可回放。
best-effort:归档关 / 任一步失败 → 返回 None 并告警,**退回旧行为(scaffold 直接覆盖)**,绝不阻断生成
(归档是纯增量旁路,关开关即回)。返回归档目录绝对路径字符串(供批次账每 run 记指针)或 None。
"""
if not _archive_enabled():
return None
src_amgen = game_dir(game_id)
src_wg1 = wg1_game_dir(game_id)
if not src_amgen.exists() and not src_wg1.exists():
return None # 首跑:无上一 run 可归档
try:
# 时间戳归档位;同秒重跑加 -n 后缀防撞(极少数并发/秒内重跑)。
stamp = time.strftime("%Y%m%d-%H%M%S")
base = _ARCHIVE_DIR / str(game_id)
dst = base / stamp
n = 1
while dst.exists():
dst = base / f"{stamp}-{n}"
n += 1
dst.mkdir(parents=True, exist_ok=True)
moved = []
if src_amgen.exists():
shutil.move(str(src_amgen), str(dst / "amgen"))
moved.append("amgen")
if src_wg1.exists():
shutil.move(str(src_wg1), str(dst / "wg1"))
moved.append("wg1")
print(f"[cheap-run] per-run 归档:game={game_id} 上一 run [{'+'.join(moved)}] → {dst}", flush=True)
return str(dst)
except Exception as e: # noqa: BLE001 —— 归档失败退回旧行为(覆盖),不阻断生成;告警便于排障
print(f"[cheap-run] per-run 归档失败(退回旧行为=scaffold 覆盖,不阻断生成):{type(e).__name__}: {e}",
flush=True)
return None
def clean_stale_evidence(game_id: str) -> dict:
"""scaffold 后按【固定清单】清 evidence 残留(F0-c:取代旧红线③只清 verdict.json 一个文件)。
主清 _wg1-gen/<id>/evidence/(scaffold 不碰它、逐 run 累积):verdict/反馈/日志/截图/快照/报告全列清;
再兜底清 amgen-<id>/evidence/(正常已被 scaffold rmSync,列此防不归档/异常路)。封「读回上一 run 反馈/绿
verdict → 误归因/假绿」。best-effort:逐文件 unlink(missing_ok),任何异常只告警、不阻断。返回 {removed:[...]}。
"""
removed = []
try:
wg1_ev = wg1_game_dir(game_id) / "evidence"
for name in _STALE_EVIDENCE_WG1:
p = wg1_ev / name
if p.exists():
p.unlink(missing_ok=True)
removed.append(f"wg1/evidence/{name}")
amgen_ev = game_dir(game_id) / "evidence"
for name in _STALE_EVIDENCE_AMGEN:
p = amgen_ev / name
if p.exists():
p.unlink(missing_ok=True)
removed.append(f"amgen/evidence/{name}")
except Exception as e: # noqa: BLE001 —— 清理 best-effort,失败只告警、不阻断生成
print(f"[cheap-run] evidence 清理清单化异常(忽略,不阻断生成):{type(e).__name__}: {e}", flush=True)
if removed:
print(f"[cheap-run] evidence 清理清单化:game={game_id}{len(removed)}{removed}", flush=True)
return {"removed": removed}
def _resolve_in_repo(rel: str) -> Path:
"""把 repo 相对路径解析为绝对路径,校验不逃出 repo 根(对 tools.mjs resolveInRepo"""
abs_p = (_REPO_ROOT / rel).resolve()
if abs_p != _REPO_ROOT and _REPO_ROOT not in abs_p.parents:
raise ValueError(f"路径越界(必须在 repo 根内):{rel}")
return abs_p
# ───────────────────────── 知识件外置 shadowW-CFG-KB 切片一)─────────────────────────
# read_file 根解析先查「知识根」TIER2_KB_ROOTexternalized 知识件的物化落点,在 git 检出之外、
# deploy-safe命中同相对路径的文件即读它未命中回落「仓根」_REPO_ROOTapi.d.ts 等代码类不外置、
# 仍读仓。TIER2_KB_ROOT 未配置时整条 shadow 短路 —— read_file 行为与外置前逐字节一致(默认关,纯增量,
# 不碰生成主链。写侧kb_store.materialize_active 物化到此根)与本读侧读同一个 env TIER2_KB_ROOT。
def _kb_root() -> Optional[Path]:
"""知识根env TIER2_KB_ROOT未配 / 空 → Noneread_file 退回只读仓根、行为字节不变)。"""
raw = os.environ.get("TIER2_KB_ROOT")
if not raw or not raw.strip():
return None
try:
return Path(raw).expanduser().resolve()
except Exception: # noqa: BLE001 —— 路径异常按未配处理(退回仓根,绝不因知识根拖垮读)
return None
def _resolve_in_root(rel: str, root: Path) -> Path:
"""把相对路径解析到给定根内,校验不逃出该根(知识根 shadow 用;与 _resolve_in_repo 同边界语义)。"""
abs_p = (root / rel).resolve()
if abs_p != root and root not in abs_p.parents:
raise ValueError(f"路径越界(必须在根内):{rel}")
return abs_p
def _read_bounded(abs_p: Path) -> dict:
"""读一个已解析的文件200KB 截断(对 tools.mjs MAX_READ_BYTES。返回 {ok, content, truncated}。"""
raw = abs_p.read_bytes()
truncated = False
if len(raw) > _MAX_READ_BYTES:
raw = raw[:_MAX_READ_BYTES] # 截断:超大文件只给前 200KB
truncated = True
return {"ok": True, "content": raw.decode("utf-8", "ignore"), "truncated": truncated}
# ───────────────────────── read / list / writePython 直接实现)─────────────────────────
def read_file(path: str) -> dict:
"""read_file知识根 shadow 优先、回落仓根,两根均只读 + 200KB 截断 + 路径不逃逸(对 tools.mjs readFileTool
根解析W-CFG-KB 切片一纯增量TIER2_KB_ROOT 配置了就先查知识根同相对路径的文件,命中即读
externalized 知识件热换即生效);未命中(如插件 api.d.ts 等未外置的代码类回落仓根。TIER2_KB_ROOT
未配时整条 shadow 短路read_file 行为与外置前逐字节一致(默认关)。
"""
try:
kb_root = _kb_root()
if kb_root is not None:
# 知识根内解析(越界即拒;两根同相对路径,越界对仓根同样越界,不静默回落绕过边界)。
kb_abs = _resolve_in_root(path, kb_root)
if kb_abs.is_file():
return _read_bounded(kb_abs) # 知识根命中:读 externalized 知识件
# 知识根内无此文件(未外置的代码类)→ 回落仓根(下面)。
abs_p = _resolve_in_repo(path)
if abs_p.is_dir():
return {"ok": False, "error": f"是目录,不是文件:{path}(请用 list_dir"}
return _read_bounded(abs_p)
except Exception as e: # noqa: BLE001 工具层统一软失败返回给 agent不抛断循环
return {"ok": False, "error": str(e)}
def list_dir(path: str) -> dict:
"""list_dirrepo 根内列目录(目录名带尾 /)。返回 {ok, entries?, error?}(对 tools.mjs listDirTool"""
try:
abs_p = _resolve_in_repo(path)
entries = sorted((e.name + "/" if e.is_dir() else e.name) for e in abs_p.iterdir())
return {"ok": True, "entries": entries}
except Exception as e: # noqa: BLE001
return {"ok": False, "error": str(e)}
def write_file(game_id: str, path: str, content: str) -> dict:
"""write_file仅许写本 run game 目录的 L3 文件。返回 {ok, bytes?, error?}(对 tools.mjs writeFileTool
边界abs 必须落在 games/amgen-<id>/ 内basename 命中 L1_FIXED 则拒写(根除把 wiring 写坏类错误)。
"""
try:
abs_p = _resolve_in_repo(path)
gd = game_dir(game_id).resolve()
if abs_p != gd and gd not in abs_p.parents:
return {"ok": False, "error": f"write 越界:只许写 games/amgen-{game_id}/ 内(你给的是 {path}"}
base = abs_p.name
if base in _L1_FIXED:
return {"ok": False, "error": (
f"{base} 是 L1 固定 plumbingboot 链/插件装配/viewport/工厂 wiring不要写它。"
"你只写 L3 游戏本体src/{game-logic,core,render,balance,assets}.js。"
"游戏逻辑写在 game-logic.js 的 createGame({plugins,bundle,viewport});插件经 plugins.<键> 直接用(已替你注入)。")}
abs_p.parent.mkdir(parents=True, exist_ok=True)
abs_p.write_text(content, encoding="utf-8")
return {"ok": True, "bytes": len(content.encode("utf-8"))}
except Exception as e: # noqa: BLE001
return {"ok": False, "error": str(e)}
# ── edit_file 锚点容错(W-AXIS 波2:治「一字之差」5 连拒螺旋)──────────────────────────────
# 精确匹配(逐字节)是首选、语义最稳;精确 0 命中时再走【空白归一回退】——只吸收行内空白/缩进差异(模型手抄
# 锚点时最常见的偏差),归一后仍须【唯一】命中才接受;绝不做相似度阈值类模糊匹配(会改错地方=危险)。归一后仍
# 找不到/不唯一 → 报错附【最近似片段 + 行号窗口】,帮模型下一次给准锚点,而不是只丢一句"没找到"(5 连拒的病根)。
def _norm_ws_line(ln: str) -> str:
"""行内空白归一:去首尾空白 + 内部连续空白(空格/制表)压成单空格(' '.join(split) 天然如此)。空行→''"""
return " ".join(ln.split())
def _locate_ws_normalized(content: str, old: str) -> dict:
"""空白归一回退定位:content 与 old 按【整行序列】做行内空白/缩进归一后找命中(只定位不改)。
返回 {status:'unique', start, klen} | {status:'ambiguous'|'notfound', hint}。
"""
content_lines = content.split("\n")
old_lines = old.split("\n")
norm_content = [_norm_ws_line(l) for l in content_lines]
norm_old = [_norm_ws_line(l) for l in old_lines]
k = len(norm_old)
if k == 0 or all(l == "" for l in norm_old):
# old 归一后无实锚(纯空白)——无法安全定位。
return {"status": "notfound", "hint": (
"edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。old 归一后无实质内容(纯空白)、无法定位;"
"请复制文件里的原文当 old(含唯一上下文),或改用 write_file 整体覆盖。")}
starts = [i for i in range(0, len(norm_content) - k + 1)
if norm_content[i:i + k] == norm_old]
if len(starts) == 1:
return {"status": "unique", "start": starts[0], "klen": k}
if len(starts) > 1:
rows = "/".join(str(s + 1) for s in starts)
return {"status": "ambiguous", "hint": (
f"edit 的 old 忽略空白/缩进差异后仍命中 {len(starts)} 处(分别起于第 {rows} 行)、无法唯一定位;"
"请把 old 加长到含唯一上下文(多带几行),锚定到唯一位置。")}
return {"status": "notfound", "hint": _edit_nearest_hint(content_lines, norm_content, norm_old)}
def _edit_nearest_hint(content_lines: list, norm_content: list, norm_old: list) -> str:
"""找与 norm_old 整行重合最多的同长窗口作【最近似片段】提示(仅诊断、不作匹配接受),附原文行号窗口。"""
k = len(norm_old)
best_i, best_score = -1, 0
for i in range(0, max(0, len(norm_content) - k + 1)):
score = sum(1 for j in range(k) if norm_old[j] and norm_content[i + j] == norm_old[j])
if score > best_score:
best_score, best_i = score, i
win = k
if best_i < 0 or best_score == 0:
# 全无整行重合 → 退化:按首个非空 old 行做子串近似定位(单行窗口)。
first = next((l for l in norm_old if l), "")
for i, l in enumerate(norm_content):
if first and first in l:
best_i, best_score, win = i, 1, 1
break
if best_i < 0:
return ("edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。确认 path 是否为你要改的文件、"
"old 是否复制自该文件当前内容(可先 read_file 核对),或改用 write_file 整体覆盖。")
end = min(len(content_lines), best_i + win)
snippet = "\n".join(f"{best_i + 1 + off}| {content_lines[best_i + off]}"
for off in range(0, end - best_i))
return ("edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。"
f"最接近的是第 {best_i + 1}-{end} 行(整行匹配 {best_score}/{win}):\n{snippet}\n"
"若要改的就是这里,请复制上面【原文】(含其真实缩进/空白)当 old 重发;否则确认 path/old 来源。")
def edit_file(game_id: str, path: str, old: str, new: str) -> dict:
"""edit_file增量修改——把 game 目录内某 L3 文件里出现一次的 old 串替换成 new避免"改三行→重写整文件"
死圈修复 fix①-A2026-07-08 双评审):便宜档六工具原本只有整文件覆盖的 write_file
"改一个 rng 小函数"被迫变成"重写 22KB"MiniMax 在超长 tool-call 参数上反复漏 path 触发熔断。
edit_file 让小修表达为短参数替换,天然绕开漏参高发区。
锚点容错W-AXIS 波2精确匹配逐字节唯一命中即用精确 0 命中时走空白归一回退(只吸收行内空白/缩进
差异,归一后仍须唯一命中才接受,绝不相似度模糊匹配);仍找不到/不唯一 → 报错附最近似片段 + 行号窗口,帮
模型下一次给准锚点治审计实证的「一字之差」5 连拒螺旋)。
边界继承:读现文件走 game_dir 内解析(不走 read_file 的知识根 shadow要改的是工程盘上的真文件
回写复用 write_file继承 L1_FIXED 拒写 + game_dir 越界拒写write_whitelist 由 toolkit 层把守。
"""
try:
abs_p = _resolve_in_repo(path)
gd = game_dir(game_id).resolve()
if abs_p != gd and gd not in abs_p.parents:
return {"ok": False, "error": f"edit 越界:只许改 games/amgen-{game_id}/ 内(你给的是 {path}"}
if not abs_p.is_file():
return {"ok": False, "error": f"文件不存在,无法 edit{path}(先用 write_file 建它,再 edit 增量改)"}
content = abs_p.read_text(encoding="utf-8")
cnt = content.count(old)
if cnt > 1:
return {"ok": False, "error": (
f"edit 的 old 串在文件里出现 {cnt} 次、不唯一,无法定位。"
"请把 old 加长到含唯一上下文(多带几行),锚定到唯一位置。")}
matched_by = "exact"
if cnt == 1:
new_content = content.replace(old, new, 1) # 精确唯一命中(首选、最稳)
else:
# cnt == 0精确没找到 → 空白归一回退(只吸收行内空白/缩进差异,仍须唯一命中)。
loc = _locate_ws_normalized(content, old)
if loc["status"] != "unique":
return {"ok": False, "error": loc["hint"]} # 归一后仍非唯一命中 → 带最近似片段/行号窗口提示
i, k = loc["start"], loc["klen"]
cl = content.split("\n")
new_content = "\n".join(cl[:i] + new.split("\n") + cl[i + k:]) # 按整行替换命中窗口,落 new 原样
matched_by = "ws-normalized"
# 回写经 write_file继承 L1_FIXED 拒写 + game_dir 越界拒写edit 不新开绕过面)。
r = write_file(game_id, path, new_content)
if r.get("ok"):
r["message"] = ("edit 已应用(替换 1 处)" if matched_by == "exact"
else "edit 已应用old 精确没匹配上,按行内空白/缩进归一后唯一命中 1 处;已落 new 原样)")
return r
except Exception as e: # noqa: BLE001
return {"ok": False, "error": str(e)}
# ───────────────────────── check / buildshell-out node tools.mjs─────────────────────────
def _run_node_tools(cmd: str, game_id: str, timeout: float = 90.0) -> subprocess.CompletedProcess:
"""shell-out `node tools.mjs <cmd> <id>`cwd=game-runtime对 tools.mjs 的路径基准)。"""
return subprocess.run(
["node", str(_AMODEL_GEN / "tools.mjs"), cmd, game_id],
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=timeout,
check=False,
)
def check(game_id: str) -> dict:
"""check循环内快反馈门shell-out `node tools.mjs check <id>`node --check + 全部静态门 + 两条形状门)。
退出码 0=PASS非 0=FAILstdout 含逐条错误清单(直接喂 agent 自纠)。返回 {ok, output}。
"""
try:
r = _run_node_tools("check", game_id)
return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()}
except Exception as e: # noqa: BLE001
return {"ok": False, "output": f"check shell-out 异常:{e}"}
def build(game_id: str) -> dict:
"""build循环内 esbuild 打包shell-out `node tools.mjs build-saa <id>`SAA 信封 __GameBundle
返回 {ok, output}。
"""
try:
r = _run_node_tools("build-saa", game_id)
return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()}
except Exception as e: # noqa: BLE001
return {"ok": False, "output": f"build shell-out 异常:{e}"}
# ───────────────────────── U4scaffold / stage / smoke / play / ensure_play_specshell-out node/cjs─────────────────────────
def _shell_env() -> dict:
"""shell-out node/bash 用的 env本机 Mac Chrome 路径 + NO_PROXYserve 是 localhostplay 不连内网,但对齐本机跑法)。"""
env = dict(os.environ)
if os.path.exists(_DEFAULT_CHROME): # 仅 Mac 注入;Linux 不注入交 play.mjs 自身默认(见 _DEFAULT_CHROME 注)
env.setdefault("CHROME_BIN", _DEFAULT_CHROME)
no_proxy = env.get("NO_PROXY", "")
for h in ("localhost", "127.0.0.1", "100.64.0.8"):
if h not in no_proxy:
no_proxy = (no_proxy + "," + h) if no_proxy else h
env["NO_PROXY"] = no_proxy
env["no_proxy"] = no_proxy
return env
def scaffold(game_id: str, template: Optional[str] = None) -> dict:
"""scaffoldshell-out `node tools.mjs scaffold-saa <id> [template]`clone 模板 + SAA 信封 __GameBundle。返回 {ok, output}。
template=None默认→ 通用 _template点圆得分起点扩模板传 per-genre 黄金骨架目录名
(经营=_template-shop 等),让难品类 AI 从预置经营循环起步、少写。模板不存在则 node 侧回落 _template。
"""
try:
argv = ["node", str(_AMODEL_GEN / "tools.mjs"), "scaffold-saa", game_id]
if template:
argv.append(template)
r = subprocess.run(argv, cwd=str(_GAME_RUNTIME), capture_output=True, text=True,
timeout=90.0, check=False)
return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()}
except Exception as e: # noqa: BLE001
return {"ok": False, "output": f"scaffold shell-out 异常:{e}"}
def stage(game_id: str) -> dict:
"""stageshell-out `node tools.mjs stage <id>`(原子拷 bundle.iife.js + index.html → _wg1-gen/<id>/)。返回 {ok, output}。"""
try:
r = _run_node_tools("stage", game_id)
return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()}
except Exception as e: # noqa: BLE001
return {"ok": False, "output": f"stage shell-out 异常:{e}"}
def smoke(game_id: str, port: int = 4320, cdp_port: int = 9222) -> dict:
"""smokeshell-out `node play.mjs smoke <id>`SAA 信封 boot + 帧推进 + 抓 _forensicsView state
解析 play.mjs 末行 `[smoke] PASS/FAIL {json}` 拿 ok + statestate 供 ensure_play_spec 推断 driver 形态)。
返回 {ok, state, raw}。
"""
try:
r = subprocess.run(
["node", str(_AMODEL_GEN / "play.mjs"), "smoke", game_id, str(port), str(cdp_port)],
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=120,
env=_shell_env(), check=False,
)
out = r.stdout or ""
ok = r.returncode == 0
state = None
idx = out.rfind("[smoke]")
if idx >= 0:
line = out[idx:].splitlines()[0]
brace = line.find("{")
if brace >= 0:
try:
o = json.loads(line[brace:])
ok = bool(o.get("ok"))
state = o.get("state")
except json.JSONDecodeError:
pass
return {"ok": ok, "state": state, "raw": (out + (r.stderr or ""))[-2000:]}
except Exception as e: # noqa: BLE001
return {"ok": False, "state": None, "raw": f"smoke shell-out 异常:{e}"}
# 品类 → 通用 L2 插件前缀期望(held 期望,外生常量;对齐 fixtures/golden-specs/*.play-spec.json 三份金标的 expectedEngineCallPrefixes)。
# 防自证铁律(gen-path-parity skill):此表是【预置常量】,F_wiring 期望基准绝不从该局实际 __engineCalls 反推——
# 一旦反推,期望恒等于实际、F_wiring 退化自证、必过、假绿。tap-targets occupied 族三品类(点击得分/打地鼠/经营点客)共用此前缀集。
_TAP_TARGETS_CALL_PREFIXES = ("sessionScore.", "audioMusic.", "juice.")
# C5 契约(contracts/play-loop/play-spec.schema.json)接线常量:
# 本产线自动 spec 的 generator 标识——ensure_play_spec 据它区分「本产线自动 spec」与「手工金标/外部注入 spec」:
# 只有本产线自动 spec 才做 sourceHash 陈旧重生;金标考卷(fixtures/golden-specs,经 compare_node.inject_golden
# 强制覆写注入)不带此标识,held-constant 永不被 hash 检查覆盖(对照公平的落点,金标不误杀)。
_SPEC_GENERATOR = "cheap_run.ensure_play_spec"
# 源工程 hash 不可算(src/ 缺失 / IO 异常)时写进 derivedFrom.sourceHash 的占位值(C5 required 非空):
# 语义=「产 spec 时源不可读」;下次源可读时真 hash ≠ 占位 → 自然触发重生,不留恒等假绑定。
_SOURCE_HASH_UNAVAILABLE = "unavailable"
def _source_hash(game_id: str) -> Optional[str]:
"""算源工程内容 hash(games/amgen-<id>/src/ 全部文件,按相对路径排序聚合 sha256)。
C5 derivedFrom.sourceHash 的取数源:agent 只写 src/ 下 L3 文件(write_file 边界),故 src/ 内容
即「源工程会变的全部」;续修轮 agent 改了任一 src 文件 → hash 变 → 旧考卷判陈旧、重生。
读不到(目录缺失 / IO 异常)返回 None——调用方回落保守行为(不据 hash 重生),绝不抛。
"""
try:
src = game_dir(game_id) / "src"
if not src.is_dir():
return None
h = hashlib.sha256()
for f in sorted(src.rglob("*")):
if f.is_file():
# 路径与内容都进 hash(文件改名/增删也算源变),NUL 分隔防拼接歧义。
h.update(f.relative_to(src).as_posix().encode("utf-8"))
h.update(b"\0")
h.update(f.read_bytes())
h.update(b"\0")
return h.hexdigest()
except Exception: # noqa: BLE001 —— hash 失败按「不可算」回落,不连累门流水线
return None
def _derived_from(source_hash: Optional[str]) -> dict:
"""组 C5 derivedFrom 派生绑定段(spec↔源工程绑定锚;hash 不可算时写占位、不留恒等假绑定)。"""
return {
"sourceHash": source_hash or _SOURCE_HASH_UNAVAILABLE,
"generatedAt": str(int(time.time() * 1000)), # 毫秒时间戳字符串(C5 允许 string|integer,取 string 稳妥)
"generator": _SPEC_GENERATOR,
"regenerateOnSourceChange": True,
}
def _build_play_spec(state: Optional[dict], *, source_hash: Optional[str] = None) -> dict:
"""据 _forensicsView().state() 形态产 play-spec dict(纯函数、不写盘,便于单测;产物须过 C5 契约校验)。
tap-targets occupied 族(state 有 targets 数组):加厚到金标同质——补 F_wiring 的 expectedEngineCallPrefixes
语义期望(外生 held 期望、预置常量,防自证);key-cycle 族(按键类):薄版,期望前缀待输入键契约(WU-C 5.4)。
C5 接线(W-S1 单①):driver 带显式 selectionBasis(把「state 有无 targets/target 键定族」这条约定写进
考卷自解释,不再是只活在本函数里的隐形开关);key-cycle 族带 startRitual 默认起局仪式(菜单/开始页游戏
先起局再按键,治「driver 只按键、不会起局 → 卡 menu」的确定性漂移);两族都带 derivedFrom 派生绑定。
"""
# 以键存在性判定「可点目标」类游戏:游戏暴露 targets/target 键即声明有可点目标,菜单态 null/空数组仍视为 tap-targets
has_targets = isinstance(state, dict) and ("targets" in state or "target" in state)
if has_targets:
driver = {
"type": "tap-targets", "targetMode": "occupied", "targetsPath": "targets",
"steps": 50, "stepMs": 220,
# C5:驱动器族选择依据显式进考卷(自解释,取代读代码才知道的隐形约定)。
"selectionBasis": "state 暴露 targets/target 键 → 可点目标类 → tap-targets occupied 族",
}
return {
"schemaVersion": "play-spec/1",
"derivedFrom": _derived_from(source_hash),
"exportState": ["phase", "score", "targets"],
"driver": driver,
# U2 加厚到金标同质:F_wiring 语义期望前缀(外生 held 期望,绝不从实际调用反推 → 防自证假绿)。
"expectedEngineCallPrefixes": list(_TAP_TARGETS_CALL_PREFIXES),
"assertAfterPlay": [{"path": "score", "op": "increased", "why": "真玩应加分"}],
"expectLatch": True, # latch advisory 兜底:限时类玩不到终局但有真进展→降 advisory 不致命
# tap-targets occupied 族不声明 startRitual:runTapTargets 已内建「非游玩态→中线扫点起局」
# (play.cdp.cjs 场景推进段),再声明会重复动作;key-cycle 族才需要显式仪式。
}
driver = {
"type": "key-cycle", "keys": ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"],
"steps": 60, "stepMs": 180, "downMs": 60,
"selectionBasis": "state 无 targets/target 键 → 按键类回退 → key-cycle 族",
}
return {
"schemaVersion": "play-spec/1",
"derivedFrom": _derived_from(source_hash),
"exportState": ["phase", "score"],
"driver": driver,
# C5 startRitual(W-S1 单①):key-cycle 游戏停在菜单/开始页时按键循环驱不动(fx4c 卡 menu 实证),
# 先依序试 Enter / Space / 画布中心点起局(harness 每步后查 phase,进游玩态即止)。
"startRitual": {
"kind": "input-sequence",
"inputs": [
{"t": "key", "code": "Enter"},
{"t": "key", "code": "Space"},
{"t": "tap", "x": 195, "y": 422},
],
"why": "菜单/开始页游戏需先起局,按键循环才驱得动(未进游玩态则 G_input/H_progress 必挂)",
},
"assertAfterPlay": [{"path": "score", "op": "increased", "why": "真玩应加分"}],
"expectLatch": True,
# key-cycle 薄版:expectedEngineCallPrefixes 待『两路绑同一套输入键』契约(WU-C 5.4),M1 不加厚成可能驱不动的厚 spec。
"_note": "key-cycle 自动 spec 薄版:F_wiring 期望前缀待输入键契约(WU-C 5.4)、M1 不加厚",
}
def _validate_c5_best_effort(spec: dict) -> None:
"""产出的 play-spec 对 C5 契约(contracts/play-loop)做 best-effort 自校验:失败只告警、绝不阻断门流水线。
校验器 = contracts/play-loop/validate.py(零依赖 stdlib),经 importlib 按路径加载(它不是包)。
为什么只告警不阻断:校验挂生产路径是为了漂移可见(响亮日志),阻断会让契约演进期的新字段直接打红生成
主链——强校验在单测层(tests/test_play_spec_contract.py 对两族产物全量过 C5)。
"""
try:
import importlib.util # noqa: PLC0415
vp = _REPO_ROOT / "contracts" / "play-loop" / "validate.py"
sp = _REPO_ROOT / "contracts" / "play-loop" / "play-spec.schema.json"
if not (vp.exists() and sp.exists()):
return # 契约目录不在(裁剪部署)→ 静默跳过
mod_spec = importlib.util.spec_from_file_location("_playloop_validate", vp)
mod = importlib.util.module_from_spec(mod_spec)
mod_spec.loader.exec_module(mod)
schema = json.loads(sp.read_text(encoding="utf-8"))
errors = mod.validate(schema, spec, schema)
if errors:
print(f"[cheap-run] ⚠ 自动 play-spec 未过 C5 契约校验(不阻断,请修 _build_play_spec):{errors[:3]}",
flush=True)
except Exception as e: # noqa: BLE001 —— 校验器自身异常绝不连累生成
print(f"[cheap-run] C5 自校验异常(忽略):{type(e).__name__}: {e}", flush=True)
def ensure_play_spec(game_id: str, state: Optional[dict]) -> dict:
"""据 _forensicsView().state() 形态自动产 _wg1-gen/<id>/play-spec.json(对 tools.mjs ensurePlaySpec)。
【退役·保留一个版本窗口(W-AXIS-V2 波2,2026-07-10)】生产验收链(cheap_gates/cheap_studio/cheap_modify/
gen.mjs done 门)已全部摘除对本函数的调用——便宜档验收权威 = 四门投影(A/B/C/D)∧ 测试 agent 真玩,
不再靠 tap-targets/key-cycle 驱动器把九门驱成 driven。实现保留仅供封存对照工具(auto_vs_golden/
compare_node)历史复跑与单测;窗口期满随 play-spec 生成层一并清理。
driver 推断(照 play.cdp.cjs 的 driver 家族):有 targets 数组(点击类)→ tap-targets occupied;
否则(按键类)→ key-cycle。让九门 driven=true、E_live/H_progress 由 advisory 升为致命,
根治"纯 harness 裸跑无 driver → 假绿"
C5 陈旧可判定(W-S1 单①,取代旧「已存在一律不覆盖」):
· 已存在且是【本产线自动 spec】(derivedFrom.generator 以 cheap_run.ensure_play_spec 开头):
比对 derivedFrom.sourceHash 与当前源工程 hash——一致=源未变,复用;不一致=源已变(续修轮 agent
改过代码),旧考卷陈旧、重生。这是「拿旧考卷判新工程 → 假绿/错判」的封口点。
· 已存在但【非本产线出品】(无 derivedFrom / generator 异,即手工金标或外部注入,如
compare_node.inject_golden 的金标考卷):held-constant 保持不覆盖——金标是对照公平的锚,
绝不被 hash 检查误杀;旧世代自动 spec(无 derivedFrom)同样落此保守支,新链路产的 spec 都带
绑定、增量收敛。
· 源 hash 不可算(src/ 缺失):保守不覆盖已有 spec(判不了陈旧就不动)。
Args:
game_id: stage 后的 gameId(写 _wg1-gen/<id>/play-spec.json)。
state: smoke 抓到的 state 快照(None/非 dict → 保守按键类)。
"""
try:
dst = wg1_game_dir(game_id)
spec_path = dst / "play-spec.json"
cur_hash = _source_hash(game_id)
if spec_path.exists():
old_generator = None
old_hash = None
try:
old = json.loads(spec_path.read_text(encoding="utf-8"))
df = old.get("derivedFrom") if isinstance(old, dict) else None
if isinstance(df, dict):
old_generator = df.get("generator")
old_hash = df.get("sourceHash")
except Exception: # noqa: BLE001 —— 坏 JSON:按非本产线处理(保守不覆盖,由人工清理)
pass
is_auto_spec = isinstance(old_generator, str) and old_generator.startswith(_SPEC_GENERATOR)
if not is_auto_spec:
# 手工金标 / 外部注入 / 旧世代无绑定 spec:held-constant,不覆盖(金标不误杀)。
return {"ok": True, "wrote": False,
"reason": "已存在且非本产线自动 spec(手工金标/外部注入/旧世代),held-constant 不覆盖"}
if cur_hash is None:
return {"ok": True, "wrote": False, "reason": "已存在;源工程 hash 不可算,保守不覆盖"}
if old_hash == cur_hash:
return {"ok": True, "wrote": False, "reason": "已存在且 sourceHash 一致(源工程未变),复用"}
print(f"[cheap-run] play-spec 陈旧(sourceHash 已变):game={game_id} 旧={str(old_hash)[:12]}"
f"新={cur_hash[:12]}… → 重生考卷", flush=True)
spec = _build_play_spec(state, source_hash=cur_hash)
_validate_c5_best_effort(spec) # 生产路径 C5 自校验(告警不阻断;强校验在单测)
dst.mkdir(parents=True, exist_ok=True)
tmp = dst / "play-spec.json.tmp"
tmp.write_text(json.dumps(spec, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(spec_path) # 原子写,防 play 读半成品
return {"ok": True, "wrote": True, "driverType": spec["driver"]["type"]}
except Exception as e: # noqa: BLE001
return {"ok": False, "wrote": False, "reason": str(e)}
def play(game_id: str, port: int = 4320, cdp_port: int = 9222) -> dict:
"""play循环外九门真玩shell-out `bash serve-and-play.sh <id>`CDP、引擎无关九门
读 _wg1-gen/<id>/evidence/verdict.json 拿九门逐门 verdict。返回 {ok, verdict, exitCode, raw}。
"""
try:
r = subprocess.run(
["bash", str(_SERVE_AND_PLAY), game_id, str(port), str(cdp_port)],
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=180,
env=_shell_env(), check=False,
)
verdict = None
vp = wg1_game_dir(game_id) / "evidence" / "verdict.json"
if vp.exists():
try:
verdict = json.loads(vp.read_text(encoding="utf-8"))
except json.JSONDecodeError:
pass
return {"ok": r.returncode == 0, "verdict": verdict, "exitCode": r.returncode,
"raw": (r.stdout + r.stderr)[-2000:]}
except Exception as e: # noqa: BLE001
return {"ok": False, "verdict": None, "exitCode": -1, "raw": f"play shell-out 异常:{e}"}