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)) }, })