> **状态(2026-10)**:本仓库现在是 **TypeScript 单一技术栈**。 > 下文描述的 Python 实现已整体移出、并经审查后彻底删除(见 [BACKUP.md](BACKUP.md)); > 文中的 `python xxx.py` / `pytest` 命令指向**已彻底移除**的参考实现(见 [BACKUP.md](BACKUP.md)); > 对应的 TS 入口见 [README.md](README.md)。 > 设计规范、实测结论与用例记录照旧有效——TS 代码的注释大量引用它们。 # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is A browser-automation middleware built on top of the **pip-installed `laya` package** (v0.3.5, a non-autoregressive decision engine — each question is one forward pass ≈35 ms on the local CUDA GPU). The laya *source* is not in this repo; it lives in `C:\Python314\Lib\site-packages\laya\` (`common.py` has the model architecture, `build_sequence` serialization, `proper_reward` scoring rule). Nothing here trains laya — fine-tuning exists only in `_laya_finetune_ref.ipynb` (official Kaggle notebook, saved from NandhaKishorM/laya). Environment: the author's box is Windows (bash shell), Python 3.14, CUDA (≈35 ms per laya forward pass); this Linux checkout runs the same suite CPU-only (≈0.5–1 s per forward pass, Python 3.12) and resolves chromium through `browser_env.py`. Model checkpoints are cached in `~/.cache/huggingface` (`convaiinnovations/laya` plus the `multilingual/` subfolder); everything runs offline unless an LLM endpoint is configured. ## Commands 一条命令开始一项工作(README 的首选入口): ```bash npm start # 开箱任务:自带页面上跑一份真实计划(离线、零配置) npm start -- --goal "把商品加入购物车" --url <页面> # 演示一次 → 归纳完成信号 + 点名目标 → 执行 npm run doctor # 环境体检:Node / chromium / 决策模型 / LLM ``` ```bash npm ci # Node ≥ 22.6;工具脚本跑在 `node --experimental-strip-types` 下 npm run verify # 一键门禁:typecheck + 全部测试 + 六层跨实现对拍 bash bench/smoke.sh # CI 调用的唯一"绿色"定义(离线部分无条件;模型侧缺产物时打印 SKIP) npm test # 873 个测试(仓库不变量 / 执行器 / 提取 / 计划 / 用例 / 对拍语料) npm run test:parity # 六层跨实现对拍:rules/extraction/executor/datasource/ctrip/transfer npm run bench:fixtures # 冻结计划跑分矩阵的离线夹具用例(需 ONNX 产物,走生产执行器循环) npm run bench -- --force-model router # 同一批用例,按页面选择 checkpoint(laya.Router 语义) bash bench/ablate.sh [tag] # 消融矩阵:english|multilingual × rules on|off node --experimental-strip-types src/ctrip-task.ts # ctrip 用例:19 冻结子目标 + 独立判分 7/7 node --experimental-strip-types src/ctrip-task.ts --no-hints # 消融:去掉 hint → 0/7(置信度 0.13) node --experimental-strip-types src/transfer-task.ts --site skybook node --experimental-strip-types src/transfer-task.ts --site farehaven --no-when # 去掉条件守卫 → 1/7 node --experimental-strip-types src/transfer-task.ts --site slowjet --no-retries # 去掉重试 → 0/7 node --experimental-strip-types src/transfer-task.ts --metrics-only # 不装模型、不开浏览器 node --experimental-strip-types src/datasource.ts --source ctrip-flights-h5 # 真实票价,config 驱动 node --experimental-strip-types src/datasource.ts --source ctrip-hotels-pc --dates 2026-10-09 node --experimental-strip-types src/datasource.ts --list # 应用包也是配置目录:/*/sources 与 apps/*/sources node --experimental-strip-types src/datasource.ts --source git-log # CLI 通道:git 子进程 + lines_regex node --experimental-strip-types src/datasource.ts --source bench-cases-shape # CLI 通道:jq + json_stdout node --experimental-strip-types src/qunar-task.ts # qunar 迁移测试(边界已实测) node --experimental-strip-types src/ip-config.ts # Excel IP 规划表 → 设备网络页 node --experimental-strip-types src/camera-entry.ts # Excel 类型列路由两套表单 node --experimental-strip-types src/long-task.ts # 全链路 demo:plan → execute → audit node --experimental-strip-types src/path-a.ts [--confirm-safe] # Path A:Playwright + 固定动作拾取器 node --experimental-strip-types src/picker-server.ts # Path B:扩展用的本地模型服务 node --experimental-strip-types src/tune-guard.ts # 注入闸门分离度实测 node --experimental-strip-types src/live-probe.ts # 携程可达性矩阵(真实站点) npm run plan-cli -- --help # 计划 CLI:new/add/edit/check/run node --experimental-strip-types tools/learn.ts # 演示一次就录成应用(零参数) node --experimental-strip-types tools/learn.ts --json --yes --goal … --url … # 外层 Agent 的一次调用 node --experimental-strip-types tools/spec-to-pack.ts --check # 生成的包 == 上游的展开 npm run shortlist # 真 ONNX 的 shortlist demo ``` 线上平台用例:`node --experimental-strip-types src/bench.ts --cases bench/cases-live.json --mode-tag live`(8 个真实站点,见 README 的实测一节)。 线上复测(2026-10-03,含四个可达性探针的逐项原始观测):[bench/live-retest/REPORT.md](bench/live-retest/REPORT.md)。 复测时所有写文件的命令都带 `--out`,**不覆盖已提交的冻结语料**;探针的 URL/特征/结果目录全在 `apps/{ctrip,ota,qunar}-live/pack.json` 里声明(见 `src/probe-set.ts`)。 长流程线上用例:`node --experimental-strip-types src/bench.ts --cases bench/cases-live-long.json --mode-tag live-long` (3 个真实站点 / 34 个子目标,一条命令跑完三条完整旅程;不进 CI,外部站点慢且会波动。 设计、逐子目标信号证据与它暴露的四个真实缺陷见 [bench/LONG_FLOW_REPORT.md](bench/LONG_FLOW_REPORT.md))。 应用包(应用层):`npm run apps -- --list` / `npm run apps -- --app demo-app --no-model`(离线整跑: 数据腿 → 混合旅程(工具步 + 浏览器步)→ 结束状态不变量)/ `npm run apps -- --app demo-app --dry`(只展开计划)。 真实应用:`--app ctrip-case --no-model --no-sources`(19 个冻结子目标跑本地复刻站,与 `src/ctrip-task.ts` 的计划**逐字段全等**)、`--app ip-config --no-model`(Excel IP 规划表 → 设备网络页,含"表格写错的那行由设备页 自己拒绝")、`--app camera-entry --no-model`(Excel 类型列路由两套表单:空类型行按**行内证据**显式选页、 坏序列号那行显式成步)、`--app transfer-voyagio --no-model --no-sources`(第二个复刻站:**两页向导** 24 步)、 `--app transfer-slowjet --no-model --no-sources`(第三个复刻站:**冷启动重试** 19 步)、 `--app transfer-trekly --no-model --no-sources`(第四个复刻站:价格上限是**滑块**,19 步)、 `--app transfer-farehaven --no-model --no-sources`(第五个复刻站:**条件浮层 `when`**,24 步 = 20 done + 4 skipped)、 `--app transfer-skybook --no-model --no-sources`(第六个:23 步、唯一带**分页**的骨架)、 `--app transfer-flyquest --no-model --no-sources`(第七个:结果页**只能靠提交搜索表单**到达)。 线上应用:`--app qunar-live --out /tmp/qunar`(真网络 + 真模型,**包里声明了 iPhone 设备面**;见下条规则)。清单与"完成一个应用"的五条定义:[apps/INVENTORY.md](apps/INVENTORY.md);独立复核(题 → 命令 → 原始观测 → 结论,含 11 处缺陷与 3 处共享层修补):[bench/APP_VERIFY_REPORT.md](bench/APP_VERIFY_REPORT.md)。 新增一个应用 = 新目录 `apps//`(`pack.json` + 可选 `sources/*.json`),**共享层零改动**; **也可以"演示一次"就录出来**:`npm run learn`(人:连他正开着的浏览器 `--cdp`——**挂载模式也认 `--url`**, 给了就导航那一页,退出**只断开不关闭**;实测过不给 `--url` 会停在 about:blank 然后拿用户的选择器点不到东西。 或给 `--url`;他做一遍, 信号与不变量由引擎归纳,他只回 y/n)/`npm run agent -- --goal … --url …`(外层 Agent 的一次调用,同引擎 `--json --yes`)。 两条硬规矩:① **不变量从"回放形状的那次运行"归纳**,不是从人演示的那次(演示是连续会话、会累积页面状态, 回放是每个子目标重新导航——实测过角标在演示里到 2、回放只到 1);② **落盘门槛 = 规则模式零模型调用回放通过**, 回放不过就不写文件,复跑里不成立的候选会被明确剔除并写进 notes(不静默改绿)。包里带 `recorded_from` 血统。 **不变量也不必猜**:`npm run apps:induce -- --app [--write]` 跑通一次,从"旅程少做最后一步"与 "结束后"两个状态提出候选(区分性 + 稳定句柄 + 复跑一致三关),`--write` 只追加并在写回后**立刻重跑**, 重跑不成立的候选自动撤掉。 **流程不必手写**:`npm run apps:gen -- --write --id --spec `(或 `--source <模块>#<导出名>` 从代码里的应用级计划)生成整个包 (旅程逐字段 = `emitPlan(spec)` 的展开结果,`generated_from.plan_sha256` 是展开内容的指纹; `--check` 已进 `bench/smoke.sh`)。**展开日期是钉住的**(单一出处 `tools/frozen-today.ts` 的 `FROZEN_TODAY = 2026-10-02`):spec 的 `per_date` 阶段按"下周"算,所以"当天是哪天"会改变展开结果——2026-10-04(周日)算出的那一周是 10-05..09, 跨到 10-05(周一)就变成 10-12..16,**同一个提交周日绿、周一红**。本轮实测到它同时打红四处: `apps:gen --check`(7 个包漂移)、`tests/transfer.test.ts`(15 条)、`tools/transfer-parity.ts`(41 项不一致)、 `bench/smoke.sh` 的 3b/3c oracle 用例(各 0/7)。现在四处共用同一个常量:生成器/检查器(环境变量不算,只有显式 `--today` 能换周)、对拍工具、`tests/frozen-today.ts`(测试夹具)、smoke 的第 3b/3c 步。要换周就同时改这一处。当前 8 个应用是生成的(`apps:matrix` 的"来源"列标出)。 生成器**沿用种子包的角色结构、只把值换成展开结果**,并且**不发明判断**——`invariants`/`notes`/探针 这些判断与测量仍然是人的,缺 `invariants` 时它会明说。 度量与防泄漏门禁:`npm run apps:matrix`。**"所有应用都完成了吗"的答案**:`npm run apps:suite` (跑完每个旅程型应用的验收,一行一个应用:步骤/跳过/不变量/用时/结论;线上应用与测量型应用自动被识别为 "要真网络"并**明着跳过**——跳过不等于通过;危险步未放行记成**未执行(危险步未放行)**,既不算绿也不算红, 因为绿色的前提是真的执行过)。**它的退出码是分层的**:`0`=全部真执行且通过、`1`=有未通过(含"加载失败", 逐应用 try/catch,坏包只影响它自己那一行)、`2`=用法错误(`--only` 未命中会列出已知应用名)、`3`=有未执行。 一轮真实用户体验评审(三个子代理扮演使用者,见 [bench/UX_REVIEW.md](bench/UX_REVIEW.md))揪出的问题都已在 同一轮修掉:`--slot` 的键必须声明过(以前打错名字是**静默假绿**)、`--only` 打错字不再"0/0 通过"、 缺目标时的报错改成 `step N names no target: …`(以前是 `picked ref 'undefined' is not in the element pool`, 使用者完全看不懂)、`npm start` 会把**写进仓库的检查点路径**打印出来、危险步在 CLI/报告/套件三处只有一套说法 (`REFUSAL_LABELS`,含"怎么补救"与"有没有落盘")。怎么加见 [apps/README.md](apps/README.md)。 工具步(命令行当动作腿):`npm run apps -- --app cli-report --no-model --allow-dangerous`(桩工具: 退出码 / JSON 路径 / stderr / 幂等重试 / 干跑+确认+落盘)、`--app repo-report --no-model`(真实 git + jq)。 不带 `--allow-dangerous` 时危险步会被**拒绝**且不落盘。 探针基线:`npm run probes:baseline -- --check` 把包里 `probe.baseline.verdicts`(上一次真实探测的判决) 与已入库的探测记录比——站点变了、或有人手改了包,都会红;`--write` 刷新(只存标量观察,不存 `*_reason`)。 探针集(测量型应用):`apps/ctrip-live` / `apps/ota-live` / `apps/qunar-live` 声明了全部 URL、站点特征、 结果目录与判决角色;驱动只剩"怎么测"(`src/live-probe.ts` / `live-capture.ts` / `ota-probe.ts` / `qunar-probe.ts` / `qunar-task.ts`,它们现在都在共享层**核心**名单里)。`npm run apps:matrix` 的债计数: **0 处**(含"非核心文件里写死的 http 主机"这一独立的扫描口径)。复测用 `--out`: `src/live-probe.ts --out /tmp/x.json` / `src/live-capture.ts --out /tmp/dir` / `src/ota-probe.ts --out /tmp/x.json`。 **Python 参考命令**(`pytest`、`python3 xxx.py`、`pip install -r requirements.txt`)属于**已彻底移除**的参考实现: 工作树与 git 历史都已删除,磁盘上不再有 Python 文件(见 `BACKUP.md`)。 本仓库不包含 Python,也不再需要 Python 环境;六层对拍改为对**已入库的冻结语料**运行。 CI:`.github/workflows/checks.yml` 只做一件事——装 Node 依赖 + chromium,然后跑 `bench/smoke.sh`。 保持 `bench/smoke.sh` 是"绿色"的唯一定义。 Playwright 需要浏览器:`src/browser-env.ts` 按平台解析 chromium(Playwright 管理的版本,或 `PLAYWRIGHT_BROWSERS_PATH` 下已安装的 `chromium-*`),MV3 扩展必须走 `launchPersistentContext`。Mock 页面在 `testpages/`,bench 夹具在 `bench/fixtures/`。 LLM planner/auditor 在 `OPENAI_BASE_URL` + `OPENAI_API_KEY`(可加 `PLANNER_MODEL` / `AUDIT_MODEL`)齐备时自动启用; 否则用 `MockAuditor` / `ScriptedPlanner`,全流程照常运行。模型运行时(ONNX)缺产物时,所有离线测试用 `installLayaRuntime(scriptedRuntime(...))` 注入脚本模型,**不会**去碰 1.7GB 的权重。 ## Architecture **Python 参考 → TS 模块对照**(实现已全部迁移;Python 侧已移除,对拍改为对冻结语料): | Python(备份中) | TypeScript | |---|---| | `layaapp/planner.py` | `src/plan.ts`(TaskPlan/PlanningRules)+ `src/planner.ts`(runPlan/LLMPlanner) | | `layaapp/executor.py` | `src/executor.ts`(BrowserMiddleware,唯一执行器循环) | | `layaapp/extraction.py` / `rules.py` / `signals.py` / `audit.py` / `picker.py` | `src/extraction.ts` / `src/rules.ts` / `src/signals.ts` / `src/audit.ts` / `src/picker.ts` | | `layaapp/__init__.py` | `src/index.ts` | | `browser_env.py` | `src/browser-env.ts` | | `bench/run_bench.py` | `src/bench.ts`(BenchMiddleware 子类化生产循环) | | `browser_picker_playwright.py` / `picker_server.py` | `src/path-a.ts` / `src/picker-server.ts` | | `datasource.py` / `ctrip/*.py` / `transfer/*.py` / `qunar/*.py` | `src/datasource.ts` / `src/ctrip-*.ts`·`h5-flights`·`live-*` / `src/transfer-*.ts`·`flow`·`ota-probe`·`profiles` / `src/qunar-*.ts` | Layered, one direction of dependency: ``` layaapp/planner.py PlanningRules (validate LLM output AND manual edits), TaskPlan (JSON checkpoint + resume semantics, preserves target_hint), run_plan (wraps the whole plan in ONE browser_session: subgoals share the page/playwright, cookies and JS state survive) ↓ layaapp/executor.py BrowserMiddleware: THE executor loop (single implementation). Per step: discover interactive elements (no per-site selectors) → deterministic rule / plan-named target_hint → else laya pick → confidence floor → risk gate → actionability probe → execute → inert snapshot → DOM done-signal check. Pluggable auditor via auditor.py. bench/run_bench.py's BenchMiddleware SUBCLASSES this loop and only chooses the checkpoint / flips ablation switches — do not fork it again. ↓ layaapp/picker.py QuickActionPicker: fixed-action-set picker + guards (injection noul, irreversible noul, confidence gate). Used by Path A/B ↓ laya (pip) Agent.predict(...) choice/score/noul · Router.route/predict for the checkpoint choice (english vs multilingual) ``` **Application layer**: drivers import `from layaapp import ...` (the surface above). The top-level `browser_agent.py` / `task_planner.py` / `browser_picker.py` / `auditor.py` are compatibility shims re-exporting the package; `layaapp/extraction.py` (page view) and `layaapp/rules.py` (literal mappings) sit under the executor. Separate entry points that reuse the core: `browser_picker_playwright.py` (Path A: Playwright automation, exports `PAGES` and `extract_state` reused elsewhere), `picker_server.py` + `extension/` (Path B: FastAPI + MV3 extension; `test_extension_load.py` drives the extension via the `laya-trigger` DOM-event hook in content.js because Playwright cannot evaluate inside an MV3 service worker on this chromium), `plan_cli.py` (plan CRUD + run), `camera_entry.py` (two-form entry; the sheet's 类型 column picks the page via `TYPE_TO_FORM`, blank/unknown 类型 falls through to `laya.Router` + the routing question, values filled verbatim from Excel; `CAMERA_ROUTE=type|model` switches the precedence), `ctrip/` (the 携程 test case: replica site + 19-subgoal frozen plan + independent oracle -- read `ctrip/CTRIP_TESTCASE_REPORT.md`), `transfer/` (the same flow as a JSON spec, replayed on a deliberately different replica; read `transfer/TRANSFER_REPORT.md`). ## Rules the code encodes (measured, not stylistic) These are empirical findings from this codebase's own experiments — preserve them when refactoring: - **Navigation on real sites waits for `domcontentloaded`, never the default `load`.** Production pages keep fetching images/trackers: on zh.wikipedia `load` blew the 30 s budget outright. `goto_timeout` is configurable. - **The signal language is `url_contains` / `css_exists` / `css_text_equals` / `css_text_contains` / `css_value_equals`.** Prefer `css_text_contains` on real pages: zh.wikipedia's article `

` reads "艾伦·图灵编辑" (the edit link sits inside the heading), so exact equality can never fire. - **DOM signals are the only completion truth.** laya's noul "done" judgment false-positives on unchanged pages; it is logged as advisory and never grants `done` (see the `is_done` comment in browser_agent.py). - **Risk = deterministic keyword rules OR model vote ≥ 0.6.** The base checkpoint's risk head flips with goal phrasing (a real checkout click scored 0.18 under a "navigate" goal; cart navigation scored 0.81 under "open cart page"). `DESTRUCTIVE_PATTERNS` catches the high-stakes verbs; the gate asks for confirmation rather than blocking. - **Literal mappings go to rules, semantics to laya.** `_nav_rule` (href match, gated on navigation verbs) and `_affordance_rule` (unique data-test/label token match) run before laya. Both need their guards: "shopping cart" must not href-match cart.html; ambiguity falls through to laya. - **Two implementations of the hint grammar must stay in sync.** `BrowserMiddleware._hint_match` resolves hints against the in-memory pool; `hint_lookup` re-queries the DOM in JS for a target the MAX_ELEMENTS budget evicted. They drifted once (`#id` was added to one side only) and the plan-named target was silently lost on en.wikipedia. `hint_lookup` also **stamps** its matches (`data-hintref`) in the same pass that matches them: re-resolving scan indices against a later `page.locator(...).all()` returned a *different* element on python.org because the DOM mutated in between. Multi-word labels additionally need real `\s` escapes inside the raw JS string. - **Hint attribute values are case-FOLDED, not `==`.** The `=` branch lower-cases the hint on entry, so comparing it against the raw attribute silently missed upper-case ids (`data-test="net-CAM-1001"`, the normal shape for device ids) and the plan-named target fell back to laya (~0.03 conf, whole batch BLOCKED). `~` and `#id` already case-folded; `=` was the odd one out -- the same two-implementation drift as before. Pinned by a unit test. - **Language routing is opt-in AT THE EXECUTOR.** `BrowserMiddleware(route_models=True)` (bench: `model="router"` / `--force-model router`) asks `laya.Router` which checkpoint suits each page's state -- script detection is the primary signal -- so a plan needs no hand-written `model` field. Two details are load-bearing: the default checkpoint built in `__init__` is **attached** to the router (`Router.attach`), never duplicated; and on a swap the executor must drop **its own** reference and `gc.collect()` BEFORE `Router.load`, because the old Agent (~0.7-1.7 GB) would otherwise stay alive while the new one is built -- that co-residency window is what OOMs a 3 GB box. Any router failure degrades to the resident agent. Measured: router mode holds **9/9 cases, 25/25 subgoals**, and the CJK cases log `checkpoint -> multilingual`; but the run took **410.9 s vs 58.1 s** fixed-english, i.e. routing buys language coverage, not speed (checkpoint reload per language switch, `max_loaded=1` on this box). - **Indistinguishable duplicate targets are decidable.** `_first_if_same()` takes the first match in scan order when every hit shares (tag,label,href,data-test) -- bbc.com ships two identical `/news` links -- and still returns None for genuinely different candidates. - **The planner must see the page, or it cannot write a hint.** `LLMPlanner.build_messages` embeds `snapshot_text(elems)` -- a rendering that REUSES `criteria_view`, i.e. exactly the element space the executor will see -- and `SYSTEM`/`PlanningRules.prompt_text()` ask for `target_hint`. `plan_cli new --llm --url ` takes the snapshot with Playwright first. Passing a different rendering would produce hints that can never match extraction. - **Replan is wired, not a stub.** `run_plan(..., planner=..., max_replans=2)`: a blocked subgoal keeps its obstacle for the record, the amended subgoals are appended as pending, an amendment that adds nothing ends the loop (a planner echoing its input can never spin), and a replan EXCEPTION is always printed -- a swallowed planner error is indistinguishable from "nothing to add". `plan_cli run --replan` turns it on. - **The data bag belongs to the plan (`TaskPlan.slots`).** Values must not be smuggled through goal text: `extract_slots("shop: coupon=SAVE10")` reads key `shop`. `run_plan` merges `{**extract_slots(goal), **plan.slots}`. - **A frozen plan may NAME its target (`target_hint`).** The planner sees the DOM snapshot, so a one-line hint (`input[type=submit]`, `button[near~onesie]`, `data-test=coupon`) resolves the pick by rule at zero model cost; when a hint exists it is authoritative even if it matches nothing (the caller then falls through to laya rather than gambling on a token). `TaskPlan` must preserve the field through every disk round-trip, `plan_cli add/edit --hint` authors it, and `hint_lookup()` re-retrieves a plan-named element that MAX_ELEMENTS evicted — the model-view budget must never eat the element the plan already named. - **Extraction is ONE JS pass, over the main frame AND same-origin child frames, with a shadow-aware collector.** `extract_elements` returns tag/label/attrs/visibility/main-flag/order in a single `page.evaluate` per frame and stamps the winners with `data-dshref` in that same pass; the per-element path survives only as a fallback. Measured on en.wikipedia (677 matches): 4.5-5.2 s per step -> 0.02 s. `_COLLECT_JS` descends into open shadow roots because `document.querySelectorAll` does not pierce them while Playwright's CSS engine does -- the mismatch is why the JS-side passes saw 0 elements on shadow apps. - **Frames are first-class.** Child-frame elements are namespaced (`f1:e3`) and carry `frame`; every `_locator_for` lookup -- and everything that resolves a signal (`locator_any_frame`) -- is scoped to that frame, because `page.locator` only ever searches the main frame. `snapshot()` includes frame text/values so inert detection still works when the action happened inside an iframe. Frame count is capped (6) so ad iframes cannot eat the MAX_ELEMENTS budget. Covered offline by `frames-entry-zh` (a form inside an iframe). - **Executor robustness lives in the one production loop.** `BrowserMiddleware.run` is the only executor; `BenchMiddleware` subclasses it and only picks the checkpoint / flips ablation switches. Forbiddances are keyed by element *signature* (refs are positional and re-indexed on every scan), clicks are actionability-probed before execution, overlay-blocked refs are re-admitted once the page changes, an action that leaves url+text+input-values identical is `inert` and forbidden, submit-type inputs are clicked never filled, and empty fields the plan can supply are pre-filled before any click. Never fork this loop. - **Feed laya only the current subgoal**, never the whole long goal — long goal text halves pick confidence (0.42 → 0.16 measured). - **State text is compressed by keyword overlap** (`state_text`), not embeddings: the laya encoder is domain-tuned and fails as a general text-similarity ranker. `build_sequence` keeps the head of the state and truncates the tail at max_len=512; content beyond ~720 chars is invisible, so `page_text` ships first 300 + last 150 chars. CJK costs ~1 token/char — for Chinese content use `laya.load(..., subfolder="multilingual")` (max_len 1024). - **Checkpoint routing is script-based, never confidence-based.** `laya.Router` routes on the detected script because the English checkpoint *collapses* off-English (0.100 on Hindi vs 0.050 for random guessing) while still reporting high confidence (ECE 0.855). Size the cache from real RAM (`camera_entry.max_loaded()` probes `os.sysconf`, ≥4 GB → 2, else 1) and free the resident checkpoint before a language switch: `Router.load` builds the new Agent *before* evicting the victim, so both are briefly resident. Beware that a row mixing ASCII identifiers with CJK prose is detected as Latin (camera_entry rows 0/2/3: non_latin 0.33–0.40) — routing on the raw record is what keeps those rows correct, and forcing multilingual on them is measurably worse (2/4). - **English checkpoint on CJK-heavy states yields very low confidence** (0.03–0.09; argmax still often correct) — that is expected, not a bug: routing is the fix (see the Router bullet above), not a confidence floor. The old `ROUTE_CONF_FLOOR` fallback is gone; instead the 类型 column is a FIRST-CLASS rule (`TYPE_TO_FORM`, camera_entry) that decides the page whenever it is recognised, with the model answer kept as a logged audit for disagreement. - **The injection guard on the base checkpoint is both over-sensitive on benign DOM text and phrase-dependent on real attacks** (benign shop pages score 0.79–0.90; weak-worded attacks score ~0.2). Threshold 0.95 is the measured split in `QuickActionPicker`; see `tune_guard.py`. - `predict_shortlist` passes through unchanged when labels ≤ k (then `scores` is None); it is only active above ~8 labels. - **Fetching a site is CONFIG, not a script -- and "the engine knows no site" is a unit test.** `datasource.ts` is a generic engine: transports (`curl` subprocess / `httpx` global fetch / `cli` arbitrary command), four extractors (`json_in_script` / `html_regex` / `json_stdout` / `lines_regex`), a deliberately tiny path language (`a.b`, `[0]`, `[-1]`, `[]` = map, ops `time|join:x|len|len-1|int|str|strip`), config-driven counts/selects/ audits, and a config-driven report. Everything site-specific lives in `/sources/*.json`: URL template, how the date parameters are computed, the row path, each field's path+ops, the time window, the selection rules, the report columns and notes. `test_the_engine_knows_no_site` strips the engine's DOCSTRINGS and then greps the remaining code for `ctrip.com` / `__INITIAL_STATE__` / `flightItem` / `price-line` / `kmg` / `08:00` / `¥` -- and it caught three real leaks on first run (a Ctrip default for `extract.var`, a hard-coded `¥` in the report, another in the print path). The second source is the proof: `ctrip-hotels-pc` uses the SAME engine with zero code change by switching to `kind: html_regex`, `checkin/checkout` date params and an `audits` entry that measures "price node empty 5/5". `validate_source` runs at load time (unknown extractor/op/client, wrong capture-group count, unknown date expression, unknown `default_dates` expression, non-list `block_markers`, unknown `transport.cli` key) so a bad config fails loudly instead of silently extracting wrong data. Source lookup is DISCOVERY, not a directory list: every `*/sources` and `apps/*/sources` dir under the repo is a config dir (`--sources-dir` overrides; the engine file contains no site names at code level, and a test greps for concrete tool names too). `default_dates: "next_week"` is an ENGINE-level date expression (`datasource.next_week_dates` — pure calendar math; `ctrip-data.ts` re-exports it, so the dependency points site → engine), and bot-block body signatures are CONFIG too (`transport.block_markers`, e.g. `["whaleguard"]`) — the engine itself only treats non-200 as blocked. Report output follows the source: `/../results` unless `--out`. - **A CLI tool is a TRANSPORT, not a new application -- and it must never see a shell.** `transport.client: "cli"` runs `transport.cli.bin` with an **argv array** (`spawnSync`, `shell: false`): templates (`{url}` + `constants`) are substituted per argument, so `;`, `$(...)`, backticks and `|` arrive as plain characters — `tests/cli-transport.test.ts` literally fires those at a temp file and asserts it was never created. The config surface is six keys (`bin / argv / env_allow / cwd / exit_ok / stdin`), unknown keys fail at load; `env_allow` is a whitelist with `PATH` always kept (otherwise the binary cannot be found), and credentials are referenced by NAME from the environment — never written into the audited plan. Exit codes map onto the engine's status code (`exit_ok`, default `[0]` → 200, anything else passes through so `blocked()` and the report say "命令退出码 N"), which means audits/counts/selects/report needed **no change at all**. Two output shapes have their own extractors: `lines_regex` (line-oriented text — kept separate because `html_regex` drops the first block, an HTML-header convention that would silently eat line 1) and `json_stdout` (the whole stdout is one JSON document). **Tool versions drift like the wall clock does**, so `transport.version_pin` + `version_argv` must match or the source throws (`ValueError`), and the observed version goes into the result and the report. Verified across four tool types: synthetic stub (`apps/demo-tool`, CI-safe), line text (git), JSON (`jq`), and the pre-existing curl/httpx HTTP clients whose 163-item parity corpus is untouched except the two "unknown value" messages. Adding an app = a directory under `apps/` (see `apps/README.md`), never a new branch in the engine. - **An application is a DIRECTORY, not a driver -- and its cost is a measured number.** `apps//pack.json` declares the app: `roles` (reusable step templates: desc/signal/hint/slots/when/retries), `journey` (which roles, in what order — inline steps merge over the template), `slots` + `required_slots` (the data bag; values still only ever come from the plan), `sources` (its data leg — the same datasource engine, HTTP or CLI channel), `invariants` (assertions on the END state) and `hosts` (documentation + the leak check's input). Discovery is `apps//pack.json`, load-time validation reuses `PlanningRules.problems` (one grammar, not a second one), `{slot}` placeholders substitute ONLY declared slots (tool args and JSON payloads are full of braces; an unknown placeholder fails at load), and relative `start_url`s resolve to `file://` from the repo root. `src/app-runner.ts` runs the whole chain in one command (`npm run apps -- --app demo-app`): data leg → journey → invariants → report. The journey is a **mixed step list** (browser steps and tool steps, in order): a browser step goes through `runSubgoalWithRetries` — the executor loop is still `BrowserMiddleware.run`, and the runner owns the session (created lazily at the FIRST browser step, `close()` deferred) so the invariants are evaluated on the journey's real end state, cookies included. It no longer hands the whole journey to `runPlan` because that entry only understands DOM steps. Invariants cannot see INTERMEDIATE requirements (that is what subgoal signals are for) and need at least one browser step (validated) — recorded in the pack's own notes rather than pretended away. The cost is checkable: `npm run apps:matrix` prints per app the declared lines, roles/browser steps/tool steps/invariants, the **code lines inside app dirs (currently 0 — apps are data)** and a leak check that fails if any app-declared host or CLI tool name appears in the CORE shared modules (word-boundary match after stripping block comments; `git` must not match `digit`). Site knowledge in per-site drivers is reported as DEBT, never as an exemption; the probe family has since been migrated into `apps//pack.json` probe sets, so that number is now 0 and those drivers joined the CORE list. Two roles keep this honest (`apps/INVENTORY.md`): an **author** writes the packs, an **independent verifier** reproduces every claim from commands and raw output (never from narration), tries to FALSIFY invariants on temp copies, and files defects with a minimal repro. Its first pass found 11 problems, 6 of which are fixed: role inline-overrides silently dropping cross-kind keys, `hosts: []` opening a hole in the probe host check, a bare-string `hosts` making the leak gate tokenise per CHARACTER, a probe-only app "passing 0/0" through the app runner, an invalid pack crashing the CLI with a stack, and a missing CLI binary throwing instead of becoming `refused: "tool-unavailable"`. The remaining five (duplicate ids across target lists, non-string ids silently `String()`-ed, a browser step without `start_url`, unchecked `max_subgoals`/`slots`/`sources` types, persisted retry counters) are recorded in `apps/INVENTORY.md` §四 for the next round — reported, not pretended. Round 2 fixed five more of them, each with a regression test: ids that are one namespace ACROSS the probe target lists, a non-string target id (silently `String()`-ed), a browser step without `start_url` (the executor re-navigates every subgoal), type checks for `max_subgoals`/`slots`/`required_slots`/ `sources`/`model` (a bare string used to produce a string of character-by-character "unknown source" pseudo-errors ON TOP of the real one), and `{run_id}` — a per-run placeholder, because a retry counter written into the shared `{out_dir}` makes the SECOND run of the same command behave differently (measured: `cli-report`'s flaky step went from `attempts=3` to `attempts=1`; with `{run_id}` it is 3 every time). Round 3 added one more, found by the AUTHOR this time: `PlanningRules.problems` validated every subgoal `signal` recursively but never its `when` — the same `signals.ts` answers both questions, so the guard went unchecked until the run, where a bad op surfaced as `stop_reason: "error: … unknown signal spec"` and a BLOCKED subgoal (loud, but a round too late). `when` now goes through the same walker (nested combinators and an empty `when` included). One boundary the tests state out loud: a green run is not automatically a model-free run — `transfer-slowjet` asks laya once per cold load and passes only because `retries` re-runs the subgoal, so its test asserts `act` was asked AND `attempts>1`; symmetrically, `transfer-farehaven`'s `when` guards are discriminatingly verified only because DELETING them (temp copy) turns the run red (exit 1, exactly those 4 subgoals blocked). - **A site that exists ONLY as a mobile page needs a declared DEVICE, or the plan silently cannot work.** Adding the last application (`apps/qunar-live`, the live `touch.qunar.com` H5 journey) exposed a schema gap rather than a site change: the same frozen plan failed 3/3 on desktop with `low confidence 0.15 < floor 0.25`, because the mobile DOM has the city chip the plan names (`div "北京"`) and the desktop DOM does not — the hint can never match, and laya on a Chinese page is at ~0.15 anyway. The fix is data, not code: a pack may declare `device` (`user_agent` / `viewport` / `is_mobile` / `has_touch` / `locale` / `timezone` / `device_scale_factor`, unknown keys fail at load), and `browserSession(headless, device)` opens a context with exactly those options — packs without a `device` keep the old `browser.newPage()` path so no existing app's output changes. Measured after the declaration: **2/2 subgoals, both `rule:match`, 46.5 s** (`click div "北京" (clicked, rule:match, conf=1.00)`). Evidence: `bench/live-retest/qunar-live-app-run.md`. The lesson generalises: when a "blocked" live app is reproducible as a hint that matches nothing, ask what the app was never allowed to declare. - **A tool step is an ACTION LEG, and the three disciplines are what make it safe.** A journey entry with a `source` is a tool step: it reuses the CLI transport (argv array, env allowlist, cwd, exit codes, version pin) and adds `args` / `dry_run_args` / `idempotent` / `dangerous`. Its completion truth is the **process observation** — `src/tool-signals.ts` grows the same shape as the DOM language on that surface (`exit_ok`, `exit_code_equals`, `stdout_contains`, `stdout_matches`, `stderr_contains`, `json_path_equals` via the datasource path language, `file_exists`, `file_absent`, plus recursive `all_of`/`any_of`/`not`), validated at load exactly like the DOM one. Because re-running a CLI command is not like re-clicking a link: (1) `retries > 0` REQUIRES `idempotent: true`; (2) `dangerous: true` REQUIRES `dry_run_args`, and a failed dry run never applies; (3) a dangerous step is REFUSED unless `--allow-dangerous` (or an injected `confirmToolStep`) approves it after seeing the dry-run evidence — the refusal is reported as `refused: dangerous|declined|dry-run-failed`, never silently skipped, and a refused step leaves no artifact behind (pinned by `tests/apps.test.ts`, which also checks the flaky-tool retry really took 3 attempts and that the file only appears after confirmation). `{out_dir}` is a RUNTIME placeholder (this run's `--out`), not a plan value: artifacts go to a run directory so the pack stays machine-independent. Verified on two tool types: the deterministic stub (`apps/cli-report`, CI-safe) and real tools (`apps/repo-report`: git line text + jq JSON). - **A probe is DATA too -- and the debt meter must not need a declaration to see it.** The last site knowledge in shared code lived in the probe/task drivers (`live-probe` / `live-capture` / `ota-probe` / `qunar-probe` / `qunar-task`): URL tables, gate/login markers, result dirs, even the frozen plan of the qunar task. It now lives in `apps//pack.json` under a `probe` block (`date`, `results_dir`, `home`/`http`/`alts`/`mobile`/`capture` targets with `{date}`-family placeholders, `browser` re-verification ids, `repeat{id,times}`, `roles` = which declared target a verdict should read, `markers` = the observed strings/regexes, `login` = redirect hosts/paths/ titles). Three load-time rules keep the declaration honest: **every URL's host must be in `hosts`** (otherwise the leak gate silently misses that host), only the four date placeholders are legal (pure calendar math, `{checkout}` = +1 day, `{date_slash}`), and `browser`/`repeat`/`roles` may only reference declared http target ids. The drivers now contain **zero** site identifiers, so all five joined the CORE list -- re-hardcoding a host there fails `apps:matrix` instead of quietly adding debt. Two details worth keeping: the verdict/reason strings must stay byte-identical to the committed corpus, so the display handle (`short`, e.g. `flights.ctrip.com` or the brand `Trip.com`) is DECLARED per target and interpolated -- and `gate_text` stays separate from `block_text` because merging them would silently change the meaning of the already-committed `whaleguard` measurement field. Finally, the debt meter no longer depends on apps having declared a token first: it ALSO scans non-CORE shared files for hard-coded `http(s)://host` literals (`localhost`/`example.com` excluded), so knowledge hidden in a driver shows up as debt the moment it is written. Measured: 3 debt entries before -> **0** after, with the probe packs declaring 20 hosts, 9 apps / 917 declared lines and still 0 code lines inside app dirs. - **Ctrip serves its H5 ROUTE pages server-side, and WhaleGuard's gate is a CLIENT fingerprint check.** `m.ctrip.com/ html5/flight/--day-.html` (N = target date minus today) returns 200 to `curl` -- with its default UA or an iPhone Safari UA -- and 432 to `curl` + a Chrome UA, to Python `httpx`, to Chromium and to Playwright's `context.request`. The page carries `window.__INITIAL_STATE__.listData.flights`: the complete itinerary list for that date (flight no, times, airline, airports, stops, price, cabin, discount; 90-98 rows for KMG-BJS). So real Ctrip fare data IS reachable from this box -- through the HTTP door, not through a browser. The hotel half has no such door: anonymous hotel responses carry names/stars/scores with EMPTY price nodes (the UI says \u201c登录以查看会员价\u201d). `ctrip/h5_flights.py` implements it (curl-only transport, pure parser, offline fixture + tests) and `--render` proves the executor can extract elements from the real DOM (29 interactive elements) when the fetched HTML is rendered. - **A verdict must be measured, not assumed -- the live-capture tool got this wrong first.** `ctrip/live_capture.py` reports one of four verdicts per target (`captured` / `blocked` / `login_required` / `unreachable`); its first version counted a page that had been REDIRECTED to `passport.ctrip.com/user/login` as "captured" (40 elements of a login form). Distinguishing "the page opened" from "the page I asked for opened" is the same failure mode as counting a `whaleguard` body as content. Measured here: flights 432, hotels -> login redirect, so `captured 0/2`. - **The flow is DATA (`transfer/specs/*.json`), not Python -- including its SHAPE.** A spec is a LIST of phases (`{phase, stage, repeat, steps}`) expanded in order, where `repeat` is one of `once` / `per_date` / `per_checkin` and `stage` says whether the phase runs before the departure date is known (flight) or after it (hotel). Opening a second `per_date` phase IS a two-page search wizard -- the loader has no idea what a wizard is (the fifth replica was added that way). The rest of a spec holds the description templates, the per-step triple (hint(s) / signal / URL template), the data bag, the ledger expression and the site directory; `layaapp`'s `SIGNAL_OPS` validates the signals at load time and unknown placeholders raise instead of silently producing a wrong plan. Proven, not asserted: `transfer/specs/ctrip.json` expands to the SAME 19 subgoals, field for field, as the hand-written `ctrip/ctrip_task.py` plan (`transfer/test_transfer.py`). Adding a similarly laid out site = one spec + one data generator, zero flow code -- and that is how the third and fourth replicas were added (`specs/trekly.json`: English table layout, a range-slider price cap, one click records every row; `specs/flyquest.json`: the results page is only reachable by SUBMITTING a search form; `specs/voyagio.json` / `specs/farehaven.json` / `specs/slowjet.json` add a search wizard, a conditional overlay and a retrying cold-load step). Seven sites now run the same 18 shared roles to 7/7 on their own oracle, each on its own inventory -- the fifth changes the SKELETON SHAPE, the sixth skips four subgoals at runtime, the seventh retries five, and the metric reports all of that (extra roles / skipped / retried) instead of pretending the plans line up. What deliberately stays in Python: the site's inventory generator and the independent judge -- a judge written in the same config as the flow would stop being independent. - **A subgoal may RETRY (`retries`), and a retry is the WHOLE subgoal.** Transient failures (a cold first load, a hydration race, a page left half-navigated by the previous action) are the most common reason a real plan stops, and the old vocabulary had only "succeeded" or "BLOCKED". `retries: N` re-runs the step N extra times, each a fresh navigation with `when` re-evaluated and the element pool re-scanned; only after that does the subgoal block (and only then does replan get its turn). Bounded by the plan, never a runtime invention. Measured on the seventh replica, whose booking-summary page is cold on its first hit per date: 7/7 with 5 retried subgoals in 19 steps, versus 0/7 with `--no-retries` blocking on the first cold page (0.16 < 0.25). Note what it does NOT change: the plan's shape, so the migration metrics look identical to the baseline -- `retries` is runtime policy, not structure. - **A subgoal may be CONDITIONAL (`when`) -- and that is the honest answer to "no branching".** The guard is a signal evaluated on the page AFTER the navigation and BEFORE any action; when it does not hold the subgoal is SKIPPED (no action, no model call, `status=skipped`, resume does not retry it). This is the shape of the most common real interruption -- a consent/promo overlay that exists on some page loads and not others. Measured on the sixth replica: the overlay is there on the three promo dates and gone afterwards, so the dismiss step must run once and skip four times (24 subgoals -> 20 steps, 4 skipped, 7/7); stripping the guards (`--no-when`) blocks at the first page WITHOUT the overlay (hint resolves to nothing -> laya picks a nav link -> the signal can never fire -> 1/7). What is still not expressible: backtracking, multi-branch choices, conditional jumps inside a repetition. - **The signal language lives in `layaapp/signals.py`, below both planner and executor.** `check_signal` answers two questions with the same five operators -- "is this subgoal DONE?" (completion) and "does this subgoal APPLY?" (the `when` guard). planner imports executor, so putting the implementation in either would force a second copy; the hint grammar already drifted once that way, so the signal language gets its own module instead. - **A subgoal may name SEVERAL targets, in order (`target_hints`).** Real travel sites hide their results behind a search FORM: fill the date, choose the window, press Search. With one `target_hint` the hint stays authoritative after the fill, so the same element is re-picked (inert -> forbidden) and the subgoal stalls -- measured on the fourth replica, whose fares page is unreachable without submitting. `target_hints` walks an ordered list, advancing once each action has executed (or its element has been forbidden); a single `target_hint` keeps its old whole-subgoal authority, which every pre-existing plan depends on (`test_a_single_hint_still_owns_the_whole_subgoal` pins exactly that). The same subgoal can also carry its own `slots` (a date to type into the form), merged over `plan.slots`, so a per-step value stays a plan datum instead of prose. Measured: that replica runs 19 subgoals in 30 steps, all rule hits. - **The executor's action model needed a THIRD case: radio/checkbox.** It used to decide "type into input/textarea/select, click everything else", so a radio-group price filter took the fill path, returned `needs_value`, was forbidden and burned the subgoal -- unreachable (filling one is not a fix either: Playwright rejects it). Found by the transfer replica, where filters are radios rather than link chips; `_is_typing` / `_is_checkable` now classify all three cases in one place. Pinned by unit tests, including the nasty shape `` with `slots={"cap": ...}`, where `_slot_value`'s substring match would have handed a value to a radio. - **`` needs no new action: `locator.fill()` sets the value and fires `input`.** Measured on the third replica, whose price cap is a slider (a slot supplies "300", the fill path runs, `oninput` navigates, the result list narrows 32 -> 12). So the executor's action model covers link / button / input / textarea / select / radio / checkbox / range; the untested shapes left are uploads, rich text, `` and anything inside a cross-origin frame. - **`