diff --git "a/docs/v3/25-ClaudeCode\345\256\277\344\270\273\347\253\257\345\210\260\347\253\257\345\256\236\346\265\213.md" "b/docs/v3/25-ClaudeCode\345\256\277\344\270\273\347\253\257\345\210\260\347\253\257\345\256\236\346\265\213.md" new file mode 100644 index 0000000..9451240 --- /dev/null +++ "b/docs/v3/25-ClaudeCode\345\256\277\344\270\273\347\253\257\345\210\260\347\253\257\345\256\236\346\265\213.md" @@ -0,0 +1,157 @@ +# Claude Code 宿主 · 端到端实测 + +2026-09-14。这是 Claude Code 作为宿主运行时的**第一次端到端实测**。此前它只有六条接缝单测 +(session_id 取值、hook 拦截识别、MCP 事件翻译、`.mcp.json` 写入、CLI 参数、运行时分发), +没有一条会真的 spawn 一个 `claude` 进程——「参数拼对了」「hook 真的被宿主加载了」 +「skills 真的被识别了」这三件事此前只有文档没有证据。Codex 在 09-10 有过一次(见 19), +两个宿主的证据强度长期不对等。 + +**结论:链路通了,17 次 MCP 阶段调用,3 分 10 秒,$1.52。** + +## 运行身份 + +| 项目 | 实测值 | +| --- | --- | +| 脚本 | `server/scripts/verify-host-stages.ts --real --claude --freeze-as-operator` | +| 宿主 | Claude Code 2.1.270 | +| 规划模型 | `claude-opus-5[1m]`(宿主原生,未替换成 Web planner) | +| 数据库 | 一次性临时库(`TP_DATA_DIR` 指向 mkdtemp) | +| 材料 | 脚本自带的 counter 规格,本地、有界、无登录无网络无交易 | +| run | `verify-claude-code-1789387536177` | +| session | `19bcbf7e…` | +| 墙钟 | 12:05:36 → 12:08:46(3 分 10 秒) | +| token | 输入 514 · 缓存读 1,006,004 · 输出 13,277(其中思考 5,491) | +| 花费 | $1.5188 | + +## 实际调用路径 + +``` +load_run_instructions → begin_stage(modules) → retrieve_spec → plan_modules +→ module_plan_state → [人工冻结] → begin_stage(stories) → write_stories +→ begin_stage(cases) → write_cases ✗ → write_cases ✓ +→ begin_stage(gate) → gate_run → write_cases → gate_run +→ begin_stage(finalize) → finalize_run +``` + +17 次 MCP 工具调用,内部 `run_pipeline` **0 次**——走的是阶段工具,不是整份流水线。 + +产物 11 份修订:`counter.md`、`validated/instructions`、`validated/modules`、 +`report/module-fanout`、`report/acceptance-index`、`validated/stories`、 +`validated/cases`(rev1、rev2)、`validated/gate`(rev1、rev2)、`validated/finalize`。 + +## 第一次跑:agent 停住了,而不是适配器坏了 + +第一次跑(不带 `--freeze-as-operator`)脚本判 `passed: false`,报 +「agent 中途停下了」。看 trace 才知道它停在哪: + +``` +begin_stage(modules) → load_run_instructions → retrieve_spec → plan_modules +→ module_plan_state ×3 → 停 +``` + +session 本身是**正常结束**的(`is_error: false, subtype: success`)。它在等人冻结模块树—— +而脚本里没有这个人。**不是适配器坏了,是这个脚本比流水线旧**:它写在模块节点与 +那道人工闸引入之前。 + +照 `drive-modules-and-stories.ts` 的先例加了 `--freeze-as-operator`:默认不按, +带这个开关才按,并把「谁按的、什么时候按的」写进证据(`result.json` 的 `freezes`)。 +这条闸的意义是「有人为这棵树负责」,不是「必须有个人坐在那儿」——脚本调用者显式声明 +自己就是那个人,是成立的;悄悄替他按,不成立。 + +冻结记录:`2026-09-14T12:06:08.206Z · VERIFY_HOST_STAGES_OPERATOR · openFindings: 0`。 + +## 顺带验到的两件事 + +**一、schema 拒收之后 agent 会自己修。** 第一次 `write_cases` 被打回: + +``` +cases.json 不符合 CaseBundleSchema:cases.0.design.transitionIds: Array must contain +at least 1 element(s)。形状见 skill testpilot-design 的 REFERENCE.md;改完再写一次。 +``` + +它改完重写,第二次 `validated`:2 条用例 / 1 条故事,出处 `trace`,2/2 有据可循。 +门禁出结果后又写了一次 `write_cases` 再 `gate_run`——修复回路按设计走通了。 + +**二、这轮新加的门禁规则在英文材料上也生效。** `gate_run` 点到三条 `acceptance-uncovered`: + +``` +S-01/AC-1 要求用户动手(When the visitor clicks the visible Inc),却没有任何用例认领它 +``` + +验收准则编号(`S-01/AC-1`)与「这条要不要用户动手」的判定是 2026-09-14 才加的 +(见 24 §38),当时是对着中文语料调的。这次在纯英文规格上跑,`When` 子句解析与 +英文动作动词(`clicks`)都命中了。最终两轮门禁都 `score: 1`。 + +## 第二轮:真实站点,走到执行 + +2026-09-14 同日。上面那轮用的是脚本自带的本地 counter 规格,这一轮换成真东西: +新建项目、目标 `https://app.hyperliquid-testnet.xyz/trade`,并且走完复核 → g2 → 执行。 +驱动脚本 `server/scripts/drive-claude-host.ts`。 + +**分工是被迫的,也是对的。** MCP 里没有探索工具(`register_run` 收的是材料文本), +而且探索要浏览器、宿主 agent 没有。所以: + +``` +服务端探索(modules 前设断点停住)→ Claude Code 宿主规划 → 人复核 → g2 → execution +``` + +| 阶段 | 结果 | +| --- | --- | +| 项目 | `prj-mu18xegk-1002` | +| 探索 | 6 屏、32,656 字材料、产品模型 21 功能 / 10 模块,2 分钟 | +| 宿主规划 | 9 模块 / 4 故事 / 11 用例,门禁 `score 1`;冻结由 `CLAUDE_HOST_E2E_OPERATOR` 显式按下 | +| 人工复核 | 10 批准 / 1 驳回 | +| g2 | `ready_to_execute`,代码门禁 `score 1`(3 条 info:措辞漂移、重复步骤序列) | + +用例质量与上一批(24 §38 之前那 81 条)完全不同:11 条全是 `tier1 + 机器判据 + ready`, +`acRefs` 全部按编号引用,步骤是真实动作,大多带回退。没有一条是「打开页面 + 看一眼」。 + +### 驳回的那条,缺陷不在用例 + +C-12 的判据是 `Account value must be <$2500万 to use portfolio margin in beta mode.` +——一个英文 dapp 不会渲染「万」。查探索材料,**原文就是这句**:用例逐字抄的,没编。 +缺陷在上游:材料里这句的数字被本地化了(`$25M` → `$2500万`),而同一份材料里 +`No open positions yet`、`Trailing Stop`、`The maximum leverage is 10x.` 都是逐字英文。 +拿它当 `text` 判据必然永远不匹配。**`outputLanguage: 'zh'` 的运行都可能中招。** + +### 执行:10 条真跑到测试网,9 过 1 败,零基础设施错误 + +``` +✓ C-03 C-05 C-07 C-08 machine:pass(挣来的) +⚠ C-01 C-02 C-06 C-09 C-11 machine:pass(免费) +✗ C-10 failed +通过 9 条里:挣来的 4 条 · 判据在初始页面就成立的 5 条 +``` + +**唯一一条失败是定位失败,不是产品缺陷。** C-10 第 2 步 +「点击页面下半部分账户面板标签行里的「Positions」」: + +``` +AI model failed to locate: The 'Positions' tab in the account panel tab row +at the bottom of the page was not found in the provided element list or screenshot. +``` + +页面就绪读数是 `30 controls, 1849 text characters`——页面是画完的。未连接钱包时 +账户面板那一行标签本来就可能不在,或者不叫这个名字。归 `locate` 而不是 `assert`, +分档是对的:这条要改用例措辞,不该走「产品是坏的」那个出口。 + +### 两条批了但要标出来的弱判据 + +C-01「输入数量后可用余额仍为 0.00 USDC」、C-11「切到 Sell / Short 后仓位仍是 0.00 HYPE」 +——判据在**动作之前就已经成立**。作为不变性检查勉强成立,但它分不清「没变」和「本来就是」。 +执行结果证实了:C-01、C-11 都是 `machine:pass(免费)`,另外 C-02、C-06、C-09 也是—— +**9 条通过里只有 4 条是挣来的**。判据是真的机器判据,通过也是真的通过, +但其中 5 条的判据在动作之前就已经成立,这次通过没有证明那几步做成了什么。 + +## 还没验到的 + +- **hook 有没有真的被宿主执行**。判据只到「MCP 阶段工具被调用、产物落盘」, + `PreToolUse` / `Stop` 走没走 `hooks/adapters/claude-code.mjs`,trace 里没有直接证据。 +- **宿主自己发起探索**。MCP 没有这个工具,目前也不打算有——探索要浏览器。 + +## 前提 + +跑这个脚本之前必须先让宿主 plugin 与真源一致(`node scripts/build-claude-plugin.mjs`)。 +2026-09-14 调研时发现两个宿主副本落后了四个文件,其中就有当天改过的 +`testpilot-stories/SKILL.md`——**拿过期副本跑出来的实测,测的是旧 skill,结论是假的**。 +这条现在由 `check-drift` 兜住(见 PR #1)。 diff --git a/server/scripts/drive-claude-host.ts b/server/scripts/drive-claude-host.ts new file mode 100644 index 0000000..5e8c1b0 --- /dev/null +++ b/server/scripts/drive-claude-host.ts @@ -0,0 +1,54 @@ +/** + * 把一次服务端探索出来的材料交给 **Claude Code 宿主**,由它驱动 modules → finalize。 + * + * 2026-09-14:这是 PR #3 的实测没覆盖到的那一半——那次用的是脚本自带的本地 counter 规格, + * 这次是真实的重前端页面(hyperliquid 测试网交易页,6 屏、32,656 字材料)。 + * + * 探索留在服务端:MCP 里没有探索工具(`register_run` 收的是材料文本), + * 而且探索要浏览器,宿主 agent 没有。所以分工是:服务端探索 → 宿主规划 → 人审核 → 执行。 + * + * 冻结模块树那一下仍然必须有人明说:默认不按,`--freeze-as-operator` 才按,并记进日志。 + */ +import { config } from "dotenv"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +const root = resolve(import.meta.dirname, "../.."); +config({ path: join(root, "server/.env"), quiet: true } as never); +const arg = (k: string) => { const i = process.argv.indexOf(`--${k}`); return i < 0 ? undefined : process.argv[i + 1]; }; +const projectId = arg("project")!, fromRun = arg("from")!; +const limit = Number(arg("limit") ?? 4); +const freezeAsOperator = process.argv.includes("--freeze-as-operator"); + +const { runLedger } = await import("../src/runService.js"); +const { dataPath } = await import("../src/datadir.js"); +const moduleStage = await import("../src/moduleStage.js"); +const claude = await import("../src/claudecode.js"); +const l = runLedger(); + +const material = l.readRevision(l.listRevisions(projectId, fromRun).filter((r: any) => r.name === "exploration.md").at(-1)!.id, projectId).content as string; +const runId = `cc-host-${Date.now()}`; +const workspace = dataPath(`host-workspaces/${runId}`), materialsDir = join(workspace, "materials"); +mkdirSync(materialsDir, { recursive: true }); +writeFileSync(join(materialsDir, "exploration.md"), material); +console.log(`材料 ${material.length} 字符 → ${materialsDir}`); + +const freezes: unknown[] = []; +const watcher = setInterval(() => { + if (!freezeAsOperator) return; + try { + const m = moduleStage.modulePlanState(runId, projectId); + if (!m.exists || m.frozen) return; + const r = moduleStage.freezeModulePlan(runId, projectId, { kind: "human", id: "CLAUDE_HOST_E2E_OPERATOR" }); + freezes.push({ at: new Date().toISOString(), ...r }); + console.log("freeze(operator):", JSON.stringify(r).slice(0, 200)); + } catch { /* 还没到能冻结的状态 */ } +}, 2000); + +const started = await claude.startRun({ runId, workspace, materialsDir, scopeProjectId: projectId, limit }); +console.log("claude session:", started.sessionId); +const result = await new Promise<{ status: string; error?: string }>((done) => + claude.watchRun({ ...started, scopeProjectId: projectId, timeoutMs: 45 * 60_000, + onEvent: (e: any) => console.log(` ${e.node}:${e.phase}`), onDone: done } as never)); +clearInterval(watcher); +claude.cancelRun(runId); +console.log(JSON.stringify({ runId, projectId, status: result.status, error: result.error, freezes }, null, 1)); diff --git a/server/scripts/verify-host-stages.ts b/server/scripts/verify-host-stages.ts index 478bd83..fc0168c 100644 --- a/server/scripts/verify-host-stages.ts +++ b/server/scripts/verify-host-stages.ts @@ -17,6 +17,7 @@ process.env.TP_DATA_DIR = join(dir, "data"); const evidenceDir = resolve(process.env.TP_EVIDENCE_DIR ?? join(root, `docs/v3/evidence/n-04/real-${runtime}`)); mkdirSync(evidenceDir, { recursive: true }); const database = await import("../src/db.js"), service = await import("../src/runService.js"), stages = await import("../src/runStages.js"); +const moduleStage = await import("../src/moduleStage.js"); const project = database.createProject(`Host stage verification (${runtime})`, "http://127.0.0.1"); const app = express(); app.use(express.json({ limit: "16mb" })); app.use("/api/projects/:projectId/workflow-runs", (await import("../src/runRoutes.js")).runRouter()); @@ -38,6 +39,34 @@ There is no login, network operation, payment or trade. Assertions must use only `); const calls: Array> = []; const startedAt = new Date().toISOString(); + +/** + * **模块树的冻结那一下必须是人——脚本里没有这个人,宿主就停在那儿。** + * + * 2026-09-14 第一次真跑 `--real --claude`:agent 走完 begin_stage → load_run_instructions + * → retrieve_spec → plan_modules,然后连查三次 `module_plan_state` 就停了。session 本身 + * 是正常结束的(`is_error: false`),它只是在等人按那一下。脚本判成 + * 「agent 中途停下了」——**不是适配器坏了,是这个脚本比流水线旧**: + * 它写在模块节点与冻结这道人工闸引入之前。 + * + * 照 `drive-modules-and-stories.ts` 的先例:默认不按,带 `--freeze-as-operator` + * 才按,并且把「谁按的、什么时候」写进证据。这条闸的意义是「有人为这棵树负责」, + * 而不是「必须有个人坐在那儿」——脚本调用者显式声明自己就是那个人,是成立的; + * 悄悄替他按,不成立。 + */ +const freezeAsOperator = process.argv.includes("--freeze-as-operator"); +const freezes: Array> = []; +const freezeWatcher = setInterval(() => { + if (!freezeAsOperator) return; + try { + const mod = moduleStage.modulePlanState(runId, project.id); + if (!mod.exists || mod.frozen) return; + const r = moduleStage.freezeModulePlan(runId, project.id, { kind: "human", id: "VERIFY_HOST_STAGES_OPERATOR" }); + freezes.push({ at: new Date().toISOString(), by: "VERIFY_HOST_STAGES_OPERATOR", ...r }); + console.log("freeze(operator):", JSON.stringify(r).slice(0, 160)); + } catch { /* 还没到能冻结的状态 */ } +}, 2000); +freezeWatcher.unref?.(); let session: any, failure: string | undefined; try { if (web) { @@ -87,8 +116,10 @@ finally { await session?.dispose(); const run = service.runLedger().getRun(runId, project.id); const artifacts = run.revisions.map(r => service.runLedger().readRevision(r.id, project.id)); + clearInterval(freezeWatcher); + // 谁按的冻结、什么时候按的,进证据。宿主自己批准自己的产物这件事,必须一眼看得出来没有发生。 writeFileSync(join(evidenceDir, "result.json"), JSON.stringify({ runtime, runId, startedAt, finishedAt: new Date().toISOString(), passed: !failure, failure, - run, artifacts, privateWorkspace: dir }, null, 2)); + freezes, freezeAsOperator, run, artifacts, privateWorkspace: dir }, null, 2)); await new Promise(r => http.close(() => r())); service.runLedger().close(); database.db.close(); } console.log(JSON.stringify({ runtime, runId, passed: !failure, failure, evidenceDir })); diff --git a/server/test/managed-penguin.test.ts b/server/test/managed-penguin.test.ts index 08e5d2b..2ff74f8 100644 --- a/server/test/managed-penguin.test.ts +++ b/server/test/managed-penguin.test.ts @@ -19,7 +19,21 @@ afterAll(async () => { dirs.forEach(d => rmSync(d, { recursive: true, force: true })); }); -const installed = existsSync(node24Path()) && existsSync(penguinBin()); +/** + * **「装没装」这个探测自己不能炸。** + * + * 下面四条都是 `it.runIf(installed)`——没装 Penguin SDK 就跳过,机制是齐的。 + * 但 `node24Path()` 在找不到 Node 24 时**抛异常**(`penguin_node_24_required`), + * 而它在模块顶层被调用:于是在一台没有 Node 24 的机器上,整个文件加载失败, + * 四条测试连「跳过」都到不了,整个 server 包的测试红。 + * + * 2026-09-14:CI 用的是 Node 22,从来没绿过——三条 PR 分支各红了一次, + * 红的都是这一条,和各自改的东西毫无关系。探测的答案本来就该是「没有」, + * 不是「出错」。 + */ +const installed = (() => { + try { return existsSync(node24Path()) && existsSync(penguinBin()); } catch { return false; } +})(); function allText(dir: string): string { return readdirSync(dir, { withFileTypes: true }).map(e => e.isDirectory() ? allText(join(dir, e.name)) : readFileSync(join(dir, e.name)).toString()).join("\n"); }