diff --git a/README.md b/README.md index cd18dd2..ef80676 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ cewp run dispatch exec worker-a --adapter codex-exec --yes cewp run dispatch exec worker-a --adapter codex-exec --yes --timeout 120 ``` -`--yes` is required for real execution and is currently limited to one worker role at a time. Reviewer execution is still manual/dry-run. After `codex exec` exits, CEWP checks changed files against `allowedFiles` and `forbiddenFiles`, verifies the worker report and adapter output, and still does not merge, push, or publish. +`--yes` is required for real execution and is currently limited to one worker role at a time. Reviewer execution is still manual/dry-run. After `codex exec` exits, CEWP checks both working tree changes and committed branch changes from the registered `baseCommit` against `allowedFiles` and `forbiddenFiles`, verifies the worker report and adapter output, and still does not merge, push, or publish. For sandbox compatibility, worker reports are written inside the assigned worktree under `.cewp-worker-output/`. The CLI copies `.cewp-worker-output/-report.md` into `.cewp/runs//reports/` after execution and appends `.cewp-worker-output/-events.jsonl` when present. `.cewp-worker-output/` is runtime output and should not be committed. diff --git a/bin/cewp.js b/bin/cewp.js index ab06b28..2bdc57b 100644 --- a/bin/cewp.js +++ b/bin/cewp.js @@ -505,6 +505,48 @@ function getGitStatusShort(worktreePath) { .filter(Boolean); } +function getGitHeadCommit(repoRoot) { + const result = getGitOutput(["rev-parse", "HEAD"], repoRoot); + + if (result.status !== 0) { + throw new Error(`Failed to read git HEAD for ${repoRoot}: ${(result.stderr || result.stdout || "").trim()}`); + } + + return result.stdout.trim(); +} + +function getCommittedChangedFiles(worktreePath, baseCommit) { + const result = getGitOutput(["diff", "--name-only", `${baseCommit}...HEAD`], worktreePath); + + if (result.status !== 0) { + throw new Error(`Failed to inspect committed changes for ${worktreePath}: ${(result.stderr || result.stdout || "").trim()}`); + } + + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim().replace(/\\/g, "/")) + .filter(Boolean); +} + +function uniqueFileList(files) { + const seen = new Set(); + const output = []; + + for (const file of files) { + const normalized = String(file || "").replace(/\\/g, "/"); + const key = process.platform === "win32" ? normalized.toLowerCase() : normalized; + + if (!normalized || seen.has(key)) { + continue; + } + + seen.add(key); + output.push(normalized); + } + + return output; +} + function getGitBranchName(worktreePath) { const result = getGitOutput(["branch", "--show-current"], worktreePath); @@ -559,6 +601,7 @@ function buildWorktreePlans(runId, runRoot) { const runJson = readJsonIfExists(path.join(runRoot, "run.json")); const repoRoot = (runJson && runJson.repoRoot) || process.cwd(); const taskEntries = readTasks(runRoot); + const baseCommit = getGitHeadCommit(repoRoot); const plans = taskEntries.map(({ task }) => { if (!task.id) { @@ -574,6 +617,7 @@ function buildWorktreePlans(runId, runRoot) { branch, targetWorktree, resolvedPath, + baseCommit, targetExists: fs.existsSync(resolvedPath), branchExists: branchExists(repoRoot, branch), }; @@ -2162,6 +2206,10 @@ function getDispatchExecPreview(options) { failures.push(`${taskId} path is not a git worktree: ${worktree.path}`); } + if (worktree && !worktree.baseCommit) { + warnings.push(`${taskId} worktree registry missing baseCommit; committed diff post-check will fail safely.`); + } + if (!fs.existsSync(promptPath)) { if (options.ignoreMissingDispatchPrompts) { warnings.push(`${taskId} dispatch prompt missing; pipeline will create it before worker execution.`); @@ -2256,6 +2304,7 @@ function getDispatchExecPreview(options) { console.log("Post-checks:"); console.log(" git status --short"); + console.log(" git diff --name-only ...HEAD"); console.log(" allowedFiles / forbiddenFiles"); console.log(" report exists"); console.log(" adapter output exists"); @@ -2537,7 +2586,17 @@ function runDispatchExecActual(options = {}) { localEventsPath: workerOutput.eventsPath, }); const statusLines = getGitStatusShort(preview.cwd); - const changedFiles = statusLines.map(parseChangedFile); + const statusChangedFiles = statusLines.map(parseChangedFile); + let committedChangedFiles = []; + let committedDiffError; + if (preview.worktree.baseCommit) { + try { + committedChangedFiles = getCommittedChangedFiles(preview.cwd, preview.worktree.baseCommit); + } catch (error) { + committedDiffError = error; + } + } + const changedFiles = uniqueFileList([...statusChangedFiles, ...committedChangedFiles]); const runtimeOutputFiles = changedFiles.filter(isWorkerRuntimeOutputPath); const scopeWarnings = findScopeWarnings(preview.task.id || "unknown-task", changedFiles, preview.task); const forbiddenWarnings = scopeWarnings.filter((warning) => warning.includes("changed forbidden file")); @@ -2559,6 +2618,14 @@ function runDispatchExecActual(options = {}) { failuresAfterExec.push(`codex exec exited with code ${exitCode}.`); } + if (!preview.worktree.baseCommit) { + failuresAfterExec.push("worktree registry missing baseCommit; committed branch scope check could not run."); + } + + if (committedDiffError) { + failuresAfterExec.push(committedDiffError.message); + } + failuresAfterExec.push(...outsideAllowedWarnings); failuresAfterExec.push(...forbiddenWarnings); @@ -2609,6 +2676,19 @@ function runDispatchExecActual(options = {}) { } } console.log(""); + console.log("Committed changes:"); + if (!preview.worktree.baseCommit) { + console.log(" skipped: worktrees.json entry missing baseCommit"); + } else if (committedDiffError) { + console.log(` failed: ${committedDiffError.message}`); + } else if (committedChangedFiles.length === 0) { + console.log(" none"); + } else { + for (const filePath of committedChangedFiles) { + console.log(` ${filePath}${isWorkerRuntimeOutputPath(filePath) ? " (runtime output)" : ""}`); + } + } + console.log(""); console.log("Runtime output:"); if (runtimeOutputFiles.length === 0) { console.log(" none tracked by git status"); @@ -3311,6 +3391,7 @@ function writeWorktreesRegistry(runRoot, runId, created) { branch: entry.branch, path: entry.resolvedPath, status: "created", + baseCommit: entry.baseCommit, })), }); } @@ -4210,6 +4291,7 @@ function runWorktreesCreate(options = {}) { taskId: plan.task.id, branch: plan.branch, path: plan.resolvedPath, + baseCommit: plan.baseCommit, })), }); @@ -4218,6 +4300,7 @@ function runWorktreesCreate(options = {}) { console.log(` ${plan.task.id}: created`); console.log(` branch: ${plan.branch}`); console.log(` path: ${plan.resolvedPath}`); + console.log(` baseCommit: ${plan.baseCommit}`); } console.log(""); console.log("Next:"); diff --git a/docs/install.md b/docs/install.md index 5e45234..a685957 100644 --- a/docs/install.md +++ b/docs/install.md @@ -156,7 +156,7 @@ With explicit user approval, the adapter can run one worker at a time: cewp run dispatch exec worker-a --adapter codex-exec --yes --timeout 120 ``` -This guarded execution mode runs `codex exec` only for worker roles, captures adapter output under `.cewp/runs//adapter-output/`, and performs post-checks for changed files, forbidden files, report existence, and exit code. Reviewer execution is not implemented yet, and merge/push/publish remain separate user-approved steps. +This guarded execution mode runs `codex exec` only for worker roles, captures adapter output under `.cewp/runs//adapter-output/`, and performs post-checks for working tree changes, committed branch changes from the registered `baseCommit`, forbidden files, report existence, and exit code. Reviewer execution is not implemented yet, and merge/push/publish remain separate user-approved steps. Worker reports are written inside the assigned worktree under `.cewp-worker-output/` so `codex exec` stays within its sandbox. After execution, the CLI copies `.cewp-worker-output/-report.md` into `.cewp/runs//reports/` and appends `.cewp-worker-output/-events.jsonl` when present. `.cewp-worker-output/` is runtime output and should not be committed.