Browse Source

feat(bridge): 接入本体重构终端审阅与可视化观察台

将仓库重构的选择、规划、确认、执行和质量审查串联在同一 CLI 任务中,并提供读取真实任务证据的可选看板,方便用户理解进度和下一步。

- 新增 software_review 工具、协议契约与 /refactor 原生终端面板
- 按阶段引导候选选择、补丁确认、失败恢复及交付审查,保留草稿与请求核对
- 展示影响路径、前后变化、测试结果与剩余问题,看板保持只读并绑定原任务
- 同步智能体规划提示、分发清单、产品标识与使用文档
- 验证:CLI 和桥接回归 51 项通过、1 项部署环境测试跳过;tui 与 opencode 类型检查通过
邵勃源 14 hours ago
parent
commit
e0db6b883b
34 changed files with 4883 additions and 30 deletions
  1. 45 1
      .opencode/agent/paas.md
  2. 7 1
      .opencode/agentpaas-manifest.json
  3. 6 0
      .opencode/command/paas-refactor.md
  4. 5 0
      .opencode/lib/software-dashboard-ui.ts
  5. 245 0
      .opencode/lib/software-review-contract.ts
  6. 33 0
      .opencode/lib/software-review-panel.ts
  7. 2 0
      .opencode/lib/software-review-ui.ts
  8. 186 0
      .opencode/tool/software_review.ts
  9. 43 0
      packages/opencode/test/tool/software-dashboard.test.ts
  10. 388 0
      packages/opencode/test/tool/software-review.test.ts
  11. 32 5
      packages/tui/src/app.tsx
  12. 10 0
      packages/tui/src/brand.ts
  13. 1186 0
      packages/tui/src/component/dialog-software-review.tsx
  14. 5 2
      packages/tui/src/component/error-component.tsx
  15. 1 1
      packages/tui/src/component/logo.tsx
  16. 1 1
      packages/tui/src/component/prompt/index.tsx
  17. 4 1
      packages/tui/src/feature-plugins/home/footer.tsx
  18. 7 2
      packages/tui/src/feature-plugins/home/tips-view.tsx
  19. 4 9
      packages/tui/src/feature-plugins/sidebar/footer.tsx
  20. 2 2
      packages/tui/src/logo.ts
  21. 8 2
      packages/tui/src/routes/home.tsx
  22. 6 3
      packages/tui/src/routes/session/permission.tsx
  23. 38 0
      packages/tui/src/util/software-review-draft.ts
  24. 125 0
      packages/tui/src/util/software-review-navigation.ts
  25. 311 0
      packages/tui/src/util/software-review-report.ts
  26. 350 0
      packages/tui/src/util/software-review.ts
  27. 1026 0
      packages/tui/test/cli/tui/software-review.test.tsx
  28. 28 0
      packages/tui/test/software-review-draft.test.ts
  29. 79 0
      packages/tui/test/software-review-navigation.test.ts
  30. 117 0
      packages/tui/test/software-review-report.test.ts
  31. 296 0
      packages/tui/test/software-review.test.ts
  32. 200 0
      script/check-software-dashboard.mjs
  33. 19 0
      script/serve-software-dashboard.ts
  34. 68 0
      specs/software-refactor-dashboard.md

+ 45 - 1
.opencode/agent/paas.md

@@ -1,6 +1,6 @@
 ---
 mode: primary
-steps: 10
+steps: 20
 color: "#0F766E"
 description: PaaS 通用入口:选择智能体、上传材料、执行任务、恢复进度、审批与下载产物。
 tools:
@@ -12,6 +12,7 @@ tools:
   agentpaas_task: true
   agentpaas_upload: true
   agentpaas_download: true
+  software_review: true
 ---
 
 你是 LambdaAgentPaaS 的客户端入口。通过工具取得事实,不自行模拟远端执行、虚构能力或产物。
@@ -35,3 +36,46 @@ tools:
 长任务提交使用 agentpaas_run.background=true,受理后向用户报告任务编号与真实状态,不在同一回合无限轮询。重新打开或不知道原编号时用 agentpaas_tasks 找回;用 task/watch 观察、cancel 停止。后台任务等待确认时需用户明确决定,不能自行批准。每次新提交会自动检查已知资源故障;要验证模型或知识检索,用 agentpaas_check(check_model 会调用模型)。
 
 若提交断线且没有 run_id,用原 idempotency_key 调用 task/recover 或 tasks;查不到也不得换键重跑。取消后必须按 cancellation_status 说明已确认或仍待核查,不能把请求成功当作已经停止。审批提醒仅在客户端运行时可用,显示 expires_at 并让用户决定。用量完整性未确认时不得声称免费或准确成本。用户要求费用限制时,仅对预检查 client_budget_supported=true 的智能体传 max_cost_usd,解释这是估算费用的结算后检查,其他引擎不得承诺限额。
+
+
+软件仓库重构采用候选审阅流程:software_review/analyze 只读分析已放入该租户服务端工作区的仓库,保存返回的 review_id;随后 open 返回入口,并提示用户输入 /refactor 打开终端面板。用户未保存选择时,不自行替其选取,不通过其他智能体任务绕开选择。用户保存后用 context 获取已选候选、原始源码和当前 revision,生成有依据的 plan 与精确 edits,通过 propose 提交。计划需要 project、goal、candidate_ids、allowed_files、focus、invariants、verification;必须保持已选范围。没有测试依据时停止在规划,不伪造保持条件或验收。终端 /refactor 面板会显示实际补丁供用户确认;本工具不能批准执行。提交计划后让用户回到终端 /refactor 面板审阅并确认,不代点。status 查询原 review_id,awaiting_review 只是验证通过待质量审查,不称已完成或候选已解决。支持 Java/Maven、一个经选择的有界批次;默认仅选定方法内部修改。用户显式保存结构重构范围后,可在所选文件中跨方法修改和新增声明的生产类;不能改测试或构建配置,超限批次需要重新缩小范围。不要把本机目录当服务端目录,不重新提交来掩盖断线。
+
+现场仓库审阅不得预设仓库名、候选 ID、补丁、测试数或成功结论。用户明确要求新的只读分析时,使用其指定的服务端相对目录和项目标识调用一次 software_review/analyze,记录本次 review_id;不要用历史报告替代。未给仓库则询问,不默认演示仓库。已有任务的查看、继续和补丁提案始终使用原 review_id。先说明实际扫描规模、支持的检测规则、遗漏与未解析关系,候选不是已证实缺陷,也不代表所有问题。当前入口仅支持已部署到租户工作区的 Java/Maven 仓库;客户端目录或未支持的工程不能假装已接入。
+
+提案交互规则:context 返回文件索引和 reading_guide(所选方法行号、测试、规则、构建与关联源码)。先读所选方法及其真实调用/验证依据,再按需补充关联文件;已读且版本不变的内容不重复翻查。通过 software_review/source 按 path、start_line、limit 读取实际方法、测试及相关 POM;大文件续读 next_line。status 候选清单按 offset/limit 分页,未读后续页不能说已看完全部候选。propose 优先传结构化 plan 和 edits,并单独传顶层 revision。focus/invariants 是数组,edits 项为 path/old/new,不能使用 old_string/new_string。Maven 验证格式是 {"kind":"maven-offline","pom_path":"真实相对路径/pom.xml"}(根 POM 为 pom.xml);它执行固定的离线 package/test,不接受 command、mode、type、expected,不支持自定义单测过滤。java-main 使用 kind/sources/test_sources/main_class,只能引用真实已存在的源码和测试。格式错误按明确字段提示修正,不能连续猜测结构。
+用户选了多项,必须全部纳入计划;若某项无法可靠验证,先列出阻塞原因,请用户在终端 /refactor 面板暂缓该项并保存,随后重读 context。不得自行删掉该项提交剩余子集。test_hints 为空、not_verified 只说明尚未找到或验证关联测试,不能说“无任何测试覆盖”;找到若干测试也不能声称覆盖全部分支。保持条件是待验证目标,不是已证实的行为等价结论。
+
+出现提案错误时按具体原因处理:格式问题按契约修正,过期版本重读 context,范围或验证缺口解释给用户。获得新证据再改提案,没有变化不重复提交。规划说明用“为什么改、改哪里、保留什么、如何验证”四点;同一文件的重叠候选合并成一致补丁,不额外增加无关抽象或假设性防护。
+
+focus 是本体节点选择器而非改动说明:复制 context.required_focus,说明文字放 goal/invariants。服务端从用户选中候选派生真正的节点绑定,并在提案阶段校验,节点校验通过不代表测试通过。执行失败时保留原审阅;请用户在终端 /refactor 面板选择“保留失败记录,返回规划”,再读取新版本 context;不得自行重新执行或沿用旧审批。
+
+提案前检查 context.executor 的工具可用性;存在工具缺失时说明执行环境阻塞,不把工具缺失说成代码缺陷,也不自行更换验证方式。执行或验证确实失败、需要重跑时,旧审批不能重用;必须由用户在恢复页返回规划,生成并确认新版本,即使补丁内容没有变化。不要承诺“无需确认直接重试”。
+
+evidence_failed 是证据整理失败,执行结果已经保存。不要据此断言补丁或测试失败,不让用户重新生成同一补丁;修复证据问题后,让用户在 /refactor 选择“重新整理已执行的证据”。该操作核验已有文件与源码摘要,恢复原验证状态,不运行测试、不应用补丁、不代替质量审查。旧任务若 error 指向证据传输/文件大小而 execution.result 已通过,先报告平台证据故障,不循环返回规划。
+
+默认使用 CLI 原生审阅:software_review/open 只返回终端入口,不启动浏览器。让用户输入 /refactor,面板可自动填入当前对话最近审阅编号;候选选择、补丁审阅、执行确认和失败恢复均在其中完成。只有用户明确要求网页时才使用 web。模型仍只能分析、读取和提案,不代替用户执行面板操作。
+
+本体驱动规划:analyze 支持用户明确提供的 rules_path(仓库相对 JSON),没有规则时明确“业务/数据规则未提供”,不能从类名推定车企业务约束。status 中 ontology 展示跨分片引用及架构线索;候选 ontology_context 带影响路径、原文规则和验证义务。读取 context.required_rules/required_invariants,全部纳入计划 invariants,并读取引用文档及关联源码;规则绑定与测试提示都不是业务验证通过。dependency_cycle/distributed_rule 是需另行规划的架构待审项,不可作为当前方法补丁扩大改动范围。引导用户用 /refactor 的“1 · 影响路径”查看关联依据。
+
+需要解释架构线索时调用 ontology,按 next_offset 续读;需要候选关系时附带 candidate_id,并用 relation_view=impact/dependencies/unresolved 分别读取上游影响、下游依赖和未解析关系。status/context 的影响路径是有限导航摘要,截断不能解释为不存在其他影响。缺少旧版分析数据时重新分析须由用户明确要求,不能把空数据说成无问题。
+
+
+仓库重构质量审查:awaiting_review 表示技术验证通过,仍需用户在 /refactor 的“质量审查”中填写通过或退回意见。
+先通过 software_review evidence/artifact 读取实际补丁、baseline/checks.json、当前 round 的 checks.json 和本体对照,再解释收益、行为依据与风险。按 next_offset 分页,不能把未读证据当作已验证。
+accepted_for_delivery 只是人工认可当前隔离补丁,不代表已合并、部署、业务认证。首页“领取本批交付材料”可保存证据并结束本批;“其他操作”中的“从审定副本开始下一批”是可选操作,不要求用户继续执行。
+退回状态 changes_requested 时,由用户选择“返回规划”;取消只是请求停止后续阶段,不能说已立即停止进程。历史任务可在 /refactor 的编号框输入“列表”。
+
+结构范围:以 context.edit_scope 为准。默认 methods 不能跨越方法边界;只有用户在 CLI 保存 structure 后,才可修改已选文件整体和声明 Maven src/main/java 的 new_files。对共享方法抽取应声明 shared_implementations(目标路径、方法名、至少两个调用文件),让编译器核对实际调用;不得以字符串出现代替调用证据。测试及构建配置仍冻结;缺少测试应先准备独立审阅的基线测试,不自行扩大权限。
+
+CLI 导航:/refactor 首页按阶段突出“下一步”,不要求用户把菜单逐项点完。候选与历史等在“材料与记录”,环境检查、取消与下一批在“其他操作”。选择保存后直接进入“让智能体生成本批计划”,按 p 将请求带回输入框,由用户检查后发送;模型仍只能提案,不能代替选择、执行确认或质量审批。
+CLI 证据展示:先进入“查看四类结果”,页内 1–4 分别为影响路径、前后变化、测试结果、剩余问题;待审时 q 进入质量审查,e 查看/下载完整证据,j 切换原始记录。审查通过意见按界面逐项填写收益、测试依据、风险,在核对页按 1–3 修改对应项、c 进入确认后输入“通过”;退回先填写理由,核对后按 c 再输入“退回”,无需竖线分隔格式。解释结果时沿用这四类结构,指出新增/保留的候选、实际测试与未知项。历史记录缺少本体上下文或测试计数时说明缺失,不补造,不把测试通过写成全部问题已解决。
+
+CLI 请求异常:如果面板提示上次请求结果尚未核对,让用户先“同步原任务状态”,不能从网络报错推断执行未启动,也不能重发执行或审批。同步只读;有未保存选择时,界面先要求用户明确同意放弃本地草稿,以服务端记录为准。
+
+可视化演示:用户要求看板或可视化时,对当前真实 review_id 调用 software_review/dashboard(web 为同义入口)。看板是只读展示,不新建分析、不改变选择、不执行补丁;自动同步本任务的项目规模、影响路径、前后变化、测试与剩余问题。完整操作仍在 CLI /refactor。看板绑定单个任务;开启下一批后,使用新的 review_id 打开对应看板。历史任务显示已有结果,不能描述为现场新生成。
+
+规划恢复:context.previous_attempt 提供上轮计划、失败摘要、质量退回意见和 history-* 证据前缀。返回规划后先读取这些反馈,必要时用 evidence/artifact 查看对应日志,明确本次修正再提案;不能原样重复失败补丁。context 的文件索引来自服务端,source 直接分页读取,关联文件总体超过 2 MiB 不必因此缩小用户选择。CLI 在分析/执行首页自动查询进度,用户无需反复说“查询进度”;只读刷新不重复创建或执行任务。
+
+重构回复面向用户说明当前结果和一个下一步,不每回合重复整套边界说明。分析中根据真实 coverage/progress 报告已扫描数量与当前建模阶段;未完成时不把局部候选数当最终数量。规划前可以提出保留现状或暂缓的建议,但不能自行改变用户已选项。最终按影响路径、前后变化、测试结果、剩余问题说明,引用实际证据,区分必须修正与可选改善。
+
+本体驱动规划:读取所选候选的 ontology_context,区分 impact_paths(上游影响)与 dependency_paths(下游依赖契约);结合 unresolved_references 判断哪些关系仍需查证。按 reading_guide 读取所选方法、关联测试、依赖源码和相关模块 POM,形成“改动点 → 关系证据 → 保持条件 → 验证依据”的简洁说明。摘要超过 30 条时仍可通过 context.files 和 source 分页读取完整关联源码。Java AST 的同包/泛型/构造类型关系是静态候选,不等于编译器调用或测试覆盖。不同仓库采用其自身构建范围和有出处的规则;没有业务规则时只做结构重构,不编造行业约束。

+ 7 - 1
.opencode/agentpaas-manifest.json

@@ -34,6 +34,12 @@
     "command/paas-check.md",
     "lib/recovery.ts",
     "lib/approval-monitor.ts",
-    "plugin/agentpaas-notify.ts"
+    "plugin/agentpaas-notify.ts",
+    "tool/software_review.ts",
+    "lib/software-review-panel.ts",
+    "lib/software-review-ui.ts",
+    "command/paas-refactor.md",
+    "lib/software-review-contract.ts",
+    "lib/software-dashboard-ui.ts"
   ]
 }

+ 6 - 0
.opencode/command/paas-refactor.md

@@ -0,0 +1,6 @@
+---
+description: 先分析、勾选候选并审阅计划,再执行重构
+agent: paas
+---
+
+使用 software_review 处理用户指定的仓库重构。用户明确要求“重新分析 / 新的现场分析”时,取得服务端仓库相对路径与项目标识后调用一次 analyze,记录本次返回的 review_id,再用 status/open 查看并提示用户输入 /refactor;不得用旧审阅或演示结论冒充新分析。用户只要求查看、继续或提案时查询原 review_id,不重新创建任务。未指定仓库时先询问,不默认选演示仓库。用户已保存选择时,读取 context,形成具体计划和精确补丁,propose 后等待用户在终端 /refactor 面板确认。不替用户选择或批准,不通过 agentpaas_run 绕开此流程。$ARGUMENTS

File diff suppressed because it is too large
+ 5 - 0
.opencode/lib/software-dashboard-ui.ts


+ 245 - 0
.opencode/lib/software-review-contract.ts

@@ -0,0 +1,245 @@
+import { tool } from "@opencode-ai/plugin"
+
+const textList = tool.schema.array(tool.schema.string().min(1).max(2000)).min(1).max(50)
+export const reviewPlan = tool.schema
+  .object({
+    project: tool.schema.string().min(1).max(160),
+    goal: tool.schema.string().min(1).max(4000),
+    candidate_ids: tool.schema
+      .array(tool.schema.string().regex(/^[a-f0-9]{24}$/))
+      .min(1)
+      .max(50)
+      .describe("必须包含用户当前选中的全部候选;不可擅自删减"),
+    new_files: tool.schema
+      .array(tool.schema.string().min(1))
+      .max(20)
+      .optional()
+      .describe("仅用户选择 structure 范围时,可声明 Maven src/main/java 新生产类;old 必须为空,new 为完整源码"),
+    shared_implementations: tool.schema
+      .array(
+        tool.schema
+          .object({
+            target_path: tool.schema.string(),
+            method: tool.schema.string(),
+            caller_paths: tool.schema.array(tool.schema.string()).min(2),
+          })
+          .strict(),
+      )
+      .max(10)
+      .optional()
+      .describe("抽取共享方法时声明目标方法与至少两个调用文件,编译器检查真实调用"),
+    allowed_files: textList.describe("全部已选候选对应的现有 Java 源文件;structure 模式可另加明确声明的 new_files"),
+    focus: textList.describe(
+      "复制 context.required_focus 中的真实本体节点 ID;服务端按已选候选绑定。修改说明放 goal,不要把自然语言当节点名。",
+    ),
+    invariants: textList.describe("待验证的行为保持条件,不表示已证明"),
+    verification: tool.schema
+      .union([
+        tool.schema
+          .object({
+            kind: tool.schema.literal("maven-offline"),
+            pom_path: tool.schema.string().optional().describe("真实仓库相对 pom.xml 路径;省略则使用根 pom.xml"),
+          })
+          .strict(),
+        tool.schema
+          .object({
+            kind: tool.schema.literal("java-main"),
+            sources: tool.schema.array(tool.schema.string()).min(1).max(100),
+            test_sources: tool.schema.array(tool.schema.string()).min(1).max(100),
+            main_class: tool.schema.string(),
+          })
+          .strict(),
+      ])
+      .describe(
+        "Maven 使用 {kind:'maven-offline',pom_path:'pom.xml'};执行固定的离线 package/test,不能指定 command 或声称只跑某个测试。java-main 只能引用已存在的真实测试文件。",
+      ),
+  })
+  .strict()
+
+export const reviewEdits = tool.schema
+  .array(
+    tool.schema
+      .object({
+        path: tool.schema.string(),
+        old: tool.schema.string().max(256000).describe("当前源码中恰好出现一次的原文;字段为 old,不是 old_string"),
+        new: tool.schema.string().max(256000).describe("替换后的原文;字段为 new,不是 new_string"),
+      })
+      .strict(),
+  )
+  .min(1)
+  .max(30)
+
+const proposal = tool.schema.object({ plan: reviewPlan, edits: reviewEdits }).strict()
+
+export function parseReviewProposal(args: {
+  revision?: number
+  plan?: unknown
+  edits?: unknown
+  proposal_json?: string
+}) {
+  if (!Number.isInteger(args.revision) || args.revision! < 0)
+    throw new Error("propose 必须单独传顶层 revision,取 context.revision;不能放进 proposal_json。")
+  if (args.proposal_json && (args.plan !== undefined || args.edits !== undefined))
+    throw new Error("使用 plan + edits 或兼容的 proposal_json,不能同时传两种格式。")
+  if (args.proposal_json && args.proposal_json.length > 1024 * 1024)
+    throw new Error("提案超过 1 MiB,请缩小经用户选择的批次。")
+  const parsed = proposal.safeParse(
+    args.proposal_json ? JSON.parse(args.proposal_json) : { plan: args.plan, edits: args.edits },
+  )
+  if (!parsed.success)
+    throw new Error(
+      `提案未发送:${parsed.error.issues
+        .slice(0, 8)
+        .map((i) => `${i.path.join(".")}: ${i.message}`)
+        .join(
+          "; ",
+        )}。请使用结构化 plan/edits;focus 为数组,verification 为 {kind:'maven-offline',pom_path:'pom.xml'} 或 {kind:'java-main',sources:[...],test_sources:[...],main_class:'...'};替换字段是 path/old/new。不要猜字段或添加 command。`,
+    )
+  return parsed.data
+}
+
+function ontologySummary(value: unknown) {
+  if (!value || typeof value !== "object") return value
+  const o = value as Record<string, unknown>
+  return {
+    ...o,
+    architecture_findings: undefined,
+    unresolved_sample: undefined,
+    inspect: "完整架构线索、未解析关系和证据可在 /refactor 的本体与架构分析中查看;不视为可执行候选。",
+  }
+}
+function candidateSummary(value: Record<string, unknown>) {
+  const o = value.ontology_context as Record<string, unknown> | undefined
+  if (!o) return value
+  const paths = Array.isArray(o.impact_paths) ? o.impact_paths : []
+  const dependencies = Array.isArray(o.dependency_paths) ? o.dependency_paths : undefined
+  const unresolved = Array.isArray(o.unresolved_references) ? o.unresolved_references : undefined
+  return {
+    ...value,
+    ontology_context: {
+      ...o,
+      impact_paths: paths
+        .slice(0, 3)
+        .map((p) => ({ path: p.path, via_total: p.via_total, via_truncated: true, via: p.via.slice(0, 3) })),
+      impact_paths_truncated: Boolean(o.impact_paths_truncated) || paths.length > 3,
+      dependency_paths: dependencies?.slice(0, 3),
+      dependency_paths_truncated: Boolean(o.dependency_paths_truncated) || (dependencies?.length || 0) > 3,
+      unresolved_references: unresolved?.slice(0, 3),
+      unresolved_references_truncated: Boolean(o.unresolved_references_truncated) || (unresolved?.length || 0) > 3,
+      full_evidence:
+        "用 ontology + candidate_id + relation_view 分页查看 impact/dependencies/unresolved;服务端截断时用 evidence/artifact 查完整本体。此处只是导航摘要。",
+    },
+  }
+}
+
+export function reviewSummary(state: Record<string, unknown>, offset = 0, limit = 8) {
+  const candidates = Array.isArray(state.candidates) ? (state.candidates as Record<string, unknown>[]) : []
+  const decisions = state.decisions as Record<string, unknown> | undefined
+  const proposal = state.proposal as Record<string, unknown> | undefined
+  return {
+    id: state.id,
+    revision: state.revision,
+    phase: state.phase,
+    project: state.project,
+    coverage: state.coverage,
+    progress: state.progress,
+    analysis_scope: state.analysis_scope,
+    repository_summary: state.repository_summary,
+    edit_scope: state.edit_scope || "methods",
+    next_review: state.next_review,
+    quality_review: state.quality_review,
+    cancel_requested: state.cancel_requested,
+    error: state.error,
+    ontology: ontologySummary(state.ontology),
+    recovery:
+      !proposal && ["analysis_failed", "cancelled", "interrupted"].includes(String(state.phase))
+        ? state.next_review
+          ? "分析已停止,后续任务已创建;打开 next_review 查看,不能重复创建或返回补丁规划。"
+          : "分析未完成。请用户在 /refactor 查看原因后选择保留失败记录、重新分析;不能进入补丁规划。"
+        : state.phase === "evidence_failed"
+          ? "执行结果已保存,但证据整理失败。请用户在 /refactor 选择重新整理已执行的证据;此操作不重跑测试或补丁,不要重新提案。是否通过以保存的验证结果为准。"
+          : [
+                "execution_failed",
+                "blocked",
+                "verification_failed",
+                "changes_requested",
+                "interrupted",
+                "cancelled",
+              ].includes(String(state.phase))
+            ? "请用户在终端 /refactor 面板选择保留失败记录、返回规划;随后读取 context 生成新提案。不可自动重试或复用旧审批。"
+            : null,
+    selected_ids: candidates.filter((c) => decisions?.[String(c.id)] === "selected").map((c) => c.id),
+    candidates_total: candidates.length,
+    offset,
+    next_offset: offset + limit < candidates.length ? offset + limit : null,
+    candidates: candidates.slice(offset, offset + limit).map(candidateSummary),
+    proposal: proposal
+      ? {
+          version: proposal.version,
+          plan: proposal.plan,
+          selected_ids: proposal.selected_ids,
+          readiness: proposal.readiness,
+          ontology_binding: proposal.ontology_binding,
+          focus_notes: proposal.focus_notes,
+          edit_count: Array.isArray(proposal.edits) ? proposal.edits.length : 0,
+          inspect: "终端 /refactor 面板显示全部精确补丁,确认前逐项检查",
+        }
+      : null,
+    execution: state.execution,
+    note: "候选已分页;没有测试提示不代表没有测试覆盖,提示也不能证明分支覆盖。",
+  }
+}
+
+export function reviewContext(state: Record<string, unknown>) {
+  const files = state.files as Record<string, string>
+  return {
+    ...state,
+    ontology: ontologySummary(state.ontology),
+    selected: Array.isArray(state.selected) ? state.selected.map(candidateSummary) : state.selected,
+    files: Array.isArray(state.files)
+      ? state.files
+      : Object.entries(files).map(([path, text]) => ({
+          path,
+          lines: text.split("\n").length,
+          characters: text.length,
+        })),
+    source_reading:
+      "这里只返回文件索引,避免大文件截断。提案前使用 source action 按 path/start_line/limit 分页读取方法、测试和 pom.xml;只基于实际已读源码。",
+  }
+}
+
+export function reviewOntology(
+  state: Record<string, unknown>,
+  candidateID?: string,
+  offset = 0,
+  limit = 5,
+  view: "impact" | "dependencies" | "unresolved" = "impact",
+) {
+  if (!candidateID && view !== "impact") throw new Error("查看依赖或未解析关系需要 candidate_id;先读取候选清单")
+  const ontology = state.ontology as Record<string, unknown> | undefined
+  const candidates = state.candidates as Record<string, unknown>[] | undefined
+  const candidate = candidateID ? candidates?.find((c) => c.id === candidateID) : undefined
+  if (candidateID && !candidate) throw new Error("未知候选编号;先读取当前候选清单")
+  const detail = candidate?.ontology_context as Record<string, unknown> | undefined
+  const field = { impact: "impact_paths", dependencies: "dependency_paths", unresolved: "unresolved_references" }[view]
+  const values = (candidateID ? detail?.[field] : ontology?.architecture_findings) as unknown[] | undefined
+  const result = {
+    id: state.id,
+    revision: state.revision,
+    statistics: ontology?.statistics,
+    rules_status: ontology?.rules_status,
+    candidate_id: candidateID,
+    view: candidateID ? view : "architecture",
+    authority: "pending_candidate_preview",
+    status: values ? "available" : "not_available_in_this_analysis",
+    items: values?.slice(offset, offset + limit) || [],
+    stored_total: values?.length || 0,
+    total: candidateID ? detail?.[field + "_total"] : ontology?.architecture_findings_total,
+    upstream_truncated: candidateID ? detail?.[field + "_truncated"] : ontology?.architecture_findings_truncated,
+    next_offset: values && offset + limit < values.length ? offset + limit : null,
+    note: "上游影响、下游依赖和未解析关系分别阅读。静态关联不是运行时调用;外部库未解析不自动判为缺陷。服务端摘要截断时用 context.files/source 读关联源码,用 evidence/artifact 读完整本体。",
+  }
+  if (JSON.stringify(result).length > 16000)
+    throw new Error("本体证据页超过 16000 字符;减小 limit,或在终端审阅完整内容。")
+  return result
+}

+ 33 - 0
.opencode/lib/software-review-panel.ts

@@ -0,0 +1,33 @@
+import { randomBytes } from "node:crypto"
+import { softwareReviewHTML } from "./software-review-ui"
+import { softwareDashboardHTML } from "./software-dashboard-ui"
+import { requestJSON, type Connection } from "./agentpaas"
+
+// Credentials never enter the page. This explicit operator UI is bound to one
+// review, with Host/Origin/CSRF checks and no general-purpose forwarding route.
+export function startSoftwareReviewPanel(config: Connection, reviewID: string, port = 0, mode: "review" | "dashboard" = "review") {
+  if (!/^[a-f0-9]{32}$/.test(reviewID)) throw new Error("无效审阅编号")
+  const token = randomBytes(32).toString("hex")
+  const server = Bun.serve({
+    hostname: "127.0.0.1", port,
+    async fetch(req, server) {
+      const origin = `http://127.0.0.1:${server.port}`
+      const route = new URL(req.url).pathname
+      const headers = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer" }
+      if (req.headers.get("host") !== `127.0.0.1:${server.port}`) return new Response("Forbidden", { status: 403 })
+      if (req.method === "GET" && route === "/") return new Response(mode === "dashboard" ? softwareDashboardHTML(token) : softwareReviewHTML(token), { headers: { ...headers, "Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'" } })
+      if (req.headers.get("X-Review-Token") !== token || (req.method !== "GET" && req.headers.get("origin") !== origin)) return Response.json({ error: "Forbidden" }, { status: 403 })
+      if (mode === "dashboard" && req.method !== "GET") return new Response("Read only", { status: 405, headers })
+      const suffix = route === "/api/review" && req.method === "GET" ? "" : route === "/api/evidence" && req.method === "GET" ? "/evidence" : route === "/api/evidence-text" && req.method === "GET" ? "/evidence-text" + new URL(req.url).search : route === "/api/selection" && req.method === "POST" ? "/selection" : route === "/api/execute" && req.method === "POST" ? "/execute" : route === "/api/reopen" && req.method === "POST" ? "/reopen" : undefined
+      if (suffix === undefined) return new Response("Not found", { status: 404 })
+      try {
+        const body = req.method === "POST" ? await req.text() : undefined
+        if (body && body.length > 64000) return new Response("Too large", { status: 413 })
+        return Response.json(await requestJSON(config, `/software-reviews/${reviewID}${suffix}`, new AbortController().signal, { method: req.method, headers: { "Content-Type": "application/json" }, body }), { headers })
+      } catch (error) {
+        return Response.json({ error: error instanceof Error ? error.message : "读取失败" }, { status: 502, headers })
+      }
+    },
+  })
+  return { url: `http://127.0.0.1:${server.port}/`, server }
+}

File diff suppressed because it is too large
+ 2 - 0
.opencode/lib/software-review-ui.ts


+ 186 - 0
.opencode/tool/software_review.ts

@@ -0,0 +1,186 @@
+import { tool } from "@opencode-ai/plugin"
+import { loadAgentPaaSConfig, requestJSON } from "../lib/agentpaas"
+import { startSoftwareReviewPanel } from "../lib/software-review-panel"
+import {
+  reviewPlan,
+  reviewEdits,
+  parseReviewProposal,
+  reviewSummary,
+  reviewContext,
+  reviewOntology,
+} from "../lib/software-review-contract"
+
+export default tool({
+  description:
+    "本体候选审阅流程:analyze 分析服务端仓库;open 返回终端 /refactor 面板入口;dashboard/web 在用户要求可视化时打开同任务只读看板,自动同步;操作仍在 CLI;status 查询;ontology 分页读取架构线索及候选的上游影响、下游依赖、未解析关系;context 读取已选候选与文件索引;source 分页读取实际源码;propose 提交具体计划和补丁等待人工确认。evidence 列出真实证据;artifact 分页读取补丁、测试和日志。此工具不能替用户勾选、批准、质量审查或启动执行。",
+  args: {
+    action: tool.schema.enum([
+      "analyze",
+      "status",
+      "open",
+      "web",
+      "dashboard",
+      "context",
+      "source",
+      "ontology",
+      "evidence",
+      "artifact",
+      "propose",
+    ]),
+    review_id: tool.schema.string().optional(),
+    artifact_name: tool.schema.string().optional().describe("artifact: evidence 索引中的文件名"),
+    evidence_token: tool.schema.string().optional().describe("artifact: 当前 evidence.token"),
+    repository: tool.schema.string().optional().describe("相对于该租户服务端工作区的仓库目录;不是客户端本地路径"),
+    project: tool.schema.string().optional(),
+    candidate_id: tool.schema
+      .string()
+      .optional()
+      .describe("ontology 可选:读取该候选已保存的关系证据,按 offset/limit 分页;留意服务端截断提示"),
+    relation_view: tool.schema
+      .enum(["impact", "dependencies", "unresolved"])
+      .optional()
+      .describe("ontology 的候选关系视图:上游影响(默认)、下游依赖、未解析关系;后两项须带 candidate_id"),
+    rules_path: tool.schema
+      .string()
+      .optional()
+      .describe(
+        "analyze 可选:用户提供的仓库相对规则 JSON 路径。规则须引用真实文档原文及源码目标;不可编造规则或默认使用演示规则。",
+      ),
+    revision: tool.schema
+      .number()
+      .int()
+      .nonnegative()
+      .optional()
+      .describe("propose 必填,独立的顶层参数,使用 context 返回的当前版本"),
+    plan: reviewPlan.optional().describe("propose 的结构化计划,必须处理全部已选候选;无法处理时先请用户调整选择"),
+    edits: reviewEdits.optional(),
+    proposal_json: tool.schema
+      .string()
+      .optional()
+      .describe("旧客户端兼容:{plan,edits} JSON,字段与结构化参数相同;优先使用 plan/edits。revision 必须另传。"),
+    path: tool.schema.string().optional().describe("source: context.files 中的真实路径"),
+    start_line: tool.schema.number().int().min(1).optional(),
+    offset: tool.schema.number().int().min(0).optional().describe("status 候选分页起点,默认 0"),
+    limit: tool.schema.number().int().min(1).max(80).optional().describe("status 默认 8 项;source 默认 40 行"),
+  },
+  async execute(args, context) {
+    const base = await loadAgentPaaSConfig()
+    // Optional co-hosted review API for deployments that keep long-running
+    // agent execution separate. Require the same host before reusing its key.
+    const url = process.env.AGENTPAAS_SOFTWARE_REVIEW_URL?.replace(/\/+$/, "") || base.url
+    const target = new URL(url)
+    const original = new URL(base.url)
+    if (
+      target.hostname !== original.hostname ||
+      target.protocol !== original.protocol ||
+      target.username ||
+      target.password ||
+      target.search ||
+      target.hash ||
+      target.pathname !== "/"
+    )
+      throw new Error("审阅 API 必须使用同一 PaaS 主机与协议,不向其他主机转发凭证")
+    const config = { ...base, url }
+    if (args.action === "analyze") {
+      if (!args.repository || !args.project) throw new Error("需要服务端仓库相对目录与项目标识")
+      return JSON.stringify(
+        reviewSummary(
+          await requestJSON(config, "/software-reviews", context.abort, {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify({
+              repository: args.repository,
+              project: args.project,
+              ...(args.rules_path ? { rules_path: args.rules_path } : {}),
+            }),
+          }),
+        ),
+      )
+    }
+    if (!args.review_id || !/^[a-f0-9]{32}$/.test(args.review_id)) throw new Error("需要真实审阅编号")
+    const route = `/software-reviews/${args.review_id}`
+    if (args.action === "open") {
+      const review = await requestJSON(config, route, context.abort)
+      return JSON.stringify({
+        ...reviewSummary(review),
+        interface: "cli",
+        next: "在 OpenCode 输入 /refactor,打开当前审阅,按首页“下一步”继续。四类结果、材料与记录可按需查看;其他操作不是必做步骤。无需打开网页。模型不能代替用户选择、执行确认或质量审查。",
+      })
+    }
+    if (args.action === "web" || args.action === "dashboard") {
+      await requestJSON(config, route, context.abort)
+      const panel = startSoftwareReviewPanel(config, args.review_id, 0, "dashboard")
+      const command =
+        process.platform === "darwin"
+          ? ["open", panel.url]
+          : process.platform === "win32"
+            ? ["cmd", "/c", "start", "", panel.url]
+            : ["xdg-open", panel.url]
+      const processResult = Bun.spawn(command, { stdout: "ignore", stderr: "ignore" })
+      const code = await processResult.exited
+      return JSON.stringify({
+        review_id: args.review_id,
+        url: panel.url,
+        opened: code === 0,
+        next: "看板只读并自动同步该审阅任务。选择候选、确认执行与质量审查继续在 OpenCode /refactor 内完成;不需要重新分析。",
+      })
+    }
+    if (args.action === "evidence") return JSON.stringify(await requestJSON(config, route + "/evidence", context.abort))
+    if (args.action === "artifact") {
+      if (!args.artifact_name || !args.evidence_token) throw new Error("先读取 evidence,使用真实文件名和 token")
+      return JSON.stringify(
+        await requestJSON(
+          config,
+          route +
+            `/evidence-text?name=${encodeURIComponent(args.artifact_name)}&token=${encodeURIComponent(args.evidence_token)}&offset=${args.offset || 0}`,
+          context.abort,
+        ),
+      )
+    }
+    if (args.action === "ontology")
+      return JSON.stringify(
+        reviewOntology(
+          await requestJSON(config, route, context.abort),
+          args.candidate_id,
+          args.offset,
+          args.limit,
+          args.relation_view,
+        ),
+      )
+    if (args.action === "propose") {
+      const proposal = parseReviewProposal(args)
+      const selected = await requestJSON(config, route + "/context?include_sources=false", context.abort)
+      if (selected.revision !== args.revision) throw new Error("revision 已变化;重新读取 context,不重跑分析。")
+      const ids = (selected.selected as { id: string }[]).map((c) => c.id).sort()
+      if (JSON.stringify([...proposal.plan.candidate_ids].sort()) !== JSON.stringify(ids))
+        throw new Error(
+          `提案未发送:必须覆盖用户全部已选候选 ${ids.join(", ")}。不能自行略去缺测试的候选;解释缺口并请用户在终端 /refactor 面板调整选择,再读取 context。`,
+        )
+      return JSON.stringify(
+        reviewSummary(
+          await requestJSON(config, route + "/proposal", context.abort, {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify({ revision: args.revision, plan: proposal.plan, edits: proposal.edits }),
+          }),
+        ),
+      )
+    }
+    if (args.action === "context")
+      return JSON.stringify(
+        reviewContext(await requestJSON(config, route + "/context?include_sources=false", context.abort)),
+      )
+    if (args.action === "source") {
+      if (!args.path) throw new Error("source 需要 context.files 中的 path")
+      return JSON.stringify(
+        await requestJSON(
+          config,
+          route +
+            `/source?path=${encodeURIComponent(args.path)}&start_line=${args.start_line || 1}&limit=${args.limit || 40}`,
+          context.abort,
+        ),
+      )
+    }
+    return JSON.stringify(reviewSummary(await requestJSON(config, route, context.abort), args.offset, args.limit))
+  },
+})

+ 43 - 0
packages/opencode/test/tool/software-dashboard.test.ts

@@ -0,0 +1,43 @@
+import { expect, test } from "bun:test"
+import { startSoftwareReviewPanel } from "../../../../.opencode/lib/software-review-panel"
+import { softwareDashboardHTML } from "../../../../.opencode/lib/software-dashboard-ui"
+
+test("dashboard reads only its bound review, keeps credentials server-side and forbids every mutation", async () => {
+  const calls: string[] = []
+  const upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) {
+    expect(req.headers.get("Authorization")).toBe("Bearer private-test-key")
+    calls.push(new URL(req.url).pathname + new URL(req.url).search)
+    return Response.json({ revision: 2, files: [] })
+  } })
+  const panel = startSoftwareReviewPanel({ url: `http://127.0.0.1:${upstream.port}`, apiKey: "private-test-key" }, "a".repeat(32), 0, "dashboard")
+  try {
+    const response = await fetch(panel.url)
+    expect(response.headers.get("Content-Security-Policy")).toContain("frame-ancestors 'none'")
+    const html = await response.text()
+    expect(html).not.toContain("private-test-key")
+    const token = html.match(/const token="([a-f0-9]+)"/)![1]
+    expect((await fetch(panel.url + "api/evidence")).status).toBe(403)
+    for (const route of ["selection", "execute", "reopen", "quality", "cancel"]) {
+      expect((await fetch(panel.url + "api/" + route, { method: "POST", headers: { "X-Review-Token": token, Origin: panel.url.slice(0,-1) }, body: "{}" })).status).toBe(405)
+    }
+    expect(calls).toEqual([])
+    const headers = { "X-Review-Token": token }
+    expect((await fetch(panel.url + "api/review", { headers })).status).toBe(200)
+    expect((await fetch(panel.url + "api/evidence", { headers })).status).toBe(200)
+    expect((await fetch(panel.url + "api/evidence-text?name=changes.patch&token=abc&offset=0&limit=100", { headers })).status).toBe(200)
+    expect(calls).toEqual([
+      "/api/v1/software-reviews/" + "a".repeat(32),
+      "/api/v1/software-reviews/" + "a".repeat(32) + "/evidence",
+      "/api/v1/software-reviews/" + "a".repeat(32) + "/evidence-text?name=changes.patch&token=abc&offset=0&limit=100",
+    ])
+    expect((await fetch(panel.url + "api/another-review", { headers })).status).toBe(404)
+  } finally { panel.server.stop(true); upstream.stop(true) }
+})
+
+test("dashboard script parses without build dependencies or external content", () => {
+  const html = softwareDashboardHTML("b".repeat(64))
+  expect(() => new Function(html.match(/<script>([\s\S]*)<\/script>/)![1])).not.toThrow()
+  expect(html).not.toContain('src="https://')
+  expect(html).toContain('重构观察台')
+  expect(html).not.toContain('\\u91CD')
+})

+ 388 - 0
packages/opencode/test/tool/software-review.test.ts

@@ -0,0 +1,388 @@
+import { expect, test } from "bun:test"
+import { startSoftwareReviewPanel } from "../../../../.opencode/lib/software-review-panel"
+import { softwareReviewHTML } from "../../../../.opencode/lib/software-review-ui"
+
+test("publication failures direct users to evidence recovery rather than another proposal", async () => {
+  const { reviewSummary } = await import("../../../../.opencode/lib/software-review-contract")
+  const summary = reviewSummary({ phase: "evidence_failed", candidates: [], decisions: {} })
+  expect(summary.recovery).toContain("不重跑测试或补丁")
+  expect(summary.recovery).not.toContain("返回规划")
+})
+
+test("review panel is bound to one review and rejects cross-origin or tokenless mutations", async () => {
+  const calls: { path: string; body: string; authorization: string | null }[] = []
+  const upstream = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(req) {
+      calls.push({
+        path: new URL(req.url).pathname,
+        body: await req.text(),
+        authorization: req.headers.get("Authorization"),
+      })
+      return Response.json({ phase: "awaiting_selection" })
+    },
+  })
+  const panel = startSoftwareReviewPanel(
+    { url: `http://127.0.0.1:${upstream.port}`, apiKey: "test-only-not-a-real-key" },
+    "a".repeat(32),
+  )
+  try {
+    const html = await (await fetch(panel.url)).text()
+    expect(html).not.toContain("test-only-not-a-real-key")
+    const token = html.match(/const token="([a-f0-9]+)"/)![1]
+    expect((await fetch(panel.url + "api/execute", { method: "POST", body: "{}" })).status).toBe(403)
+    expect(
+      (
+        await fetch(panel.url + "api/execute", {
+          method: "POST",
+          headers: { "X-Review-Token": token, Origin: "https://unrelated.invalid" },
+          body: "{}",
+        })
+      ).status,
+    ).toBe(403)
+    expect(calls).toHaveLength(0)
+    const body = JSON.stringify({ revision: 3, version: "b".repeat(64) })
+    const result = await fetch(panel.url + "api/execute", {
+      method: "POST",
+      headers: { "X-Review-Token": token, Origin: panel.url.slice(0, -1) },
+      body,
+    })
+    expect(result.status).toBe(200)
+    expect(calls).toEqual([
+      {
+        path: "/api/v1/software-reviews/" + "a".repeat(32) + "/execute",
+        body,
+        authorization: "Bearer test-only-not-a-real-key",
+      },
+    ])
+    expect((await fetch(panel.url + "api/reopen", { method: "POST", body: "{}" })).status).toBe(403)
+    expect(
+      (
+        await fetch(panel.url + "api/reopen", {
+          method: "POST",
+          headers: { "X-Review-Token": token, Origin: panel.url.slice(0, -1) },
+          body: JSON.stringify({ revision: 7 }),
+        })
+      ).status,
+    ).toBe(200)
+    expect(calls.at(-1)?.path).toBe("/api/v1/software-reviews/" + "a".repeat(32) + "/reopen")
+    expect(
+      (
+        await fetch(panel.url + "api/proposal", {
+          method: "POST",
+          headers: { "X-Review-Token": token, Origin: panel.url.slice(0, -1) },
+          body: "{}",
+        })
+      ).status,
+    ).toBe(404)
+  } finally {
+    panel.server.stop(true)
+    upstream.stop(true)
+  }
+})
+
+test("review UI script parses and presents separate selection and concrete approval", () => {
+  const html = softwareReviewHTML("c".repeat(64))
+  expect(() => new Function(html.match(/<script>([\s\S]*)<\/script>/)![1])).not.toThrow()
+  expect(html).toContain('type="checkbox"')
+  expect(html).toContain("确认当前计划并开始执行")
+  expect(html).toContain("源码依据、关联范围与测试提示")
+  expect(html).toContain("保留失败记录,返回规划")
+  expect(html).toContain("绑定的本体节点")
+})
+
+import { parseReviewProposal, reviewContext, reviewSummary } from "../../../../.opencode/lib/software-review-contract"
+
+const plan = {
+  project: "example",
+  goal: "Separate assignment while preserving behavior",
+  candidate_ids: ["a".repeat(24)],
+  allowed_files: ["Price.java"],
+  focus: ["total"],
+  invariants: ["Preserve signed integer doubling"],
+  verification: { kind: "maven-offline" as const, pom_path: "pom.xml" },
+}
+const edits = [{ path: "Price.java", old: "if ((x = n) > 0)", new: "x = n; if (x > 0)" }]
+
+test("proposal exposes exact structured contract and rejects transcript-shaped guessing", () => {
+  expect(parseReviewProposal({ revision: 2, plan, edits })).toEqual({ plan, edits })
+  expect(parseReviewProposal({ revision: 2, proposal_json: JSON.stringify({ plan, edits }) })).toEqual({ plan, edits })
+  expect(() => parseReviewProposal({ proposal_json: JSON.stringify({ revision: 2, plan, edits }) })).toThrow(
+    "顶层 revision",
+  )
+  expect(() => parseReviewProposal({ revision: 2, plan: { ...plan, focus: "total" }, edits })).toThrow("focus")
+  expect(() =>
+    parseReviewProposal({ revision: 2, plan, edits: [{ path: "Price.java", old_string: "x", new_string: "y" }] }),
+  ).toThrow("old")
+  for (const verification of [
+    "maven-offline",
+    { mode: "maven-offline" },
+    { type: "maven-offline" },
+    { kind: "maven-offline", command: "mvn test" },
+  ]) {
+    expect(() => parseReviewProposal({ revision: 2, plan: { ...plan, verification }, edits })).toThrow("verification")
+  }
+  expect(
+    parseReviewProposal({
+      revision: 2,
+      plan: {
+        ...plan,
+        verification: { kind: "java-main", sources: ["Price.java"], test_sources: ["Check.java"], main_class: "Check" },
+      },
+      edits,
+    }).plan.verification.kind,
+  ).toBe("java-main")
+})
+
+test("planning context keeps server file indexes and previous failure feedback", () => {
+  const state = {
+    revision: 4,
+    selected: [],
+    files: [{ path: "Signal.java", bytes: 3000000 }],
+    previous_attempt: { phase: "verification_failed", evidence_prefix: "history-3/" },
+  }
+  expect(reviewContext(state).files).toEqual(state.files)
+  expect(reviewContext(state)).toHaveProperty("previous_attempt", state.previous_attempt)
+  expect(reviewContext({ files: { "Legacy.java": "one\ntwo" } }).files).toEqual([
+    { path: "Legacy.java", lines: 2, characters: 7 },
+  ])
+})
+
+test("actual tool reads indexed context and a source page without fetching whole sources", async () => {
+  const calls: string[] = []
+  const upstream = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      const url = new URL(req.url)
+      calls.push(req.method + " " + url.pathname + url.search)
+      if (url.pathname.endsWith("/context") && url.searchParams.get("include_sources") === "false")
+        return Response.json({ revision: 4, selected: [], files: [{ path: "Signal.java", bytes: 3000000 }] })
+      if (url.pathname.endsWith("/source"))
+        return Response.json({
+          revision: 4,
+          path: "Signal.java",
+          lines: [{ line: 81, text: "  return x;" }],
+          next_line: 82,
+        })
+      if (url.pathname.endsWith("c".repeat(32)))
+        return Response.json({
+          candidates: [
+            {
+              id: "candidate",
+              ontology_context: {
+                dependency_paths: [{ path: "Policy.java", via: [] }],
+                dependency_paths_total: 1,
+              },
+            },
+          ],
+        })
+      return new Response("Whole sources should not be fetched", { status: 413 })
+    },
+  })
+  try {
+    const toolPath = new URL("../../../../.opencode/tool/software_review.ts", import.meta.url).pathname
+    const program = `import tool from ${JSON.stringify(toolPath)};
+      const context={abort:new AbortController().signal};
+      console.log(await tool.execute({action:"context",review_id:"${"c".repeat(32)}"},context));
+      console.log(await tool.execute({action:"source",review_id:"${"c".repeat(32)}",path:"Signal.java",start_line:81,limit:1},context));
+      console.log(await tool.execute({action:"ontology",review_id:"${"c".repeat(32)}",candidate_id:"candidate",relation_view:"dependencies"},context));`
+    const proc = Bun.spawn([process.execPath, "-e", program], {
+      env: {
+        ...process.env,
+        AGENTPAAS_URL: `http://127.0.0.1:${upstream.port}`,
+        AGENTPAAS_SOFTWARE_REVIEW_URL: `http://127.0.0.1:${upstream.port}`,
+        AGENTPAAS_API_KEY: "test-only-not-real",
+      },
+      stdout: "pipe",
+      stderr: "pipe",
+    })
+    const output = await new Response(proc.stdout).text()
+    expect(await proc.exited).toBe(0)
+    const results = output
+      .trim()
+      .split("\n")
+      .map((line) => JSON.parse(line))
+    expect(results[0].files).toEqual([{ path: "Signal.java", bytes: 3000000 }])
+    expect(results[1].lines).toEqual([{ line: 81, text: "  return x;" }])
+    expect(results[2].view).toBe("dependencies")
+    expect(results[2].items).toEqual([{ path: "Policy.java", via: [] }])
+    expect(calls).toEqual([
+      `GET /api/v1/software-reviews/${"c".repeat(32)}/context?include_sources=false`,
+      `GET /api/v1/software-reviews/${"c".repeat(32)}/source?path=Signal.java&start_line=81&limit=1`,
+      `GET /api/v1/software-reviews/${"c".repeat(32)}`,
+    ])
+  } finally {
+    upstream.stop(true)
+  }
+})
+
+test("status pagination retains all selected IDs even outside the current page", () => {
+  const candidates = Array.from({ length: 38 }, (_, i) => ({ id: String(i), symbol: `method ${i}` }))
+  const state = {
+    revision: 2,
+    phase: "selected",
+    candidates,
+    decisions: { "1": "selected", "30": "selected" },
+    source_inventory: { private: "omit" },
+  }
+  const first = reviewSummary(state)
+  expect(first.selected_ids).toEqual(["1", "30"])
+  expect(first.candidates).toHaveLength(8)
+  expect(first.next_offset).toBe(8)
+  expect(first.candidates_total).toBe(38)
+  expect(reviewSummary(state, 32).next_offset).toBeNull()
+  expect(first).not.toHaveProperty("source_inventory")
+})
+
+test("actual tool refuses an omitted selection before sending any proposal", async () => {
+  const calls: string[] = []
+  const upstream = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      calls.push(req.method + " " + new URL(req.url).pathname)
+      return Response.json({ revision: 2, selected: [{ id: "a".repeat(24) }, { id: "b".repeat(24) }], files: {} })
+    },
+  })
+  try {
+    const toolPath = new URL("../../../../.opencode/tool/software_review.ts", import.meta.url).pathname
+    const program = `import tool from ${JSON.stringify(toolPath)};
+      const args=${JSON.stringify({ action: "propose", review_id: "c".repeat(32), revision: 2, plan, edits })};
+      try { await tool.execute(args,{abort:new AbortController().signal}); process.exit(1); }
+      catch(e) { console.log(e.message); }`
+    const proc = Bun.spawn([process.execPath, "-e", program], {
+      env: {
+        ...process.env,
+        AGENTPAAS_URL: `http://127.0.0.1:${upstream.port}`,
+        AGENTPAAS_SOFTWARE_REVIEW_URL: `http://127.0.0.1:${upstream.port}`,
+        AGENTPAAS_API_KEY: "test-only-not-real",
+      },
+      stdout: "pipe",
+      stderr: "pipe",
+    })
+    const output = await new Response(proc.stdout).text()
+    expect(await proc.exited).toBe(0)
+    expect(output).toContain("必须覆盖用户全部已选候选")
+    expect(output).toContain("请用户在终端 /refactor 面板调整选择")
+    expect(calls).toEqual(["GET /api/v1/software-reviews/" + "c".repeat(32) + "/context"])
+  } finally {
+    upstream.stop(true)
+  }
+})
+
+test("open returns a native CLI entry without launching a browser or creating a panel", async () => {
+  const calls: string[] = []
+  const upstream = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      calls.push(req.method + " " + new URL(req.url).pathname)
+      return Response.json({
+        id: "c".repeat(32),
+        revision: 2,
+        phase: "awaiting_selection",
+        candidates: [],
+        decisions: {},
+      })
+    },
+  })
+  try {
+    const toolPath = new URL("../../../../.opencode/tool/software_review.ts", import.meta.url).pathname
+    const program = `import tool from ${JSON.stringify(toolPath)};
+      Bun.spawn=()=>{throw new Error("browser spawn forbidden")};
+      Bun.serve=()=>{throw new Error("web panel forbidden")};
+      console.log(await tool.execute({action:"open",review_id:"${"c".repeat(32)}"},{abort:new AbortController().signal}));`
+    const proc = Bun.spawn([process.execPath, "-e", program], {
+      env: {
+        ...process.env,
+        AGENTPAAS_URL: `http://127.0.0.1:${upstream.port}`,
+        AGENTPAAS_SOFTWARE_REVIEW_URL: `http://127.0.0.1:${upstream.port}`,
+        AGENTPAAS_API_KEY: "test-only",
+      },
+      stdout: "pipe",
+      stderr: "pipe",
+    })
+    const output = await new Response(proc.stdout).text()
+    expect(await proc.exited).toBe(0)
+    expect(JSON.parse(output).interface).toBe("cli")
+    expect(JSON.parse(output).next).toContain("/refactor")
+    expect(calls).toEqual(["GET /api/v1/software-reviews/" + "c".repeat(32)])
+  } finally {
+    upstream.stop(true)
+  }
+})
+
+import { reviewOntology } from "../../../../.opencode/lib/software-review-contract"
+test("ontology navigation preserves bounds and never upgrades advisory findings to edits", () => {
+  const state = {
+    id: "a".repeat(32),
+    revision: 3,
+    ontology: {
+      rules_status: "not_supplied",
+      architecture_findings_total: 2,
+      architecture_findings: [
+        { kind: "dependency_cycle", executable: false },
+        { kind: "distributed_rule", executable: false },
+      ],
+    },
+    candidates: [
+      { id: "c", ontology_context: { impact_paths_total: 1, impact_paths: [{ path: "Test.java", via: [] }] } },
+    ],
+  }
+  expect(reviewOntology(state, undefined, 0, 1).next_offset).toBe(1)
+  expect(reviewOntology(state, undefined, 1, 1).items).toEqual([{ kind: "distributed_rule", executable: false }])
+  expect(reviewOntology(state, "c").items).toEqual([{ path: "Test.java", via: [] }])
+  expect(() => reviewOntology(state, "unknown")).toThrow("未知候选")
+  expect(reviewOntology({}).status).toBe("not_available_in_this_analysis")
+})
+
+test("all candidate relation views remain paged and context retains navigation counts", () => {
+  const candidate = {
+    id: "c",
+    ontology_context: {
+      impact_paths: [],
+      dependency_paths: Array.from({ length: 5 }, (_, i) => ({ path: `module/Policy${i}.java`, via: [] })),
+      dependency_paths_total: 9,
+      dependency_paths_truncated: true,
+      unresolved_references: Array.from({ length: 4 }, (_, i) => ({
+        path: "Service.java",
+        line: i + 1,
+        target: `External${i}`,
+      })),
+      unresolved_references_total: 4,
+    },
+  }
+  const state = { id: "review", revision: 4, candidates: [candidate] }
+  const first = reviewOntology(state, "c", 0, 2, "dependencies")
+  expect(first.items).toHaveLength(2)
+  expect(first.next_offset).toBe(2)
+  expect(first.total).toBe(9)
+  expect(first.upstream_truncated).toBe(true)
+  expect(reviewOntology(state, "c", 4, 2, "dependencies").next_offset).toBeNull()
+  expect(reviewOntology(state, "c", 0, 2, "unresolved").view).toBe("unresolved")
+  expect(() => reviewOntology(state, undefined, 0, 2, "dependencies")).toThrow("candidate_id")
+  expect(reviewOntology({ candidates: [{ id: "legacy" }] }, "legacy", 0, 5, "dependencies").status).toBe(
+    "not_available_in_this_analysis",
+  )
+  const summary = reviewContext({ selected: [candidate], files: [] })
+  expect(summary).toHaveProperty(
+    "selected.0.ontology_context.dependency_paths",
+    candidate.ontology_context.dependency_paths.slice(0, 3),
+  )
+  expect(summary).toHaveProperty(
+    "selected.0.ontology_context.unresolved_references",
+    candidate.ontology_context.unresolved_references.slice(0, 3),
+  )
+  expect(summary).toHaveProperty("selected.0.ontology_context.dependency_paths_total", 9)
+  expect(summary).toHaveProperty("selected.0.ontology_context.unresolved_references_truncated", true)
+})
+
+test("agent recovery distinguishes a stopped analysis from a failed patch", () => {
+  for (const phase of ["analysis_failed", "cancelled", "interrupted"]) {
+    expect(reviewSummary({ phase, candidates: [] }).recovery).toContain("重新分析")
+    expect(reviewSummary({ phase, candidates: [], next_review: "child" }).recovery).toContain("打开 next_review")
+  }
+  expect(reviewSummary({ phase: "cancelled", candidates: [], proposal: { plan: {} } }).recovery).toContain("返回规划")
+})

+ 32 - 5
packages/tui/src/app.tsx

@@ -1,3 +1,4 @@
+import { productName } from "./brand"
 import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
 import { registerOpencodeSpinner } from "./component/register-spinner"
 import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
@@ -42,6 +43,8 @@ import { PermissionProvider } from "./context/permission"
 import { DialogModel } from "./component/dialog-model"
 import { useConnected } from "./component/use-connected"
 import { DialogMcp } from "./component/dialog-mcp"
+import { DialogSoftwareReviewLauncher } from "./component/dialog-software-review"
+import { latestReviewID } from "./util/software-review"
 import { DialogStatus } from "./component/dialog-status"
 import { DialogDebug } from "./component/dialog-debug"
 import { DialogThemeList } from "./component/dialog-theme-list"
@@ -454,24 +457,24 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
     if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
 
     if (route.data.type === "home") {
-      renderer.setTerminalTitle("OpenCode")
+      renderer.setTerminalTitle(productName)
       return
     }
 
     if (route.data.type === "session") {
       const session = sync.session.get(route.data.sessionID)
       if (!session || isDefaultTitle(session.title)) {
-        renderer.setTerminalTitle("OpenCode")
+        renderer.setTerminalTitle(productName)
         return
       }
 
       const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
-      renderer.setTerminalTitle(`OC | ${title}`)
+      renderer.setTerminalTitle(`${productName} | ${title}`)
       return
     }
 
     if (route.data.type === "plugin") {
-      renderer.setTerminalTitle(`OC | ${route.data.id}`)
+      renderer.setTerminalTitle(`${productName} | ${route.data.id}`)
     }
   })
 
@@ -683,6 +686,30 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
           dialog.replace(() => <DialogAgent />)
         },
       },
+      {
+        name: "software.review",
+        title: "仓库重构:选择候选、审阅补丁与确认执行",
+        category: "PaaS",
+        slashName: "refactor",
+        run: () => {
+          const session = route.data.type === "session" ? route.data.sessionID : ""
+          const parts = (sync.data.message[session] || []).flatMap(m => sync.data.part[m.id] || [])
+          dialog.replace(() => (
+            <DialogSoftwareReviewLauncher
+              initialID={latestReviewID(parts)}
+              downloadDirectory={project.instance.directory() || undefined}
+              onDraft={(text) => {
+                const current = promptRef.current
+                if (!current) throw new Error("当前没有可用输入框,请手动复制规划请求。")
+                current.set({
+                  ...current.current,
+                  input: current.current.input ? current.current.input + "\n\n" + text : text,
+                })
+              }}
+            />
+          ))
+        },
+      },
       {
         name: "mcp.list",
         title: "Toggle MCPs",
@@ -1070,7 +1097,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
     await DialogAlert.show(
       dialog,
       "Update Complete",
-      `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
+      `Successfully updated to ${productName} v${result.data.version}. Please restart the application.`,
     )
 
     void exit()

+ 10 - 0
packages/tui/src/brand.ts

@@ -0,0 +1,10 @@
+/** User-facing product identity; protocol and configuration identifiers stay stable. */
+export const productName = "LambdaAgentPaaS"
+export const productTagline = "企业智能体工作台 · 理解资产 · 重构系统 · 调用能力"
+
+export const productTips = [
+  "先说明业务目标,再让智能体查找依据并形成执行方案",
+  "任务已受理后,查询原任务进度,避免重复提交",
+  "先检查变更与测试证据,再确认是否采用重构方案",
+  "通过 PaaS 调用已配置的智能体、Skill 和 MCP 能力",
+]

+ 1186 - 0
packages/tui/src/component/dialog-software-review.tsx

@@ -0,0 +1,1186 @@
+import { createSignal, createEffect, onCleanup, onMount, Show } from "solid-js"
+import type { ScrollBoxRenderable } from "@opentui/core"
+import { useTerminalDimensions } from "@opentui/solid"
+import { reviewDraftKey, saveReviewDraft, takeReviewDraft, selectionChanged } from "../util/software-review-draft"
+import { reviewNavigation } from "../util/software-review-navigation"
+import { DialogSelect } from "../ui/dialog-select"
+import { DialogPrompt } from "../ui/dialog-prompt"
+import { useDialog } from "../ui/dialog"
+import { useTheme } from "../context/theme"
+import { useBindings } from "../keymap"
+import {
+  candidateImpact,
+  reviewHistory,
+  reviewReport,
+  reportSections,
+  type ReportSection,
+} from "../util/software-review-report"
+import {
+  reviewConnection,
+  reviewClient,
+  proposalText,
+  saveReviewEvidence,
+  evidenceText,
+  listReviews,
+  type Evidence,
+  type Review,
+  type Decision,
+  type ReviewConnection,
+} from "../util/software-review"
+const labels: Record<string, string> = {
+  analyzing: "分析中",
+  cancelled: "已取消",
+  interrupted: "执行中断,需重新规划",
+  analysis_failed: "分析失败",
+  awaiting_selection: "等待选择",
+  selected: "等待生成计划",
+  awaiting_confirmation: "等待确认",
+  running: "执行中",
+  blocked: "执行阻塞",
+  execution_failed: "执行失败",
+  evidence_failed: "执行结果已保存,证据整理受阻",
+  verification_failed: "修改后验证失败",
+  awaiting_review: "验证通过,待质量审查",
+  changes_requested: "审查退回,待修改",
+  accepted_for_delivery: "人工审查通过,待交付",
+}
+export function DialogSoftwareReviewLauncher(props: {
+  initialID?: string
+  downloadDirectory?: string
+  onDraft?: (text: string) => void
+}) {
+  const dialog = useDialog()
+  const [error, setError] = createSignal("")
+  return (
+    <DialogPrompt
+      description={() => <text>{error()}</text>}
+      title="仓库重构 · 打开审阅"
+      value={props.initialID}
+      placeholder="审阅编号;输入 列表 查看历史任务"
+      onConfirm={(value) => {
+        if (value.trim() === "列表")
+          dialog.replace(() => (
+            <DialogSoftwareReviewList downloadDirectory={props.downloadDirectory} onDraft={props.onDraft} />
+          ))
+        else if (/^[a-f0-9]{32}$/.test(value.trim()))
+          dialog.replace(() => (
+            <DialogSoftwareReview
+              reviewID={value.trim()}
+              downloadDirectory={props.downloadDirectory}
+              onDraft={props.onDraft}
+            />
+          ))
+        else setError("请填写本次分析返回的 32 位审阅编号。")
+      }}
+    />
+  )
+}
+export function DialogSoftwareReview(props: {
+  reviewID: string
+  connection?: ReviewConnection
+  downloadDirectory?: string
+  onDraft?: (text: string) => void
+}) {
+  const dialog = useDialog(),
+    { theme } = useTheme(),
+    dimensions = useTerminalDimensions()
+  let scroll: ScrollBoxRenderable | undefined
+  const abort = new AbortController()
+  let progressTimer: ReturnType<typeof setInterval> | undefined
+  let polling = false
+  const [progressNotice, setProgressNotice] = createSignal("")
+  const [draftKey, setDraftKey] = createSignal("")
+  onCleanup(() => {
+    if (dirty() && state() && draftKey())
+      saveReviewDraft(draftKey(), { revision: state()!.revision, scope: scope(), decisions: decisions() })
+    if (progressTimer) clearInterval(progressTimer)
+    abort.abort()
+  })
+  const [state, setState] = createSignal<Review>()
+  const [needsSync, setNeedsSync] = createSignal(false)
+  const [scope, setScope] = createSignal<"methods" | "structure">("methods")
+  const [decisions, setDecisions] = createSignal<Record<string, Decision>>({})
+  const [dirty, setDirty] = createSignal(false),
+    [busy, setBusy] = createSignal(false)
+  const [error, setError] = createSignal(""),
+    [notice, setNotice] = createSignal("")
+  const [mode, setMode] = createSignal<
+    | "menu"
+    | "sync"
+    | "reports"
+    | "materials"
+    | "more"
+    | "planning"
+    | "candidates"
+    | "details"
+    | "proposal"
+    | "execute"
+    | "result"
+    | "ontology"
+    | "evidence"
+    | "artifact"
+    | "quality"
+    | "quality-summary"
+    | "quality-submit"
+    | "accept"
+    | "revise"
+    | "history"
+    | "operation"
+  >("menu")
+  createEffect(() => {
+    if (mode() !== "execute") dialog.setSize("xlarge")
+  })
+  const [verdict, setVerdict] = createSignal<"accept" | "revise">("accept")
+  const [reviewField, setReviewField] = createSignal(0)
+  const [reviewNotes, setReviewNotes] = createSignal<Record<string, string>>({})
+  const [reviewSeal, setReviewSeal] = createSignal<{ revision: number; version: string; token: string }>()
+  const [operation, setOperation] = createSignal<"cancel" | "recover" | "next" | "restart">("cancel")
+  const [evidence, setEvidence] = createSignal<Evidence>()
+  const [section, setSection] = createSignal<ReportSection>(0)
+  const [raw, setRaw] = createSignal(false)
+  const [comparison, setComparison] = createSignal<unknown>()
+  const [reportWarning, setReportWarning] = createSignal("")
+  const reportMode = () => ["ontology", "result", "quality"].includes(mode())
+  const [artifact, setArtifact] = createSignal("")
+  const [candidate, setCandidate] = createSignal("")
+  const [viewed, setViewed] = createSignal("")
+  const [confirmation, setConfirmation] = createSignal<{ revision: number; version?: string }>()
+  const [client, setClient] = createSignal<ReturnType<typeof reviewClient>>()
+  const selectable = () => ["awaiting_selection", "selected", "awaiting_confirmation"].includes(state()?.phase || "")
+  const failed = () =>
+    ["blocked", "execution_failed", "verification_failed", "changes_requested", "cancelled", "interrupted"].includes(
+      state()?.phase || "",
+    )
+  async function perform(action: () => Promise<Review>, message = "", nextMode: "menu" | "planning" = "menu") {
+    if (busy()) return
+    setBusy(true)
+    setError("")
+    setNotice("")
+    try {
+      const value = await action()
+      setState(value)
+      setNeedsSync(false)
+      setEvidence(undefined)
+      setComparison(undefined)
+      setReportWarning("")
+      setRaw(false)
+      setScope(value.edit_scope || "methods")
+      setDecisions({ ...value.decisions })
+      setDirty(false)
+      setNotice(
+        message && value.phase === "awaiting_selection"
+          ? "处理结果已保存。本批未选中修改项,可结束查看或调整选择。"
+          : message,
+      )
+      setMode(nextMode === "planning" && value.phase === "selected" ? "planning" : "menu")
+      setViewed("")
+      setConfirmation(undefined)
+    } catch (e) {
+      setNeedsSync(true)
+      setError(e instanceof Error ? e.message : String(e))
+      setViewed("")
+      setConfirmation(undefined)
+      setMode("menu")
+    } finally {
+      setBusy(false)
+    }
+  }
+  async function connect() {
+    if (busy()) return
+    setError("")
+    setNotice("")
+    setBusy(true)
+    try {
+      const connection = props.connection || (await reviewConnection())
+      setDraftKey(reviewDraftKey(connection, props.reviewID))
+      const value = reviewClient(connection, props.reviewID, abort.signal)
+      if (abort.signal.aborted) return
+      setClient(value)
+      setBusy(false)
+      await perform(value.load)
+      if (!needsSync() && state()) {
+        const saved = takeReviewDraft(draftKey(), state()!)
+        if (saved?.status === "restored") {
+          setScope(saved.draft.scope)
+          setDecisions(saved.draft.decisions)
+          setDirty(selectionChanged(state()!, saved.draft.scope, saved.draft.decisions))
+          setNotice("已恢复本客户端未保存的选择;请核对后保存,不会自动提交。")
+        }
+        if (saved?.status === "outdated")
+          setNotice(`旧草稿基于版本 ${saved.revision},当前状态已变化,未应用旧选择。请按当前候选重新选择。`)
+      }
+    } catch (e) {
+      setNeedsSync(true)
+      setError(String(e))
+      setMode("menu")
+    } finally {
+      setBusy(false)
+    }
+  }
+  async function refreshProgress() {
+    const before = state()
+    if (
+      !before ||
+      !client() ||
+      polling ||
+      busy() ||
+      dirty() ||
+      needsSync() ||
+      mode() !== "menu" ||
+      !["analyzing", "running"].includes(before.phase)
+    )
+      return
+    polling = true
+    try {
+      const next = await client()!.load()
+      // A foreground operation owns its result. Background reads never replace
+      // a newer state, an edit, or a page the user has since opened.
+      if (abort.signal.aborted || state() !== before || busy() || dirty() || needsSync() || mode() !== "menu") return
+      setProgressNotice("")
+      if (next.revision === before.revision && next.phase === before.phase) return
+      setState(next)
+      setDecisions({ ...next.decisions })
+      setScope(next.edit_scope || "methods")
+      setEvidence(undefined)
+      setComparison(undefined)
+      setViewed("")
+      setConfirmation(undefined)
+    } catch {
+      if (!abort.signal.aborted && state() === before) setProgressNotice("进度暂未更新,稍后自动重连;原任务继续保留。")
+    } finally {
+      polling = false
+    }
+  }
+  onMount(() => {
+    dialog.setSize("xlarge")
+    void connect()
+    progressTimer = setInterval(() => void refreshProgress(), 2000)
+  })
+  function choose(value: Decision) {
+    if (!selectable() || busy()) return
+    setDecisions({ ...decisions(), [candidate()]: value })
+    setDirty(selectionChanged(state()!, scope(), decisions()))
+    setViewed("")
+    setMode("candidates")
+  }
+  function prepareExecution() {
+    const s = state()
+    if (
+      !s?.proposal ||
+      dirty() ||
+      s.phase !== "awaiting_confirmation" ||
+      s.proposal.readiness?.status === "blocked" ||
+      viewed() !== s.proposal.version
+    )
+      return
+    setConfirmation({ revision: s.revision, version: s.proposal.version })
+    setMode("execute")
+  }
+  useBindings(() => ({
+    enabled:
+      [
+        "proposal",
+        "details",
+        "result",
+        "ontology",
+        "artifact",
+        "quality",
+        "history",
+        "planning",
+        "quality-summary",
+      ].includes(mode()) && !busy(),
+    bindings: [
+      {
+        key: "p",
+        desc: "把规划请求带回输入框",
+        group: "Review",
+        cmd: () => {
+          if (mode() !== "planning" || !props.onDraft) return
+          try {
+            props.onDraft(planningPrompt())
+            dialog.clear()
+          } catch (error) {
+            setError(String(error))
+          }
+        },
+      },
+      ...([0, 1, 2, 3] as const).map((index) => ({
+        key: String(index + 1),
+        desc: reportSections[index],
+        group: "Review",
+        cmd: () => {
+          if (mode() === "quality-summary" && index < fields().length) {
+            setReviewField(index)
+            setMode(verdict())
+            return
+          }
+          if (!reportMode()) return
+          setSection(index)
+          setRaw(false)
+          scroll?.scrollTo(0)
+          if (index !== 0 && !evidence()) void loadEvidence(mode() === "quality" ? "quality" : "result")
+        },
+      })),
+      {
+        key: "j",
+        desc: "切换原始记录",
+        group: "Review",
+        cmd: () => {
+          if (reportMode() || mode() === "history" || mode() === "details") {
+            setRaw(!raw())
+            scroll?.scrollTo(0)
+          }
+        },
+      },
+      {
+        key: "e",
+        desc: "查看证据文件",
+        group: "Review",
+        cmd: () => {
+          if (reportMode()) void loadEvidence("evidence")
+        },
+      },
+      {
+        key: "q",
+        desc: "进入质量审查",
+        group: "Review",
+        cmd: () => {
+          if (reportMode() && state()?.phase === "awaiting_review") void loadEvidence("quality")
+        },
+      },
+      {
+        key: "a",
+        desc: "提交审查通过意见",
+        group: "Review",
+        cmd: () => {
+          if (
+            mode() === "quality" &&
+            state()?.phase === "awaiting_review" &&
+            evidence()?.revision === state()?.revision
+          )
+            startQuality("accept")
+        },
+      },
+      {
+        key: "r",
+        desc: "退回修改",
+        group: "Review",
+        cmd: () => {
+          if (
+            mode() === "quality" &&
+            state()?.phase === "awaiting_review" &&
+            evidence()?.revision === state()?.revision
+          )
+            startQuality("revise")
+        },
+      },
+      { key: "down", desc: "向下滚动", group: "Review", cmd: () => scroll?.scrollBy(1) },
+      { key: "up", desc: "向上滚动", group: "Review", cmd: () => scroll?.scrollBy(-1) },
+      { key: "pagedown", desc: "下一页", group: "Review", cmd: () => scroll?.scrollBy(Math.max(1, scroll.height - 2)) },
+      { key: "pageup", desc: "上一页", group: "Review", cmd: () => scroll?.scrollBy(-Math.max(1, scroll.height - 2)) },
+      { key: "home", desc: "开头", group: "Review", cmd: () => scroll?.scrollTo(0) },
+      { key: "end", desc: "末尾", group: "Review", cmd: () => scroll?.scrollTo(scroll.scrollHeight) },
+      {
+        key: "b",
+        desc: "返回重构菜单",
+        group: "Review",
+        cmd: () =>
+          setMode(
+            mode() === "quality-summary"
+              ? "quality"
+              : mode() === "details"
+                ? "candidates"
+                : mode() === "artifact"
+                  ? "evidence"
+                  : "menu",
+          ),
+      },
+      {
+        key: "c",
+        desc: "确认当前提案",
+        group: "Review",
+        cmd: () => {
+          if (mode() === "proposal") prepareExecution()
+          if (mode() === "quality-summary") setMode("quality-submit")
+        },
+      },
+      {
+        key: "s",
+        desc: "选择候选",
+        group: "Review",
+        cmd: () => {
+          if (mode() === "details") choose("selected")
+        },
+      },
+      {
+        key: "d",
+        desc: "暂缓候选",
+        group: "Review",
+        cmd: () => {
+          if (mode() === "details") choose("deferred")
+        },
+      },
+      {
+        key: "k",
+        desc: "保留现状",
+        group: "Review",
+        cmd: () => {
+          if (mode() === "details") choose("retained")
+        },
+      },
+    ],
+  }))
+  const actions = () => [
+    ...reportSections.map((title, index) => ({
+      value: `report-${index}`,
+      title: `${index + 1} · ${title}`,
+      description: [
+        "候选源码、关联依据、业务约束",
+        "计划与实际变化、新增和消失的线索",
+        "基线与改后检查、失败和跳过项",
+        "未解决线索、缺失证据与下一步",
+      ][index],
+      disabled: !state(),
+    })),
+    {
+      value: "next",
+      title: "从审定副本开始下一批",
+      disabled: state()?.phase !== "accepted_for_delivery" || !!state()?.next_review,
+      description: "保留本批,创建新基线并重新分析;不改原仓库",
+    },
+    { value: "child", title: "打开已创建的后续任务", disabled: !state()?.next_review },
+    {
+      value: "cancel",
+      title: "请求取消当前任务",
+      disabled: !["analyzing", "running"].includes(state()?.phase || "") || state()?.cancel_requested,
+      description: "当前检查可能继续,结束后停止后续步骤",
+    },
+    {
+      value: "recover",
+      title: "检查中断并恢复操作",
+      disabled: !["analyzing", "running"].includes(state()?.phase || ""),
+      description: "仅无活动工作进程时转为中断状态,不自动执行",
+    },
+    {
+      value: "restart",
+      title: "保留失败记录,重新分析",
+      disabled:
+        !!state()?.proposal ||
+        !!state()?.next_review ||
+        !["analysis_failed", "cancelled", "interrupted"].includes(state()?.phase || ""),
+      description: "对当前源码创建新的分析任务",
+    },
+    {
+      value: "refresh",
+      title: "刷新原任务状态",
+      disabled: dirty(),
+      description: dirty() ? "请先保存或放弃本地选择" : "只查询,不重跑",
+    },
+    {
+      value: "candidates",
+      title: `候选清单(${state()?.candidates.length || 0} 项)`,
+      description: "查看依据,选择 / 暂缓 / 保留现状",
+      disabled: !state(),
+    },
+    {
+      value: "proposal",
+      title: "查看完整计划与补丁",
+      disabled: !state()?.proposal,
+      description: state()?.phase === "awaiting_confirmation" ? "审阅后按 c 进入执行确认" : "查看本批计划与补丁记录",
+    },
+    {
+      value: "quality",
+      title: "质量审查",
+      disabled: state()?.phase !== "awaiting_review",
+      description: "查看证据后,人工通过或退回;不自动合并",
+    },
+    {
+      value: "evidence",
+      title: "证据文件与下载",
+      disabled: !state(),
+      description: "测试明细、日志、本体变化、正反向补丁",
+    },
+    { value: "history", title: "操作与审查历史", disabled: !state(), description: "历次选择、提案、审批与退回记录" },
+    {
+      value: "recheck",
+      title: "重新检查执行环境",
+      disabled: dirty() || state()?.phase !== "awaiting_confirmation",
+      description: "重新检测,更新提案版本;随后重新审阅确认",
+    },
+    {
+      value: "reopen",
+      title: "保留失败记录,返回规划",
+      disabled: !failed() || !state()?.proposal,
+      description: "撤销旧审批,不执行任务",
+    },
+  ]
+  const navigation = () => state() && reviewNavigation(state()!)
+  const menu = () => {
+    if (needsSync() || !state())
+      return [
+        {
+          value: "sync",
+          title: "同步原任务状态",
+          description: dirty() ? "先确认如何处理未保存选择;不重跑、不重发审批" : "重新连接,只读取上次操作的实际结果",
+        },
+      ]
+    const next = navigation()!
+    return [
+      { ...next, title: "下一步 · " + next.title, disabled: dirty() && next.value !== "candidates" },
+      {
+        value: "candidates",
+        title: "继续处理未保存的选择",
+        disabled: !dirty() || next.value === "candidates",
+        description: "先保存或撤销,才能读取其他材料",
+      },
+      {
+        value: "reports",
+        title: "查看四类结果",
+        description: "影响路径 / 前后变化 / 测试结果 / 剩余问题",
+        disabled: dirty(),
+      },
+      {
+        value: "reopen",
+        title: "原因已处理,返回规划",
+        disabled: !failed() || !state()?.proposal || dirty(),
+        description: "保留失败记录;新计划仍需确认",
+      },
+      { value: "materials", title: "材料与记录", description: "候选清单、补丁、证据文件、操作历史", disabled: dirty() },
+      { value: "more", title: "其他操作(可选)", description: "刷新、环境检查、取消、后续批次", disabled: dirty() },
+    ]
+  }
+  const submenu = () => [
+    { value: "menu", title: "返回当前步骤" },
+    ...actions().filter((item) => {
+      if (mode() === "reports") return item.value.startsWith("report-")
+      if (mode() === "materials") return ["candidates", "proposal", "evidence", "history"].includes(item.value)
+      return ["next", "child", "cancel", "recover", "restart", "refresh", "recheck"].includes(item.value)
+    }),
+  ]
+  function startQuality(target: "accept" | "revise") {
+    if (
+      busy() ||
+      state()?.phase !== "awaiting_review" ||
+      !state()?.proposal ||
+      evidence()?.revision !== state()?.revision
+    )
+      return
+    const saved = reviewSeal()
+    const resume =
+      verdict() === target &&
+      saved?.revision === state()!.revision &&
+      saved?.version === state()!.proposal!.version &&
+      saved?.token === evidence()!.token
+    setReviewSeal({ revision: state()!.revision, version: state()!.proposal!.version, token: evidence()!.token })
+    setVerdict(target)
+    if (!resume) setReviewNotes({})
+    const next = fields().findIndex((field) => !reviewNotes()[field.key])
+    setReviewField(Math.max(0, next))
+    setError("")
+    setMode(next < 0 ? "quality-summary" : target)
+  }
+  const fields = () =>
+    verdict() === "accept"
+      ? [
+          {
+            key: "benefits",
+            title: "1/3 · 这次重构改善了什么?",
+            hint: "用一句话描述你实际确认的收益;不确定时返回查看前后变化。",
+          },
+          {
+            key: "invariants",
+            title: "2/3 · 哪些测试支持行为保持?",
+            hint: "填写实际测试依据及覆盖范围,不把测试通过等同于所有行为等价。",
+          },
+          {
+            key: "risks",
+            title: "3/3 · 还有哪些风险或未验证项?",
+            hint: "填写剩余风险;依据不足时应取消通过,改为退回修改。",
+          },
+        ]
+      : [{ key: "reason", title: "退回理由 · 哪些地方需要修改?", hint: "直接填写具体问题与修改要求,无需特殊格式。" }]
+  function selectMenu(value: string) {
+    if (value === "recover-evidence" && state()?.phase === "evidence_failed") {
+      void perform(() => client()!.recoverEvidence(state()!.revision), "证据已重新核验,未重跑补丁或测试。")
+      return
+    }
+    if (busy()) return
+    setRaw(false)
+    setError("")
+    if (value === "sync") {
+      if (dirty()) setMode("sync")
+      else void connect()
+      return
+    }
+    if (["menu", "reports", "materials", "more", "planning"].includes(value))
+      setMode(value as "menu" | "reports" | "materials" | "more" | "planning")
+    if (value === "quality") setSection(0)
+    if (/^report-[0-3]$/.test(value)) {
+      setSection(Number(value.slice(-1)) as ReportSection)
+      if (value === "report-0") setMode("ontology")
+      else void loadEvidence("result")
+    }
+    if (value === "child" && state()?.next_review)
+      dialog.replace(() => (
+        <DialogSoftwareReview
+          reviewID={state()!.next_review!}
+          connection={props.connection}
+          downloadDirectory={props.downloadDirectory}
+          onDraft={props.onDraft}
+        />
+      ))
+    if (["cancel", "recover", "next", "restart"].includes(value)) {
+      setOperation(value as "cancel" | "recover" | "next" | "restart")
+      setConfirmation({ revision: state()!.revision })
+      setMode("operation")
+    }
+    if (value === "recheck" && !dirty() && state()?.phase === "awaiting_confirmation")
+      void perform(() => client()!.recheck(state()!.revision), "环境预检已更新,请重新查看计划。")
+    if (value === "history") setMode("history")
+    if (value === "evidence" || value === "quality") void loadEvidence(value)
+    if (value === "refresh" && !dirty()) void perform(() => client()!.load())
+    if (value === "candidates") setMode("candidates")
+    if (value === "proposal" && state()?.proposal) {
+      setViewed(state()!.proposal!.version)
+      setMode("proposal")
+    }
+    if (value === "reopen" && failed()) {
+      void perform(() => client()!.reopen(state()!.revision), "已保留失败记录,返回规划。", "planning")
+    }
+  }
+  async function loadEvidence(target: "evidence" | "quality" | "result") {
+    if (dirty()) {
+      setError("请先保存或放弃本地选择")
+      return
+    }
+    setBusy(true)
+    setError("")
+    setEvidence(undefined)
+    setComparison(undefined)
+    setReportWarning("")
+    setRaw(false)
+    try {
+      const current = await client()!.load()
+      const index = await client()!.evidence()
+      if (current.revision !== index.revision) throw new Error("任务版本已更新,请重新打开证据")
+      setState(current)
+      setEvidence(index)
+      setScope(current.edit_scope || "methods")
+      setDecisions({ ...current.decisions })
+      const generation = (current.execution as { result?: { generation?: number } } | null)?.result?.generation
+      const name = typeof generation === "number" ? `after-${generation}/comparison.json` : ""
+      const item = index.files.find((item) => item.name === name)
+      if (item && target !== "evidence") {
+        if (item.bytes > 2 * 1024 * 1024) setReportWarning("本体对照超过展示限额,请下载完整文件检查")
+        else {
+          try {
+            setComparison(JSON.parse(new TextDecoder().decode(await client()!.artifact(index, name))))
+          } catch (e) {
+            setReportWarning("本体对照读取失败:" + String(e))
+          }
+        }
+      }
+      setMode(target)
+    } catch (e) {
+      setReportWarning(String(e))
+      setError(String(e))
+      if (target !== "evidence") setMode(target)
+    } finally {
+      setBusy(false)
+    }
+  }
+  async function readArtifact(name: string) {
+    setBusy(true)
+    setError("")
+    try {
+      const item = evidence()!.files.find((item) => item.name === name)!
+      if (item.bytes > 2 * 1024 * 1024) throw new Error("文件超过 2 MiB,请下载后检查完整内容")
+      setArtifact(evidenceText(name, await client()!.artifact(evidence()!, name)))
+      setMode("artifact")
+    } catch (e) {
+      setError(String(e))
+    } finally {
+      setBusy(false)
+    }
+  }
+  async function downloadEvidence() {
+    setBusy(true)
+    setError("")
+    try {
+      setNotice("完整证据已下载到:" + (await saveReviewEvidence(client()!, evidence()!, props.downloadDirectory)))
+      setMode("menu")
+    } catch (e) {
+      setError("下载未完成:" + String(e))
+    } finally {
+      setBusy(false)
+    }
+  }
+  const options = () => [
+    {
+      value: "scope",
+      title: scope() === "methods" ? "范围:方法内修改(切换为结构重构)" : "范围:结构重构(切换为方法内修改)",
+      disabled: !selectable(),
+      description: "结构模式可改已选生产文件整体并新增生产类,测试和构建配置仍冻结;保存后生效",
+    },
+    {
+      value: "save",
+      title: "保存选择,交给智能体规划",
+      disabled: !dirty() || !selectable(),
+      description: "保存后直接进入规划指令,按 p 带回输入框",
+    },
+    { value: "back", title: "返回菜单", description: "未保存选择仅在当前客户端运行期间保留" },
+    { value: "discard", title: "撤销本地调整", disabled: !dirty() },
+    ...(state()?.candidates || []).map((c) => ({
+      value: c.id,
+      title: `${{ selected: "[✓]", deferred: "[ ]", retained: "[保留]" }[decisions()[c.id] || "deferred"]} ${c.symbol}`,
+      description: `${c.path}:${c.method_range.start} · 回车查看源码依据`,
+    })),
+  ]
+  const detail = () => {
+    const c = state()?.candidates.find((c) => c.id === candidate())
+    if (!c) return "候选不存在"
+    if (raw()) return JSON.stringify(c, null, 2)
+    return [
+      c.symbol,
+      `${c.path}:${c.method_range.start}–${c.method_range.end}`,
+      c.suggestion,
+      "源码依据",
+      c.source_excerpt.map((l) => `${l.line}: ${l.text}`).join("\n"),
+      c.excerpt_truncated ? "仅展示分析摘录;完整源码请在对话中请求 source 分页读取。" : "",
+      candidateImpact(c),
+    ]
+      .filter(Boolean)
+      .join("\n\n")
+  }
+  const planningPrompt = () =>
+    `请读取审阅任务 ${props.reviewID} 的最新 context,基于我已保存的候选选择和修改范围生成重构提案。若有 previous_attempt,先读取失败证据与退回意见,说明本次怎样修正。按 reading_guide 分页读取所选方法、上游影响、下游依赖契约、测试和构建文件;必要时用 ontology 的 relation_view 分别查看 impact、dependencies、unresolved。用“改动点 → 关系证据 → 保持条件 → 验证依据”解释方案,说明收益和剩余缺口。只生成计划,不执行。`
+  const body = () => {
+    if (mode() === "quality-summary")
+      return [
+        "核对意见,确认提交 · 尚未提交",
+        ...fields().map((field) => `${field.title}\n${reviewNotes()[field.key]}`),
+        "以上是人工判断,不能替代未完成的验证;提交后不自动合并或部署。",
+      ].join("\n\n")
+    if (mode() === "artifact") return artifact()
+    if (mode() === "details") return detail()
+    if (!state()) return "正在读取任务…"
+    if (mode() === "planning")
+      return [
+        "下一步回到对话,让智能体为已保存的选择生成计划。",
+        props.onDraft ? "按 p 将以下请求带回输入框;保留原草稿,由你检查后发送。" : "按 Esc 关闭面板,复制并发送:",
+        planningPrompt(),
+        "智能体生成提案后,重新打开这个任务,首页会显示“查看完整计划与补丁”。",
+        "当前任务:" + props.reviewID,
+      ].join("\n\n")
+    if (mode() === "proposal") return proposalText(state()!)
+    if (mode() === "history") return raw() ? JSON.stringify(state()?.audit || [], null, 2) : reviewHistory(state()!)
+    if (raw())
+      return JSON.stringify(
+        {
+          ontology: state()?.ontology,
+          execution: state()?.execution,
+          comparison: comparison(),
+          checks: evidence()?.checks,
+          quality_review: state()?.quality_review,
+        },
+        null,
+        2,
+      )
+    return (
+      (mode() === "quality" && state()?.phase === "awaiting_review"
+        ? "人工质量审查 · a 填写通过意见 · r 填写退回理由\n请逐类检查依据;质量结论不会自动合并或部署。\n\n"
+        : "") +
+      reviewReport(state()!, section(), { evidence: evidence(), comparison: comparison(), warning: reportWarning() })
+    )
+  }
+  return (
+    <box flexDirection="column" paddingLeft={2} paddingRight={2} gap={1}>
+      <text fg={theme.text}>
+        {state()?.project || "仓库审阅"} · {labels[state()?.phase || ""] || state()?.phase || "连接中"}
+      </text>
+      <Show when={!["accept", "revise", "quality-submit", "execute", "operation", "sync"].includes(mode())}>
+        <text fg={theme.textMuted}>
+          任务 {props.reviewID.slice(0, 8)} · 版本 {state()?.revision ?? "—"} · 已分析{" "}
+          {state()?.coverage.analyzed_files ?? "—"}/
+          {state()?.analysis_scope?.total_files ?? state()?.coverage.total_files ?? state()?.coverage.java_files ?? "—"}{" "}
+          · 未保存选择 {dirty() ? "有" : "无"}
+        </text>
+      </Show>
+      <Show when={mode() === "menu" && dimensions().height >= 40}>
+        <text fg={theme.textMuted}>流程:分析 → 选择 → 计划 → 验证 → 审查 → 交付</text>
+      </Show>
+      <Show when={state()?.cancel_requested && ["analyzing", "running"].includes(state()?.phase || "")}>
+        <text>已请求取消,等待当前检查结束;进度自动更新。</text>
+      </Show>
+      <Show when={state() && mode() === "menu" && !needsSync()}>
+        <text fg={theme.primary}>
+          当前:{navigation()?.stage}。
+          {dimensions().height >= 40 ? navigation()?.hint : "按首页第一项继续,其他入口按需查看。"}
+        </text>
+      </Show>
+      <Show when={needsSync() && mode() === "menu"}>
+        <text fg={theme.primary}>上次请求结果尚未核对。先同步原任务,确认后再继续。</text>
+      </Show>
+      <Show when={error()}>
+        <text fg={theme.error}>{error()}</text>
+      </Show>
+      <Show when={progressNotice() && mode() === "menu"}>
+        <text fg={theme.textMuted}>{progressNotice()}</text>
+      </Show>
+      <Show when={notice()}>
+        <text fg={theme.success}>{notice()}</text>
+      </Show>
+      <Show when={busy()}>
+        <text fg={theme.textMuted}>请求处理中,请勿重复操作…</text>
+      </Show>
+      <Show when={mode() === "menu"}>
+        <Show keyed when={`${needsSync()}-${state()?.revision}-${state()?.phase}`}>
+          {(_version) => (
+            <DialogSelect
+              title="仓库重构 / CLI · ↑↓ 选择,Enter 打开"
+              placeholder="筛选操作(也可直接按上下键)"
+              locked={busy()}
+              options={menu()}
+              onSelect={(o) => selectMenu(o.value)}
+            />
+          )}
+        </Show>
+      </Show>
+      <Show when={["reports", "materials", "more"].includes(mode())}>
+        <DialogSelect
+          title={
+            mode() === "reports"
+              ? "查看四类结果 · 只读"
+              : mode() === "materials"
+                ? "材料与记录 · 按需查看"
+                : "其他操作 · 无需逐项完成"
+          }
+          placeholder="↑↓ 选择,Enter 打开;第一项返回"
+          locked={busy()}
+          options={submenu()}
+          onSelect={(o) => selectMenu(o.value)}
+        />
+      </Show>
+      <Show when={mode() === "candidates"}>
+        <Show when={`${scope()}:${dirty()}`} keyed>
+          {(_selection) => (
+            <DialogSelect
+              title={dirty() ? "候选选择 · 本地未保存草稿" : "候选选择 · 沿用已保存的选择"}
+              locked={busy()}
+              options={options()}
+              onSelect={(o) => {
+                if (busy()) return
+                if (o.value === "scope" && selectable()) {
+                  setScope(scope() === "methods" ? "structure" : "methods")
+                  setDirty(selectionChanged(state()!, scope(), decisions()))
+                  setViewed("")
+                  return
+                }
+                if (o.value === "save" && dirty()) {
+                  void perform(
+                    () => client()!.select(state()!.revision, decisions(), scope()),
+                    "选择已保存,下一步生成计划。",
+                    "planning",
+                  )
+                  return
+                }
+                if (o.value === "back") {
+                  setMode("menu")
+                  return
+                }
+                if (o.value === "discard") {
+                  setScope(state()!.edit_scope || "methods")
+                  setDecisions({ ...state()!.decisions })
+                  setDirty(false)
+                  return
+                }
+                setCandidate(o.value)
+                setMode("details")
+              }}
+            />
+          )}
+        </Show>
+      </Show>
+      <Show
+        when={[
+          "details",
+          "proposal",
+          "result",
+          "ontology",
+          "artifact",
+          "quality",
+          "history",
+          "planning",
+          "quality-summary",
+        ].includes(mode())}
+      >
+        <Show when={reportMode()}>
+          <text fg={theme.primary}>
+            {reportSections
+              .map((name, index) => `${section() === index ? "▶" : ""}${index + 1} ${name}`)
+              .join("  │  ")}
+          </text>
+        </Show>
+        <text fg={theme.primary}>
+          {mode() === "details"
+            ? selectable()
+              ? "s 选中 · d 暂缓 · k 保留现状 · j 原始记录 · b 返回"
+              : "候选记录(只读) · j 原始记录 · b 返回"
+            : mode() === "proposal"
+              ? state()?.phase === "awaiting_confirmation" && state()?.proposal?.readiness?.status !== "blocked"
+                ? "↑↓ / PgUp PgDn 滚动 · c 进入确认 · b 返回"
+                : "计划记录 · ↑↓ 滚动 · b 返回"
+              : reportMode()
+                ? "1–4 切换 · ↑↓ 滚动 · e 证据 · " +
+                  (state()?.phase === "awaiting_review" ? "q 审查 · " : "") +
+                  "j 原始记录 · b 返回"
+                : mode() === "planning"
+                  ? props.onDraft
+                    ? "p 带回输入框(不发送) · b 返回"
+                    : "Esc 关闭面板,回到对话 · b 返回"
+                  : mode() === "quality-summary"
+                    ? (verdict() === "accept" ? "1–3 修改对应意见 · " : "1 修改理由 · ") +
+                      "↑↓ 滚动 · c 确认提交 · b 返回审查"
+                    : "j 原始记录 · b 返回"}
+        </text>
+        <scrollbox
+          ref={(value) => {
+            scroll = value
+          }}
+          height={Math.max(5, Math.min(32, Math.floor(dimensions().height * 0.75) - 18))}
+          focused
+        >
+          <text fg={theme.text} wrapMode="word">
+            {body()}
+          </text>
+        </scrollbox>
+      </Show>
+      <Show when={mode() === "sync"}>
+        <DialogPrompt
+          title="同步前确认未保存选择"
+          description={() => (
+            <text>
+              输入“同步”:放弃本地未保存选择,以服务端实际记录为准。仅查询,不重发上次操作。输入“返回”保留本地选择。
+            </text>
+          )}
+          placeholder="同步 / 返回"
+          busy={busy()}
+          onConfirm={(value) => {
+            if (busy()) return
+            if (value.trim() === "返回") {
+              setMode("menu")
+              return
+            }
+            if (value.trim() === "同步") {
+              void connect()
+              return
+            }
+            setError("输入“同步”或“返回”;尚未放弃本地选择。")
+          }}
+        />
+      </Show>
+      <Show when={mode() === "operation"}>
+        <DialogPrompt
+          title={{ cancel: "请求取消", recover: "检查中断", next: "创建下一批", restart: "重新分析" }[operation()]}
+          description={() => (
+            <text>
+              {
+                {
+                  cancel: "输入“确认”请求停止后续步骤;当前检查可能继续。关闭面板不会取消任务。",
+                  recover: "输入“确认”检查工作进程;仅无活动进程时标为中断,不会重跑。",
+                  next: "输入“确认”从审定副本创建下一批分析。保留本批证据,原仓库不变。",
+                  restart: "输入“确认”保留旧记录并创建新的分析任务;不会执行重构。",
+                }[operation()]
+              }
+            </text>
+          )}
+          busy={busy()}
+          onCancel={() => setMode("menu")}
+          onConfirm={(value) => {
+            if (value.trim() !== "确认" || busy()) return
+            const action = operation()
+            const revision = confirmation()!.revision
+            if (action === "cancel" || action === "recover")
+              void perform(() => client()![action](revision), "操作已记录,请查看状态。")
+            else {
+              setBusy(true)
+              void client()!
+                .fork(action, revision)
+                .then((child) =>
+                  dialog.replace(() => (
+                    <DialogSoftwareReview
+                      reviewID={child.id}
+                      connection={props.connection}
+                      downloadDirectory={props.downloadDirectory}
+                      onDraft={props.onDraft}
+                    />
+                  )),
+                )
+                .catch((e) => {
+                  setNeedsSync(true)
+                  setNotice("")
+                  setError(String(e))
+                  setMode("menu")
+                })
+                .finally(() => setBusy(false))
+            }
+          }}
+        />
+      </Show>
+      <Show when={mode() === "evidence"}>
+        <DialogSelect
+          title="证据文件(远程读取)"
+          locked={busy()}
+          options={[
+            { value: "back", title: "返回菜单" },
+            { value: "download", title: "下载全部证据到本地新目录", description: "包含正反向补丁;不覆盖已有文件" },
+            ...(evidence()?.files || []).map((item) => ({
+              value: item.name,
+              title: item.name,
+              description: `${item.bytes} bytes`,
+            })),
+          ]}
+          onSelect={(o) => {
+            if (o.value === "back") setMode("menu")
+            else if (o.value === "download") void downloadEvidence()
+            else void readArtifact(o.value)
+          }}
+        />
+      </Show>
+      <Show when={mode() === "accept" || mode() === "revise"}>
+        <Show keyed when={`${mode()}-${reviewField()}`}>
+          {(_step) => (
+            <DialogPrompt
+              title={fields()[reviewField()]!.title}
+              value={reviewNotes()[fields()[reviewField()]!.key] || ""}
+              description={() => <text>{fields()[reviewField()]!.hint}</text>}
+              placeholder="直接输入意见,Enter 保存到本地草稿"
+              busy={busy()}
+              onConfirm={(value) => {
+                if (busy()) return
+                if (!value.trim()) {
+                  setError("请填写这一项;尚未提交审查结论。")
+                  return
+                }
+                if ([...value.trim()].length > 8000) {
+                  setError("这一项最多 8000 个字符,请精简后继续;内容仍保留,尚未提交。")
+                  return
+                }
+                const notes = { ...reviewNotes(), [fields()[reviewField()]!.key]: value.trim() }
+                setReviewNotes(notes)
+                setError("")
+                const next = fields().findIndex((field) => !notes[field.key])
+                if (next < 0) {
+                  setMode("quality-summary")
+                  return
+                }
+                setReviewField(next)
+              }}
+            />
+          )}
+        </Show>
+      </Show>
+      <Show when={mode() === "quality-submit"}>
+        <DialogPrompt
+          title={verdict() === "accept" ? "确认通过本批审查" : "确认退回本批修改"}
+          description={() => (
+            <text>输入“{verdict() === "accept" ? "通过" : "退回"}”提交;输入“返回”继续核对意见。不会合并或部署。</text>
+          )}
+          placeholder={verdict() === "accept" ? "通过 / 返回" : "退回 / 返回"}
+          busy={busy()}
+          onConfirm={(value) => {
+            if (busy()) return
+            if (value.trim() === "返回") {
+              setError("")
+              setMode("quality-summary")
+              return
+            }
+            if (value.trim() !== (verdict() === "accept" ? "通过" : "退回")) {
+              setError("请输入提示的确认词;尚未提交。")
+              return
+            }
+            const seal = reviewSeal()
+            if (!seal || state()?.phase !== "awaiting_review") return
+            void perform(
+              () => client()!.quality(seal.revision, seal.version, seal.token, verdict(), reviewNotes()),
+              verdict() === "accept"
+                ? "人工审查结论已保存。领取交付材料即可收尾,原仓库未修改。"
+                : "已记录退回意见。返回规划后生成新提案。",
+            )
+          }}
+        />
+      </Show>
+      <Show when={mode() === "execute"}>
+        <DialogPrompt
+          title="确认执行这一版补丁"
+          description={() => (
+            <text fg={theme.textMuted}>
+              {`提案 ${confirmation()?.version}。在副本中执行已审阅的补丁。输入“执行”开始,Esc 返回。`}
+            </text>
+          )}
+          busy={busy()}
+          onCancel={() => setMode("menu")}
+          onConfirm={(value) => {
+            const frozen = confirmation()
+            if (!frozen || busy()) return
+            if (mode() === "execute" && value.trim() === "执行" && frozen.version)
+              void perform(() => client()!.execute(frozen.revision, frozen.version!), "已提交,进度将自动更新。")
+          }}
+        />
+      </Show>
+    </box>
+  )
+}
+
+function DialogSoftwareReviewList(props: { downloadDirectory?: string; onDraft?: (text: string) => void }) {
+  const dialog = useDialog()
+  const abort = new AbortController()
+  onCleanup(() => abort.abort())
+  const [page, setPage] = createSignal<Awaited<ReturnType<typeof listReviews>>>()
+  const [error, setError] = createSignal("")
+  const [busy, setBusy] = createSignal(false)
+  async function load(offset = 0) {
+    setBusy(true)
+    try {
+      setPage(await listReviews(await reviewConnection(), offset, abort.signal))
+      setError("")
+    } catch (e) {
+      setError(String(e))
+    } finally {
+      setBusy(false)
+    }
+  }
+  onMount(() => {
+    void load()
+  })
+  return (
+    <box flexDirection="column">
+      <text>{error()}</text>
+      <DialogSelect
+        title="仓库审阅任务"
+        locked={busy()}
+        options={[
+          { value: "first", title: "刷新 / 返回第一页" },
+          ...(page()?.items || []).map((item) => ({
+            value: item.id,
+            title: item.project,
+            description: `${labels[item.phase] || item.phase} · ${item.id} · r${item.revision}`,
+          })),
+          ...(page()?.next_offset != null ? [{ value: "next", title: "下一页" }] : []),
+        ]}
+        onSelect={(o) => {
+          if (o.value === "first") void load()
+          else if (o.value === "next") void load(page()!.next_offset!)
+          else
+            dialog.replace(() => (
+              <DialogSoftwareReview
+                reviewID={o.value}
+                downloadDirectory={props.downloadDirectory}
+                onDraft={props.onDraft}
+              />
+            ))
+        }}
+      />
+    </box>
+  )
+}

+ 5 - 2
packages/tui/src/component/error-component.tsx

@@ -1,3 +1,4 @@
+import { productName } from "../brand"
 import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
 import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
 import { createSignal, For, Show } from "solid-js"
@@ -108,7 +109,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
         {/* Headline */}
         <box flexDirection="column" alignItems="center" flexShrink={0}>
           <text attributes={TextAttributes.BOLD} fg={colors.text}>
-            opencode crashed
+            {productName} 运行中断
           </text>
           <Show when={showSubtext()}>
             <text fg={colors.muted}>An unexpected error stopped the session.</text>
@@ -192,7 +193,9 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
                 ? "Report copied — paste it into a new GitHub issue."
                 : "Copy the report and open a GitHub issue to help us fix this."}
             </text>
-            <text fg={colors.muted}>opencode {InstallationVersion}</text>
+            <text fg={colors.muted}>
+              {productName} {InstallationVersion}
+            </text>
           </box>
         </Show>
       </box>

+ 1 - 1
packages/tui/src/component/logo.tsx

@@ -51,7 +51,7 @@ export function Logo() {
       <For each={logo.left}>
         {(line, index) => (
           <box flexDirection="row" gap={1}>
-            <box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
+            <box flexDirection="row">{renderLine(line, theme.primary, true)}</box>
             <box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
           </box>
         )}

+ 1 - 1
packages/tui/src/component/prompt/index.tsx

@@ -1315,7 +1315,7 @@ export function Prompt(props: PromptProps) {
       return `Run a command... "${example}"`
     }
     if (!list().length) return undefined
-    return `Ask anything... "${list()[store.placeholder % list().length]}"`
+    return `描述你的任务… ${list()[store.placeholder % list().length]}`
   })
 
   const spinnerDef = createMemo(() => {

+ 4 - 1
packages/tui/src/feature-plugins/home/footer.tsx

@@ -1,3 +1,4 @@
+import { productName } from "../../brand"
 import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
 import type { BuiltinTuiPlugin } from "../builtins"
 import { createMemo, Match, Show, Switch } from "solid-js"
@@ -56,7 +57,9 @@ function Version(props: { api: TuiPluginApi }) {
 
   return (
     <box flexShrink={0}>
-      <text fg={theme().textMuted}>{props.api.app.version}</text>
+      <text fg={theme().textMuted}>
+        {productName} · {props.api.app.version}
+      </text>
     </box>
   )
 }

+ 7 - 2
packages/tui/src/feature-plugins/home/tips-view.tsx

@@ -1,3 +1,4 @@
+import { productTips } from "../../brand"
 import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
 import { createMemo, For, type Accessor } from "solid-js"
 import { DEFAULT_THEMES, useTheme } from "../../context/theme"
@@ -134,9 +135,13 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
   }
   const tip = createMemo(() => {
     if (props.connected === false) return NO_MODELS_TIP
-    const tips = [...TIPS, process.platform !== "win32" ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap((item) => {
+    const tips = [
+      ...productTips,
+      ...TIPS,
+      process.platform !== "win32" ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP,
+    ].flatMap((item) => {
       const value = typeof item === "string" ? item : item(shortcuts)
-      return value ? [value] : []
+      return value && !/opencode/i.test(value) ? [value] : []
     })
     return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
   }, NO_MODELS_TIP)

+ 4 - 9
packages/tui/src/feature-plugins/sidebar/footer.tsx

@@ -1,3 +1,4 @@
+import { productName } from "../../brand"
 import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
 import type { BuiltinTuiPlugin } from "../builtins"
 import { createMemo, Show } from "solid-js"
@@ -53,10 +54,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
                 ✕
               </text>
             </box>
-            <text fg={theme().textMuted}>OpenCode includes free models so you can start immediately.</text>
-            <text fg={theme().textMuted}>
-              Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
-            </text>
+            <text fg={theme().textMuted}>配置模型后即可开始使用企业智能体工作台。</text>
+            <text fg={theme().textMuted}>连接团队使用的模型服务,并按任务选择合适的模型。</text>
             <box flexDirection="row" gap={1} justifyContent="space-between">
               <text fg={theme().text}>Connect provider</text>
               <text fg={theme().textMuted}>/connect</text>
@@ -69,11 +68,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
         <span style={{ fg: theme().text }}>{path().name}</span>
       </text>
       <text fg={theme().textMuted}>
-        <span style={{ fg: theme().success }}>•</span> <b>Open</b>
-        <span style={{ fg: theme().text }}>
-          <b>Code</b>
-        </span>{" "}
-        <span>{props.api.app.version}</span>
+        <span style={{ fg: theme().success }}>•</span> <b>{productName}</b> <span>{props.api.app.version}</span>
       </text>
     </box>
   )

+ 2 - 2
packages/tui/src/logo.ts

@@ -1,6 +1,6 @@
 export const logo = {
-  left: ["                   ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
-  right: ["             ▄     ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
+  left: ["  ██    ", "   ██   ", "  ██ ██ ", " ██   ██"],
+  right: ["                   ", "LambdaAgentPaaS     ", "AGENT WORKSPACE    ", "                   "],
 }
 
 export const go = {

+ 8 - 2
packages/tui/src/routes/home.tsx

@@ -1,3 +1,5 @@
+import { productTagline } from "../brand"
+import { useTheme } from "../context/theme"
 import { Prompt, type PromptRef } from "../component/prompt"
 import { createEffect, createMemo, createSignal, onMount } from "solid-js"
 import { Logo } from "../component/logo"
@@ -15,11 +17,12 @@ import { HomeSessionDestinationProvider } from "./home/session-destination"
 
 let once = false
 const placeholder = {
-  normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
+  normal: ["分析现有系统,说明业务与代码的关联", "查看重构方案和测试证据", "调用平台智能体处理业务任务"],
   shell: ["ls -la", "git status", "pwd"],
 }
 
 export function Home() {
+  const { theme } = useTheme()
   const pluginRuntime = usePluginRuntime()
   const sync = useSync()
   const route = useRouteData("home")
@@ -74,7 +77,10 @@ export function Home() {
         <box height={4} minHeight={0} flexShrink={1} />
         <box flexShrink={0}>
           <pluginRuntime.Slot name="home_logo" mode="replace">
-            <Logo />
+            <box alignItems="center" gap={1}>
+              <Logo />
+              <text fg={theme.textMuted}>{productTagline}</text>
+            </box>
           </pluginRuntime.Slot>
         </box>
         <box height={1} minHeight={0} flexShrink={1} />

+ 6 - 3
packages/tui/src/routes/session/permission.tsx

@@ -1,3 +1,4 @@
+import { productName } from "../../brand"
 import { createStore } from "solid-js/store"
 import { dirname } from "node:path"
 import { createMemo, For, Match, Show, Switch } from "solid-js"
@@ -141,11 +142,13 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
           body={
             <Switch>
               <Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
-                <TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
+                <TextBody
+                  title={"This will allow " + props.request.permission + ` until ${productName} is restarted.`}
+                />
               </Match>
               <Match when={true}>
                 <box paddingLeft={1} gap={1}>
-                  <text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
+                  <text fg={theme.textMuted}>这些权限将持续到 {productName} 重启</text>
                   <box>
                     <For each={props.request.always}>
                       {(pattern) => (
@@ -483,7 +486,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
           <text fg={theme.text}>Reject permission</text>
         </box>
         <box paddingLeft={1}>
-          <text fg={theme.textMuted}>Tell OpenCode what to do differently</text>
+          <text fg={theme.textMuted}>告诉 {productName} 应该如何调整</text>
         </box>
       </box>
       <box

+ 38 - 0
packages/tui/src/util/software-review-draft.ts

@@ -0,0 +1,38 @@
+import { createHash } from "node:crypto"
+import type { Decision, Review, ReviewConnection } from "./software-review"
+
+type Draft = { revision: number; scope: "methods" | "structure"; decisions: Record<string, Decision> }
+// Only selection intent is retained for this client process, never approval or credentials.
+const drafts = new Map<string, Draft>()
+
+export function reviewDraftKey(connection: ReviewConnection, id: string) {
+  return createHash("sha256")
+    .update(JSON.stringify([connection.url, connection.apiKey, id]))
+    .digest("hex")
+}
+
+export function saveReviewDraft(key: string, draft: Draft) {
+  drafts.set(key, { ...draft, decisions: { ...draft.decisions } })
+}
+
+export function takeReviewDraft(key: string, state: Review) {
+  const draft = drafts.get(key)
+  drafts.delete(key)
+  if (!draft) return
+  if (
+    draft.revision !== state.revision ||
+    !["awaiting_selection", "selected", "awaiting_confirmation"].includes(state.phase) ||
+    Object.keys(draft.decisions).some((id) => !state.candidates.some((c) => c.id === id))
+  )
+    return { status: "outdated" as const, revision: draft.revision }
+  return { status: "restored" as const, draft }
+}
+
+export function selectionChanged(state: Review, scope: "methods" | "structure", decisions: Record<string, Decision>) {
+  return (
+    scope !== (state.edit_scope || "methods") ||
+    state.candidates.some(
+      (candidate) => (decisions[candidate.id] || "deferred") !== (state.decisions[candidate.id] || "deferred"),
+    )
+  )
+}

+ 125 - 0
packages/tui/src/util/software-review-navigation.ts

@@ -0,0 +1,125 @@
+import type { Review } from "./software-review"
+
+/** Navigation only: choosing a recommendation never grants execution or quality approval. */
+export function reviewNavigation(s: Review) {
+  if (s.phase === "evidence_failed")
+    return {
+      stage: "4 · 证据整理受阻",
+      value: "recover-evidence",
+      title: "重新整理已执行的证据",
+      description: "保留执行结果,不重新应用补丁或运行测试",
+      hint: "执行结果已保存,证据整理失败。先修复所示原因,再重整证据,不需要重新生成补丁。",
+    }
+  if (s.phase === "accepted_for_delivery")
+    return {
+      stage: "6 · 交付",
+      value: "evidence",
+      title: "领取本批交付材料",
+      description: "查看并下载补丁、测试与审查记录",
+      hint: "本批审查已完成。领取材料即可收尾;下一批是可选操作,原仓库尚未合并。",
+    }
+  if (s.phase === "awaiting_review")
+    return {
+      stage: "5 · 质量审查",
+      value: "quality",
+      title: "检查结果,填写质量审查",
+      description: "核对四类证据,再选择通过或退回",
+      hint: "验证已结束,等待你审查实际收益、测试依据和剩余风险。",
+    }
+  if (["analyzing", "running"].includes(s.phase))
+    return {
+      stage: s.phase === "analyzing" ? "1 · 分析" : "4 · 执行验证",
+      value: "refresh",
+      title: "刷新原任务进度",
+      description: "进度每 2 秒自动更新,也可立即刷新",
+      hint: s.cancel_requested
+        ? "已请求取消,等待当前检查结束。"
+        : s.phase === "analyzing" && s.progress?.stage === "link"
+          ? "源码扫描完成,正在关联跨文件本体与约束;完成后可选择候选。"
+          : s.phase === "analyzing" && s.progress?.total_batches !== undefined
+            ? `已完成 ${s.progress.completed_batches ?? 0}/${s.progress.total_batches} 个分析批次,进度自动更新。`
+            : "系统正在处理,进度自动更新;完成后首页会显示下一步。",
+    }
+  if (!s.proposal && ["analysis_failed", "cancelled", "interrupted"].includes(s.phase))
+    return {
+      stage: "1 · 分析未完成",
+      value: "report-3",
+      title: "查看分析停止原因",
+      description: s.next_review ? "已有后续任务,可从其他操作打开" : "处理原因后,从其他操作重新分析",
+      hint: s.next_review
+        ? "后续分析任务已创建,请打开该任务继续查看,无需重复分析。"
+        : "分析尚未完成,不能生成补丁。查看原因后,在其他操作选择保留记录、重新分析。",
+    }
+  if (s.phase === "awaiting_selection" && s.candidates.length === 0)
+    return {
+      stage: "2 · 无可选候选",
+      value: "report-0",
+      title: "查看分析覆盖与本体结果",
+      description: "本轮未发现可选线索,不需要生成空计划",
+      hint: "本轮没有可选候选。可查看分析范围后结束;这不代表仓库没有问题。",
+    }
+  if (
+    s.phase === "awaiting_selection" &&
+    s.candidates.every((c) => ["deferred", "retained"].includes(s.decisions[c.id]))
+  )
+    return {
+      stage: "2 · 本批暂不修改",
+      value: "candidates",
+      title: "查看已保存的候选处理结果",
+      description: "全部暂缓或保留;仅需调整时再选择",
+      hint: "当前未选中任何修改项,可以结束本批查看;需要重构时再调整选择。",
+    }
+  if (s.phase === "awaiting_selection")
+    return {
+      stage: "2 · 选择候选",
+      value: "candidates",
+      title: "查看候选清单并选择",
+      description: "先看源码依据,再选择并保存",
+      hint: "只选择这一批要处理的线索;保存选择还不会修改代码。",
+    }
+  if (s.phase === "selected")
+    return {
+      stage: "3 · 生成计划",
+      value: "planning",
+      title: "让智能体生成本批计划",
+      description: "查看可复制的对话指令",
+      hint: "选择已保存。下一步回到对话生成计划,计划完成后再由你确认执行。",
+    }
+  if (s.phase === "awaiting_confirmation" && s.proposal?.readiness?.status !== "blocked")
+    return {
+      stage: "3 · 确认计划",
+      value: "proposal",
+      title: "查看完整计划与补丁",
+      description: "审阅后按 c,输入“执行”才会启动",
+      hint: "核对修改范围、补丁和测试安排;确认后才在隔离副本中执行。",
+    }
+  if (s.phase === "changes_requested")
+    return {
+      stage: "5 · 审查退回",
+      value: "report-3",
+      title: "查看退回意见与剩余问题",
+      description: "确认修改要求后,返回规划",
+      hint: "本批没有通过质量审查。先读意见,再返回规划生成新提案。",
+    }
+  if (
+    ["blocked", "execution_failed", "verification_failed", "analysis_failed", "cancelled", "interrupted"].includes(
+      s.phase,
+    ) ||
+    s.proposal?.readiness?.status === "blocked"
+  )
+    return {
+      stage:
+        s.phase === "awaiting_confirmation" ? "3 · 环境预检阻塞" : s.proposal ? "4 · 需要处理阻塞" : "1 · 分析未完成",
+      value: "report-3",
+      title: "查看阻塞原因与处理建议",
+      description: "先定位原因,不直接重跑",
+      hint: "流程已暂停。检查剩余问题,再按原因修复环境或调整计划。",
+    }
+  return {
+    stage: "任务状态待确认",
+    value: "refresh",
+    title: "刷新原任务状态",
+    description: "只查询,不重跑",
+    hint: "暂不能判断下一步,请先刷新状态。",
+  }
+}

+ 311 - 0
packages/tui/src/util/software-review-report.ts

@@ -0,0 +1,311 @@
+import type { Evidence, Review } from "./software-review"
+import { reviewNavigation } from "./software-review-navigation"
+
+export const reportSections = ["影响路径", "前后变化", "测试结果", "剩余问题"] as const
+export type ReportSection = 0 | 1 | 2 | 3
+export type ReportData = { evidence?: Evidence; comparison?: unknown; warning?: string }
+const object = (value: unknown): Record<string, unknown> =>
+  value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
+const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
+const rows = (value: unknown) => array(value).map(object)
+const clean = (value: unknown) => (typeof value === "string" ? value.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "") : "")
+const category = (value: unknown) =>
+  ({
+    input_validation: "输入校验",
+    write_scope: "写入范围",
+    resource_ownership: "资源归属",
+    workflow_integrity: "业务流程完整性",
+    query_context: "查询上下文",
+    units_ranges: "单位与取值范围",
+    variant_revision: "车型与版本",
+    protocol_compatibility: "协议兼容",
+    retry_idempotency: "重试与幂等",
+    audit_traceability: "审计可追溯",
+  })[String(value)] || text(value)
+const count = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? String(value) : "未采集")
+const text = (value: unknown, fallback = "未提供") => clean(value) || fallback
+const status = (value: unknown) =>
+  ({
+    passed: "通过",
+    failed: "失败",
+    blocked: "阻塞",
+    not_run: "未运行",
+    timeout: "超时",
+    skipped: "跳过",
+    not_declared: "未声明",
+    not_collected: "未采集",
+    no_reported_gaps: "未报告缺口",
+  })[String(value)] || text(value, "未验证")
+const limited = (values: string[], max = 8, empty = "未提供") =>
+  values.length
+    ? [...values.slice(0, max), ...(values.length > max ? [`…其余 ${values.length - max} 项见原始证据`] : [])].join(
+        "\n",
+      )
+    : empty
+const location = (value: Record<string, unknown>) =>
+  `${text(value.path)}${typeof value.line === "number" ? ":" + value.line : ""}`
+const named = (value: Record<string, unknown>) =>
+  `${text(value.symbol, text(value.name, text(value.path)))}${object(value.metrics).method_lines !== undefined ? `(${count(object(value.metrics).method_lines)} 行)` : ""}`
+
+export function nextReviewStep(s: Review) {
+  return reviewNavigation(s).hint
+}
+
+export function candidateImpact(c: Review["candidates"][number]) {
+  const context = object(c.ontology_context)
+  const paths = rows(context.impact_paths)
+  const dependencies = rows(context.dependency_paths)
+  const ruleLines = rows(context.rules).map(
+    (r) => `• ${text(r.statement, text(r.id))}\n  文档依据:${location(object(r.evidence || r.source))}`,
+  )
+  return [
+    `分析目标:${c.path}\n方法:${c.symbol}`,
+    paths.length
+      ? limited(
+          paths.map((p) =>
+            [
+              `  ↳ 关联影响:${text(p.path)}`,
+              ...rows(p.via)
+                .slice(0, 4)
+                .map((edge) => {
+                  const origin = object(edge.origin)
+                  return `    ${text(edge.relation, "关联")} · ${location(origin)}${origin.quote ? "\n    证据:" + text(origin.quote).slice(0, 220) : ""}`
+                }),
+              p.via_truncated || rows(p.via).length > 4 ? "    路径依据未完全展开,完整内容见原始证据。" : "",
+            ]
+              .filter(Boolean)
+              .join("\n"),
+          ),
+          6,
+        )
+      : "本次记录没有可展示的路径依据;不能据此断言不存在影响。",
+    context.impact_paths_truncated || paths.length > 6
+      ? `路径摘要:展示 ${Math.min(paths.length, 6)} 项,记录总数 ${count(context.impact_paths_total)};存在未展开部分。`
+      : "",
+    "箭头表示静态关联影响,不代表运行时调用;关系方向以原始证据为准。",
+    `下游依赖 · 需要核对的组件契约\n${limited(
+      dependencies.map(
+        (d) =>
+          `• ${text(d.path)}\n  依据:${rows(d.via)
+            .map((e) => location(object(e.origin)))
+            .join(";")}`,
+      ),
+      6,
+      Array.isArray(context.dependency_paths) ? "本次未解析到直接依赖;不代表没有依赖" : "旧记录未采集下游依赖摘要",
+    )}`,
+    context.dependency_paths_truncated
+      ? `依赖摘要存在截断,记录总数 ${count(context.dependency_paths_total)};完整关联源码可在规划时分页读取。`
+      : "",
+    typeof context.unresolved_references_total === "number"
+      ? `本候选邻域未解析引用:${count(context.unresolved_references_total)};可能包含外部库,不自动判为缺陷。`
+      : "",
+    `相关测试提示(尚不证明覆盖)\n${limited(
+      c.test_hints.map((p) => "• " + p),
+      8,
+      "未发现测试提示,需检查实际测试",
+    )}`,
+    `业务 / 数据约束\n${limited(ruleLines, 6, "本候选未提供绑定规则,不代表业务无约束")}`,
+    array(context.required_invariants).length
+      ? "必须保持\n" + limited(array(context.required_invariants).map((v) => "• " + text(v)))
+      : "",
+  ]
+    .filter(Boolean)
+    .join("\n\n")
+}
+
+export function reviewReport(s: Review, section: ReportSection, data: ReportData = {}) {
+  const execution = object(s.execution),
+    result = object(execution.result),
+    ontology = object(s.ontology)
+  const comparison = object(data.comparison || execution.ontology_feedback)
+  const candidateChanges = object(comparison.candidate_changes)
+  const selected = s.candidates.filter((c) => s.decisions[c.id] === "selected")
+  const checks = object(data.evidence?.checks)
+  const baseline = object(checks["baseline/checks.json"])
+  const current = typeof result.generation === "number" ? object(checks[`round-${result.generation}/checks.json`]) : {}
+  const header = `${reportSections[section]}\n项目:${s.project} · 本批已选 ${selected.length}/${s.candidates.length} 条候选\n下一步:${nextReviewStep(s)}`
+  const warning = data.warning ? `证据读取不完整:${data.warning}\n以下缺失项不计通过。` : ""
+  if (section === 0) {
+    const statistics = object(ontology.statistics)
+    return [
+      header,
+      warning,
+      `分析覆盖:${count(s.coverage.analyzed_files)} / ${count(s.analysis_scope?.total_files ?? s.coverage.total_files ?? s.coverage.java_files)} 个${s.analysis_scope?.label || (s.coverage.java_files !== undefined ? " Java 文件" : "范围内文件")};不等于全部行为已覆盖。`,
+      Object.keys(ontology).length
+        ? `本体状态:候选预览,待审定\n业务规则 ${count(ontology.business_rule_count)} · 数据规则 ${count(ontology.data_rule_count)}\n未解析引用 ${count(statistics.unresolved_references)}`
+        : "本次旧记录没有全库本体汇总;已有补丁和测试证据仍可检查,完整路径需新分析获取。",
+      `本批候选关联\n${limited(selected.map(candidateImpact), 12, "尚未选择候选;请在候选清单中查看各项关联后选择。")}`,
+      "架构待审线索(不直接执行,不等于已确认缺陷)\n" +
+        limited(
+          rows(ontology.architecture_findings).map(
+            (f) =>
+              `${f.kind === "dependency_cycle" ? "依赖环" : f.kind === "distributed_rule" ? "规则分布于多个文件" : text(f.kind)}\n${limited(
+                array(f.paths).map((p) => "  • " + text(p)),
+                6,
+              )}`,
+          ),
+        ),
+      ontology.architecture_findings_truncated ? "架构线索汇总存在截断,不能推断列表外没有问题。" : "",
+    ]
+      .filter(Boolean)
+      .join("\n\n")
+  }
+  if (section === 1)
+    return [
+      header,
+      warning,
+      `目标\n${s.proposal?.plan.goal || "尚未生成计划"}`,
+      `计划范围\n${limited(
+        (s.proposal?.plan.allowed_files || []).map((p) => "• " + p),
+        20,
+        "无已冻结计划",
+      )}`,
+      `实际隔离副本变化\n${limited(
+        array(result.changed_files).map((p) => "• " + text(p)),
+        20,
+        "尚无已记录的实际修改文件;计划不等于执行结果",
+      )}`,
+      execution.source_unchanged === true
+        ? "原仓库:保持不变;上面的实际变化仅在隔离副本中。"
+        : "原仓库状态:以执行记录为准,尚未确认保持不变。",
+      Object.keys(comparison).length
+        ? [
+            "本体与代码线索前后对照(仅本次事务分析范围)",
+            "旧线索不再出现\n" +
+              limited(
+                rows(candidateChanges.resolved).map((c) => "• " + named(c)),
+                12,
+                "未报告消失线索",
+              ),
+            "仍然出现\n" +
+              limited(
+                rows(candidateChanges.remaining).map((c) => "• " + named(c)),
+                12,
+                "未报告保留线索",
+              ),
+            "新增待审线索\n" +
+              limited(
+                rows(candidateChanges.introduced).map((c) => "• " + named(c)),
+                12,
+                "未报告新增线索",
+              ),
+            candidateChanges.complete === false
+              ? "候选对照不完整,不能据此认定全部已解决。"
+              : "线索减少不自动等于行为改善或重构合理;请核对具体补丁。",
+          ].join("\n\n")
+        : "本体前后对照尚未取得;不能由测试通过推断结构已改善。",
+      "完整修改前后代码:菜单中的「查看完整计划与补丁」。实际正反向补丁:证据文件 changes.patch / rollback.patch。",
+    ]
+      .filter(Boolean)
+      .join("\n\n")
+  if (section === 2) {
+    const describe = (name: string, check: Record<string, unknown>, file: string) =>
+      !Object.keys(check).length
+        ? `${name}:未取得测试明细,不计通过。`
+        : [
+            `${name} · 构建 ${status(check.build_status)} · 测试 ${status(check.test_status)}`,
+            `实际执行 ${count(check.executed_tests)} 项 · 失败 ${count(object(check.case_counts).failed)} · 跳过 ${count(object(check.case_counts).skipped)}`,
+            `验证器:${text(check.verification_kind)} · 结构调用检查:${check.structural_passed === true ? "通过" : check.structural_passed === false ? "未通过" : "未声明或未采集"}`,
+            ...rows(check.failed_cases).map((c) => `  失败:${text(c.id)}(${status(c.status)})`),
+            check.failed_cases_truncated ? "失败用例未完全展开,请查看完整证据。" : "",
+            check.build_reason ? `构建原因:${text(check.build_reason)}` : "",
+            check.test_reason ? `测试原因:${text(check.test_reason)}` : "",
+            `证据:${file}`,
+          ]
+            .filter(Boolean)
+            .join("\n")
+    const business = object(object(result.business_acceptance).current)
+    return [
+      header,
+      warning,
+      describe("重构前基线", baseline, "baseline/checks.json"),
+      describe("重构后", current, `round-${result.generation || "?"}/checks.json`),
+      `业务验收:${status(business.status)}${business.passed === true ? "(仅声明范围)" : ";未验证部分不能计为通过"}`,
+      "测试通过只支持已执行的验证范围,不代表全分支覆盖、生产验收或所有业务等价。",
+      `日志与断言:在「证据文件与下载」检查 build.log、test.log、test-evidence.json。`,
+    ]
+      .filter(Boolean)
+      .join("\n\n")
+  }
+  const readiness = object(s.proposal?.readiness || result.readiness)
+  const business = object(object(result.business_acceptance).current)
+  const remaining = rows(candidateChanges.remaining),
+    introduced = rows(candidateChanges.introduced)
+  const unresolved = object(ontology.statistics).unresolved_references
+  const gaps = object(current.test_gaps),
+    loss = object(current.test_inventory_loss)
+  return [
+    header,
+    warning,
+    s.error ? "执行问题\n" + s.error : "",
+    "环境阻塞\n" +
+      limited(
+        rows(readiness.blockers).map(
+          (b) => `• ${text(b.name, text(b.code))}:${text(b.action, "处理后重新检查执行环境")}`,
+        ),
+        8,
+        "当前记录未报告环境阻塞;不代表测试已通过",
+      ),
+    `待处理候选:未选 ${s.candidates.filter((c) => s.decisions[c.id] !== "selected" && s.decisions[c.id] !== "retained").length} · 人工保留现状 ${s.candidates.filter((c) => s.decisions[c.id] === "retained").length}`,
+    "本次修改后仍需关注\n" +
+      limited(
+        [...remaining.map((c) => "保留线索:" + named(c)), ...introduced.map((c) => "新增线索:" + named(c))],
+        12,
+        Object.keys(comparison).length
+          ? "本次对照未报告保留或新增线索;不代表全仓无问题。"
+          : "未取得本体对照,剩余问题尚不能评估。",
+      ),
+    `未解析引用:${count(unresolved)};缺失关系不能当作无影响。`,
+    `跳过用例:${Array.isArray(gaps.skipped_cases) ? gaps.skipped_cases.length : "未采集"} · 零用例套件:${Array.isArray(gaps.zero_case_suites) ? gaps.zero_case_suites.length : "未采集"}\n基线用例丢失:${Array.isArray(loss.lost_cases) ? loss.lost_cases.length : "未采集"} · 基线通过用例丢失:${Array.isArray(loss.lost_passing_cases) ? loss.lost_passing_cases.length : "未采集"}`,
+    business.passed !== true
+      ? `业务验证尚未通过:${status(business.status)}\n${limited(
+          array(business.missing_categories).map((v) => "• " + category(v)),
+          8,
+          "需检查声明范围与实际业务证据",
+        )}`
+      : "业务检查只对已声明的范围有效,仍需核对排除项。",
+    s.phase === "awaiting_review" ? "人工质量审查尚未完成:请评价收益、行为依据与风险,选择通过或退回。" : "",
+    s.quality_review
+      ? "本批人工审查意见\n" +
+        reviewHistory({ ...s, audit: [{ action: "quality_review", ...object(s.quality_review) }] })
+      : "",
+    "本体仍为候选知识;没有自动发布、合并或部署。",
+  ]
+    .filter(Boolean)
+    .join("\n\n")
+}
+
+export function reviewHistory(s: Review) {
+  const labels: Record<string, string> = {
+    select: "保存选择",
+    propose: "提交提案",
+    approve_and_execute: "确认执行",
+    quality_review: "质量审查",
+    reopen_for_planning: "退回规划",
+    recheck_readiness: "环境复检",
+    request_cancel: "请求取消",
+    recover_interrupted: "恢复中断",
+    start_next_batch: "开始下一批",
+    restart_analysis: "重新分析",
+  }
+  return limited(
+    rows(s.audit).map((a, i) =>
+      [
+        `${i + 1}. ${labels[String(a.action)] || text(a.action)} · 操作者 ${text(a.actor)}`,
+        a.at ? text(a.at) : "",
+        a.decision
+          ? `结论:${a.decision === "accept" ? "通过(待交付)" : a.decision === "revise" ? "退回修改" : text(a.decision)}`
+          : "",
+        ...Object.entries(object(a.notes)).map(
+          ([k, v]) =>
+            `${({ benefits: "收益", invariants: "行为依据", risks: "风险", reason: "退回理由" } as Record<string, string>)[k] || k}:${text(v)}`,
+        ),
+        a.child ? "后续任务:" + text(a.child) : "",
+      ]
+        .filter(Boolean)
+        .join("\n"),
+    ),
+    100,
+    "尚无操作记录",
+  )
+}

+ 350 - 0
packages/tui/src/util/software-review.ts

@@ -0,0 +1,350 @@
+import path from "node:path"
+import { homedir } from "node:os"
+import { mkdtemp, mkdir, writeFile, rename, rm, open } from "node:fs/promises"
+import { createHash, randomUUID } from "node:crypto"
+export type Decision = "selected" | "deferred" | "retained"
+export type Review = {
+  id: string
+  revision: number
+  project: string
+  phase: string
+  error?: string
+  candidates: {
+    id: string
+    path: string
+    symbol: string
+    suggestion: string
+    method_range: { start: number; end: number }
+    source_excerpt: { line: number; text: string }[]
+    excerpt_truncated?: boolean
+    test_hints: string[]
+    ontology_context?: unknown
+    evidence: unknown
+  }[]
+  edit_scope?: "methods" | "structure"
+  decisions: Record<string, Decision>
+  coverage: { analyzed_files?: number; java_files?: number; total_files?: number }
+  analysis_scope?: { label?: string; total_files?: number; adapter?: string }
+  progress?: { stage: string; completed_batches?: number; total_batches?: number }
+  proposal: null | {
+    version: string
+    plan: {
+      goal: string
+      focus: string[]
+      allowed_files: string[]
+      invariants: string[]
+      new_files?: string[]
+      shared_implementations?: unknown[]
+      review_rules?: unknown[]
+      verification: unknown
+    }
+    edits: { path: string; old: string; new: string }[]
+    readiness?: { status: string }
+  }
+  ontology?: unknown
+  audit?: unknown[]
+  next_review?: string
+  cancel_requested?: boolean
+  quality_review?: unknown
+  execution: unknown
+}
+export type ReviewConnection = { url: string; apiKey: string }
+export async function reviewConnection(
+  env: Record<string, string | undefined> = process.env,
+): Promise<ReviewConnection> {
+  const file = Bun.file(path.join(env.HOME?.trim() || homedir(), ".agentpaas", "config.json"))
+  const saved = (await file.exists()) ? await file.json() : {}
+  if (!saved || typeof saved !== "object" || Array.isArray(saved)) throw new Error("PaaS 配置不是 JSON 对象")
+  const original = new URL(env.AGENTPAAS_URL?.trim() || saved.server || "http://127.0.0.1:8000")
+  const target = new URL(env.AGENTPAAS_SOFTWARE_REVIEW_URL?.trim() || original.href)
+  if (
+    !["http:", "https:"].includes(original.protocol) ||
+    target.hostname !== original.hostname ||
+    target.protocol !== original.protocol ||
+    [target, original].some((u) => u.username || u.password || u.search || u.hash || u.pathname !== "/")
+  )
+    throw new Error("审阅 API 必须为同主机、同协议的根地址;不向其他主机转发凭证")
+  const apiKey = env.AGENTPAAS_API_KEY?.trim() || saved.api_key
+  if (typeof apiKey !== "string" || !apiKey) throw new Error("请先配置 PaaS 地址及凭证;不要把密钥输入对话")
+  return { url: target.href.replace(/\/$/, ""), apiKey }
+}
+export function reviewClient(connection: ReviewConnection, id: string, signal: AbortSignal) {
+  if (!/^[a-f0-9]{32}$/.test(id)) throw new Error("请输入真实的 32 位审阅编号")
+  async function fetchReview(action: string, body?: unknown) {
+    const response = await fetch(`${connection.url}/api/v1/software-reviews/${id}${action}`, {
+      method: body ? "POST" : "GET",
+      redirect: "error",
+      headers: { Authorization: `Bearer ${connection.apiKey}`, "Content-Type": "application/json" },
+      body: body ? JSON.stringify(body) : undefined,
+      signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]),
+    }).catch(() => {
+      throw new Error("请求未确认。请刷新原审阅核对状态,不要重复提交执行。")
+    })
+    if (!response.ok)
+      throw new Error(
+        `PaaS ${response.status}: ${(await response.text()).replaceAll(connection.apiKey, "[redacted]").slice(0, 800)}。刷新原审阅后再判断,不自动重试。`,
+      )
+    return response
+  }
+  async function request(action: string, body?: unknown): Promise<Review> {
+    return readReview(await (await fetchReview(action, body)).json(), id)
+  }
+  async function transfer(evidence: Evidence, name: string, write: (chunk: Uint8Array) => void | Promise<void>) {
+    readEvidence(evidence, id)
+    const item = evidence.files.find((item) => item.name === name)
+    if (!item) throw new Error("证据不在当前索引中")
+    const response = await fetchReview(`/artifact?name=${encodeURIComponent(name)}&token=${evidence.token}`)
+    if (!response.body) throw new Error("证据响应没有内容")
+    const reader = response.body.getReader()
+    const hash = createHash("sha256")
+    let size = 0
+    try {
+      while (true) {
+        const chunk = await reader.read()
+        if (chunk.done) break
+        size += chunk.value.length
+        if (size > item.bytes) throw new Error("证据下载大小超出索引,校验失败")
+        hash.update(chunk.value)
+        await write(chunk.value)
+      }
+    } catch (error) {
+      await reader.cancel().catch(() => {})
+      throw error
+    } finally {
+      reader.releaseLock()
+    }
+    if (size !== item.bytes || hash.digest("hex") !== item.sha256) throw new Error("证据下载校验失败,请刷新索引")
+  }
+  return {
+    load: () => request(""),
+    evidence: async (): Promise<Evidence> => readEvidence(await (await fetchReview("/evidence")).json(), id),
+    artifact: async (evidence: Evidence, name: string) => {
+      readEvidence(evidence, id)
+      const item = evidence.files.find((item) => item.name === name)
+      if (!item) throw new Error("证据不在当前索引中")
+      if (item.bytes > 32 * 1024 * 1024) throw new Error("大证据文件请使用完整下载,不在终端一次性加载")
+      const bytes = new Uint8Array(item.bytes)
+      let offset = 0
+      await transfer(evidence, name, (chunk) => {
+        bytes.set(chunk, offset)
+        offset += chunk.length
+      })
+      return bytes
+    },
+    downloadArtifact: async (evidence: Evidence, name: string, target: string) => {
+      const file = await open(target, "wx")
+      try {
+        await transfer(evidence, name, (chunk) => file.writeFile(chunk))
+      } finally {
+        await file.close()
+      }
+    },
+    recoverEvidence: (revision: number) => request("/recover-evidence", { revision }),
+    cancel: (revision: number) => request("/cancel", { revision }),
+    recover: (revision: number) => request("/recover", { revision }),
+    fork: async (action: "next" | "restart", revision: number): Promise<Review> => {
+      const child = await (await fetchReview("/" + action, { revision })).json()
+      if (!/^[a-f0-9]{32}$/.test(child?.id || "") || child.parent_review !== id || child.id === id)
+        throw new Error("新任务身份不匹配,请刷新原任务")
+      return readReview(child, child.id)
+    },
+    recheck: (revision: number) => request("/recheck", { revision }),
+    quality: (revision: number, version: string, token: string, decision: string, notes: Record<string, string>) =>
+      request("/quality", { revision, version, token, decision, notes }),
+    select: (revision: number, decisions: Record<string, Decision>, scope?: "methods" | "structure") =>
+      request("/selection", { revision, decisions, ...(scope ? { scope } : {}) }),
+    execute: (revision: number, version: string) => request("/execute", { revision, version }),
+    reopen: (revision: number) => request("/reopen", { revision }),
+  }
+}
+export function latestReviewID(parts: unknown[]) {
+  for (const part of parts.toReversed()) {
+    if (!part || typeof part !== "object" || !("tool" in part) || part.tool !== "software_review" || !("state" in part))
+      continue
+    const state = part.state as { input?: { review_id?: string }; output?: string }
+    if (/^[a-f0-9]{32}$/.test(state.input?.review_id || "")) return state.input!.review_id
+    if (!state.output) continue
+    try {
+      const value = JSON.parse(state.output)
+      if (/^[a-f0-9]{32}$/.test(value.id || value.review_id || "")) return value.id || value.review_id
+    } catch {
+      /* Tool errors are not review identifiers. */
+    }
+  }
+}
+export function proposalText(s: Review) {
+  const p = s.proposal
+  if (!p) return "尚无提案。保存选择后,请智能体生成计划。"
+  return [
+    `项目:${s.project}\n审阅:${s.id}\n版本:${s.revision}\n提案版本:${p.version}`,
+    `目标\n${p.plan.goal}`,
+    `修改范围:${s.edit_scope === "structure" ? "结构重构(已选文件整体及声明的新生产类)" : "已选方法内部"}\n新增文件:${p.plan.new_files?.join("\n") || "无"}\n结构检查:${JSON.stringify(p.plan.shared_implementations || [])}`,
+    `允许修改\n${p.plan.allowed_files.join("\n")}`,
+    `本体目标\n${p.plan.focus.join("\n")}`,
+    `行为保持条件(待验证)\n${p.plan.invariants.join("\n")}`,
+    `验证配置\n${JSON.stringify(p.plan.verification, null, 2)}`,
+    `文档绑定的业务/数据规则(声明,不代表验证通过)\n${JSON.stringify(p.plan.review_rules || [], null, 2)}`,
+    `环境预检(不代表测试通过)\n${JSON.stringify(p.readiness || { status: "未检查" }, null, 2)}`,
+    ...p.edits.map((e, i) => `补丁 ${i + 1}/${p.edits.length} · ${e.path}\n--- 修改前\n${e.old}\n+++ 修改后\n${e.new}`),
+    "只修改隔离副本。验证通过仍需质量审查,不自动合并、部署或标记候选已解决。",
+  ].join("\n\n")
+}
+
+export type Evidence = {
+  checks?: unknown
+  id: string
+  revision: number
+  token: string
+  files: { name: string; bytes: number; sha256: string }[]
+}
+export async function saveReviewEvidence(
+  client: ReturnType<typeof reviewClient>,
+  evidence: Evidence,
+  directory = process.cwd(),
+) {
+  readEvidence(evidence, evidence.id)
+  const record = await client.load()
+  if (record.id !== evidence.id || record.revision !== evidence.revision)
+    throw new Error("任务版本已变化,请刷新证据再下载")
+  const folder = await mkdtemp(path.join(directory, `.review-${evidence.id}-partial-`))
+  try {
+    for (const item of evidence.files) {
+      const target = path.join(folder, item.name)
+      await mkdir(path.dirname(target), { recursive: true })
+      await client.downloadArtifact(evidence, item.name, target)
+    }
+    const current = await client.evidence()
+    if (
+      (await client.load()).revision !== evidence.revision ||
+      current.revision !== evidence.revision ||
+      current.token !== evidence.token
+    )
+      throw new Error("下载期间任务或证据已更新,请刷新重试")
+    await writeFile(path.join(folder, "evidence-index.json"), JSON.stringify(evidence, null, 2), { flag: "wx" })
+    await writeFile(path.join(folder, "review-record.json"), JSON.stringify(record, null, 2), { flag: "wx" })
+    const published = path.join(directory, `review-${evidence.id}-${randomUUID()}`)
+    await rename(folder, published)
+    return published
+  } catch (error) {
+    await rm(folder, { recursive: true, force: true })
+    throw error
+  }
+}
+
+export async function listReviews(connection: ReviewConnection, offset: number, signal: AbortSignal) {
+  const response = await fetch(`${connection.url}/api/v1/software-reviews?offset=${offset}`, {
+    headers: { Authorization: `Bearer ${connection.apiKey}` },
+    redirect: "error",
+    signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]),
+  })
+  if (!response.ok) throw new Error(`任务列表读取失败 (${response.status})`)
+  const value = await response.json()
+  if (
+    !object(value) ||
+    !Array.isArray(value.items) ||
+    value.items.some(
+      (item: unknown) =>
+        !object(item) ||
+        !/^[a-f0-9]{32}$/.test(String(item.id)) ||
+        typeof item.project !== "string" ||
+        typeof item.phase !== "string" ||
+        !Number.isSafeInteger(item.revision) ||
+        Number(item.revision) < 0,
+    ) ||
+    !(value.next_offset === null || (Number.isSafeInteger(value.next_offset) && Number(value.next_offset) > offset))
+  )
+    throw new Error("任务列表响应不完整,请重新连接兼容的 PaaS 服务")
+  return value as {
+    items: { id: string; project: string; phase: string; revision: number }[]
+    next_offset: number | null
+  }
+}
+
+export function evidenceText(name: string, bytes: Uint8Array) {
+  return `${name}\n\n` + new TextDecoder().decode(bytes).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "")
+}
+
+const object = (value: unknown): value is Record<string, unknown> =>
+  !!value && typeof value === "object" && !Array.isArray(value)
+const strings = (value: unknown) => Array.isArray(value) && value.every((item) => typeof item === "string")
+function readReview(value: unknown, id: string): Review {
+  if (
+    !object(value) ||
+    value.id !== id ||
+    !Number.isSafeInteger(value.revision) ||
+    Number(value.revision) < 0 ||
+    typeof value.phase !== "string" ||
+    typeof value.project !== "string" ||
+    !object(value.coverage) ||
+    !object(value.decisions) ||
+    Object.values(value.decisions).some((item) => !["selected", "deferred", "retained"].includes(String(item))) ||
+    !Array.isArray(value.candidates)
+  )
+    throw new Error("审阅响应不完整或编号不一致,请同步兼容的 PaaS 服务")
+  if (
+    value.candidates.some(
+      (item: unknown) =>
+        !object(item) ||
+        ![item.id, item.path, item.symbol, item.suggestion].every((v) => typeof v === "string") ||
+        !object(item.method_range) ||
+        !Number.isSafeInteger(item.method_range.start) ||
+        !Number.isSafeInteger(item.method_range.end) ||
+        Number(item.method_range.start) < 1 ||
+        Number(item.method_range.end) < Number(item.method_range.start) ||
+        !strings(item.test_hints) ||
+        !Array.isArray(item.source_excerpt) ||
+        item.source_excerpt.some(
+          (line: unknown) => !object(line) || !Number.isSafeInteger(line.line) || typeof line.text !== "string",
+        ),
+    )
+  )
+    throw new Error("候选响应缺少源码或范围信息,请同步兼容的 PaaS 服务")
+  const p = value.proposal
+  if (
+    p != null &&
+    (!object(p) ||
+      typeof p.version !== "string" ||
+      !object(p.plan) ||
+      typeof p.plan.goal !== "string" ||
+      ![p.plan.focus, p.plan.allowed_files, p.plan.invariants].every(strings) ||
+      (p.plan.new_files != null && !strings(p.plan.new_files)) ||
+      (p.plan.shared_implementations != null && !Array.isArray(p.plan.shared_implementations)) ||
+      (p.plan.review_rules != null && !Array.isArray(p.plan.review_rules)) ||
+      (p.readiness != null && (!object(p.readiness) || typeof p.readiness.status !== "string")) ||
+      !Array.isArray(p.edits) ||
+      p.edits.some(
+        (edit: unknown) => !object(edit) || ![edit.path, edit.old, edit.new].every((v) => typeof v === "string"),
+      ))
+  )
+    throw new Error("提案响应缺少可审阅的计划或补丁,请同步兼容的 PaaS 服务")
+  return value as Review
+}
+function readEvidence(value: unknown, id: string): Evidence {
+  if (
+    !/^[a-f0-9]{32}$/.test(id) ||
+    !object(value) ||
+    value.id !== id ||
+    !Number.isSafeInteger(value.revision) ||
+    Number(value.revision) < 0 ||
+    !/^[a-f0-9]{64}$/.test(String(value.token)) ||
+    !Array.isArray(value.files)
+  )
+    throw new Error("证据索引不完整或任务身份不一致")
+  const names = new Set<string>()
+  for (const file of value.files) {
+    if (
+      !object(file) ||
+      typeof file.name !== "string" ||
+      !/^[a-zA-Z0-9_./-]+$/.test(file.name) ||
+      file.name.split("/").some((part) => !part || part === "." || part === "..") ||
+      ["evidence-index.json", "review-record.json"].includes(file.name) ||
+      names.has(file.name) ||
+      !Number.isSafeInteger(file.bytes) ||
+      Number(file.bytes) < 0 ||
+      !/^[a-f0-9]{64}$/.test(String(file.sha256))
+    )
+      throw new Error("证据文件索引不合法:名称重复、路径不安全或大小超限")
+    names.add(file.name)
+  }
+  return value as Evidence
+}

+ 1026 - 0
packages/tui/test/cli/tui/software-review.test.tsx

@@ -0,0 +1,1026 @@
+/** @jsxImportSource @opentui/solid */
+import { TextareaRenderable } from "@opentui/core"
+import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
+import { testRender, useRenderer } from "@opentui/solid"
+import { expect, test } from "bun:test"
+import { mkdir } from "node:fs/promises"
+import path from "node:path"
+import { onCleanup, onMount } from "solid-js"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
+import type { TuiKeybind } from "../../../src/config/keybind"
+import { TestTuiContexts } from "../../fixture/tui-environment"
+
+async function wait(fn: () => boolean, timeout = 2000) {
+  const start = Date.now()
+  while (!fn()) {
+    if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
+    await Bun.sleep(10)
+  }
+}
+
+async function mountReview(input: {
+  root: string
+  reviewID?: string
+  onDraft?: (text: string) => void
+  width?: number
+  height?: number
+  keybinds: Partial<TuiKeybind.Keybinds>
+  connection: { url: string; apiKey: string }
+}) {
+  const state = path.join(input.root, "state")
+  await mkdir(state, { recursive: true })
+  await Bun.write(path.join(state, "kv.json"), "{}")
+
+  const [
+    { DialogProvider, useDialog },
+    { DialogSoftwareReview },
+    { KVProvider },
+    { ThemeProvider },
+    { TuiConfigProvider },
+    { ToastProvider },
+    { OpencodeKeymapProvider, registerOpencodeKeymap },
+  ] = await Promise.all([
+    import("../../../src/ui/dialog"),
+    import("../../../src/component/dialog-software-review"),
+    import("../../../src/context/kv"),
+    import("../../../src/context/theme"),
+    import("../../../src/config"),
+    import("../../../src/ui/toast"),
+    import("../../../src/keymap"),
+  ])
+
+  function OpenReview() {
+    const dialog = useDialog()
+    onMount(() =>
+      dialog.replace(() => (
+        <DialogSoftwareReview
+          reviewID={input.reviewID || "a".repeat(32)}
+          connection={input.connection}
+          onDraft={input.onDraft}
+        />
+      )),
+    )
+    return null
+  }
+
+  function Harness() {
+    const renderer = useRenderer()
+    const keymap = createDefaultOpenTuiKeymap(renderer)
+    const resolvedConfig = createTuiResolvedConfig({
+      keybinds: input.keybinds,
+      leader_timeout: 1000,
+    })
+    const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
+    onCleanup(off)
+
+    return (
+      <TestTuiContexts
+        directory={input.root}
+        paths={{
+          home: input.root,
+          state,
+          worktree: input.root,
+        }}
+      >
+        <OpencodeKeymapProvider keymap={keymap}>
+          <TuiConfigProvider config={resolvedConfig}>
+            <KVProvider>
+              <ThemeProvider mode="dark">
+                <ToastProvider>
+                  <DialogProvider>
+                    <OpenReview />
+                  </DialogProvider>
+                </ToastProvider>
+              </ThemeProvider>
+            </KVProvider>
+          </TuiConfigProvider>
+        </OpencodeKeymapProvider>
+      </TestTuiContexts>
+    )
+  }
+
+  const app = await testRender(() => <Harness />, {
+    kittyKeyboard: true,
+    width: input.width || 120,
+    height: input.height || 48,
+  })
+  return {
+    app,
+    async cleanup() {
+      app.renderer.destroy()
+    },
+  }
+}
+
+const fixture = () => ({
+  id: "a".repeat(32),
+  revision: 2,
+  project: "CLI synthetic review",
+  phase: "awaiting_confirmation",
+  candidates: [
+    {
+      id: "b".repeat(24),
+      path: "Price.java",
+      symbol: "Price#total",
+      suggestion: "Separate assignment",
+      method_range: { start: 1, end: 4 },
+      source_excerpt: [{ line: 1, text: "if ((x = n) > 0)" }],
+      test_hints: [],
+      evidence: [],
+    },
+  ],
+  decisions: { ["b".repeat(24)]: "selected" },
+  coverage: { analyzed_files: 1, java_files: 1 },
+  execution: null,
+  proposal: {
+    version: "c".repeat(64),
+    plan: {
+      goal: "Preserve arithmetic",
+      focus: ["Price.java"],
+      allowed_files: ["Price.java"],
+      invariants: ["Keep outputs"],
+      verification: { kind: "java-main" },
+    },
+    edits: [{ path: "Price.java", old: "if ((x = n) > 0)", new: "x=n; if (x>0)" }],
+    readiness: { status: "execution_required" },
+  },
+})
+
+test("native review shows complete patch and requires an explicit typed confirmation", async () => {
+  await using tmp = await tmpdir()
+  const state = fixture()
+  const calls: { method: string; path: string; body: unknown }[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(req) {
+      calls.push({
+        method: req.method,
+        path: new URL(req.url).pathname,
+        body: req.method === "POST" ? await req.json() : null,
+      })
+      return Response.json({ ...state, phase: req.method === "POST" ? "running" : state.phase })
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test-key" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("等待确认"))
+    // Filter the built-in menu, then enter the actual patch viewer.
+    const filter = ui.app.renderer.currentFocusedEditor
+    if (!filter) throw new Error("expected menu filter")
+    filter.insertText("查看完整")
+    ui.app.mockInput.pressEnter()
+    await wait(() => ui.app.captureCharFrame().includes("PgUp PgDn"))
+    ui.app.mockInput.pressKey("END")
+    await wait(() => ui.app.captureCharFrame().includes("修改前"))
+    expect(ui.app.captureCharFrame()).toContain("if ((x = n) > 0)")
+    expect(calls.filter((c) => c.method === "POST")).toHaveLength(0)
+    ui.app.mockInput.pressKey("c")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    ui.app.mockInput.pressEnter()
+    expect(calls.filter((c) => c.method === "POST")).toHaveLength(0)
+    const entry = ui.app.renderer.currentFocusedEditor
+    if (!(entry instanceof TextareaRenderable)) throw new Error("expected confirmation textarea")
+    entry.insertText("执行")
+    ui.app.mockInput.pressEnter()
+    await wait(() => calls.some((c) => c.method === "POST"))
+    expect(calls.filter((c) => c.method === "POST")).toEqual([
+      {
+        method: "POST",
+        path: "/api/v1/software-reviews/" + state.id + "/execute",
+        body: { revision: 2, version: state.proposal.version },
+      },
+    ])
+    await wait(() => ui.app.captureCharFrame().includes("执行中"))
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+async function selectMenu(ui: Awaited<ReturnType<typeof mountReview>>, text: string) {
+  await wait(() => Boolean(ui.app.renderer.currentFocusedEditor))
+  ui.app.renderer.currentFocusedEditor!.insertText(text)
+  await ui.app.renderOnce()
+  await Bun.sleep(10)
+  ui.app.mockInput.pressEnter()
+}
+
+test("candidate decisions remain local until saved and saving never executes", async () => {
+  await using tmp = await tmpdir()
+  const state = { ...fixture(), phase: "awaiting_selection", proposal: null, decisions: {} }
+  const calls: { path: string; body: unknown }[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(req) {
+      if (req.method === "POST") {
+        const body = await req.json()
+        calls.push({ path: new URL(req.url).pathname, body })
+        return Response.json({ ...state, revision: 3, phase: "selected", decisions: body.decisions })
+      }
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test-key" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("等待选择"))
+    await selectMenu(ui, "候选清单")
+    await wait(() => ui.app.captureCharFrame().includes("候选选择 ·"))
+    await selectMenu(ui, "Price#total")
+    await wait(() => ui.app.captureCharFrame().includes("s 选中"))
+    expect(ui.app.captureCharFrame()).toContain("if ((x = n) > 0)")
+    ui.app.mockInput.pressKey("s")
+    await wait(() => ui.app.captureCharFrame().includes("[✓]"))
+    expect(calls).toHaveLength(0)
+    await selectMenu(ui, "交给智能体规划")
+    await wait(() => calls.length === 1)
+    expect(calls).toEqual([
+      {
+        path: `/api/v1/software-reviews/${state.id}/selection`,
+        body: { revision: 2, decisions: { ["b".repeat(24)]: "selected" }, scope: "methods" },
+      },
+    ])
+    await wait(() => ui.app.captureCharFrame().includes("下一步回到对话"))
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("failed review returns directly to planning and never executes on reopen", async () => {
+  await using tmp = await tmpdir()
+  const state = { ...fixture(), phase: "blocked" }
+  const calls: { path: string; body: unknown }[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(req) {
+      if (req.method === "POST") {
+        calls.push({ path: new URL(req.url).pathname, body: await req.json() })
+        return Response.json({ ...state, revision: 3, phase: "selected", proposal: null })
+      }
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test-key" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("执行阻塞"))
+    await selectMenu(ui, "原因已处理")
+    await wait(() => calls.length === 1)
+    expect(calls).toEqual([{ path: `/api/v1/software-reviews/${state.id}/reopen`, body: { revision: 2 } }])
+    await wait(() => ui.app.captureCharFrame().includes("下一步回到对话"))
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+// Opt-in read-only smoke against a deployed review. A proxy rejects every write.
+test.skipIf(!process.env.SOFTWARE_REVIEW_SMOKE_CONNECTION)(
+  "deployed review renders candidates and evidence in native terminal without writes",
+  async () => {
+    await using tmp = await tmpdir()
+    const privateConfig = await Bun.file(process.env.SOFTWARE_REVIEW_SMOKE_CONNECTION!).json()
+    const url = process.env.SOFTWARE_REVIEW_SMOKE_URL!
+    const id = process.env.SOFTWARE_REVIEW_SMOKE_ID!
+    if (!/^[a-f0-9]{32}$/.test(id) || new URL(url).hostname !== new URL(privateConfig.url).hostname)
+      throw new Error("Invalid smoke target")
+    const route = `/api/v1/software-reviews/${id}`
+    const read = async (suffix = "") => {
+      const response = await fetch(url + route + suffix, {
+        headers: { Authorization: `Bearer ${privateConfig.api_key}` },
+        redirect: "error",
+      })
+      if (!response.ok) throw new Error(`Read failed: ${response.status}`)
+      return response.json()
+    }
+    const before = await read()
+    const writes: string[] = []
+    const proxy = Bun.serve({
+      hostname: "127.0.0.1",
+      port: 0,
+      async fetch(req) {
+        if (
+          req.method !== "GET" ||
+          ![route, route + "/evidence", route + "/artifact"].includes(new URL(req.url).pathname)
+        ) {
+          writes.push(req.method)
+          return new Response("Read-only smoke", { status: 403 })
+        }
+        return fetch(url + new URL(req.url).pathname + new URL(req.url).search, {
+          headers: { Authorization: `Bearer ${privateConfig.api_key}` },
+          redirect: "error",
+        })
+      },
+    })
+    const ui = await mountReview({
+      root: tmp.path,
+      reviewID: id,
+      keybinds: { input_submit: "return" },
+      connection: { url: `http://127.0.0.1:${proxy.port}`, apiKey: "read-only-proxy" },
+    })
+    try {
+      await wait(() => ui.app.captureCharFrame().includes(before.project))
+      expect(ui.app.captureCharFrame()).toContain(String(before.coverage.analyzed_files))
+      await selectMenu(ui, "材料与记录")
+      await wait(() => ui.app.captureCharFrame().includes("按需查看"))
+      await selectMenu(ui, "候选清单")
+      await wait(() => ui.app.captureCharFrame().includes("候选选择 ·"))
+      await selectMenu(ui, before.candidates[0].symbol)
+      await wait(() => ui.app.captureCharFrame().includes("候选记录(只读)"))
+      expect(ui.app.captureCharFrame()).toContain(before.candidates[0].symbol)
+      ui.app.mockInput.pressKey("b")
+      await wait(() => ui.app.captureCharFrame().includes("候选选择 ·"))
+      await selectMenu(ui, "返回菜单")
+      await wait(() => ui.app.captureCharFrame().includes("仓库重构 / CLI"))
+      await selectMenu(ui, "查看四类结果")
+      await wait(() => ui.app.captureCharFrame().includes("查看四类结果 · 只读"))
+      await selectMenu(ui, "3 · 测试结果")
+      await wait(() => ui.app.captureCharFrame().includes("重构前基线"), 10000)
+      expect(ui.app.captureCharFrame()).toContain("测试结果")
+      ui.app.mockInput.pressKey("2")
+      await wait(() => ui.app.captureCharFrame().includes("实际隔离副本变化"))
+      ui.app.mockInput.pressKey("4")
+      await wait(() => ui.app.captureCharFrame().includes("本次修改后仍需关注"))
+      ui.app.mockInput.pressKey("1")
+      await wait(() => ui.app.captureCharFrame().includes("分析覆盖"))
+      ui.app.mockInput.pressKey("j")
+      await wait(() => ui.app.captureCharFrame().includes('"ontology"'))
+      ui.app.mockInput.pressKey("j")
+      await wait(() => ui.app.captureCharFrame().includes("分析覆盖"))
+      if (before.phase === "awaiting_review") {
+        ui.app.mockInput.pressKey("b")
+        await wait(() => ui.app.captureCharFrame().includes("仓库重构 / CLI"))
+        await selectMenu(ui, "质量审查")
+        await wait(() => ui.app.captureCharFrame().includes("a 填写通过意见"), 10000)
+      }
+      const after = await read()
+      expect(after.revision).toBe(before.revision)
+      expect(after.decisions).toEqual(before.decisions)
+      expect(writes).toEqual([])
+      if (process.env.SOFTWARE_REVIEW_SMOKE_REPORT)
+        await Bun.write(
+          process.env.SOFTWARE_REVIEW_SMOKE_REPORT,
+          JSON.stringify(
+            {
+              id,
+              revision: after.revision,
+              phase: after.phase,
+              candidates: after.candidates.length,
+              analyzed_files: after.coverage.analyzed_files,
+              native_candidate_detail: true,
+              native_execution_record: true,
+              four_evidence_views: true,
+              original_record_toggle: true,
+              writes: 0,
+              source_task_executed: false,
+            },
+            null,
+            2,
+          ),
+        )
+    } catch (error) {
+      console.error(ui.app.captureCharFrame())
+      throw error
+    } finally {
+      await ui.cleanup()
+      proxy.stop(true)
+    }
+  },
+  15000,
+)
+
+test("ontology view exposes architectural evidence without executing advisory findings", async () => {
+  await using tmp = await tmpdir()
+  const state = {
+    ...fixture(),
+    ontology: {
+      rules_status: "not_supplied",
+      statistics: { cross_shard_references: 4 },
+      architecture_findings: [{ kind: "dependency_cycle", paths: ["Pricing.java", "Catalog.java"], executable: false }],
+    },
+  }
+  const calls: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      calls.push(req.method)
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("等待确认"))
+    await selectMenu(ui, "查看四类结果")
+    await wait(() => ui.app.captureCharFrame().includes("查看四类结果 · 只读"))
+    await selectMenu(ui, "1 · 影响路径")
+    await wait(() => ui.app.captureCharFrame().includes("分析覆盖"))
+    ui.app.mockInput.pressKey("END")
+    await wait(() => ui.app.captureCharFrame().includes("Pricing.java"))
+    ui.app.mockInput.pressKey("c")
+    expect(ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable).toBe(false)
+    expect(calls).toEqual(["GET"])
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("quality review is an explicit operator decision bound to inspected evidence", async () => {
+  await using tmp = await tmpdir()
+  const state = { ...fixture(), phase: "awaiting_review", execution: { result: { verification_passed: true } } }
+  const posts: unknown[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(req) {
+      if (new URL(req.url).pathname.endsWith("/evidence"))
+        return Response.json({ id: state.id, revision: state.revision, token: "d".repeat(64), files: [] })
+      if (req.method === "POST") {
+        posts.push(await req.json())
+        return Response.json({ ...state, revision: 3, phase: "accepted_for_delivery" })
+      }
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    width: 80,
+    height: 32,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test-key" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("待质量审查"))
+    await selectMenu(ui, "质量审查")
+    await wait(() => ui.app.captureCharFrame().includes("a 填写通过意见"))
+    ui.app.mockInput.pressKey("a")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    ui.app.mockInput.pressEnter()
+    expect(posts).toHaveLength(0)
+    for (const [index, value] of ["拆分赋值与判断", "Check 覆盖正负值", "仍需集成检查"].entries()) {
+      await wait(() => ui.app.captureCharFrame().includes(`${index + 1}/3`))
+      await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+      ui.app.renderer.currentFocusedEditor!.insertText(value)
+      ui.app.mockInput.pressEnter()
+      await wait(() => ui.app.captureCharFrame().includes(index === 2 ? "核对意见,确认提交" : `${index + 2}/3`))
+      expect(posts).toHaveLength(0)
+    }
+    ui.app.mockInput.pressKey("b")
+    await wait(() => ui.app.captureCharFrame().includes("a 填写通过意见"))
+    ui.app.mockInput.pressKey("a")
+    await wait(() => ui.app.captureCharFrame().includes("核对意见,确认提交"))
+    // Edit only the risk field; the other inspected opinions must remain intact.
+    ui.app.mockInput.pressKey("3")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    expect(ui.app.renderer.currentFocusedEditor!.plainText).toBe("仍需集成检查")
+    ui.app.renderer.currentFocusedEditor!.insertText(",未覆盖设备")
+    ui.app.mockInput.pressEnter()
+    await wait(() => ui.app.captureCharFrame().includes("核对意见,确认提交"))
+    expect(posts).toHaveLength(0)
+    ui.app.mockInput.pressKey("c")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    ui.app.mockInput.pressEnter()
+    expect(posts).toHaveLength(0)
+    ui.app.renderer.currentFocusedEditor!.insertText("返回")
+    ui.app.mockInput.pressEnter()
+    await wait(() => ui.app.captureCharFrame().includes("核对意见,确认提交"))
+    ui.app.mockInput.pressKey("c")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    ui.app.renderer.currentFocusedEditor!.insertText("通过")
+    ui.app.mockInput.pressEnter()
+    await wait(() => ui.app.captureCharFrame().includes("人工审查通过,待交付"))
+    expect(posts).toEqual([
+      {
+        revision: 2,
+        version: state.proposal.version,
+        token: "d".repeat(64),
+        decision: "accept",
+        notes: { benefits: "拆分赋值与判断", invariants: "Check 覆盖正负值", risks: "仍需集成检查,未覆盖设备" },
+      },
+    ])
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+for (const [phase, title] of [
+  ["running", "刷新原任务进度"],
+  ["selected", "让智能体生成本批计划"],
+  ["accepted_for_delivery", "领取本批交付材料"],
+]) {
+  test(`stage navigation keeps ${phase} focused without issuing writes`, async () => {
+    await using tmp = await tmpdir()
+    const state = { ...fixture(), phase }
+    const calls: string[] = []
+    const server = Bun.serve({
+      hostname: "127.0.0.1",
+      port: 0,
+      fetch(req) {
+        calls.push(req.method)
+        return Response.json(state)
+      },
+    })
+    const ui = await mountReview({
+      root: tmp.path,
+      keybinds: { input_submit: "return" },
+      connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+    })
+    try {
+      await wait(() => ui.app.captureCharFrame().includes(title))
+      const home = ui.app.captureCharFrame()
+      expect(home).not.toContain("从审定副本开始下一批")
+      expect(home).not.toContain("操作与审查历史")
+      expect(home).not.toContain("c 进入确认")
+      if (phase === "selected") {
+        await selectMenu(ui, title)
+        await wait(() => ui.app.captureCharFrame().includes("按 Esc 关闭面板"))
+        expect(ui.app.captureCharFrame()).toContain(state.id)
+        expect(ui.app.captureCharFrame()).toContain("只生成计划,不执行")
+      }
+      if (phase === "accepted_for_delivery") {
+        expect(home).toContain("本批审查已完成")
+        await selectMenu(ui, "其他操作")
+        await wait(() => ui.app.captureCharFrame().includes("从审定副本开始下一批"))
+        expect(ui.app.captureCharFrame()).toContain("无需逐项完成")
+        expect(ui.app.captureCharFrame()).not.toContain("质量审查")
+      }
+      if (phase === "running") {
+        await selectMenu(ui, title)
+        await wait(() => calls.length === 2)
+      }
+      expect(calls.every((method) => method === "GET")).toBe(true)
+    } finally {
+      await ui.cleanup()
+      server.stop(true)
+    }
+  })
+}
+
+test("cancelling a partially filled quality opinion does not approve the task", async () => {
+  await using tmp = await tmpdir()
+  const state = { ...fixture(), phase: "awaiting_review", execution: {} }
+  const writes: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      if (req.method !== "GET") writes.push(req.method)
+      if (new URL(req.url).pathname.endsWith("/evidence"))
+        return Response.json({ id: state.id, revision: state.revision, token: "d".repeat(64), files: [] })
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("待质量审查"))
+    await selectMenu(ui, "质量审查")
+    await wait(() => ui.app.captureCharFrame().includes("a 填写通过意见"))
+    ui.app.mockInput.pressKey("a")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    ui.app.renderer.currentFocusedEditor!.insertText("方法职责更清晰")
+    ui.app.mockInput.pressEnter()
+    await wait(() => ui.app.captureCharFrame().includes("2/3"))
+    ui.app.mockInput.pressKey("ESCAPE")
+    await ui.app.renderOnce()
+    expect(writes).toEqual([])
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("planning request returns to a draft without sending or changing a review", async () => {
+  await using tmp = await tmpdir()
+  const state = { ...fixture(), phase: "selected" }
+  const requests: string[] = []
+  const drafts: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      requests.push(req.method)
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    width: 80,
+    height: 32,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+    onDraft: (text) => {
+      drafts.push(text)
+    },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("让智能体生成本批计划"))
+    expect(drafts).toHaveLength(0)
+    await selectMenu(ui, "让智能体生成本批计划")
+    await wait(() => ui.app.captureCharFrame().includes("p 带回输入框"))
+    ui.app.mockInput.pressKey("p")
+    await wait(() => drafts.length === 1)
+    expect(drafts[0]).toContain(state.id)
+    expect(drafts[0]).toContain("只生成计划,不执行")
+    expect(drafts[0]).toContain("下游依赖契约")
+    expect(drafts[0]).toContain("impact、dependencies、unresolved")
+    expect(requests).toEqual(["GET"])
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("long rejection reasons remain scrollable and require separate confirmation in a small terminal", async () => {
+  await using tmp = await tmpdir()
+  const state = { ...fixture(), phase: "awaiting_review", execution: {} }
+  const reason = "请补充测试依据与失败路径。".repeat(80) + "最终需要核对回滚行为"
+  const posts: unknown[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(req) {
+      if (new URL(req.url).pathname.endsWith("/evidence"))
+        return Response.json({ id: state.id, revision: state.revision, token: "d".repeat(64), files: [] })
+      if (req.method === "POST") {
+        posts.push(await req.json())
+        return Response.json({ ...state, phase: "changes_requested", revision: 3 })
+      }
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    width: 80,
+    height: 32,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("待质量审查"))
+    await selectMenu(ui, "质量审查")
+    await wait(() => ui.app.captureCharFrame().includes("a 填写通过意见"))
+    ui.app.mockInput.pressKey("r")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    ui.app.renderer.currentFocusedEditor!.insertText(reason)
+    ui.app.mockInput.pressEnter()
+    await wait(() => ui.app.captureCharFrame().includes("核对意见,确认提交"))
+    expect(posts).toHaveLength(0)
+    ui.app.mockInput.pressKey("END")
+    await wait(() => ui.app.captureCharFrame().includes("最终需要核对回滚行为"))
+    ui.app.mockInput.pressKey("c")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    ui.app.mockInput.pressEnter()
+    expect(posts).toHaveLength(0)
+    ui.app.renderer.currentFocusedEditor!.insertText("退回")
+    ui.app.mockInput.pressEnter()
+    await wait(() => posts.length === 1)
+    expect(posts).toEqual([
+      { revision: 2, version: state.proposal.version, token: "d".repeat(64), decision: "revise", notes: { reason } },
+    ])
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("an initial read failure leaves a working reconnect action", async () => {
+  await using tmp = await tmpdir()
+  const state = fixture()
+  const calls: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      calls.push(req.method)
+      if (calls.length === 1) return new Response("temporarily unavailable", { status: 503 })
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("上次请求结果尚未核对"))
+    await selectMenu(ui, "同步原任务状态")
+    await wait(() => ui.app.captureCharFrame().includes("查看完整计划与补丁"))
+    expect(calls).toEqual(["GET", "GET"])
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("an uncertain execution response requires reading the original task before any further action", async () => {
+  await using tmp = await tmpdir()
+  const state = fixture()
+  const calls: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      calls.push(req.method)
+      if (req.method === "POST") {
+        state.phase = "running"
+        state.revision = 3
+        return new Response("response unavailable", { status: 503 })
+      }
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("等待确认"))
+    await selectMenu(ui, "查看完整计划")
+    await wait(() => ui.app.captureCharFrame().includes("c 进入确认"))
+    ui.app.mockInput.pressKey("c")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    ui.app.renderer.currentFocusedEditor!.insertText("执行")
+    ui.app.mockInput.pressEnter()
+    await wait(() => ui.app.captureCharFrame().includes("上次请求结果尚未核对"))
+    expect(ui.app.captureCharFrame()).not.toContain("查看完整计划与补丁")
+    expect(calls).toEqual(["GET", "POST"])
+    await selectMenu(ui, "同步原任务状态")
+    await wait(() => ui.app.captureCharFrame().includes("刷新原任务进度"))
+    expect(calls).toEqual(["GET", "POST", "GET"])
+    expect(ui.app.captureCharFrame()).toContain("执行中")
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("synchronization after a failed save cannot silently discard local selections", async () => {
+  await using tmp = await tmpdir()
+  const state = { ...fixture(), phase: "awaiting_selection", proposal: null, decisions: {} }
+  const calls: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      calls.push(req.method)
+      if (req.method === "POST") return new Response("save failed", { status: 503 })
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("等待选择"))
+    await selectMenu(ui, "候选清单")
+    await wait(() => ui.app.captureCharFrame().includes("候选选择 ·"))
+    await selectMenu(ui, "Price#total")
+    await wait(() => ui.app.captureCharFrame().includes("s 选中"))
+    ui.app.mockInput.pressKey("s")
+    await wait(() => ui.app.captureCharFrame().includes("[✓]"))
+    await selectMenu(ui, "交给智能体规划")
+    await wait(() => ui.app.captureCharFrame().includes("上次请求结果尚未核对"))
+    await selectMenu(ui, "同步原任务状态")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    ui.app.mockInput.pressEnter()
+    expect(calls).toEqual(["GET", "POST"])
+    ui.app.renderer.currentFocusedEditor!.insertText("返回")
+    ui.app.mockInput.pressEnter()
+    await wait(() => ui.app.captureCharFrame().includes("同步原任务状态"))
+    expect(calls).toEqual(["GET", "POST"])
+    await selectMenu(ui, "同步原任务状态")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    ui.app.renderer.currentFocusedEditor!.insertText("同步")
+    ui.app.mockInput.pressEnter()
+    await wait(() => ui.app.captureCharFrame().includes("查看候选清单并选择"))
+    expect(calls).toEqual(["GET", "POST", "GET"])
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("closing a panel retains selection intent only for the same connection and revision", async () => {
+  await using tmp = await tmpdir()
+  const state = { ...fixture(), phase: "awaiting_selection", proposal: null, decisions: {} }
+  const calls: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      calls.push(req.method)
+      return Response.json(state)
+    },
+  })
+  const input = {
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+  }
+  const first = await mountReview(input)
+  try {
+    await wait(() => first.app.captureCharFrame().includes("等待选择"))
+    await selectMenu(first, "候选清单")
+    await wait(() => first.app.captureCharFrame().includes("候选选择 ·"))
+    await selectMenu(first, "Price#total")
+    await wait(() => first.app.captureCharFrame().includes("s 选中"))
+    first.app.mockInput.pressKey("s")
+    await wait(() => first.app.captureCharFrame().includes("[✓]"))
+    first.app.mockInput.pressKey("ESCAPE")
+    await first.app.renderOnce()
+  } finally {
+    await first.cleanup()
+  }
+  const reopened = await mountReview(input)
+  try {
+    await wait(() => reopened.app.captureCharFrame().includes("已恢复本客户端未保存的选择"))
+    await selectMenu(reopened, "候选清单")
+    await wait(() => reopened.app.captureCharFrame().includes("[✓]"))
+    expect(reopened.app.captureCharFrame()).toContain("本地未保存草稿")
+    expect(calls.every((method) => method === "GET")).toBe(true)
+  } finally {
+    await reopened.cleanup()
+  }
+  state.revision = 3
+  const changed = await mountReview(input)
+  try {
+    await wait(() => changed.app.captureCharFrame().includes("未应用旧选择"))
+    await selectMenu(changed, "候选清单")
+    await wait(() => changed.app.captureCharFrame().includes("候选选择 ·"))
+    expect(changed.app.captureCharFrame()).not.toContain("[✓]")
+    expect(calls.every((method) => method === "GET")).toBe(true)
+  } finally {
+    await changed.cleanup()
+    server.stop(true)
+  }
+})
+
+test("reselecting the same candidate or toggling scope back does not revoke an existing proposal", async () => {
+  await using tmp = await tmpdir()
+  const state = fixture()
+  const calls: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      calls.push(req.method)
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("等待确认"))
+    await selectMenu(ui, "材料与记录")
+    await wait(() => ui.app.captureCharFrame().includes("按需查看"))
+    await selectMenu(ui, "候选清单")
+    await wait(() => ui.app.captureCharFrame().includes("候选选择 ·"))
+    await selectMenu(ui, "Price#total")
+    await wait(() => ui.app.captureCharFrame().includes("s 选中"))
+    ui.app.mockInput.pressKey("s")
+    await wait(() => ui.app.captureCharFrame().includes("[✓]"))
+    expect(ui.app.captureCharFrame()).not.toContain("保存选择,交给智能体规划")
+    await selectMenu(ui, "范围:方法内修改")
+    await wait(() => ui.app.captureCharFrame().includes("保存选择,交给智能体规划"))
+    await selectMenu(ui, "范围:结构重构")
+    await wait(() => ui.app.captureCharFrame().includes("候选选择 · 沿用已保存的选择"))
+    expect(ui.app.captureCharFrame()).not.toContain("保存选择,交给智能体规划")
+    await selectMenu(ui, "返回菜单")
+    await wait(() => ui.app.captureCharFrame().includes("查看完整计划与补丁"))
+    expect(calls).toEqual(["GET"])
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("overlong quality opinions stay editable without a failed server submission", async () => {
+  await using tmp = await tmpdir()
+  const state = { ...fixture(), phase: "awaiting_review", execution: {} }
+  const writes: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      if (req.method !== "GET") writes.push(req.method)
+      if (new URL(req.url).pathname.endsWith("/evidence"))
+        return Response.json({ id: state.id, revision: state.revision, token: "d".repeat(64), files: [] })
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("待质量审查"))
+    await selectMenu(ui, "质量审查")
+    await wait(() => ui.app.captureCharFrame().includes("a 填写通过意见"))
+    ui.app.mockInput.pressKey("r")
+    await wait(() => ui.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    const opinion = "需".repeat(8001)
+    ui.app.renderer.currentFocusedEditor!.insertText(opinion)
+    ui.app.mockInput.pressEnter()
+    await wait(() => ui.app.captureCharFrame().includes("最多 8000"))
+    expect(ui.app.renderer.currentFocusedEditor!.plainText).toBe(opinion)
+    expect(writes).toEqual([])
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("evidence publication recovery does not submit another execution", async () => {
+  await using tmp = await tmpdir()
+  const state = { ...fixture(), phase: "evidence_failed" }
+  const writes: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(req) {
+      if (req.method === "POST") {
+        writes.push(new URL(req.url).pathname)
+        expect(await req.json()).toEqual({ revision: state.revision })
+        return Response.json({ ...state, revision: state.revision + 1, phase: "awaiting_review" })
+      }
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+  })
+  try {
+    await wait(() => ui.app.captureCharFrame().includes("重新整理已执行的证据"))
+    expect(ui.app.captureCharFrame()).not.toContain("查看完整计划与补丁")
+    await selectMenu(ui, "重新整理已执行的证据")
+    await wait(() => ui.app.captureCharFrame().includes("待质量审查"))
+    expect(writes).toEqual([`/api/v1/software-reviews/${state.id}/recover-evidence`])
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})
+
+test("running review refreshes into quality review without resubmitting execution", async () => {
+  await using tmp = await tmpdir()
+  let state = { ...fixture(), phase: "running" }
+  const calls: string[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      calls.push(req.method)
+      return Response.json(state)
+    },
+  })
+  const ui = await mountReview({
+    root: tmp.path,
+    keybinds: { input_submit: "return" },
+    connection: { url: `http://127.0.0.1:${server.port}`, apiKey: "test-key" },
+  })
+  try {
+    await wait(() => calls.length > 0)
+    await wait(() => ui.app.captureCharFrame().includes("执行中"))
+    state = { ...state, revision: 3, phase: "awaiting_review" }
+    await wait(() => ui.app.captureCharFrame().includes("待质量审查"), 5000)
+    expect(calls.length).toBeGreaterThan(1)
+    expect(calls.every((method) => method === "GET")).toBe(true)
+  } finally {
+    await ui.cleanup()
+    server.stop(true)
+  }
+})

+ 28 - 0
packages/tui/test/software-review-draft.test.ts

@@ -0,0 +1,28 @@
+import { expect, test } from "bun:test"
+import { reviewDraftKey, saveReviewDraft, takeReviewDraft } from "../src/util/software-review-draft"
+import type { Review } from "../src/util/software-review"
+
+test("selection drafts do not cross credentials and never restore into a finished task", () => {
+  const connection = { url: "http://127.0.0.1:9999", apiKey: "synthetic-key" }
+  const key = reviewDraftKey(connection, "draft-test")
+  const other = reviewDraftKey({ ...connection, apiKey: "different-operator" }, "draft-test")
+  const state: Review = {
+    id: "draft-test",
+    revision: 1,
+    project: "Example",
+    phase: "awaiting_selection",
+    coverage: {},
+    candidates: [],
+    decisions: {},
+    proposal: null,
+    execution: null,
+  }
+  saveReviewDraft(key, { revision: 1, scope: "structure", decisions: {} })
+  expect(takeReviewDraft(other, state)).toBeUndefined()
+  expect(takeReviewDraft(key, { ...state, phase: "accepted_for_delivery" })).toEqual({
+    status: "outdated",
+    revision: 1,
+  })
+  expect(takeReviewDraft(key, state)).toBeUndefined()
+  expect(key).not.toContain(connection.apiKey)
+})

+ 79 - 0
packages/tui/test/software-review-navigation.test.ts

@@ -0,0 +1,79 @@
+import { expect, test } from "bun:test"
+import { reviewNavigation } from "../src/util/software-review-navigation"
+import type { Review } from "../src/util/software-review"
+
+const review = (phase: string): Review => ({
+  id: "a".repeat(32),
+  revision: 1,
+  project: "Synthetic project",
+  phase,
+  candidates: [],
+  decisions: {},
+  coverage: {},
+  proposal: null,
+  execution: null,
+})
+
+test("blocked and failed phases recommend diagnosis, never execution", () => {
+  for (const phase of [
+    "blocked",
+    "execution_failed",
+    "verification_failed",
+    "analysis_failed",
+    "cancelled",
+    "interrupted",
+    "changes_requested",
+  ])
+    expect(reviewNavigation(review(phase)).value).toBe("report-3")
+  const state = review("awaiting_confirmation")
+  state.proposal = {
+    version: "v1",
+    readiness: { status: "blocked" },
+    edits: [],
+    plan: { goal: "Example", focus: [], allowed_files: [], invariants: [], verification: {} },
+  }
+  expect(reviewNavigation(state).value).toBe("report-3")
+  expect(reviewNavigation(state).stage).toContain("3 ·")
+})
+
+test("cancellation and subsequent batches do not imply completion or require another run", () => {
+  const running = review("running")
+  running.cancel_requested = true
+  expect(reviewNavigation(running).hint).toContain("等待当前检查结束")
+  expect(reviewNavigation(running).value).toBe("refresh")
+  const accepted = review("accepted_for_delivery")
+  accepted.next_review = "b".repeat(32)
+  expect(reviewNavigation(accepted).value).toBe("evidence")
+  expect(reviewNavigation(accepted).hint).toContain("下一批是可选操作")
+})
+
+test("empty analysis and an explicitly deferred batch do not ask for an empty proposal", () => {
+  const state = review("awaiting_selection")
+  expect(reviewNavigation(state).title).toBe("查看分析覆盖与本体结果")
+  expect(reviewNavigation(state).hint).toContain("不代表仓库没有问题")
+  state.candidates = [
+    {
+      id: "c1",
+      path: "A.java",
+      symbol: "A#run",
+      suggestion: "Check",
+      method_range: { start: 1, end: 2 },
+      source_excerpt: [],
+      test_hints: [],
+      evidence: [],
+    },
+  ]
+  expect(reviewNavigation(state).title).toBe("查看候选清单并选择")
+  state.decisions = { c1: "deferred" }
+  expect(reviewNavigation(state).stage).toContain("本批暂不修改")
+  expect(reviewNavigation(state).value).not.toBe("planning")
+})
+
+test("stopped analysis directs recovery to analysis, not a patch proposal", () => {
+  for (const phase of ["analysis_failed", "cancelled", "interrupted"]) {
+    const state = review(phase)
+    expect(reviewNavigation(state).hint).toContain("不能生成补丁")
+    state.next_review = "b".repeat(32)
+    expect(reviewNavigation(state).hint).toContain("无需重复分析")
+  }
+})

+ 117 - 0
packages/tui/test/software-review-report.test.ts

@@ -0,0 +1,117 @@
+import { expect, test } from "bun:test"
+import { candidateImpact, nextReviewStep, reviewReport } from "../src/util/software-review-report"
+import type { Review, Evidence } from "../src/util/software-review"
+
+const fixture = (): Review => ({
+  id: "a".repeat(32),
+  revision: 5,
+  project: "Synthetic fleet",
+  phase: "awaiting_review",
+  candidates: [
+    {
+      id: "c1",
+      path: "Fleet.java",
+      symbol: "Fleet#start",
+      suggestion: "Extract responsibility",
+      method_range: { start: 1, end: 106 },
+      source_excerpt: [],
+      test_hints: ["FleetTest.java"],
+      evidence: [],
+    },
+  ],
+  decisions: { c1: "selected" },
+  coverage: { analyzed_files: 2, java_files: 2 },
+  proposal: null,
+  execution: { source_unchanged: true, result: { generation: 1, changed_files: ["Fleet.java"] } },
+})
+const comparison = {
+  candidate_changes: {
+    resolved: [{ symbol: "Fleet#start", metrics: { method_lines: 106 } }],
+    remaining: [],
+    introduced: [{ symbol: "Fleet#createProfile", metrics: { method_lines: 75 } }],
+    complete: true,
+  },
+}
+
+test("reports residual extracted methods without upgrading a disappearing finding to success", () => {
+  const value = reviewReport(fixture(), 1, { comparison })
+  expect(value).toContain("106 行")
+  expect(value).toContain("75 行")
+  expect(value).toContain("新增待审线索")
+  expect(value).toContain("原仓库:保持不变")
+  expect(value).not.toContain('"candidate_changes"')
+  expect(reviewReport(fixture(), 3, { comparison })).toContain("新增线索:Fleet#createProfile")
+})
+
+test("impact paths retain source evidence and do not invent runtime calls or missing legacy context", () => {
+  const c = fixture().candidates[0]
+  c.ontology_context = {
+    dependency_paths: [{ path: "contracts/Policy.java", via: [{ origin: { path: "Fleet.java", line: 8 } }] }],
+    unresolved_references_total: 2,
+    impact_paths: [
+      {
+        path: "FleetTest.java",
+        via: [{ relation: "REFERENCES", origin: { path: "FleetTest.java", line: 2, quote: "import Fleet;" } }],
+      },
+    ],
+  }
+  const value = candidateImpact(c)
+  expect(value).toContain("FleetTest.java:2")
+  expect(value).toContain("import Fleet;")
+  expect(value).toContain("不代表运行时调用")
+  expect(value).toContain("下游依赖")
+  expect(value).toContain("contracts/Policy.java")
+  expect(value).toContain("Fleet.java:8")
+  expect(value).toContain("未解析引用:2")
+  expect(candidateImpact(fixture().candidates[0])).toContain("旧记录未采集")
+  expect(reviewReport(fixture(), 0)).toContain("旧记录没有全库本体汇总")
+})
+
+test("missing test evidence is unknown while measured zero remains zero", () => {
+  expect(reviewReport(fixture(), 2)).toContain("未取得测试明细,不计通过")
+  const evidence: Evidence = {
+    id: "a".repeat(32),
+    revision: 5,
+    token: "d".repeat(64),
+    files: [],
+    checks: {
+      "baseline/checks.json": { build_status: "passed", test_status: "passed", executed_tests: 10 },
+      "round-1/checks.json": {
+        build_status: "passed",
+        test_status: "failed",
+        executed_tests: 10,
+        case_counts: { failed: 1, skipped: 0 },
+        failed_cases: [{ id: "FleetTest.denied", status: "failed" }],
+      },
+    },
+  }
+  const value = reviewReport(fixture(), 2, { evidence })
+  expect(value).toContain("失败 未采集")
+  expect(value).toContain("失败 1 · 跳过 0")
+  expect(value).toContain("FleetTest.denied")
+})
+
+test("partial evidence and pending cancellation never appear as completion", () => {
+  const state = fixture()
+  state.phase = "running"
+  state.cancel_requested = true
+  expect(nextReviewStep(state)).toContain("等待当前检查结束")
+  expect(reviewReport(state, 3, { warning: "校验失败" })).toContain("以下缺失项不计通过")
+  state.phase = "accepted_for_delivery"
+  expect(nextReviewStep(state)).toContain("原仓库尚未合并")
+})
+
+test("measured analysis progress and declared adapter scope guide the next step", () => {
+  const state = fixture()
+  state.phase = "analyzing"
+  state.progress = { stage: "scan", completed_batches: 2, total_batches: 5 }
+  state.coverage = { analyzed_files: 240, total_files: 500 }
+  state.analysis_scope = { label: "TypeScript 文件", total_files: 500, adapter: "synthetic-typescript" }
+  expect(nextReviewStep(state)).toContain("2/5 个分析批次")
+  expect(reviewReport(state, 0)).toContain("240 / 500 个TypeScript 文件")
+  expect(reviewReport(state, 0)).not.toContain("Java 文件")
+  state.progress.stage = "link"
+  expect(nextReviewStep(state)).toContain("正在关联跨文件本体")
+  state.cancel_requested = true
+  expect(nextReviewStep(state)).toContain("等待当前检查结束")
+})

+ 296 - 0
packages/tui/test/software-review.test.ts

@@ -0,0 +1,296 @@
+import { test, expect } from "bun:test"
+import { latestReviewID, proposalText, reviewClient, reviewConnection, type Review } from "../src/util/software-review"
+import { tmpdir } from "./fixture/fixture"
+
+const state: Review = {
+  id: "a".repeat(32),
+  revision: 9,
+  project: "Synthetic",
+  phase: "selected",
+  candidates: [],
+  decisions: {},
+  coverage: {},
+  proposal: null,
+  execution: null,
+}
+
+test("native client binds selection and recovery to the exact review and never retries a rejected approval", async () => {
+  const calls: { path: string; body: unknown }[] = []
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    async fetch(req) {
+      calls.push({ path: new URL(req.url).pathname, body: req.method === "POST" ? await req.json() : null })
+      if (new URL(req.url).pathname.endsWith("/execute"))
+        return Response.json({ detail: "stale test-key" }, { status: 409 })
+      return Response.json(state)
+    },
+  })
+  try {
+    const client = reviewClient(
+      { url: `http://127.0.0.1:${server.port}`, apiKey: "test-key" },
+      state.id,
+      new AbortController().signal,
+    )
+    await client.load()
+    expect(calls).toHaveLength(1)
+    await client.select(9, { ["b".repeat(24)]: "selected" })
+    await client.reopen(10)
+    await expect(client.execute(11, "c".repeat(64))).rejects.toThrow("[redacted]")
+    expect(calls).toHaveLength(4)
+    expect(calls[1].body).toEqual({ revision: 9, decisions: { ["b".repeat(24)]: "selected" } })
+    expect(calls[2].path).toBe(`/api/v1/software-reviews/${state.id}/reopen`)
+    expect(calls[3].body).toEqual({ revision: 11, version: "c".repeat(64) })
+    expect(() =>
+      reviewClient({ url: "http://127.0.0.1", apiKey: "x" }, "../escape", new AbortController().signal),
+    ).toThrow()
+  } finally {
+    server.stop(true)
+  }
+})
+
+test("native connection refuses cross-host or credential-bearing URLs", async () => {
+  await using tmp = await tmpdir()
+  for (const url of ["https://unrelated.invalid", "http://user:pass@localhost", "http://localhost/private"]) {
+    await expect(
+      reviewConnection({
+        HOME: tmp.path,
+        AGENTPAAS_URL: "http://localhost:8000",
+        AGENTPAAS_SOFTWARE_REVIEW_URL: url,
+        AGENTPAAS_API_KEY: "x",
+      }),
+    ).rejects.toThrow()
+  }
+  expect(
+    await reviewConnection({
+      HOME: tmp.path,
+      AGENTPAAS_URL: "http://localhost:8000",
+      AGENTPAAS_SOFTWARE_REVIEW_URL: "http://localhost:8010",
+      AGENTPAAS_API_KEY: "x",
+    }),
+  ).toEqual({ url: "http://localhost:8010", apiKey: "x" })
+})
+
+test("latest review comes only from this session's review tool and complete patch remains available", () => {
+  expect(latestReviewID([{ tool: "other", state: { input: { review_id: state.id } } }])).toBeUndefined()
+  expect(latestReviewID([{ tool: "software_review", state: { output: JSON.stringify({ id: state.id }) } }])).toBe(
+    state.id,
+  )
+  expect(
+    latestReviewID([
+      { tool: "software_review", state: { output: JSON.stringify({ id: state.id }) } },
+      { tool: "software_review", state: { input: { review_id: "b".repeat(32) } } },
+    ]),
+  ).toBe("b".repeat(32))
+  expect(proposalText(state)).toContain("尚无提案")
+  const old = "before\n".repeat(100),
+    next = "after\n".repeat(100)
+  const text = proposalText({
+    ...state,
+    proposal: {
+      version: "c".repeat(64),
+      plan: { goal: "test", focus: [], allowed_files: [], invariants: [], verification: {} },
+      edits: [{ path: "A.java", old, new: next }],
+    },
+  })
+  expect(text).toContain(old)
+  expect(text).toContain(next)
+})
+
+test("remote evidence is verified, exported without overwrite, and rejects corrupted bytes", async () => {
+  const { createHash } = await import("node:crypto")
+  const { saveReviewEvidence } = await import("../src/util/software-review")
+  await using tmp = await tmpdir()
+  const content = "--- a/Price.java\n+++ b/Price.java\n"
+  const evidence = {
+    id: state.id,
+    revision: state.revision,
+    token: "d".repeat(64),
+    files: [
+      {
+        name: "changes.patch",
+        bytes: Buffer.byteLength(content),
+        sha256: createHash("sha256").update(content).digest("hex"),
+      },
+    ],
+  }
+  let corrupt = false
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      if (new URL(req.url).pathname.endsWith("/artifact")) return new Response(corrupt ? "corrupt" : content)
+      if (new URL(req.url).pathname.endsWith("/evidence")) return Response.json(evidence)
+      return Response.json(state)
+    },
+  })
+  try {
+    const client = reviewClient(
+      { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+      state.id,
+      new AbortController().signal,
+    )
+    const folder = await saveReviewEvidence(client, await client.evidence(), tmp.path)
+    expect(await Bun.file(folder + "/changes.patch").text()).toBe(content)
+    expect((await Bun.file(folder + "/review-record.json").json()).revision).toBe(state.revision)
+    expect(await saveReviewEvidence(client, evidence, tmp.path)).not.toBe(folder)
+    corrupt = true
+    await expect(client.artifact(evidence, "changes.patch")).rejects.toThrow("校验失败")
+    await expect(client.artifact(evidence, "../escape")).rejects.toThrow("不在当前索引")
+  } finally {
+    server.stop(true)
+  }
+})
+
+test("malformed review and list responses fail before rendering nested fields", async () => {
+  const { listReviews } = await import("../src/util/software-review")
+  let payload: unknown = { ...state, coverage: null }
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch() {
+      return Response.json(payload)
+    },
+  })
+  const connection = { url: `http://127.0.0.1:${server.port}`, apiKey: "test" }
+  const client = reviewClient(connection, state.id, new AbortController().signal)
+  try {
+    await expect(client.load()).rejects.toThrow("响应不完整")
+    payload = { ...state, candidates: [{ id: "c", method_range: null }] }
+    await expect(client.load()).rejects.toThrow("候选响应")
+    payload = { ...state, proposal: { version: "v", plan: { goal: "g" }, edits: [] } }
+    await expect(client.load()).rejects.toThrow("提案响应")
+    for (const new_files of ["not-an-array", [null]]) {
+      payload = {
+        ...state,
+        proposal: {
+          version: "v",
+          plan: { goal: "g", focus: [], allowed_files: [], invariants: [], new_files },
+          edits: [],
+        },
+      }
+      await expect(client.load()).rejects.toThrow("提案响应")
+    }
+    payload = { items: [null], next_offset: null }
+    await expect(listReviews(connection, 0, new AbortController().signal)).rejects.toThrow("任务列表响应")
+    payload = { items: [], next_offset: 0 }
+    await expect(listReviews(connection, 0, new AbortController().signal)).rejects.toThrow("任务列表响应")
+  } finally {
+    server.stop(true)
+  }
+})
+
+test("evidence rejects unsafe names, duplicate files and invalid sizes before downloading", async () => {
+  const item = { name: "checks.json", bytes: 2, sha256: "a".repeat(64) }
+  const evidence = { id: state.id, revision: state.revision, token: "d".repeat(64), files: [item] }
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch() {
+      return Response.json(evidence)
+    },
+  })
+  const client = reviewClient(
+    { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+    state.id,
+    new AbortController().signal,
+  )
+  try {
+    for (const name of ["../outside", "/absolute", "./checks.json", "review-record.json", "evidence-index.json"]) {
+      evidence.files = [{ ...item, name }]
+      await expect(client.evidence()).rejects.toThrow("索引不合法")
+    }
+    evidence.files = [item, item]
+    await expect(client.evidence()).rejects.toThrow("索引不合法")
+    evidence.files = [{ ...item, bytes: Number.MAX_SAFE_INTEGER + 1 }]
+    await expect(client.evidence()).rejects.toThrow("索引不合法")
+  } finally {
+    server.stop(true)
+  }
+})
+
+test("large ontology evidence is streamed to a verified delivery instead of rejected or loaded for display", async () => {
+  const { createHash } = await import("node:crypto")
+  const { mkdir, readFile } = await import("node:fs/promises")
+  const { saveReviewEvidence } = await import("../src/util/software-review")
+  await using tmp = await tmpdir()
+  const payload = Buffer.alloc(33 * 1024 * 1024, 120)
+  const digest = createHash("sha256").update(payload).digest("hex")
+  await Bun.write(`${tmp.path}/large.json`, payload)
+  const evidence = {
+    id: state.id,
+    revision: state.revision,
+    token: "d".repeat(64),
+    files: [{ name: "analysis/linked-ontology.json", bytes: payload.length, sha256: digest }],
+  }
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      const url = new URL(req.url)
+      if (url.pathname.endsWith("/artifact")) return new Response(Bun.file(`${tmp.path}/large.json`))
+      if (url.pathname.endsWith("/evidence")) return Response.json(evidence)
+      return Response.json(state)
+    },
+  })
+  try {
+    const client = reviewClient(
+      { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+      state.id,
+      new AbortController().signal,
+    )
+    expect((await client.evidence()).files[0].bytes).toBe(payload.length)
+    await expect(client.artifact(evidence, evidence.files[0].name)).rejects.toThrow("完整下载")
+    await mkdir(`${tmp.path}/exports`)
+    const folder = await saveReviewEvidence(client, evidence, `${tmp.path}/exports`)
+    const saved = await readFile(`${folder}/analysis/linked-ontology.json`)
+    expect(saved.length).toBe(payload.length)
+    expect(createHash("sha256").update(saved).digest("hex")).toBe(digest)
+  } finally {
+    server.stop(true)
+  }
+})
+
+test("failed or changing downloads never publish a partial delivery directory", async () => {
+  const { createHash } = await import("node:crypto")
+  const { readdir } = await import("node:fs/promises")
+  const { saveReviewEvidence } = await import("../src/util/software-review")
+  await using tmp = await tmpdir()
+  const text = "verified evidence"
+  const evidence = {
+    id: state.id,
+    revision: state.revision,
+    token: "d".repeat(64),
+    files: [
+      {
+        name: "changes.patch",
+        bytes: Buffer.byteLength(text),
+        sha256: createHash("sha256").update(text).digest("hex"),
+      },
+    ],
+  }
+  let corrupt = false
+  const server = Bun.serve({
+    hostname: "127.0.0.1",
+    port: 0,
+    fetch(req) {
+      if (new URL(req.url).pathname.endsWith("/artifact")) return new Response(corrupt ? text + "overflow" : text)
+      if (new URL(req.url).pathname.endsWith("/evidence")) return Response.json({ ...evidence, token: "e".repeat(64) })
+      return Response.json(state)
+    },
+  })
+  const client = reviewClient(
+    { url: `http://127.0.0.1:${server.port}`, apiKey: "test" },
+    state.id,
+    new AbortController().signal,
+  )
+  try {
+    await expect(saveReviewEvidence(client, evidence, tmp.path)).rejects.toThrow("证据已更新")
+    expect(await readdir(tmp.path)).toEqual([])
+    corrupt = true
+    await expect(saveReviewEvidence(client, evidence, tmp.path)).rejects.toThrow("大小超出")
+    expect(await readdir(tmp.path)).toEqual([])
+  } finally {
+    server.stop(true)
+  }
+})

+ 200 - 0
script/check-software-dashboard.mjs

@@ -0,0 +1,200 @@
+// Browser acceptance against a real, already-running read-only panel.
+// Failure/empty checks below intercept responses only inside a fresh browser context.
+import assert from 'node:assert/strict'
+import { mkdir } from 'node:fs/promises'
+const { chromium } = await import(process.env.PLAYWRIGHT_MODULE)
+const url = process.env.DASHBOARD_URL
+assert(url, 'DASHBOARD_URL is required')
+const out = process.env.DASHBOARD_OUTPUT || '/tmp/software-dashboard-check'
+await mkdir(out, { recursive: true })
+const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_EXECUTABLE, headless: true })
+try {
+  const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 })
+  const errors = []
+  const mutations = []
+  page.on('pageerror', e => errors.push(e.message))
+  page.on('request', r => { if (r.method() !== 'GET') mutations.push(r.url()) })
+  await page.goto(url)
+  await page.locator('#summary h1').waitFor()
+  await page.waitForFunction(() => document.querySelector('#sync').textContent.startsWith('已同步'))
+  assert.equal(await page.locator('.metric').count(), 4)
+  assert(await page.locator('.directory').count() > 0, 'server must provide real repository inventory')
+  await page.evaluate(() => window.scrollTo(0, 0))
+  await page.screenshot({ path: out + '/01-overview.png', fullPage: true })
+  await page.locator('nav [data-tab="impact"]').click()
+  assert(await page.locator('svg[aria-label="候选的静态可达关联图"]').count())
+  await page.locator('details').first().locator('summary').click()
+  assert(!(await page.locator('details').first().innerText()).includes('[object Object]'))
+  await page.evaluate(() => window.scrollTo(0, 0))
+  await page.screenshot({ path: out + '/02-impact.png', fullPage: true })
+  await page.locator('nav [data-tab="changes"]').click()
+  await page.locator('[data-comparison]').click()
+  await page.locator('#comparison .minimetrics').waitFor()
+  await page.evaluate(() => window.scrollTo(0, 0))
+  await page.screenshot({ path: out + '/03-changes.png', fullPage: true })
+  await page.locator('[data-evidence="changes.patch"]').click()
+  await page.waitForFunction(() => document.querySelector('#artifact-text').textContent.startsWith('---'))
+  await page.locator('#close').click()
+  await page.locator('nav [data-tab="tests"]').click()
+  await page.locator('tbody tr').first().waitFor()
+  assert.equal(await page.locator('tbody tr').count(), 2)
+  await page.evaluate(() => window.scrollTo(0, 0))
+  await page.screenshot({ path: out + '/04-tests.png', fullPage: true })
+  await page.locator('nav [data-tab="risks"]').click()
+  await page.locator('#filter').selectOption('selected')
+  assert(await page.locator('.candidate-row').count() > 0)
+  await page.locator('#search').fill('not-a-real-file-xyz')
+  assert.equal(await page.locator('.candidate-row').count(), 0)
+  await page.setViewportSize({ width: 390, height: 844 })
+  await page.locator('nav [data-tab="overview"]').click()
+  assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no mobile overflow')
+  await page.evaluate(() => window.scrollTo(0, 0))
+  await page.screenshot({ path: out + '/05-mobile.png', fullPage: true })
+  assert.deepEqual(errors, [])
+  assert.deepEqual(mutations, [])
+  await page.close()
+
+  // Synthetic boundaries: unknown data, failed tests, update, disconnection and hostile text.
+  const boundary = await browser.newPage({ viewport: { width: 1200, height: 900 } })
+  let state = { id: 'boundary', revision: 0, phase: 'analyzing', project: '<img src=x onerror=alert(1)>', candidates: [], decisions: {}, coverage: {} }
+  let fail = false
+  let liveEvidence = null
+  boundary.on('pageerror', e => errors.push(e.message))
+  await boundary.route('**/api/**', async route => {
+    if (fail) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'Synthetic connection failure' }) })
+    const evidence = liveEvidence || { revision: state.revision, files: [], checks: state.phase === 'verification_failed' ? {
+      'baseline/checks.json': { passed: true, case_counts: { passed: 10, failed: 0, skipped: 2, unknown: 0 } },
+      'round-1/checks.json': { passed: false, case_counts: { passed: 9, failed: 1, skipped: 2, unknown: 0 } },
+    } : {} }
+    return route.fulfill({ contentType: 'application/json', body: JSON.stringify(new URL(route.request().url()).pathname === '/api/review' ? state : evidence) })
+  })
+  await boundary.goto(url)
+  await boundary.locator('#summary h1').waitFor()
+  assert.equal(await boundary.locator('#summary img').count(), 0, 'untrusted data must be escaped')
+  assert.equal(await boundary.locator('.metric strong').last().textContent(), '—')
+  await boundary.locator('nav [data-tab="tests"]').click()
+  await boundary.getByText('本轮尚无测试检查报告;未执行不计通过。', { exact: true }).waitFor()
+  state = { ...state, revision: 1, phase: 'verification_failed', execution: { result: { generation: 1, changed_files: [], verification_passed: false } } }
+  await boundary.locator('#refresh').click()
+  await boundary.getByText('改后验证失败', { exact: true }).waitFor()
+  await boundary.locator('tbody tr').nth(1).waitFor()
+  assert((await boundary.locator('tbody tr').nth(1).innerText()).includes('未通过'))
+  fail = true
+  await boundary.locator('#refresh').click()
+  await boundary.getByText('连接中断 · 展示上次快照', { exact: true }).waitFor()
+  assert((await boundary.locator('#error').innerText()).includes('不是最新状态'))
+  assert.equal(await boundary.locator('tbody tr').count(), 2, 'preserve last snapshot on disconnect')
+  fail = false
+  for (const example of [
+    { name: '财务核算服务', label: 'Python 文件', adapter: 'synthetic-python', path: 'ledger/rules.py', title: '费用边界规则', extension: '.py' },
+    { name: '仓储调度前端', label: 'TypeScript 文件', adapter: 'synthetic-typescript', path: 'dispatch/index.ts', title: '接口职责边界', extension: '.ts' },
+  ]) {
+    state = { id: 'boundary', revision: state.revision + 1, phase: 'awaiting_selection', project: example.name,
+      analysis_scope: { label: example.label, total_files: 12, adapter: example.adapter },
+      coverage: { analyzed_files: 9 }, decisions: {},
+      repository_summary: { files: 20, directory_group_basis: 'snapshot_files', directory_groups: [{ name: 'src', files: 12 }, { name: 'docs', files: 8 }],
+        facts: [{ label: '纳入快照的文件', value: 20 }], file_types: [{ extension: example.extension, files: 12 }] },
+      candidates: [{ id: 'new-kind', kind: 'adapter_specific_rule', title: example.title, path: example.path, source_range: { start: 3, end: 9 }, ontology_context: { dependency_paths: [{ path: 'contracts/contract'+example.extension, via: [{ origin: { path: example.path, line: 3 } }] }], dependency_paths_total: 1, unresolved_references_total: 2, unresolved_references: [{ path: example.path, line: 4, target: "ExternalPolicy", reason: "external_or_unindexed_symbol" }] } }],
+    }
+    await boundary.locator('#refresh').click()
+    await boundary.getByRole('heading', { name: example.name, exact: true }).waitFor()
+    await boundary.locator('nav [data-tab="overview"]').click()
+    assert((await boundary.locator('.metric').first().innerText()).includes(example.label))
+    assert(!(await boundary.locator('#content').innerText()).includes('Maven'))
+    assert.equal(await boundary.locator('.directory').count(), 2)
+    await boundary.locator('nav [data-tab="impact"]').click()
+    await boundary.getByRole('heading', { name: example.title, exact: true }).waitFor()
+    await boundary.getByRole('heading', { name: '下游依赖 · 需要保持哪些组件契约', exact: true }).waitFor()
+    assert((await boundary.locator('#content').innerText()).includes('contracts/contract'+example.extension))
+    assert((await boundary.locator('#content').innerText()).includes('2 条未解析引用'))
+    await boundary.getByText('待查证引用 · 2 条', { exact: true }).click()
+    await boundary.getByText('ExternalPolicy', { exact: true }).waitFor()
+    await boundary.getByText('外部库或未索引类型', { exact: true }).waitFor()
+    assert(!(await boundary.locator('#content').innerText()).includes('[object Object]'))
+    await boundary.locator('nav [data-tab="risks"]').click()
+    assert((await boundary.locator('#content').innerText()).includes(example.adapter))
+  }
+  state = { ...state, revision: state.revision + 1, phase: 'running', execution: { result: { generation: 1 } } }
+  liveEvidence = { revision: state.revision, token: 'first', files: [], checks: {
+    'baseline/checks.json': { passed: true, build_status: 'passed', test_status: 'passed', case_counts: { passed: 7 } },
+  } }
+  await boundary.locator('#refresh').click()
+  await boundary.locator('nav [data-tab="tests"]').click()
+  await boundary.locator('tbody tr').waitFor()
+  assert.equal(await boundary.locator('tbody tr').count(), 1)
+  liveEvidence = { ...liveEvidence, token: 'second', checks: { ...liveEvidence.checks,
+    'round-1/checks.json': { passed: false, build_status: 'passed', test_status: 'failed', test_reason: 'Synthetic assertion regression', case_counts: { passed: 6, failed: 1 } },
+  } }
+  await boundary.locator('#refresh').click()
+  await boundary.locator('tbody tr').nth(1).waitFor()
+  assert((await boundary.locator('tbody tr').nth(1).innerText()).includes('通过 / 失败'))
+  await boundary.getByText('Synthetic assertion regression', { exact: true }).waitFor()
+
+  state = { ...state, revision: state.revision + 1, phase: 'changes_requested',
+    quality_review: { decision: 'revise', notes: { reason: '保留原有错误顺序,补充边界依据。' } },
+    candidates: Array.from({ length: 65 }, (_, i) => ({ id: 'entry-' + i, path: 'src/Entry' + i + '.ts', symbol: 'Entry' + i, kind: 'synthetic' })),
+  }
+  liveEvidence = { ...liveEvidence, revision: state.revision }
+  await boundary.locator('#refresh').click()
+  await boundary.locator('nav [data-tab="risks"]').click()
+  await boundary.getByText('保留原有错误顺序,补充边界依据。', { exact: true }).waitFor()
+  assert.equal(await boundary.locator('.candidate-row').count(), 20)
+  for (let i = 0; i < 3; i++) await boundary.locator('[data-candidate-page="1"]').click()
+  assert.equal(await boundary.locator('.candidate-row').count(), 5)
+  await boundary.getByRole('button', { name: '下一页', exact: true }).isDisabled().then(value => assert(value))
+  await boundary.locator('#search').fill('Entry64')
+  assert.equal(await boundary.locator('.candidate-row').count(), 1)
+  state = { ...state, revision: state.revision + 1 }
+  liveEvidence = { ...liveEvidence, revision: state.revision }
+  await boundary.evaluate(() => poll())
+  assert.equal(await boundary.locator('#search').inputValue(), 'Entry64')
+  assert(await boundary.locator('#search').evaluate(el => el === document.activeElement), 'refresh must preserve active search')
+  state = { ...state, revision: state.revision + 1, phase: 'analyzing', candidates: [], coverage: { analyzed_files: 240, java_files: 240 },
+    progress: { stage: 'link', completed_batches: 2, total_batches: 2 } }
+  liveEvidence = { ...liveEvidence, revision: state.revision }
+  await boundary.locator('#refresh').click()
+  await boundary.getByText('源码扫描完成,正在关联跨文件本体与约束。', { exact: true }).waitFor()
+  assert.equal(await boundary.locator('.metric strong').nth(1).textContent(), '—', 'unfinished findings are not zero')
+  assert.deepEqual(errors, [])
+  // A running task may publish evidence without changing its review revision.
+  // Never concatenate pages from different manifests, including modal next-page reads.
+  const race = await browser.newPage()
+  let evidenceToken = 'v1', replaceDuringRead = false
+  const pageTokens = []
+  const documentText = '{"changed":["Example.java"]}'
+  race.on('pageerror', e => errors.push(e.message))
+  await race.route('**/api/**', async route => {
+    const target = new URL(route.request().url())
+    if (target.pathname === '/api/review') return route.fulfill({ json: {
+      id: 'race', revision: 1, phase: 'running', project: 'Evidence pagination fixture', candidates: [], decisions: {}, coverage: {},
+    } })
+    if (target.pathname === '/api/evidence') return route.fulfill({ json: {
+      id: 'race', revision: 1, token: evidenceToken, files: [{ name: 'comparison.json', bytes: documentText.length }], checks: {},
+    } })
+    const requested = target.searchParams.get('token'), offset = Number(target.searchParams.get('offset'))
+    pageTokens.push(requested)
+    if (requested !== evidenceToken) return route.fulfill({ status: 409, json: { error: 'Evidence version changed; reopen the file.' } })
+    if (offset === 0 && replaceDuringRead) {
+      evidenceToken += '-updated'
+      await race.evaluate(() => poll())
+    }
+    return route.fulfill({ json: { text: offset === 0 ? documentText.slice(0, 12) : documentText.slice(12),
+      offset, next_offset: offset === 0 ? 12 : null, total_chars: documentText.length } })
+  })
+  await race.goto(url)
+  await race.waitForFunction(() => index !== null && busy === false)
+  assert.deepEqual(await race.evaluate(() => readDocument('comparison.json')), { changed: ['Example.java'] })
+  assert.deepEqual(pageTokens.splice(0), ['v1', 'v1'])
+  replaceDuringRead = true
+  const failure = await race.evaluate(() => readDocument('comparison.json').catch(e => e.message))
+  assert.equal(failure, 'Evidence version changed; reopen the file.')
+  assert.deepEqual(pageTokens.splice(0), ['v1', 'v1'], 'JSON pages must retain the first token')
+  const beforeModal = evidenceToken
+  await race.evaluate(() => showArtifact('comparison.json'))
+  await race.locator('#more').click()
+  await race.getByText('Evidence version changed; reopen the file.', { exact: true }).waitFor()
+  assert.deepEqual(pageTokens, [beforeModal, beforeModal], 'modal next page must retain the first token')
+  assert.deepEqual(errors, [])
+  await race.close()
+  console.log('PASS: real review 5 views; unknown/failure/disconnection; Python/TypeScript schemas; live evidence at unchanged revision; rejection notes; all 65 paginated candidates; search focus; measured progress; version-pinned evidence pagination')
+} finally { await browser.close() }

+ 19 - 0
script/serve-software-dashboard.ts

@@ -0,0 +1,19 @@
+import { parseArgs } from "node:util"
+import { loadAgentPaaSConfig, requestJSON } from "../.opencode/lib/agentpaas"
+import { startSoftwareReviewPanel } from "../.opencode/lib/software-review-panel"
+
+const args = parseArgs({ options: { review: { type: "string" }, port: { type: "string" } } }).values
+if (!args.review || !/^[a-f0-9]{32}$/.test(args.review)) throw new Error("使用 --review <真实审阅编号>;凭证通过现有 AGENTPAAS 配置读取")
+const base = await loadAgentPaaSConfig()
+const url = process.env.AGENTPAAS_SOFTWARE_REVIEW_URL?.replace(/\/+$/, "") || base.url
+const target = new URL(url)
+const origin = new URL(base.url)
+if (target.hostname !== origin.hostname || target.protocol !== origin.protocol || target.username || target.password || target.search || target.hash || target.pathname !== "/") throw new Error("审阅 API 必须与 PaaS 使用相同主机与协议")
+const port = args.port ? Number(args.port) : 0
+if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("无效端口")
+const config = { ...base, url }
+await requestJSON(config, `/software-reviews/${args.review}`, new AbortController().signal)
+const panel = startSoftwareReviewPanel(config, args.review, port, "dashboard")
+console.log(JSON.stringify({ url: panel.url, review_id: args.review, read_only: true }))
+process.on("SIGTERM", () => { panel.server.stop(true); process.exit(0) })
+process.on("SIGINT", () => { panel.server.stop(true); process.exit(0) })

+ 68 - 0
specs/software-refactor-dashboard.md

@@ -0,0 +1,68 @@
+# 重构观察台
+
+观察台是 OpenCode 重构流程的可选 Web 窗口。它读取同一个 PaaS review_id,自动同步结果;候选选择、执行确认、质量审查仍在 CLI `/refactor` 完成。
+
+## 打开
+
+在已有重构任务的 OpenCode 会话中输入:
+
+> 打开当前重构任务的可视化看板,展示真实分析与执行证据,不新建任务。
+
+工具入口为 `software_review` 的 `dashboard`(`web` 为同义入口)。`open` 仍返回 CLI,不强制跳网页。看板只绑定一个任务;新建任务或下一批后,用新 review_id 打开对应看板。
+
+也可以在已设置 AGENTPAAS_URL、AGENTPAAS_API_KEY 的环境中运行:
+
+```sh
+bun script/serve-software-dashboard.ts --review <32位审阅编号>
+```
+
+可选 --port 固定本机端口。分离审阅服务通过 AGENTPAAS_SOFTWARE_REVIEW_URL 配置,必须与 PaaS 同主机、同协议。凭证不要写入命令行或 URL。
+
+## 五个视图
+
+| 视图 | 真实来源 | 边界 |
+| --- | --- | --- |
+| 项目总览 | repository_summary、coverage、ontology.statistics | 冻结清单中的目录统计,不以候选文件代替全仓库;未测量 LOC 不编造 |
+| 影响路径 | 候选 ontology_context 与源码摘录 | 静态可达关联不是实际动态调用;截断明确显示 |
+| 前后变化 | proposal、execution.result、changes.patch、comparison.json | 计划片段与实际补丁分开;线索消失不等于业务缺陷已解决 |
+| 测试结果 | 当前 generation 的 evidence.checks | 通过、失败、跳过、未知分别显示;缺报告不计通过 |
+| 剩余问题 | 候选决策、未解析引用、规则状态、quality_review | 搜索筛选只影响展示,人工意见按原文展示 |
+
+看板从服务端 analysis_scope 读取分析范围和适配器名称,候选类型与节点标签允许扩展。目录图按冻结清单中的所有文件统计,文件扩展名分布可展开。现有分析执行器仍遵循平台 Java/Maven 支持边界;界面兼容 Python/TypeScript 数据并不表示这些语言已获执行支持。旧 PaaS 无 repository_summary 时显示未提供,不把未知当成零。目录分组不是已验证的业务模块;文件类型统计不是执行或测试支持声明。
+
+## 同步与权限
+
+- 本机回环端口,每 3 秒同步原任务;隐藏页面暂停轮询。关闭页面不会取消任务。
+- 断线保留上次快照并明确告知;版本不一致时重新读取证据,不拼成一次验收。
+- API key 仅在客户端代理中使用,不进入 HTML、浏览器存储或 URL。
+- 看板模式拒绝所有非 GET 请求,固定审阅编号并保留 PaaS 租户鉴权。
+- Host、页面 token、CSP 与输出转义限制访问;源码和日志作为文本展示。
+- 证据按需、分页读取,由 PaaS 验证证据 token。无 CDN 或第三方页面依赖。
+- 本机地址不能作为企业外网分享链接;对外发布需独立配置认证和访问范围。
+
+## 验证
+
+在 packages/opencode 下执行:
+
+```sh
+bun test test/tool/software-review.test.ts test/tool/software-dashboard.test.ts
+bun run typecheck
+```
+
+浏览器验收脚本为 script/check-software-dashboard.mjs。DASHBOARD_URL 指向已有完整证据的真实看板;PLAYWRIGHT_MODULE、CHROMIUM_EXECUTABLE 指向本机工具,DASHBOARD_OUTPUT 指定截图目录。检查真实五视图、补丁与响应布局;另在独立浏览器上下文中模拟空结果、测试失败、版本更新、恶意文本和断线。不修改真实任务。
+
+## 连续过程与可读性
+
+- 分析中显示服务端实测批次进度与本体关联阶段;未完成的候选数显示待确定。
+- 执行中即使审阅 revision 不变,也刷新本任务的证据索引,用 token 判断内容更新。
+- 质量退回直接展示 reason;测试状态翻译为中文,同时展示实际失败原因。
+- 候选列表每页 20 条,可完整翻页,筛选或搜索会回到第一页。状态刷新保留搜索输入、焦点及已展开材料。
+- `script/check-software-dashboard.mjs` 覆盖真实任务五视图,以及合成状态的实时证据、退回理由、65 条候选分页、搜索焦点和分析进度。不同语言的 UI 测试只验证数据适配,不宣称后端已支持对应语言重构。
+
+证据分页从第一页固定 manifest token;实时刷新不会切换正在阅读的文件版本。期间证据更新会要求重新打开文件,不能拼接旧页与新页。取消或中断分析时,阶段停在“构建本体”,并提示重新分析或查看已创建的后续任务。
+
+## 本体关系与智能体规划同步
+
+影响页区分上游影响和下游依赖,未解析引用可展开目标、源码位置和原因;不把外部库未解析当作缺陷。旧任务缺少这些字段时显示未采集,新字段不由前端推算。
+
+OpenCode `software_review/ontology` 的 `relation_view` 提供 `impact`、`dependencies`、`unresolved` 三类候选视图,分页与服务端截断分别标明。CLI 规划草稿引导智能体读取同一任务 context/source,再按上述关系组织“改动点、关系证据、保持条件、验证依据”。看板保持只读,选择、确认、质量审查仍在 CLI;不另建一套审批状态。

Some files were not shown because too many files changed in this diff