diff --git a/src/kernel/task.ts b/src/kernel/task.ts new file mode 100644 index 0000000..69d10da --- /dev/null +++ b/src/kernel/task.ts @@ -0,0 +1,1004 @@ +/** + * Task 生命周期 kernel 逻辑(spec §2.3 task_control)。 + * 八 op:create-task / start-task / submit-task / submit-review / + * merge-task / cancel-task / destroy-worktree / status-task。 + * caller:primary 全量;developer 不允许调用 task_control(无门禁绕过面)。 + * gh/git 调用走可注入 executor(测试用);records/worktree/merge 复用各自模块的 executor。 + */ +import { exec } from "node:child_process" +import { promisify } from "node:util" +import { mkdir, readFile, rename, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { gh as ghCli } from "../util/gh.js" +import { escapeShellArg } from "../util/shell.js" +import { pathExists } from "../util/fs.js" +import { PLUGIN_ID } from "../util/paths.js" +import { validateTitle, branchFor, worktreeFor } from "./slug.js" +import { readProjectProfile, type ProjectProfile } from "./profile.js" +import { getCompletedStages } from "./flow.js" +import { worktreeStart, destroyWorktree } from "./worktree.js" +import { createTaskRecord, readFlowRecord, readTddEvidenceComment, extractEvidenceBlock } from "./records.js" +import { captureWorkspaceBaseline, type WorkspaceBaseline } from "./tdd/evidence.js" +import { evaluateTddCompliance } from "./tdd/evaluator.js" +// merge 门禁复用 flowrun/merge.ts 的既有实现(spec §4.1:mergeTaskPR / checkBranchProtection;批 15 统一迁 kernel/review.ts) +import { mergeTaskPR, checkBranchProtection } from "../flowrun/merge.js" +import type { + AcceptanceCriterion, + FinalVerificationEvidence, + TaskRecordRef, + TddCycleEvidence, + TddEvidence, + TddPolicy, + TddRegressionEvidence, +} from "./types.js" + +const execAsync = promisify(exec) + +// ─── 常量 ─── + +/** Task Record 状态标签前缀:cabbage:task: */ +export const TASK_STATUS_LABEL_PREFIX = "cabbage:task:" +/** Task 状态标签取值 */ +export type TaskStatus = "running" | "reviewing" | "merged" | "cancelled" +/** 默认最大并行 Task 数(PRD R7,Profile 可覆盖但内核按此判定) */ +export const MAX_PARALLEL_TASKS = 5 +/** 内核内置风险 pattern(spec §12 #12:migrations/auth/permissions/release workflow/密钥/公共 API),与 Profile riskPatterns 合并 */ +export const BUILTIN_RISK_PATTERNS = [ + "**/migrations/**", + "**/auth/**", + "**/permissions/**", + ".github/workflows/**", + "**/.env*", + "**/*.pem", + "**/*.key", + "src/**/api/**", +] + +// ─── 可替换的 gh/git executor(测试用,仿 flow.ts) ─── + +export type CmdResult = { stdout: string; stderr: string } +export type CmdFn = (args: string) => Promise + +let taskGhExecutor: CmdFn | null = null +let taskGitExecutor: CmdFn | null = null + +export function setTaskGhExecutor(fn: CmdFn | null): void { + taskGhExecutor = fn +} + +export function setTaskGitExecutor(fn: CmdFn | null): void { + taskGitExecutor = fn +} + +export async function taskGh(args: string, timeout?: number): Promise { + if (taskGhExecutor) return taskGhExecutor(args) + return ghCli(args, timeout) +} + +export async function taskGit(args: string): Promise { + if (taskGitExecutor) return taskGitExecutor(args) + return execAsync(`git ${args}`) +} + +/** 在指定目录内执行 git(worktree 内 push/status 等) */ +async function gitIn(dir: string, args: string): Promise { + return taskGit(`-C '${escapeShellArg(dir)}' ${args}`) +} + +// ─── Task Record body 模板与解析(纯函数) ─── + +/** + * 新建 Task Record 的 body:acceptance criteria + 依赖 + 状态 checklist + Closes 约定。 + * criteria 渲染为 `- [ ] : []`,可被 parseCriteriaFromBody 还原。 + */ +export function buildTaskBody(slug: string, criteria: AcceptanceCriterion[], dependencies: number[]): string { + const criteriaLines = + criteria.length > 0 + ? criteria.map(c => `- [ ] ${c.id}: ${c.description} [${c.verification}]`).join("\n") + : "- [ ] TBD" + const depLines = dependencies.length > 0 ? dependencies.map(n => `- #${n}`).join("\n") : "- none" + return [ + `# Task: ${slug}`, + "", + "## Acceptance Criteria", + "", + criteriaLines, + "", + "## Dependencies", + "", + depLines, + "", + "## Status", + "", + "- [ ] running", + "- [ ] reviewing", + "- [ ] merged", + "", + "", + "", + ].join("\n") +} + +/** TDD 跨批协议:Task Record body 内 TDD 上下文块 marker(tdd_checkpoint 工具解析;spec §2.3/§4) */ +export const TASK_TDD_TAG = "" + +/** 构造 TDD 上下文块 JSON(policy + criteria + worktreeDir),供 tdd_checkpoint resolveTaskContext 解析 */ +export function buildTaskTddBlock(policy: TddPolicy, criteria: AcceptanceCriterion[], worktreePath: string): string { + return `${TASK_TDD_TAG}\n${JSON.stringify({ schema: 1, policy, criteria, worktreeDir: worktreePath }, null, 2)}` +} + +/** 更新 Sub Issue body(保留既有内容,替换/追加 TDD 块) */ +async function updateTaskBody(issueNumber: number, newBody: string): Promise<{ ok: boolean; error?: string }> { + try { + const bodyArg = escapeShellArg(newBody) + await taskGh(`api repos/{owner}/{repo}/issues/${issueNumber} -X PATCH -f body=${bodyArg} --silent`) + return { ok: true } + } catch (err) { + return { ok: false, error: String(err) } + } +} + +/** 解析 create-task 传入的 acceptance_criteria JSON(字符串数组 → AcceptanceCriterion[]);非法返回 null */ +export function parseAcceptanceCriteria(json: string): AcceptanceCriterion[] | null { + try { + const parsed = JSON.parse(json) as unknown + if (!Array.isArray(parsed)) return null + const criteria: AcceptanceCriterion[] = [] + for (const item of parsed) { + if (!item || typeof item !== "object") return null + const { id, description, verification } = item as Record + if ( + typeof id !== "string" || + typeof description !== "string" || + (verification !== "tdd" && verification !== "regression" && verification !== "manual") + ) { + return null + } + criteria.push({ id, description, verification }) + } + return criteria + } catch { + return null + } +} + +/** 从 Task Record body 还原 acceptance criteria(buildTaskBody 的逆操作) */ +export function parseCriteriaFromBody(body: string): AcceptanceCriterion[] { + const criteria: AcceptanceCriterion[] = [] + for (const line of body.split("\n")) { + const match = /^-\s+\[\s\]\s+(\S+):\s+(.+)\s+\[(\w+)\]$/.exec(line) + if (match && (match[3] === "tdd" || match[3] === "regression" || match[3] === "manual")) { + criteria.push({ id: match[1], description: match[2].trim(), verification: match[3] as AcceptanceCriterion["verification"] }) + } + } + return criteria +} + +/** 从 Task Record body 的 Dependencies 区块解析依赖 Issue 号 */ +export function parseDependenciesFromBody(body: string): number[] { + const deps: number[] = [] + for (const line of body.split("\n")) { + const match = /^-\s+#(\d+)$/.exec(line.trim()) + if (match) deps.push(Number(match[1])) + } + return deps +} + +/** 解析 create-task 的 depends_on 参数:"12, 13" 或 "#12,#13" → [12, 13];空/非法输入返回 [] */ +export function parseDependenciesArg(raw: string | undefined): number[] { + if (!raw || raw.trim() === "") return [] + const deps: number[] = [] + for (const part of raw.split(",")) { + const n = Number(part.trim().replace(/^#/, "")) + if (Number.isInteger(n) && n > 0) deps.push(n) + } + return deps +} + +// ─── TDD policy 冻结(start-task) ─── + +/** 从 Project Profile 冻结 TDD policy:enforcement 固定 runtime(PRD R6 行为变更强制 Runtime TDD) */ +export function freezeTddPolicy(profile: ProjectProfile): TddPolicy { + const runner = profile.testCommand + ? { + adapter: "vitest" as const, + baseCommand: profile.testCommand, + timeoutMs: 120_000, + executionInputPatterns: [], + } + : null + return { + mode: profile.tddDefaultMode ?? "strict", + enforcement: "runtime", + runner, + testFilePatterns: profile.testFilePatterns, + implementationFilePatterns: profile.implementationFilePatterns, + generatedArtifactPatterns: [], + exception: null, + source: { manifestPath: "AGENTS.md", revisionSha: "frozen-at-start-task" }, + } +} + +// ─── 风险双层判定(merge-task) ─── + +/** 轻量 glob 匹配(支持 **、*、?),风险 pattern 匹配专用 */ +export function matchRiskPattern(filePath: string, pattern: string): boolean { + const normalized = filePath.replace(/\\/g, "/") + let regexStr = "" + let i = 0 + while (i < pattern.length) { + if (pattern[i] === "*" && pattern[i + 1] === "*") { + i += 2 + if (i < pattern.length && pattern[i] === "/") { + regexStr += "(.*/)?" + i++ + } else { + regexStr += ".*" + } + } else if (pattern[i] === "*") { + regexStr += "[^/]*" + i++ + } else if (pattern[i] === ".") { + regexStr += "\\." + i++ + } else if (pattern[i] === "?") { + regexStr += "[^/]" + i++ + } else { + if ("+^$(){}[]|\\".includes(pattern[i])) regexStr += "\\" + pattern[i] + else regexStr += pattern[i] + i++ + } + } + return new RegExp(`^${regexStr}$`).test(normalized) +} + +/** + * 风险双层判定:模型初判 + 内核按实际 diff 升级,只升不降(PRD R8 / spec §12 #12)。 + * modelRisk 为 high → high;否则 diff 命中任一 risk pattern → high;否则 low。 + */ +export function escalateRisk(modelRisk: "low" | "high", changedFiles: string[], riskPatterns: string[]): "low" | "high" { + if (modelRisk === "high") return "high" + const hit = changedFiles.some(file => riskPatterns.some(pattern => matchRiskPattern(file, pattern))) + return hit ? "high" : "low" +} + +// ─── evidence comment 解析(submit-task 门禁) ─── + +export interface ParsedEvidenceBlock { + stage: string + fields: Record +} + +/** 解析受控 evidence comment 为结构化 blocks;无 marker 或畸形返回 null */ +export function parseEvidenceComment(body: string): ParsedEvidenceBlock[] | null { + const extracted = extractEvidenceBlock(body) + if (!extracted) return null + const blocks: ParsedEvidenceBlock[] = [] + const sections = extracted.content.split(/^### stage: /m) + for (const section of sections) { + if (section.trim() === "") continue + const lines = section.split("\n") + const stage = lines[0].trim() + if (stage === "") continue + const fields: Record = {} + for (const line of lines.slice(1)) { + const match = /^-\s+([a-zA-Z]+):\s*(.*)$/.exec(line.trim()) + if (match) fields[match[1]] = match[2].trim() + } + blocks.push({ stage, fields }) + } + return blocks +} + +/** 把受控 comment 还原为 TddEvidence(供 evaluateTddCompliance 判定);无 marker 返回 null */ +export function evidenceCommentToTddEvidence(comment: { id: number; body: string; revision: number }): TddEvidence | null { + const blocks = parseEvidenceComment(comment.body) + if (!blocks) return null + const cycles: TddCycleEvidence[] = [] + let regression: TddRegressionEvidence = { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] } + let verification: FinalVerificationEvidence = { status: "pending", headSha: null, treeSha: null, runs: [] } + let index = 0 + for (const block of blocks) { + const status = mapEvidenceStatus(block.fields.status) + if (block.stage === "red" || block.stage === "green") { + index++ + cycles.push({ + cycleId: block.fields.cycle ?? `cycle-${index}`, + criterionId: block.fields.criterion ?? "c1", + reworkRevision: 0, + status: block.stage === "green" && status === "pass" ? "pass" : "failed", + startWorkspaceDigest: { algorithm: "sha256-content-v1", value: "unknown" }, + testFiles: [], + redTestDigest: null, + redAttempts: [], + greenAttempts: [], + }) + } else if (block.stage === "final-regression") { + regression = { status, headSha: null, treeSha: null, reworkRevision: 0, runs: [] } + } else if (block.stage === "final-verification") { + verification = { status, headSha: null, treeSha: null, runs: [] } + } + } + const overall: TddEvidence["status"] = + verification.status === "pass" && regression.status === "pass" + ? "pass" + : verification.status === "fail" || regression.status === "fail" + ? "fail" + : "in-progress" + return { + revision: comment.revision, + reworkRevision: 0, + status: overall, + taskStart: { status: "pass", headSha: null, treeSha: null, startedAt: null }, + cycles, + regression, + verification, + alternativeValidation: [], + reworks: [], + warnings: [], + updatedAt: null, + } +} + +function mapEvidenceStatus(value?: string): "pass" | "fail" | "pending" { + if (value === "pass") return "pass" + if (value === "fail") return "fail" + return "pending" +} + +// ─── Task 运行时状态持久化(start-task 写 / submit/merge 读) ─── + +export interface TaskRuntimeState { + slug: string + parentIssueNumber: number + issueNumber: number + branch: string + worktreePath: string + policy: TddPolicy + baseline: WorkspaceBaseline + startedAt: string +} + +export function taskStatePath(projectDir: string, slug: string): string { + return join(projectDir, ".opencode", PLUGIN_ID, "task-state", `${slug}.json`) +} + +export async function writeTaskState(projectDir: string, state: TaskRuntimeState): Promise { + const target = taskStatePath(projectDir, state.slug) + const tmp = `${target}.tmp` + await mkdir(join(projectDir, ".opencode", PLUGIN_ID, "task-state"), { recursive: true }) + await writeFile(tmp, JSON.stringify(state, null, 2), "utf8") + await rename(tmp, target) +} + +export async function readTaskState(projectDir: string, slug: string): Promise { + try { + return JSON.parse(await readFile(taskStatePath(projectDir, slug), "utf8")) as TaskRuntimeState + } catch { + return null + } +} + +// ─── Task Record 解析(Sub Issue → issueNumber / body) ─── + +/** 按 title(task slug)解析 Parent 下的 Sub Issue 号 */ +async function resolveTaskIssue(parentIssueNumber: number, taskId: string): Promise { + const { stdout } = await taskGh( + `issue list --parent ${parentIssueNumber} --state all --json number,title --jq '[.[] | select(.title == "${taskId}") | .number] | first'`, + ) + const trimmed = stdout.trim() + if (trimmed === "" || trimmed === "null") return null + const n = Number(trimmed) + return Number.isInteger(n) ? n : null +} + +/** 读 Task Record body(供依赖解析与 criteria 解析) */ +async function readTaskBody(issueNumber: number): Promise { + const { stdout } = await taskGh(`issue view ${issueNumber} --json body --jq .body`) + return stdout +} + +/** 设置 Task Record 状态标签(移除其它 cabbage:task:*,添加当前) */ +export async function setTaskStatusLabel( + issueNumber: number, + status: TaskStatus, +): Promise<{ ok: boolean; error?: string }> { + try { + const { stdout } = await taskGh(`issue view ${issueNumber} --json labels --jq '[.labels[].name] | join(" ") | tostring'`) + const current = stdout.trim() === "" ? [] : stdout.trim().split(" ") + const target = `${TASK_STATUS_LABEL_PREFIX}${status}` + const stale = current.filter(l => l.startsWith(TASK_STATUS_LABEL_PREFIX) && l !== target) + const args: string[] = [] + for (const label of stale) args.push(`--remove-label '${label}'`) + args.push(`--add-label '${target}'`) + await taskGh(`issue edit ${issueNumber} ${args.join(" ")}`) + return { ok: true } + } catch (err) { + return { ok: false, error: String(err) } + } +} + +/** 读仓库默认分支 */ +async function readDefaultBranch(): Promise { + const { stdout } = await taskGh(`repo view --json defaultBranchRef --jq .defaultBranchRef.name`) + return stdout.trim() +} + +// ─── create-task ─── + +export interface CreateTaskInput { + projectDir: string + parentIssueNumber: number + title: string + acceptanceCriteriaJson?: string + dependencies?: number[] +} + +export type CreateTaskResult = + | { ok: true; ref: TaskRecordRef } + | { ok: false; code: string; message: string } + +/** + * create-task(spec §2.3):validateTitle → slug → 未完成同名冲突检查(branch/worktree 已存在)→ + * createTaskRecord(Sub Issue,body 含 acceptance criteria、依赖、Closes 约定)关联 Parent。 + */ +export async function createTask(input: CreateTaskInput): Promise { + const validated = validateTitle(input.title) + if (!validated.ok) { + return { ok: false, code: validated.code, message: validated.message } + } + + const slug = validated.slug + const worktreeDir = join(input.projectDir, worktreeFor(slug)) + if (await pathExists(worktreeDir)) { + return { ok: false, code: "TASK_SLUG_CONFLICT", message: `worktree ${worktreeDir} already exists; choose a different title` } + } + if (await branchExists(branchFor(slug, "feat"))) { + return { ok: false, code: "TASK_SLUG_CONFLICT", message: `branch ${branchFor(slug, "feat")} already exists; choose a different title` } + } + + let criteria: AcceptanceCriterion[] = [] + if (input.acceptanceCriteriaJson !== undefined) { + const parsed = parseAcceptanceCriteria(input.acceptanceCriteriaJson) + if (parsed === null) { + return { ok: false, code: "POLICY_INVALID", message: "acceptance_criteria must be a JSON array of {id, description, verification}" } + } + criteria = parsed + } + + const body = buildTaskBody(slug, criteria, input.dependencies ?? []) + const created = await createTaskRecord({ title: slug, body, parentIssueNumber: input.parentIssueNumber }) + if (!created.ok) { + return { ok: false, code: "ISSUE_CREATE_FAILED", message: created.error } + } + return { ok: true, ref: created.ref } +} + +/** 本地分支是否已存在(rev-parse 成功且有输出) */ +async function branchExists(branch: string): Promise { + try { + const { stdout } = await taskGit(`rev-parse --verify refs/heads/${branch}`) + return stdout.trim() !== "" + } catch { + return false + } +} + +// ─── start-task ─── + +export type StartTaskResult = + | { + ok: true + issueNumber: number + branch: string + worktreePath: string + policy: TddPolicy + baseline: WorkspaceBaseline + } + | { ok: false; code: string; message: string } + +/** + * start-task(spec §2.3):前置门禁(Planning Baseline 已合并、依赖 Task 已 merged、并行数 <5)→ + * worktree-start → captureWorkspaceBaseline → freezeTddPolicy → 写 Task Record 状态。 + */ +export async function startTask( + projectDir: string, + parentIssueNumber: number, + taskId: string, +): Promise { + const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId) + if (issueNumber === null) { + return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` } + } + + const taskBody = await readTaskBody(issueNumber) + const profile = await readProjectProfile(projectDir) + + // 门禁 1:Planning Baseline 已合并(design 阶段完成 = Planning PR 已合入) + const record = await readFlowRecord(parentIssueNumber) + if (!record.ok || !getCompletedStages(record.body).includes("design")) { + return { + ok: false, + code: "PLANNING_BASELINE_NOT_MERGED", + message: "Planning Baseline (design stage) must be merged before starting tasks", + } + } + + // 门禁 2:依赖 Task 已 merged(依赖 Issue state 必须为 CLOSED) + const deps = parseDependenciesFromBody(taskBody) + for (const dep of deps) { + const state = await readIssueState(dep) + if (state !== "CLOSED") { + return { ok: false, code: "DEPENDENCY_NOT_MERGED", message: `dependency task #${dep} is not merged (state: ${state})` } + } + } + + // 门禁 3:并行数 < 5(running/reviewing 标签计数) + const running = await countRunningTasks(parentIssueNumber) + if (running >= MAX_PARALLEL_TASKS) { + return { + ok: false, + code: "PARALLEL_LIMIT_REACHED", + message: `parallel task limit (${MAX_PARALLEL_TASKS}) reached (${running} running/reviewing)`, + } + } + + const policy = freezeTddPolicy(profile) + const base = await readDefaultBranch() + const wt = await worktreeStart({ projectDir, slug: taskId, base }) + if (!wt.ok) { + return { ok: false, code: wt.code, message: wt.message } + } + + const baseline = await captureWorkspaceBaseline(wt.path, { + testFilePatterns: policy.testFilePatterns, + implementationFilePatterns: policy.implementationFilePatterns, + generatedArtifactPatterns: policy.generatedArtifactPatterns, + }) + + // 跨批协议:将 policy/criteria/worktree 写入 Task Record body 的 cabbage-task-tdd 块(tdd_checkpoint 解析) + const criteria = parseCriteriaFromBody(taskBody) + const tddBlock = buildTaskTddBlock(policy, criteria, wt.path) + const updatedBody = taskBody.includes(TASK_TDD_TAG) + ? taskBody + : `${taskBody}\n${tddBlock}\n` + const bodyUpdate = await updateTaskBody(issueNumber, updatedBody) + if (!bodyUpdate.ok) { + return { ok: false, code: "TASK_TDD_BLOCK_WRITE_FAILED", message: bodyUpdate.error ?? "failed to write TDD block" } + } + + await writeTaskState(projectDir, { + slug: taskId, + parentIssueNumber, + issueNumber, + branch: wt.branch, + worktreePath: wt.path, + policy, + baseline, + startedAt: new Date().toISOString(), + }) + + const label = await setTaskStatusLabel(issueNumber, "running") + if (!label.ok) { + return { ok: false, code: "LABEL_UPDATE_FAILED", message: label.error ?? "label update failed" } + } + + return { ok: true, issueNumber, branch: wt.branch, worktreePath: wt.path, policy, baseline } +} + +/** 读 Issue 状态(CLOSED/OPEN) */ +async function readIssueState(issueNumber: number): Promise { + const { stdout } = await taskGh(`issue view ${issueNumber} --json state --jq .state`) + return stdout.trim() +} + +/** 统计 flow 下 running/reviewing 的 Task 数(并行门禁) */ +async function countRunningTasks(parentIssueNumber: number): Promise { + const { stdout } = await taskGh( + `issue list --parent ${parentIssueNumber} --json number,labels --jq '[.[] | {number, labels: [.labels[].name]}]'`, + ) + const issues = JSON.parse(stdout) as Array<{ labels: string[] }> + return issues.filter(i => + i.labels.some(l => l === `${TASK_STATUS_LABEL_PREFIX}running` || l === `${TASK_STATUS_LABEL_PREFIX}reviewing`), + ).length +} + +// ─── submit-task ─── + +export type SubmitTaskResult = + | { ok: true; prNumber: number; evidenceRevision: number } + | { ok: false; code: string; message: string; missing?: string[] } + +/** + * submit-task(spec §4.4):TDD 硬门禁——evidence 不完整(无 comment、regression 未 pass、 + * compliance 未 pass)→ 拒绝创建 PR;通过后 push 分支 → gh pr create(body 引用 Task Record + + * evidence comment 链接,含 Closes 约定)→ 标记 reviewing。 + */ +export async function submitTask( + projectDir: string, + parentIssueNumber: number, + taskId: string, +): Promise { + const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId) + if (issueNumber === null) { + return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` } + } + + const state = await readTaskState(projectDir, taskId) + if (!state) { + return { ok: false, code: "TASK_NOT_STARTED", message: `task "${taskId}" was not started (run start-task first)` } + } + + const taskBody = await readTaskBody(issueNumber) + const criteria = parseCriteriaFromBody(taskBody) + + // TDD 硬门禁(§4.4):evidence comment 存在且状态可解析;waived(豁免)放行 + const comment = await readTddEvidenceComment(issueNumber) + if (!comment) { + return { + ok: false, + code: "TDD_EVIDENCE_INCOMPLETE", + message: "no TDD evidence comment found on the task record", + missing: ["evidence-comment"], + } + } + + // 跨批协议(与 tdd_checkpoint 一致):先提取 evidence block 内容(不含 end marker), + // 再解析其中 JSON 状态块(cabbage-tdd-state,取最后一个);失败则回退旧 markdown 有损解析 + const evidenceBlock = extractEvidenceBlock(comment.body) + const evidence = + (evidenceBlock ? parseJsonStateEvidence(evidenceBlock.content) : null) ?? evidenceCommentToTddEvidence(comment) + if (!evidence || evidence.revision < 1) { + return { + ok: false, + code: "TDD_EVIDENCE_INCOMPLETE", + message: "evidence comment is malformed or has no revision", + missing: ["evidence-revision"], + } + } + + // 豁免语义:not-applicable / exempt-request → waived → 放行 + if (evidence.status === "waived") { + return submitPr(projectDir, state, issueNumber, comment, evidence.revision, evidence, taskId) + } + + const compliance = evaluateTddCompliance(state.policy, evidence, criteria) + if (compliance.status !== "pass") { + return { + ok: false, + code: "TDD_EVIDENCE_INCOMPLETE", + message: `TDD compliance not met: ${compliance.warnings.join("; ")}`, + missing: compliance.warnings.length > 0 ? compliance.warnings : ["tdd-compliance"], + } + } + + return submitPr(projectDir, state, issueNumber, comment, evidence.revision, evidence, taskId) +} + +/** 解析 evidence comment 内 JSON 状态块(TDD_STATE_TAG,取最后一个),失败返回 null;schema 必须为 1 */ +function parseJsonStateEvidence(body: string): TddEvidence | null { + const marker = "" + const idx = body.lastIndexOf(marker) + if (idx === -1) return null + try { + const parsed = JSON.parse(body.slice(idx + marker.length).trim()) as { schema?: number; evidence?: TddEvidence } + if (parsed.schema !== 1 || !parsed?.evidence) return null + return { ...parsed.evidence, revision: parsed.evidence.revision ?? 1 } + } catch { + return null + } +} + +/** 创建 PR 的公共流程(evidence 通过后) */ +async function submitPr( + projectDir: string, + state: TaskRuntimeState, + issueNumber: number, + comment: { id: number }, + revision: number, + _evidence: TddEvidence, + taskId: string, +): Promise { + // push 分支(工具内以宿主凭据执行) + try { + await gitIn(state.worktreePath, `push -u origin ${state.branch}`) + } catch (err) { + return { ok: false, code: "PUSH_FAILED", message: String(err) } + } + + // 创建 PR:body 引用 Task Record + evidence comment 链接 + Closes 约定 + const base = await readDefaultBranch() + const { stdout: repoOut } = await taskGh("repo view --json nameWithOwner --jq .nameWithOwner") + const repo = repoOut.trim() + const evidenceLink = `https://github.com/${repo}/issues/${issueNumber}#issuecomment-${comment.id}` + const prTitle = `feat: ${taskId}` + const prBody = [`# ${taskId}`, "", `- Task Record: #${issueNumber}`, `- TDD evidence: ${evidenceLink}`, "", `Closes #${issueNumber}`, ""].join("\n") + const { stdout: prOut } = await taskGh( + `pr create --title '${escapeShellArg(prTitle)}' --body '${escapeShellArg(prBody)}' --base ${base} --head ${state.branch} --json number --jq .number`, + ) + const prNumber = Number(prOut.trim()) + if (!Number.isInteger(prNumber)) { + return { ok: false, code: "PR_CREATE_FAILED", message: `invalid PR number: ${prOut}` } + } + + const label = await setTaskStatusLabel(issueNumber, "reviewing") + if (!label.ok) { + return { ok: false, code: "LABEL_UPDATE_FAILED", message: label.error ?? "label update failed" } + } + + return { ok: true, prNumber, evidenceRevision: revision } +} + +// ─── submit-review ─── + +export type SubmitReviewResult = + | { ok: true; prNumber: number; verdict: "approve" | "request-changes" } + | { ok: false; code: string; message: string } + +/** submit-review(spec §2.3):primary 提交 reviewer 双轴审查结果(reviewer 本身只读) */ +export async function submitReview( + prNumber: number, + verdict: "approve" | "request-changes", + comment?: string, +): Promise { + try { + if (verdict === "approve") { + await taskGh(`pr review ${prNumber} --approve`) + } else { + const body = comment ? ` --body '${escapeShellArg(comment)}'` : "" + await taskGh(`pr review ${prNumber} --request-changes${body}`) + } + return { ok: true, prNumber, verdict } + } catch (err) { + return { ok: false, code: "REVIEW_SUBMIT_FAILED", message: String(err) } + } +} + +// ─── merge-task ─── + +export type MergeTaskResult = + | { ok: true; prNumber: number; risk: "low" | "high"; warning?: string } + | { ok: false; code: string; message: string } + +/** + * merge-task(spec §2.3 + PRD R8):CI checks 通过 + 分支保护 + 风险双层判定(只升不降)+ + * 高风险需非作者人类 approval → mergeTaskPR(--match-head-commit)→ 关闭 Sub Issue → + * worktree 干净销毁。 + */ +export async function mergeTask( + projectDir: string, + parentIssueNumber: number, + taskId: string, + modelRisk: "low" | "high", +): Promise { + const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId) + if (issueNumber === null) { + return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` } + } + const state = await readTaskState(projectDir, taskId) + if (!state) { + return { ok: false, code: "TASK_NOT_STARTED", message: `task "${taskId}" was not started (run start-task first)` } + } + + // 1. 定位 open PR(head 分支) + const { stdout: prOut } = await taskGh( + `pr list --head ${state.branch} --state open --json number,headRefOid,author --jq '[.[] | {number, headRefOid, author}] | first'`, + ) + if (prOut.trim() === "" || prOut.trim() === "null") { + return { ok: false, code: "PR_NOT_FOUND", message: `no open PR for branch ${state.branch}` } + } + const pr = JSON.parse(prOut) as { number: number; headRefOid: string; author: { login: string } } + + // 2. CI checks 全部 SUCCESS + const checks = await readPrChecks(pr.number) + const failing = checks.filter(c => c.state !== "SUCCESS") + if (failing.length > 0) { + return { + ok: false, + code: "CI_CHECKS_NOT_PASSED", + message: `CI checks not passed: ${failing.map(c => `${c.name}:${c.state}`).join(", ")}`, + } + } + + // 3. 分支保护存在 + const { stdout: repoOut } = await taskGh("repo view --json nameWithOwner --jq .nameWithOwner") + const [owner, repo] = repoOut.trim().split("/") + const protection = await checkBranchProtection(owner, repo) + if (!protection.exists) { + return { ok: false, code: "BRANCH_PROTECTION_REQUIRED", message: "branch protection is not enabled on the default branch" } + } + + // 4. 风险双层判定:模型初判 + 内核按实际 diff 升级(只升不降) + const profile = await readProjectProfile(projectDir) + const changedFiles = await readPrChangedFiles(pr.number) + const risk = escalateRisk(modelRisk, changedFiles, [...BUILTIN_RISK_PATTERNS, ...profile.riskPatterns]) + + // 5. 高风险需非作者人类 approval + if (risk === "high") { + const approved = await hasNonAuthorHumanApproval(pr.number, pr.author.login) + if (!approved) { + return { + ok: false, + code: "HIGH_RISK_APPROVAL_REQUIRED", + message: `high-risk PR requires a non-author human approval (author: ${pr.author.login})`, + } + } + } + + // 6. merge(--match-head-commit 防偷换) + const merged = await mergeTaskPR(pr.number, pr.headRefOid) + if (!merged.success) { + return { ok: false, code: "MERGE_FAILED", message: merged.error ?? "merge failed" } + } + + // 7. 关闭 Sub Issue + 状态 merged + await taskGh(`issue close ${issueNumber}`) + await setTaskStatusLabel(issueNumber, "merged") + + // 8. worktree 干净销毁(PR 已合并且干净 → 自动);失败仅警告,不阻塞合并结果 + const cleanup = await destroyWorktree({ worktreeDir: state.worktreePath, branch: state.branch, prMerged: true, userConfirmed: false }) + if (!cleanup.ok && cleanup.reason === "DIRTY_WORKTREE") { + return { ok: true, prNumber: pr.number, risk, warning: "PR merged but worktree has uncommitted changes; clean it manually" } + } + + return { ok: true, prNumber: pr.number, risk } +} + +/** 读 PR 的 statusCheckRollup 精简列表(CheckRun 用 conclusion、StatusContext 用 state) */ +async function readPrChecks(prNumber: number): Promise> { + const { stdout } = await taskGh( + `pr view ${prNumber} --json statusCheckRollup --jq '[.[] | {name: (.name // .context // "check"), state: (.state // .conclusion // "PENDING")}]'`, + ) + return JSON.parse(stdout) as Array<{ name: string; state: string }> +} + +/** 将原始 statusCheckRollup 映射为 {name, state}(纯函数,供测试直接验证 jq 语义) */ +export function mapCheckRollup(raw: unknown[]): Array<{ name: string; state: string }> { + return raw.map(item => { + const obj = (item ?? {}) as Record + return { + name: String(obj.name ?? obj.context ?? "check"), + state: String(obj.state ?? obj.conclusion ?? "PENDING"), + } + }) +} + +/** 读 PR 变更文件路径列表(风险判定用) */ +async function readPrChangedFiles(prNumber: number): Promise { + const { stdout } = await taskGh(`pr view ${prNumber} --json files --jq '[.files[].path]'`) + return JSON.parse(stdout) as string[] +} + +/** 是否存在非作者的人类 APPROVED review(高风险门禁) */ +async function hasNonAuthorHumanApproval(prNumber: number, authorLogin: string): Promise { + const { stdout } = await taskGh( + `pr view ${prNumber} --json author,reviews --jq '{author: .author.login, reviews: [.reviews[] | {state: .state, login: .author.login, type: .author.type}]}'`, + ) + const data = JSON.parse(stdout) as { + author: string + reviews: Array<{ state: string; login: string; type: string }> + } + return data.reviews.some(r => r.state === "APPROVED" && r.login !== data.author && r.type === "User") +} + +// ─── cancel-task / destroy-worktree / status-task ─── + +export type TaskLifecycleResult = + | { ok: true; issueNumber: number } + | { ok: false; code: string; message: string } + +export type DestroyTaskWorktreeResult = + | { ok: true; reason: string } + | { ok: false; code: string; message: string } + +export interface TaskStatusReport { + task: { issueNumber: number; slug: string; state: string; labels: string[] } + pullRequests: Array<{ number: number; state: string; headRefName: string; checks: Array<{ name: string; state: string }> }> +} + +export type TaskStatusResult = + | { ok: true; report: TaskStatusReport } + | { ok: false; code: string; message: string } + +/** cancel-task:受控取消(user_confirmed)→ 关闭 Sub Issue + 状态标签 cancelled */ +export async function cancelTask( + projectDir: string, + parentIssueNumber: number, + taskId: string, + userConfirmed: boolean, +): Promise { + if (!userConfirmed) { + return { + ok: false, + code: "USER_CONFIRMATION_REQUIRED", + message: "Cancelling a task requires user confirmation (user_confirmed: true)", + } + } + const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId) + if (issueNumber === null) { + return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` } + } + try { + await taskGh(`issue close ${issueNumber}`) + await setTaskStatusLabel(issueNumber, "cancelled") + return { ok: true, issueNumber } + } catch (err) { + return { ok: false, code: "GITHUB_ERROR", message: String(err) } + } +} + +/** destroy-worktree:preflight 销毁(§3.3),需 user_confirmed(除非 PR 已合并且干净) */ +export async function destroyTaskWorktree( + projectDir: string, + parentIssueNumber: number, + taskId: string, + userConfirmed: boolean, +): Promise { + const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId) + if (issueNumber === null) { + return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` } + } + const state = await readTaskState(projectDir, taskId) + if (!state) { + return { ok: false, code: "TASK_NOT_STARTED", message: `task "${taskId}" was not started (run start-task first)` } + } + + const prMerged = await isPrMerged(state.branch) + const result = await destroyWorktree({ + worktreeDir: state.worktreePath, + branch: state.branch, + prMerged, + userConfirmed, + }) + if (!result.ok) { + return { ok: false, code: result.reason, message: result.message } + } + return { ok: true, reason: result.reason } +} + +/** 分支是否有已合并的 PR */ +async function isPrMerged(branch: string): Promise { + const { stdout } = await taskGh(`pr list --head ${branch} --state merged --json number --jq 'length'`) + return Number(stdout.trim()) > 0 +} + +/** status-task:读 Task Record + PR + checks 汇总 */ +export async function readTaskStatus( + projectDir: string, + parentIssueNumber: number, + taskId: string, +): Promise { + const issueNumber = await resolveTaskIssue(parentIssueNumber, taskId) + if (issueNumber === null) { + return { ok: false, code: "TASK_NOT_FOUND", message: `no sub issue with title "${taskId}" under parent #${parentIssueNumber}` } + } + try { + const { stdout: issueOut } = await taskGh( + `issue view ${issueNumber} --json number,title,state,labels,body --jq '{number, title, state, labels: [.labels[].name], body}'`, + ) + const issue = JSON.parse(issueOut) as { state: string; labels: string[] } + + const branch = `feat/${taskId}` + const { stdout: prsOut } = await taskGh( + `pr list --head ${branch} --state all --json number,state,headRefName --jq '[.[] | {number, state, headRefName}]'`, + ) + const prs = JSON.parse(prsOut) as Array<{ number: number; state: string; headRefName: string }> + + const pullRequests: TaskStatusReport["pullRequests"] = [] + for (const pr of prs) { + const checks = await readPrChecks(pr.number) + pullRequests.push({ ...pr, checks }) + } + + return { + ok: true, + report: { task: { issueNumber, slug: taskId, state: issue.state, labels: issue.labels }, pullRequests }, + } + } catch (err) { + const message = String(err) + return message.includes("Could not resolve") || message.includes("not found") + ? { ok: false, code: "NOT_FOUND", message } + : { ok: false, code: "GITHUB_ERROR", message } + } +} diff --git a/src/plugin/task-control.ts b/src/plugin/task-control.ts new file mode 100644 index 0000000..144accf --- /dev/null +++ b/src/plugin/task-control.ts @@ -0,0 +1,256 @@ +/** + * task_control 工具(spec §2.3):Task Record + worktree + PR + merge(核心)。 + * 八 op:create-task / start-task / submit-task / submit-review / + * merge-task / cancel-task / destroy-worktree / status-task。 + * caller:primary 全量;developer 不允许调用 task_control(无门禁绕过面)。 + * server.ts 接线由后续批次统一处理(本批不注册,避免文件冲突)。 + */ +import { tool } from "@opencode-ai/plugin/tool" +import { requireToolCaller, CALLER_NOT_AUTHORIZED } from "../kernel/caller.js" +import { + createTask, + startTask, + submitTask, + submitReview, + mergeTask, + cancelTask, + destroyTaskWorktree, + readTaskStatus, + parseDependenciesArg, +} from "../kernel/task.js" + +/** task_control 工具依赖 */ +export interface TaskControlDeps { + projectDir: string + sessionClient: { + session: { + get(input: { sessionID: string }): Promise<{ data?: { parentID?: string | null } }> + } + } +} + +export type TaskControlResponse = { + ok: boolean + task?: { parentIssueNumber?: number; taskId?: string; issueNumber?: number } + issueNumber?: number + branch?: string + worktreePath?: string + policy?: unknown + baseline?: unknown + prNumber?: number + evidenceRevision?: number + verdict?: string + risk?: string + warning?: string + reason?: string + report?: unknown + missing?: string[] + error?: { code: string; message: string } +} + +/** + * task_control 工具工厂(spec §2.3)。 + * caller 门禁:primary 全量;developer 拒绝(无门禁绕过面)。 + */ +export function createTaskControlTool(deps: TaskControlDeps) { + return tool({ + description: `Control the Task Record lifecycle (a Task = one GitHub Sub Issue under a Flow Parent Issue). + +Operations: +- create-task: Validate the functional title, derive the slug, and create a Sub Issue (body contains acceptance criteria, dependencies, and the Closes convention) linked to the parent. +- start-task: Gate checks (Planning Baseline merged, dependencies merged, parallel < 5), then worktree-start, capture the clean workspace baseline, freeze the TDD policy, and mark the task running. +- submit-task: TDD hard gate — reject PR creation when evidence is incomplete (no comment / regression not pass / compliance fail). On pass: push the branch, create the PR (body references the Task Record + evidence comment link, Closes convention), and mark reviewing. +- submit-review: primary submits the reviewer's dual-axis result via gh pr review --approve/--request-changes (the reviewer itself is read-only). +- merge-task: Merge gates (CI checks pass + branch protection + dual-layer risk escalation, only up not down; high risk needs a non-author human approval) → merge PR (--match-head-commit) → close the Sub Issue → cleanly destroy the worktree. +- cancel-task / destroy-worktree: controlled cancellation / preflight destruction, both require user_confirmed (unless the PR is merged and clean). +- status-task: Aggregate the Task Record, PR, and checks into one report. + +Caller: primary only (developer has no task_control access).`, + args: { + op: tool.schema + .enum(["create-task", "start-task", "submit-task", "submit-review", "merge-task", "cancel-task", "destroy-worktree", "status-task"]) + .describe("Task control operation"), + title: tool.schema.string().optional().describe("Functional title (kebab-case slug) for create-task"), + parent_issue_number: tool.schema.number().optional().describe("Parent GitHub Issue number of the Flow Record"), + task_id: tool.schema.string().optional().describe("Task slug (kernel-derived)"), + acceptance_criteria: tool.schema.string().optional().describe("JSON array of acceptance criteria for create-task"), + depends_on: tool.schema.string().optional().describe("Comma-separated dependency issue numbers for create-task"), + pr_number: tool.schema.number().optional().describe("PR number (submit-review/status)"), + risk: tool.schema.enum(["low", "high"]).optional().describe("Model-assessed risk for merge-task (kernel may escalate, never downgrade)"), + verdict: tool.schema.enum(["approve", "request-changes"]).optional().describe("Review verdict for submit-review"), + comment: tool.schema.string().optional().describe("Review comment for submit-review request-changes"), + user_confirmed: tool.schema.boolean().optional().describe("User confirmation (required for cancel-task/destroy-worktree)"), + }, + async execute(args, ctx) { + const op = args.op as string + + // caller 门禁(矩阵单一来源,§2.2):task_control 仅 primary(developer 拒绝) + const denied = await requireToolCaller(ctx, "task_control", op, deps.sessionClient) + if (denied) return errorResponse(CALLER_NOT_AUTHORIZED, denied) + + try { + switch (op) { + case "create-task": + return handleCreateTask(deps, args) + case "start-task": + return handleStartTask(deps, args) + case "submit-task": + return handleSubmitTask(deps, args) + case "submit-review": + return handleSubmitReview(args) + case "merge-task": + return handleMergeTask(deps, args) + case "cancel-task": + return handleCancelTask(deps, args) + case "destroy-worktree": + return handleDestroyWorktree(deps, args) + case "status-task": + return handleStatusTask(deps, args) + default: + return errorResponse("UNKNOWN_OP", `Unknown op: "${op}"`) + } + } catch (err) { + return errorResponse("INTERNAL_ERROR", String(err)) + } + }, + }) +} + +/** 独立注册辅助:把 task_control 挂到工具注册表(后续接线批次使用) */ +export function registerTaskControl(registry: Record, deps: TaskControlDeps): void { + registry.task_control = createTaskControlTool(deps) +} + +// ─── op handlers ─── + +async function handleCreateTask(deps: TaskControlDeps, args: Record): Promise { + const parent = Number(args.parent_issue_number) + const title = (args.title as string | undefined) ?? "" + if (!Number.isInteger(parent) || parent <= 0) { + return errorResponse("POLICY_INVALID", "parent_issue_number is required") + } + if (title.trim() === "") { + return errorResponse("POLICY_INVALID", "title is required for create-task") + } + + const result = await createTask({ + projectDir: deps.projectDir, + parentIssueNumber: parent, + title, + acceptanceCriteriaJson: args.acceptance_criteria as string | undefined, + dependencies: parseDependenciesArg(args.depends_on as string | undefined), + }) + if (!result.ok) { + return errorResponse(result.code, result.message) + } + return okResponse({ task: result.ref }) +} + +async function handleStartTask(deps: TaskControlDeps, args: Record): Promise { + const req = requireTaskRef(args) + if (typeof req === "string") return req + + const result = await startTask(deps.projectDir, req.parent, req.taskId) + if (!result.ok) { + return errorResponse(result.code, result.message) + } + return okResponse({ issueNumber: result.issueNumber, branch: result.branch, worktreePath: result.worktreePath, policy: result.policy, baseline: result.baseline }) +} + +async function handleSubmitTask(deps: TaskControlDeps, args: Record): Promise { + const req = requireTaskRef(args) + if (typeof req === "string") return req + + const result = await submitTask(deps.projectDir, req.parent, req.taskId) + if (!result.ok) { + return errorResponse(result.code, result.message, result.missing) + } + return okResponse({ prNumber: result.prNumber, evidenceRevision: result.evidenceRevision }) +} + +async function handleSubmitReview(args: Record): Promise { + const pr = Number(args.pr_number) + const verdict = args.verdict as "approve" | "request-changes" | undefined + if (!Number.isInteger(pr) || pr <= 0) { + return errorResponse("POLICY_INVALID", "pr_number is required") + } + if (verdict !== "approve" && verdict !== "request-changes") { + return errorResponse("POLICY_INVALID", "verdict is required: approve|request-changes") + } + + const result = await submitReview(pr, verdict, args.comment as string | undefined) + if (!result.ok) { + return errorResponse(result.code, result.message) + } + return okResponse({ prNumber: result.prNumber, verdict: result.verdict }) +} + +async function handleMergeTask(deps: TaskControlDeps, args: Record): Promise { + const req = requireTaskRef(args) + if (typeof req === "string") return req + const risk = args.risk === "high" ? "high" : "low" + + const result = await mergeTask(deps.projectDir, req.parent, req.taskId, risk) + if (!result.ok) { + return errorResponse(result.code, result.message) + } + return okResponse({ prNumber: result.prNumber, risk: result.risk, ...(result.warning ? { warning: result.warning } : {}) }) +} + +async function handleCancelTask(deps: TaskControlDeps, args: Record): Promise { + const req = requireTaskRef(args) + if (typeof req === "string") return req + + const result = await cancelTask(deps.projectDir, req.parent, req.taskId, args.user_confirmed === true) + if (!result.ok) { + return errorResponse(result.code, result.message) + } + return okResponse({ issueNumber: result.issueNumber }) +} + +async function handleDestroyWorktree(deps: TaskControlDeps, args: Record): Promise { + const req = requireTaskRef(args) + if (typeof req === "string") return req + + const result = await destroyTaskWorktree(deps.projectDir, req.parent, req.taskId, args.user_confirmed === true) + if (!result.ok) { + return errorResponse(result.code, result.message) + } + return okResponse({ reason: result.reason }) +} + +async function handleStatusTask(deps: TaskControlDeps, args: Record): Promise { + const req = requireTaskRef(args) + if (typeof req === "string") return req + + const result = await readTaskStatus(deps.projectDir, req.parent, req.taskId) + if (!result.ok) { + return errorResponse(result.code, result.message) + } + return okResponse({ report: result.report }) +} + +// ─── helpers ─── + +/** 提取 parent_issue_number + task_id;缺失返回错误响应字符串 */ +function requireTaskRef(args: Record): { parent: number; taskId: string } | string { + const parent = Number(args.parent_issue_number) + const taskId = (args.task_id as string | undefined) ?? "" + if (!Number.isInteger(parent) || parent <= 0) { + return errorResponse("POLICY_INVALID", "parent_issue_number is required") + } + if (taskId.trim() === "") { + return errorResponse("POLICY_INVALID", "task_id is required") + } + return { parent, taskId } +} + +function okResponse(overrides: Partial = {}): string { + const resp: TaskControlResponse = { ok: true, ...overrides } + return JSON.stringify(resp, null, 2) +} + +function errorResponse(code: string, message: string, missing?: string[]): string { + const resp: TaskControlResponse = { ok: false, error: { code, message }, ...(missing ? { missing } : {}) } + return JSON.stringify(resp, null, 2) +} diff --git a/test/plugin/task-control.test.ts b/test/plugin/task-control.test.ts new file mode 100644 index 0000000..ae3f3d4 --- /dev/null +++ b/test/plugin/task-control.test.ts @@ -0,0 +1,1104 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + buildTaskBody, + parseAcceptanceCriteria, + parseCriteriaFromBody, + parseDependenciesFromBody, + escalateRisk, + matchRiskPattern, + freezeTddPolicy, + parseEvidenceComment, + evidenceCommentToTddEvidence, + mapCheckRollup, + BUILTIN_RISK_PATTERNS, + startTask, + submitTask, + submitReview, + mergeTask, + cancelTask, + destroyTaskWorktree, + readTaskStatus, + setTaskGhExecutor, + setTaskGitExecutor, + type TaskRuntimeState, +} from "../../src/kernel/task.js" +import type { ProjectProfile } from "../../src/kernel/profile.js" +import { createTaskControlTool, registerTaskControl } from "../../src/plugin/task-control.js" +import { setRecordsGhExecutor } from "../../src/kernel/records.js" +import { setWorktreeGitExecutor } from "../../src/kernel/worktree.js" +import { setMergeGhExecutor } from "../../src/flowrun/merge.js" +import type { CallerSessionClient } from "../../src/kernel/caller.js" + +const primaryClient: CallerSessionClient = { + session: { get: async () => ({ data: { parentID: null } }) }, +} +const childClient: CallerSessionClient = { + session: { get: async () => ({ data: { parentID: "parent" } }) }, +} + +function makeCtx(agent: string, sessionID: string) { + return { + agent, + sessionID, + messageID: "m1", + directory: ".", + worktree: ".", + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + } +} + +async function executeOp( + dir: string, + op: string, + args: Record = {}, + client: CallerSessionClient = primaryClient, + agent = "dev-lifecycle", +): Promise> { + const t = createTaskControlTool({ projectDir: dir, sessionClient: client }) + const out = await t.execute({ op, ...args }, makeCtx(agent, "sess-1") as never) + return JSON.parse(String(out)) as Record +} + +async function withProjectDir(fn: (dir: string) => Promise) { + const dir = await mkdtemp(join(tmpdir(), "cabbage-task-")) + try { + await fn(dir) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} + +async function writeProfile(dir: string) { + await writeFile( + join(dir, "AGENTS.md"), + "## Project Profile\n\n- test command: `cabbage-test-tool run`\n- test file patterns: `test/**/*.test.ts`\n- implementation file patterns: `src/**/*.ts`\n- tdd default mode: `strict`\n", + ) +} + +function expectAllExecutorsThrow() { + setTaskGhExecutor(() => { + throw new Error("unexpected task gh") + }) + setTaskGitExecutor(() => { + throw new Error("unexpected task git") + }) + setRecordsGhExecutor(() => { + throw new Error("unexpected records gh") + }) + setWorktreeGitExecutor(() => { + throw new Error("unexpected worktree git") + }) + setMergeGhExecutor(() => { + throw new Error("unexpected merge gh") + }) +} + +function resetAllExecutors() { + setTaskGhExecutor(null) + setTaskGitExecutor(null) + setRecordsGhExecutor(null) + setWorktreeGitExecutor(null) + // flowrun/merge.ts 的 setMergeGhExecutor 不接受 null,用恒抛错的 stub 兜底 + setMergeGhExecutor(() => { + throw new Error("unexpected merge gh") + }) +} + +describe("task kernel pure functions", () => { + describe("buildTaskBody", () => { + it("renders criteria, dependencies, status checklist, and the Closes convention", () => { + const body = buildTaskBody("flow-control-tool", [{ id: "c1", description: "registers ops", verification: "tdd" }], [12]) + expect(body).toContain("# Task: flow-control-tool") + expect(body).toContain("- [ ] c1: registers ops [tdd]") + expect(body).toContain("- #12") + expect(body).toContain("- [ ] running") + expect(body).toContain("- [ ] merged") + expect(body).toContain("Closes #") + }) + }) + + describe("parseAcceptanceCriteria", () => { + it("parses a valid JSON array", () => { + expect(parseAcceptanceCriteria('[{"id":"c1","description":"x","verification":"tdd"}]')).toEqual([ + { id: "c1", description: "x", verification: "tdd" }, + ]) + }) + it("rejects malformed JSON or invalid verification", () => { + expect(parseAcceptanceCriteria("not json")).toBeNull() + expect(parseAcceptanceCriteria('[{"id":"c1","description":"x","verification":"bogus"}]')).toBeNull() + }) + }) + + describe("parseCriteriaFromBody", () => { + it("round-trips criteria written by buildTaskBody", () => { + const body = buildTaskBody("slug", [{ id: "c1", description: "x", verification: "tdd" }], []) + expect(parseCriteriaFromBody(body)).toEqual([{ id: "c1", description: "x", verification: "tdd" }]) + }) + it("ignores unchecked TBD lines", () => { + expect(parseCriteriaFromBody("- [ ] TBD")).toEqual([]) + }) + }) + + describe("parseDependenciesFromBody", () => { + it("extracts dependency issue numbers", () => { + const body = buildTaskBody("slug", [], [12, 13]) + expect(parseDependenciesFromBody(body)).toEqual([12, 13]) + }) + }) + + describe("mapCheckRollup", () => { + it("maps GitHub Actions CheckRuns (name + conclusion)", () => { + expect(mapCheckRollup([{ name: "verify", status: "COMPLETED", conclusion: "SUCCESS" }])) + .toEqual([{ name: "verify", state: "SUCCESS" }]) + }) + it("maps StatusContext (context + state)", () => { + expect(mapCheckRollup([{ context: "continuous-integration", state: "SUCCESS" }])) + .toEqual([{ name: "continuous-integration", state: "SUCCESS" }]) + }) + it("falls back to check/PENDING for missing fields", () => { + expect(mapCheckRollup([{ status: "IN_PROGRESS" }])) + .toEqual([{ name: "check", state: "PENDING" }]) + }) + }) + + describe("escalateRisk / matchRiskPattern", () => { + it("matches builtin risk patterns", () => { + expect(matchRiskPattern("src/db/migrations/001.sql", "**/migrations/**")).toBe(true) + expect(matchRiskPattern("src/service/auth/login.ts", "**/auth/**")).toBe(true) + expect(matchRiskPattern(".github/workflows/release.yml", ".github/workflows/**")).toBe(true) + expect(matchRiskPattern("src/util/fs.ts", "**/migrations/**")).toBe(false) + }) + it("upgrades low to high when the diff hits a risk pattern", () => { + expect(escalateRisk("low", ["src/db/migrations/001.sql"], BUILTIN_RISK_PATTERNS)).toBe("high") + }) + it("never downgrades a model high risk", () => { + expect(escalateRisk("high", ["README.md"], BUILTIN_RISK_PATTERNS)).toBe("high") + }) + it("keeps low when the diff is clean", () => { + expect(escalateRisk("low", ["src/util/fs.ts"], BUILTIN_RISK_PATTERNS)).toBe("low") + }) + it("respects profile risk patterns appended to builtins", () => { + expect(escalateRisk("low", ["src/money/calc.ts"], ["**/money/**"])).toBe("high") + }) + }) + + describe("freezeTddPolicy", () => { + it("defaults to strict runtime mode and carries profile patterns", () => { + const profile: ProjectProfile = { + testCommand: "npm test", + regressionCommand: null, + testFilePatterns: ["test/**/*.test.ts"], + implementationFilePatterns: ["src/**/*.ts"], + tddDefaultMode: null, + versionBumpRule: null, + versionFile: null, + tagFormat: null, + releaseWorkflowPath: null, + riskPatterns: [], + } + const policy = freezeTddPolicy(profile) + expect(policy.mode).toBe("strict") + expect(policy.enforcement).toBe("runtime") + expect(policy.runner?.baseCommand).toBe("npm test") + expect(policy.testFilePatterns).toEqual(["test/**/*.test.ts"]) + }) + }) + + describe("evidence parsing", () => { + it("parseEvidenceComment extracts structured blocks", () => { + const body = + " revision:3\n### stage: red\n- criterion: c1\n- status: red\n### stage: final-regression\n- status: pass\n" + const blocks = parseEvidenceComment(body) + expect(blocks).toHaveLength(2) + expect(blocks?.[0]).toMatchObject({ stage: "red", fields: { criterion: "c1", status: "red" } }) + }) + + it("evidenceCommentToTddEvidence builds pass evidence for complete blocks", () => { + const body = + " revision:3\n### stage: green\n- criterion: c1\n- cycle: cyc-1\n- status: pass\n### stage: final-regression\n- status: pass\n### stage: final-verification\n- status: pass\n" + const evidence = evidenceCommentToTddEvidence({ id: 5, body, revision: 3 }) + expect(evidence).not.toBeNull() + expect(evidence?.cycles).toHaveLength(1) + expect(evidence?.cycles[0].status).toBe("pass") + expect(evidence?.regression.status).toBe("pass") + expect(evidence?.verification.status).toBe("pass") + expect(evidence?.status).toBe("pass") + }) + + it("returns null for a body without the evidence marker", () => { + expect(evidenceCommentToTddEvidence({ id: 5, body: "no marker", revision: 1 })).toBeNull() + }) + }) +}) + +describe("createTaskControlTool (spec §2.3 task_control)", () => { + beforeEach(() => { + expectAllExecutorsThrow() + }) + + afterEach(() => { + resetAllExecutors() + }) + + it("defines the eight ops in the args schema", () => { + const t = createTaskControlTool({ projectDir: ".", sessionClient: primaryClient }) + const opSchema = t.args.op as { safeParse(value: unknown): { success: boolean } } + const ops = [ + "create-task", "start-task", "submit-task", "submit-review", + "merge-task", "cancel-task", "destroy-worktree", "status-task", + ] + for (const op of ops) expect(opSchema.safeParse(op).success).toBe(true) + expect(opSchema.safeParse("bogus").success).toBe(false) + }) + + it("rejects a developer caller with CALLER_NOT_AUTHORIZED", async () => { + await withProjectDir(async dir => { + const resp = await executeOp(dir, "start-task", { parent_issue_number: 12, task_id: "x" }, childClient, "developer") + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("CALLER_NOT_AUTHORIZED") + }) + }) + + it("returns UNKNOWN_OP for an unknown op", async () => { + await withProjectDir(async dir => { + const resp = await executeOp(dir, "bogus" as string) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("UNKNOWN_OP") + }) + }) + + describe("create-task", () => { + it("validates the title, derives the slug, and creates the sub issue linked to the parent", async () => { + await withProjectDir(async dir => { + setRecordsGhExecutor(async args => { + if (args.includes("issue create")) return { stdout: "13", stderr: "" } + throw new Error(`unexpected records gh: ${args}`) + }) + setTaskGitExecutor(async args => { + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task git: ${args}`) + }) + + const resp = await executeOp(dir, "create-task", { + parent_issue_number: 12, + title: "flow-control-tool", + acceptance_criteria: JSON.stringify([{ id: "c1", description: "registers ops", verification: "tdd" }]), + depends_on: "12", + }) + expect(resp.ok).toBe(true) + expect(resp.task).toMatchObject({ parentIssueNumber: 12, taskId: "flow-control-tool", issueNumber: 13 }) + }) + }) + + it("rejects a generic or malformed title", async () => { + await withProjectDir(async dir => { + const resp = await executeOp(dir, "create-task", { parent_issue_number: 12, title: "Task 001" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("INVALID_FORMAT") + }) + }) + + it("rejects a slug conflict when the feat branch already exists", async () => { + await withProjectDir(async dir => { + setTaskGitExecutor(async args => { + if (args.includes("rev-parse --verify refs/heads/feat/")) return { stdout: "abc123", stderr: "" } + throw new Error(`unexpected task git: ${args}`) + }) + const resp = await executeOp(dir, "create-task", { parent_issue_number: 12, title: "flow-control-tool" }) + expect(resp.ok).toBe(false) + expect(resp.error.code).toBe("TASK_SLUG_CONFLICT") + }) + }) + }) + + describe("start-task", () => { + it("runs the gates, creates the worktree, captures the baseline, and freezes the policy", async () => { + await withProjectDir(async dir => { + await writeProfile(dir) + await mkdir(join(dir, ".worktree", "user-auth-login"), { recursive: true }) + const taskBody = buildTaskBody("user-auth-login", [{ id: "c1", description: "x", verification: "tdd" }], [14]) + const parentBody = "## Stages\n\n- [x] requirements\n- [x] design\n- [ ] tasks" + const patchCalls: string[] = [] + setTaskGhExecutor(async args => { + if (args.includes("--json number,labels")) { + return { stdout: JSON.stringify([{ number: 20, labels: ["cabbage:task:running"] }]), stderr: "" } + } + if (args.includes("issue list")) return { stdout: "13", stderr: "" } // resolveTaskIssue + if (args.includes("issue view 13 --json body")) return { stdout: taskBody, stderr: "" } + if (args.includes("issue view 13 --json labels")) return { stdout: "", stderr: "" } + if (args.includes("issue edit 13")) return { stdout: "", stderr: "" } + if (args.includes("issues/13 -X PATCH")) { + patchCalls.push(args) + return { stdout: "", stderr: "" } + } + if (args.includes("issue view 14")) return { stdout: "CLOSED", stderr: "" } + if (args.includes("repo view")) return { stdout: "main", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setRecordsGhExecutor(async args => { + if (args.includes("issue view 12")) return { stdout: parentBody, stderr: "" } + throw new Error(`unexpected records gh: ${args}`) + }) + setWorktreeGitExecutor(async args => { + if (args.includes("rev-parse --abbrev-ref HEAD")) return { stdout: "feat/user-auth-login", stderr: "" } + throw new Error(`unexpected worktree git: ${args}`) + }) + + const result = await startTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.branch).toBe("feat/user-auth-login") + expect(result.issueNumber).toBe(13) + expect(result.policy.mode).toBe("strict") + expect(result.policy.enforcement).toBe("runtime") + expect(result.baseline.digest.algorithm).toBe("sha256-content-v1") + } + // 跨批协议:Task Record body 必须写入 cabbage-task-tdd 块(含 policy/criteria/worktreeDir) + expect(patchCalls.length).toBeGreaterThanOrEqual(1) + const patchBody = patchCalls.join("") + expect(patchBody).toContain("") + expect(patchBody).toContain('"policy"') + expect(patchBody).toContain("user-auth-login") + }) + }) + + it("rejects when the planning baseline is not merged (design incomplete)", async () => { + await withProjectDir(async dir => { + await writeProfile(dir) + const parentBody = "## Stages\n\n- [x] requirements\n- [ ] design" + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13")) return { stdout: buildTaskBody("x", [], []), stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setRecordsGhExecutor(async args => { + if (args.includes("issue view 12")) return { stdout: parentBody, stderr: "" } + throw new Error(`unexpected records gh: ${args}`) + }) + + const result = await startTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("PLANNING_BASELINE_NOT_MERGED") + }) + }) + + it("rejects when a dependency task is not merged", async () => { + await withProjectDir(async dir => { + await writeProfile(dir) + const taskBody = buildTaskBody("user-auth-login", [], [14]) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13")) return { stdout: taskBody, stderr: "" } + if (args.includes("issue view 14")) return { stdout: '"OPEN"', stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setRecordsGhExecutor(async args => { + if (args.includes("issue view 12")) return { stdout: "## Stages\n\n- [x] requirements\n- [x] design", stderr: "" } + throw new Error(`unexpected records gh: ${args}`) + }) + + const result = await startTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("DEPENDENCY_NOT_MERGED") + }) + }) + + it("rejects when the parallel limit is reached", async () => { + await withProjectDir(async dir => { + await writeProfile(dir) + setTaskGhExecutor(async args => { + if (args.includes("--json number,labels")) { + return { + stdout: JSON.stringify([ + { number: 1, labels: ["cabbage:task:running"] }, + { number: 2, labels: ["cabbage:task:running"] }, + { number: 3, labels: ["cabbage:task:reviewing"] }, + { number: 4, labels: ["cabbage:task:running"] }, + { number: 5, labels: ["cabbage:task:running"] }, + ]), + stderr: "", + } + } + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13")) return { stdout: buildTaskBody("x", [], []), stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setRecordsGhExecutor(async args => { + if (args.includes("issue view 12")) return { stdout: "## Stages\n\n- [x] requirements\n- [x] design", stderr: "" } + throw new Error(`unexpected records gh: ${args}`) + }) + + const result = await startTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("PARALLEL_LIMIT_REACHED") + }) + }) + }) + + describe("submit-task", () => { + it("rejects when no evidence comment exists", async () => { + await withProjectDir(async dir => { + await writeTaskStateForTest(dir) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13 --json body")) return { stdout: buildTaskBody("x", [], []), stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setRecordsGhExecutor(async args => { + if (args.includes("--json comments")) return { stdout: "null", stderr: "" } + throw new Error(`unexpected records gh: ${args}`) + }) + + const result = await submitTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("TDD_EVIDENCE_INCOMPLETE") + }) + }) + + it("rejects when final regression is not pass", async () => { + await withProjectDir(async dir => { + await writeTaskStateForTest(dir) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13 --json body")) { + return { stdout: buildTaskBody("x", [{ id: "c1", description: "x", verification: "tdd" }], []), stderr: "" } + } + throw new Error(`unexpected task gh: ${args}`) + }) + const regressionFailComment = + " revision:2\n### stage: red\n- criterion: c1\n- status: red\n### stage: final-regression\n- status: fail\n" + setRecordsGhExecutor(async args => { + if (args.includes("--json comments")) { + return { stdout: JSON.stringify({ id: 5, body: regressionFailComment }), stderr: "" } + } + throw new Error(`unexpected records gh: ${args}`) + }) + + const result = await submitTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("TDD_EVIDENCE_INCOMPLETE") + }) + }) + + it("pushes the branch, creates the PR referencing the task record, and marks reviewing", async () => { + await withProjectDir(async dir => { + await writeTaskStateForTest(dir) + const calls: string[] = [] + setTaskGhExecutor(async args => { + calls.push(args) + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13 --json body")) { + return { stdout: buildTaskBody("user-auth-login", [{ id: "c1", description: "x", verification: "tdd" }], []), stderr: "" } + } + if (args.includes("repo view")) return { stdout: "acme/repo", stderr: "" } + if (args.includes("pr create")) return { stdout: "42", stderr: "" } + if (args.includes("issue view 13 --json labels")) return { stdout: "", stderr: "" } + if (args.includes("issue edit 13")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setTaskGitExecutor(async args => { + if (args.includes("push -u origin")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task git: ${args}`) + }) + const completeComment = + " revision:3\n### stage: green\n- criterion: c1\n- cycle: cyc-1\n- status: pass\n### stage: final-regression\n- status: pass\n### stage: final-verification\n- status: pass\n" + setRecordsGhExecutor(async args => { + if (args.includes("--json comments")) { + return { stdout: JSON.stringify({ id: 5, body: completeComment }), stderr: "" } + } + throw new Error(`unexpected records gh: ${args}`) + }) + + const result = await submitTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.prNumber).toBe(42) + expect(result.evidenceRevision).toBe(3) + } + const createCall = calls.find(c => c.includes("pr create")) + expect(createCall).toBeDefined() + if (createCall) { + expect(createCall).toContain("--head feat/user-auth-login") + expect(createCall).toContain("Closes #13") + expect(createCall).toContain("#13") + } + expect(calls.some(c => c.includes("issue edit 13"))).toBe(true) + }) + }) + + it("parses JSON TDD state block (cabbage-tdd-state) taking the last block", async () => { + await withProjectDir(async dir => { + await writeTaskStateForTest(dir) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13 --json body")) { + return { stdout: buildTaskBody("user-auth-login", [{ id: "c1", description: "x", verification: "tdd" }], []), stderr: "" } + } + if (args.includes("repo view")) return { stdout: "acme/repo", stderr: "" } + if (args.includes("pr create")) return { stdout: "42", stderr: "" } + if (args.includes("issue view 13 --json labels")) return { stdout: "", stderr: "" } + if (args.includes("issue edit 13")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setTaskGitExecutor(async args => { + if (args.includes("push -u origin")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task git: ${args}`) + }) + const state = { + schema: 1, + evidence: { + revision: 4, + status: "pass", + reworkRevision: 0, + taskStart: { status: "pass", headSha: null, treeSha: null, startedAt: null }, + cycles: [{ + cycleId: "cyc-1", + criterionId: "c1", + status: "pass", + startWorkspaceDigest: { algorithm: "sha256-content-v1", value: "0".repeat(64) }, + redAttempts: [], + greenEvidence: null, + redEvidence: null, + }], + regression: { status: "pass", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: { status: "pass", headSha: null, treeSha: null, runs: [] }, + alternativeValidation: [], + reworks: [], + warnings: [], + updatedAt: null, + }, + } + const jsonComment = + ` revision:1\n### stage: green\n- status: pass\n\n${JSON.stringify(state)}\n` + setRecordsGhExecutor(async args => { + if (args.includes("--json comments")) { + return { stdout: JSON.stringify({ id: 6, body: jsonComment }), stderr: "" } + } + throw new Error(`unexpected records gh: ${args}`) + }) + + const result = await submitTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(true) + if (result.ok) expect(result.evidenceRevision).toBe(4) + }) + }) + + it("passes the gate for waived evidence (not-applicable / exempt)", async () => { + await withProjectDir(async dir => { + await writeTaskStateForTest(dir) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13 --json body")) { + return { stdout: buildTaskBody("user-auth-login", [{ id: "c1", description: "x", verification: "tdd" }], []), stderr: "" } + } + if (args.includes("repo view")) return { stdout: "acme/repo", stderr: "" } + if (args.includes("pr create")) return { stdout: "42", stderr: "" } + if (args.includes("issue view 13 --json labels")) return { stdout: "", stderr: "" } + if (args.includes("issue edit 13")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setTaskGitExecutor(async args => { + if (args.includes("push -u origin")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task git: ${args}`) + }) + const waivedState = { + schema: 1, + evidence: { + revision: 1, + status: "waived", + reworkRevision: 0, + taskStart: { status: "pass", headSha: null, treeSha: null, startedAt: null }, + cycles: [], + regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, + verification: { status: "pending", headSha: null, treeSha: null, runs: [] }, + alternativeValidation: [], + reworks: [], + warnings: ["not-applicable"], + updatedAt: null, + }, + } + const waivedComment = ` revision:1\n### stage: waived\n- status: waived\n\n${JSON.stringify(waivedState)}\n` + setRecordsGhExecutor(async args => { + if (args.includes("--json comments")) { + return { stdout: JSON.stringify({ id: 7, body: waivedComment }), stderr: "" } + } + throw new Error(`unexpected records gh: ${args}`) + }) + + const result = await submitTask(dir, 12, "user-auth-login") + expect(result.ok).toBe(true) + }) + }) + }) + + describe("submit-review", () => { + it("submits an approve review", async () => { + await withProjectDir(async dir => { + const calls: string[] = [] + setTaskGhExecutor(async args => { + calls.push(args) + if (args.includes("pr review 42 --approve")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + const resp = await executeOp(dir, "submit-review", { pr_number: 42, verdict: "approve" }) + expect(resp.ok).toBe(true) + expect(calls.some(c => c.includes("pr review 42 --approve"))).toBe(true) + }) + }) + + it("submits a request-changes review with a comment", async () => { + await withProjectDir(async dir => { + const calls: string[] = [] + setTaskGhExecutor(async args => { + calls.push(args) + if (args.includes("pr review 42 --request-changes")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + const resp = await executeOp(dir, "submit-review", { pr_number: 42, verdict: "request-changes", comment: "fix X" }) + expect(resp.ok).toBe(true) + expect(calls.some(c => c.includes("pr review 42 --request-changes") && c.includes("fix X"))).toBe(true) + }) + }) + }) + + describe("merge-task", () => { + const state = (dir: string, overrides: Partial = {}): TaskRuntimeState => ({ + slug: "user-auth-login", + parentIssueNumber: 12, + issueNumber: 13, + branch: "feat/user-auth-login", + worktreePath: join(dir, ".worktree", "user-auth-login"), + policy: freezeTddPolicy({ + testCommand: "cabbage-test-tool run", + regressionCommand: null, + testFilePatterns: [], + implementationFilePatterns: [], + tddDefaultMode: "strict", + versionBumpRule: null, + versionFile: null, + tagFormat: null, + releaseWorkflowPath: null, + riskPatterns: [], + }), + baseline: { + digest: { algorithm: "sha256-content-v1", value: "abc" }, + capturedAt: new Date().toISOString(), + testFilePatterns: [], + implementationFilePatterns: [], + }, + startedAt: new Date().toISOString(), + ...overrides, + }) + + async function writeStateFor(dir: string, overrides: Partial = {}) { + await mkdir(join(dir, ".opencode", "opencode-cabbage", "task-state"), { recursive: true }) + const { writeFile: wf } = await import("node:fs/promises") + await wf(join(dir, ".opencode", "opencode-cabbage", "task-state", "user-auth-login.json"), JSON.stringify(state(dir, overrides), null, 2), "utf8") + } + + it("rejects when CI checks are not all passing", async () => { + await withProjectDir(async dir => { + await writeStateFor(dir) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("pr list --head")) { + return { stdout: JSON.stringify({ number: 42, headRefOid: "sha1", author: { login: "dev" } }), stderr: "" } + } + if (args.includes("pr view 42 --json statusCheckRollup")) { + return { stdout: JSON.stringify([{ name: "CI", state: "PENDING" }]), stderr: "" } + } + throw new Error(`unexpected task gh: ${args}`) + }) + const result = await mergeTask(dir, 12, "user-auth-login", "low") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("CI_CHECKS_NOT_PASSED") + }) + }) + + it("rejects when branch protection is not enabled", async () => { + await withProjectDir(async dir => { + await writeStateFor(dir) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("pr list --head")) { + return { stdout: JSON.stringify({ number: 42, headRefOid: "sha1", author: { login: "dev" } }), stderr: "" } + } + if (args.includes("pr view 42 --json statusCheckRollup")) return { stdout: "[]", stderr: "" } + if (args.includes("repo view")) return { stdout: "acme/repo", stderr: "" } + if (args.includes("pr view 42 --json files")) return { stdout: "[]", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setMergeGhExecutor(async args => { + if (args.includes("branches/main/protection")) { + // gh api 404(无分支保护)→ executor 抛错 + throw new Error("HTTP 404: branch protection not found") + } + throw new Error(`unexpected merge gh: ${args}`) + }) + const result = await mergeTask(dir, 12, "user-auth-login", "low") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("BRANCH_PROTECTION_REQUIRED") + }) + }) + + it("rejects a high-risk diff without a non-author human approval", async () => { + await withProjectDir(async dir => { + await writeStateFor(dir) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("pr list --head")) { + return { stdout: JSON.stringify({ number: 42, headRefOid: "sha1", author: { login: "dev" } }), stderr: "" } + } + if (args.includes("pr view 42 --json statusCheckRollup")) return { stdout: "[]", stderr: "" } + if (args.includes("repo view")) return { stdout: "acme/repo", stderr: "" } + if (args.includes("pr view 42 --json files")) { + return { stdout: JSON.stringify(["src/db/migrations/001.sql"]), stderr: "" } + } + if (args.includes("--json author,reviews")) { + return { stdout: JSON.stringify({ author: "dev", reviews: [] }), stderr: "" } + } + throw new Error(`unexpected task gh: ${args}`) + }) + setMergeGhExecutor(async args => { + if (args.includes("branches/main/protection")) { + return { + stdout: JSON.stringify({ + required_pull_request_reviews: { dismiss_stale_reviews: false }, + required_status_checks: { checks: [] }, + }), + stderr: "", + } + } + throw new Error(`unexpected merge gh: ${args}`) + }) + const result = await mergeTask(dir, 12, "user-auth-login", "low") + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("HIGH_RISK_APPROVAL_REQUIRED") + }) + }) + + it("merges a clean low-risk PR, closes the issue, and destroys the worktree", async () => { + await withProjectDir(async dir => { + await writeStateFor(dir) + await mkdir(join(dir, ".worktree", "user-auth-login"), { recursive: true }) + const calls: string[] = [] + setTaskGhExecutor(async args => { + calls.push(args) + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("pr list --head")) { + return { stdout: JSON.stringify({ number: 42, headRefOid: "sha1", author: { login: "dev" } }), stderr: "" } + } + if (args.includes("pr view 42 --json statusCheckRollup")) return { stdout: "[]", stderr: "" } + if (args.includes("repo view")) return { stdout: "acme/repo", stderr: "" } + if (args.includes("pr view 42 --json files")) return { stdout: "[]", stderr: "" } + if (args.includes("issue close 13")) return { stdout: "", stderr: "" } + if (args.includes("issue view 13 --json labels")) return { stdout: "", stderr: "" } + if (args.includes("issue edit 13")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setMergeGhExecutor(async args => { + if (args.includes("branches/main/protection")) { + return { + stdout: JSON.stringify({ + required_pull_request_reviews: { dismiss_stale_reviews: false }, + required_status_checks: { checks: [] }, + }), + stderr: "", + } + } + if (args.includes("pr merge 42")) return { stdout: "", stderr: "" } + throw new Error(`unexpected merge gh: ${args}`) + }) + setWorktreeGitExecutor(async args => { + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + if (args.includes("worktree remove")) return { stdout: "", stderr: "" } + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc", stderr: "" } + if (args.includes("branch -D")) return { stdout: "", stderr: "" } + throw new Error(`unexpected worktree git: ${args}`) + }) + + const result = await mergeTask(dir, 12, "user-auth-login", "low") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.prNumber).toBe(42) + expect(result.risk).toBe("low") + } + expect(calls.some(c => c.includes("issue close 13"))).toBe(true) + expect(calls.some(c => c.includes("--add-label 'cabbage:task:merged'"))).toBe(true) + }) + }) + + it("merges when CI checks are GitHub Actions CheckRuns (conclusion field)", async () => { + await withProjectDir(async dir => { + await writeStateFor(dir) + await mkdir(join(dir, ".worktree", "user-auth-login"), { recursive: true }) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("pr list --head")) { + return { stdout: JSON.stringify({ number: 42, headRefOid: "sha1", author: { login: "dev" } }), stderr: "" } + } + if (args.includes("pr view 42 --json statusCheckRollup")) { + // 真实 gh 经 jq 转换:CheckRun(name+conclusion)→ {name, state} + return { stdout: JSON.stringify([{ name: "verify", state: "SUCCESS" }]), stderr: "" } + } + if (args.includes("repo view")) return { stdout: "acme/repo", stderr: "" } + if (args.includes("pr view 42 --json files")) return { stdout: "[]", stderr: "" } + if (args.includes("issue close 13")) return { stdout: "", stderr: "" } + if (args.includes("issue view 13 --json labels")) return { stdout: "", stderr: "" } + if (args.includes("issue edit 13")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setMergeGhExecutor(async args => { + if (args.includes("branches/main/protection")) { + return { + stdout: JSON.stringify({ + required_pull_request_reviews: { dismiss_stale_reviews: false }, + required_status_checks: { checks: [] }, + }), + stderr: "", + } + } + if (args.includes("pr merge 42")) return { stdout: "", stderr: "" } + throw new Error(`unexpected merge gh: ${args}`) + }) + setWorktreeGitExecutor(async () => ({ stdout: "", stderr: "" })) + const result = await mergeTask(dir, 12, "user-auth-login", "low") + expect(result.ok).toBe(true) + }) + }) + + it("merges a high-risk PR when a non-author human approved", async () => { + await withProjectDir(async dir => { + await writeStateFor(dir) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("pr list --head")) { + return { stdout: JSON.stringify({ number: 42, headRefOid: "sha1", author: { login: "dev" } }), stderr: "" } + } + if (args.includes("pr view 42 --json statusCheckRollup")) return { stdout: "[]", stderr: "" } + if (args.includes("repo view")) return { stdout: "acme/repo", stderr: "" } + if (args.includes("pr view 42 --json files")) { + return { stdout: JSON.stringify(["src/db/migrations/001.sql"]), stderr: "" } + } + if (args.includes("--json author,reviews")) { + return { + stdout: JSON.stringify({ + author: "dev", + reviews: [{ state: "APPROVED", login: "human-boss", type: "User" }], + }), + stderr: "", + } + } + if (args.includes("issue close 13")) return { stdout: "", stderr: "" } + if (args.includes("issue view 13 --json labels")) return { stdout: "", stderr: "" } + if (args.includes("issue edit 13")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setMergeGhExecutor(async args => { + if (args.includes("branches/main/protection")) { + return { + stdout: JSON.stringify({ + required_pull_request_reviews: { dismiss_stale_reviews: false }, + required_status_checks: { checks: [] }, + }), + stderr: "", + } + } + if (args.includes("pr merge 42")) return { stdout: "", stderr: "" } + throw new Error(`unexpected merge gh: ${args}`) + }) + setWorktreeGitExecutor(async args => { + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + if (args.includes("worktree remove")) return { stdout: "", stderr: "" } + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc", stderr: "" } + if (args.includes("branch -D")) return { stdout: "", stderr: "" } + throw new Error(`unexpected worktree git: ${args}`) + }) + + const result = await mergeTask(dir, 12, "user-auth-login", "low") + expect(result.ok).toBe(true) + if (result.ok) expect(result.risk).toBe("high") + }) + }) + }) + + describe("cancel-task / destroy-worktree / status-task", () => { + it("cancel-task requires user confirmation", async () => { + await withProjectDir(async dir => { + const result = await cancelTask(dir, 12, "user-auth-login", false) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("USER_CONFIRMATION_REQUIRED") + }) + }) + + it("cancel-task closes the issue and marks cancelled when confirmed", async () => { + await withProjectDir(async dir => { + const calls: string[] = [] + setTaskGhExecutor(async args => { + calls.push(args) + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue close 13")) return { stdout: "", stderr: "" } + if (args.includes("issue view 13 --json labels")) return { stdout: "", stderr: "" } + if (args.includes("issue edit 13")) return { stdout: "", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + const result = await cancelTask(dir, 12, "user-auth-login", true) + expect(result.ok).toBe(true) + expect(calls.some(c => c.includes("issue close 13"))).toBe(true) + expect(calls.some(c => c.includes("--add-label 'cabbage:task:cancelled'"))).toBe(true) + }) + }) + + it("destroy-worktree requires user confirmation when the PR is not merged", async () => { + await withProjectDir(async dir => { + await mkdir(join(dir, ".opencode", "opencode-cabbage", "task-state"), { recursive: true }) + const { writeFile: wf } = await import("node:fs/promises") + await wf( + join(dir, ".opencode", "opencode-cabbage", "task-state", "user-auth-login.json"), + JSON.stringify({ + slug: "user-auth-login", + parentIssueNumber: 12, + issueNumber: 13, + branch: "feat/user-auth-login", + worktreePath: join(dir, ".worktree", "user-auth-login"), + policy: null, + baseline: null, + startedAt: new Date().toISOString(), + }), + "utf8", + ) + await mkdir(join(dir, ".worktree", "user-auth-login"), { recursive: true }) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("pr list --head")) return { stdout: "0", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setWorktreeGitExecutor(async args => { + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + throw new Error(`unexpected worktree git: ${args}`) + }) + const result = await destroyTaskWorktree(dir, 12, "user-auth-login", false) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.code).toBe("PR_NOT_MERGED") + }) + }) + + it("destroy-worktree removes a clean worktree when the PR is merged", async () => { + await withProjectDir(async dir => { + await mkdir(join(dir, ".opencode", "opencode-cabbage", "task-state"), { recursive: true }) + const { writeFile: wf } = await import("node:fs/promises") + await wf( + join(dir, ".opencode", "opencode-cabbage", "task-state", "user-auth-login.json"), + JSON.stringify({ + slug: "user-auth-login", + parentIssueNumber: 12, + issueNumber: 13, + branch: "feat/user-auth-login", + worktreePath: join(dir, ".worktree", "user-auth-login"), + policy: null, + baseline: null, + startedAt: new Date().toISOString(), + }), + "utf8", + ) + await mkdir(join(dir, ".worktree", "user-auth-login"), { recursive: true }) + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("pr list --head")) return { stdout: "1", stderr: "" } + throw new Error(`unexpected task gh: ${args}`) + }) + setWorktreeGitExecutor(async args => { + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + if (args.includes("worktree remove")) return { stdout: "", stderr: "" } + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc", stderr: "" } + if (args.includes("branch -D")) return { stdout: "", stderr: "" } + throw new Error(`unexpected worktree git: ${args}`) + }) + const result = await destroyTaskWorktree(dir, 12, "user-auth-login", false) + expect(result.ok).toBe(true) + if (result.ok) expect(result.reason).toBe("OK") + }) + }) + + it("status-task aggregates the task record, PR, and checks", async () => { + await withProjectDir(async dir => { + setTaskGhExecutor(async args => { + if (args.includes("issue list")) return { stdout: "13", stderr: "" } + if (args.includes("issue view 13 --json number,title,state,labels,body")) { + return { + stdout: JSON.stringify({ number: 13, title: "user-auth-login", state: "OPEN", labels: ["cabbage:task:running"], body: "# Task: user-auth-login" }), + stderr: "", + } + } + if (args.includes("pr list --head feat/user-auth-login")) { + return { + stdout: JSON.stringify([{ number: 42, state: "OPEN", headRefName: "feat/user-auth-login" }]), + stderr: "", + } + } + if (args.includes("pr view 42")) { + return { stdout: JSON.stringify([{ name: "CI", state: "SUCCESS" }]), stderr: "" } + } + throw new Error(`unexpected task gh: ${args}`) + }) + const result = await readTaskStatus(dir, 12, "user-auth-login") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.report.task.issueNumber).toBe(13) + expect(result.report.pullRequests).toHaveLength(1) + expect(result.report.pullRequests[0]).toMatchObject({ number: 42, state: "OPEN" }) + expect(result.report.pullRequests[0].checks).toEqual([{ name: "CI", state: "SUCCESS" }]) + } + }) + }) + }) +}) + +describe("registerTaskControl", () => { + it("mounts task_control on the tool registry", () => { + const registry: Record = {} + registerTaskControl(registry, { projectDir: ".", sessionClient: primaryClient }) + expect(registry.task_control).toBeDefined() + }) +}) + +/** 写一个最小可用的 task-state(submit-task 测试用) */ +async function writeTaskStateForTest(dir: string) { + const { writeFile: wf } = await import("node:fs/promises") + await mkdir(join(dir, ".opencode", "opencode-cabbage", "task-state"), { recursive: true }) + await wf( + join(dir, ".opencode", "opencode-cabbage", "task-state", "user-auth-login.json"), + JSON.stringify({ + slug: "user-auth-login", + parentIssueNumber: 12, + issueNumber: 13, + branch: "feat/user-auth-login", + worktreePath: join(dir, ".worktree", "user-auth-login"), + policy: freezeTddPolicy({ + testCommand: "cabbage-test-tool run", + regressionCommand: null, + testFilePatterns: [], + implementationFilePatterns: [], + tddDefaultMode: "strict", + versionBumpRule: null, + versionFile: null, + tagFormat: null, + releaseWorkflowPath: null, + riskPatterns: [], + }), + baseline: { + digest: { algorithm: "sha256-content-v1", value: "abc" }, + capturedAt: new Date().toISOString(), + testFilePatterns: [], + implementationFilePatterns: [], + }, + startedAt: new Date().toISOString(), + }), + "utf8", + ) +}