diff --git a/extensions/taskplane/completion-authority.ts b/extensions/taskplane/completion-authority.ts index a4e9a04f..f152a270 100644 --- a/extensions/taskplane/completion-authority.ts +++ b/extensions/taskplane/completion-authority.ts @@ -112,7 +112,7 @@ export function evaluateRatificationBlock( }); if (stale) return `stale (${linkId} is no longer the latest APPROVE for ${gate})`; // R004 issue 2: HEAD may equal the proof commit yet the working tree can carry - // uncommitted source changes that the post-task `git add -A` would sweep into + // uncommitted source changes that the post-task artifact staging would sweep into // the merge candidate. Bind authority to a clean (source) working tree. // R005 issue 2: a failed git probe is fail-closed, never "clean". const drift = ctx.workingTreeDrift(); diff --git a/extensions/taskplane/execution.ts b/extensions/taskplane/execution.ts index f7834f8a..bba36a6a 100644 --- a/extensions/taskplane/execution.ts +++ b/extensions/taskplane/execution.ts @@ -561,7 +561,36 @@ export async function pollUntilTaskComplete( * @param task - The task that just completed * @param laneId - Lane identifier for logging */ -function commitTaskArtifacts(lane: AllocatedLane, task: AllocatedTask, laneId: string): void { +export function collectTaskArtifactPaths( + repoRoot: string, + taskFolder: string, + statusOutput: string, +): { artifactPaths: string[]; skippedPaths: string[] } { + const relTaskFolder = relative(repoRoot, taskFolder).replace(/\\/g, "/"); + if (relTaskFolder.startsWith("..") || relTaskFolder.startsWith("/")) + return { artifactPaths: [], skippedPaths: statusOutput.split("\n").filter(Boolean) }; + const artifactPaths = [".DONE", "STATUS.md", "REVIEW_VERDICT.json"] + .map((name) => `${relTaskFolder}/${name}`) + .filter((path) => existsSync(join(repoRoot, path))); + const skippedPaths = statusOutput + .split("\n") + .filter(Boolean) + .filter((line) => { + const path = line.slice(3).trim().replace(/^"|"$/g, "").replace(/\\/g, "/"); + return ( + !artifactPaths.includes(path) && + !artifactPaths.some((candidate) => path.startsWith(`${candidate}/`)) + ); + }); + return { artifactPaths, skippedPaths }; +} + +function commitTaskArtifacts( + lane: AllocatedLane, + task: AllocatedTask, + laneId: string, + packetTaskFolder?: string, +): void { const worktreePath = lane.worktreePath; // Check if there are any uncommitted changes in the worktree @@ -571,8 +600,35 @@ function commitTaskArtifacts(lane: AllocatedLane, task: AllocatedTask, laneId: s return; } - // Stage all changes in the worktree - const addResult = runGit(["add", "-A"], worktreePath); + // Stage only task-owned completion artifacts. Never use `git add -A`: workers + // can leave unrelated source or supervisor files in the lane worktree. + const repoResult = runGit(["rev-parse", "--show-toplevel"], worktreePath); + const repoRoot = repoResult.ok ? repoResult.stdout : worktreePath; + const taskFolder = packetTaskFolder + ? resolve(packetTaskFolder) + : resolve(worktreePath, task.task.taskFolder); + const relTaskFolder = relative(repoRoot, taskFolder).replace(/\\/g, "/"); + if (!repoResult.ok || relTaskFolder.startsWith("..") || relTaskFolder.startsWith("/")) { + execLog(laneId, task.taskId, "post-task stage skipped: task folder is outside worktree", { + taskFolder, + }); + return; + } + const collected = collectTaskArtifactPaths(repoRoot, taskFolder, statusResult.stdout); + const artifactPaths = collected.artifactPaths; + const reviewsDir = join(repoRoot, relTaskFolder, ".reviews"); + if (existsSync(reviewsDir)) { + const reviewFiles = runGit( + ["ls-files", "--others", "--modified", "--exclude-standard", "--", `${relTaskFolder}/.reviews`], + repoRoot, + ); + if (reviewFiles.ok && reviewFiles.stdout) + artifactPaths.push(...reviewFiles.stdout.split("\n").filter(Boolean)); + } + const addResult = + artifactPaths.length > 0 + ? runGit(["add", "--", ...artifactPaths], repoRoot) + : { ok: true, stdout: "", stderr: "" }; if (!addResult.ok) { execLog( laneId, @@ -581,6 +637,17 @@ function commitTaskArtifacts(lane: AllocatedLane, task: AllocatedTask, laneId: s ); return; } + const skipped = collected.skippedPaths; + if (skipped.length > 0) { + execLog( + laneId, + task.taskId, + `post-task checkpoint skipped ${skipped.length} out-of-scope path(s)`, + { + paths: skipped.map((line) => line.slice(3).trim()).join(","), + }, + ); + } // Commit with task ID for traceability const commitResult = runGit( @@ -3178,7 +3245,7 @@ export async function executeLaneV2( // Commit artifacts after success (same as legacy path) if (result.outcome.status === "succeeded") { - commitTaskArtifacts(lane, task, laneId); + commitTaskArtifacts(lane, task, laneId, unit.packet.taskFolder); // Reset worktree for next task if (lane.tasks.indexOf(task) < lane.tasks.length - 1) { runGit(["checkout", "--", "."], lane.worktreePath); diff --git a/extensions/taskplane/resume.ts b/extensions/taskplane/resume.ts index a58a1e39..aa2460f8 100644 --- a/extensions/taskplane/resume.ts +++ b/extensions/taskplane/resume.ts @@ -543,7 +543,7 @@ export function collectDoneTaskIdsForResume( // R003 parity with the live finalize gate: when the lane worktree exists, // bind ratification authority to a CLEAN (source) working tree. Without // this, a crash-then-resume would accept a `.DONE` over uncommitted source - // drift that the engine's post-task `git add -A` could sweep into the + // drift that the engine's post-task artifact staging could sweep into the // merge candidate, defeating the ratification's proof-to-code binding. The // task folder's own files (STATUS.md, .reviews, .DONE) are runtime-owned // and allowed to be dirty. A failed git probe is fail-closed (refuse). diff --git a/extensions/tests/status-reconciliation.test.ts b/extensions/tests/status-reconciliation.test.ts index 0c164f53..7d94b44a 100644 --- a/extensions/tests/status-reconciliation.test.ts +++ b/extensions/tests/status-reconciliation.test.ts @@ -18,6 +18,8 @@ import { join, resolve, relative, dirname } from "path"; import { fileURLToPath } from "url"; import { tmpdir } from "os"; +import { collectTaskArtifactPaths } from "../taskplane/execution.ts"; + import { applyStatusReconciliation, type StatusReconciliation, @@ -522,6 +524,33 @@ describe("4.x: Artifact staging allowlist", () => { // 5.x — Artifact staging rejection: outside-task, repo-escape, no-op // ══════════════════════════════════════════════════════════════════════ +describe("5.6: Runtime checkpoint scope", () => { + it("collects task artifacts and reports unrelated worktree paths", () => { + const repoRoot = makeTestDir("checkpoint-scope"); + const taskFolder = join(repoRoot, "tasks", "TP-SCOPE"); + mkdirSync(taskFolder, { recursive: true }); + writeFileSync(join(taskFolder, ".DONE"), "done", "utf8"); + writeFileSync(join(taskFolder, "STATUS.md"), "status", "utf8"); + const result = collectTaskArtifactPaths( + repoRoot, + taskFolder, + " M tasks/TP-SCOPE/STATUS.md\n?? tasks/TP-SCOPE/notes.txt\n?? .pi/supervisor-note.md", + ); + expect(result.artifactPaths).toEqual(["tasks/TP-SCOPE/.DONE", "tasks/TP-SCOPE/STATUS.md"]); + expect(result.skippedPaths).toHaveLength(2); + expect(result.skippedPaths[0]).toContain("notes.txt"); + expect(result.skippedPaths[1]).toContain("supervisor-note.md"); + }); + + it("rejects a task folder outside the repository", () => { + const repoRoot = makeTestDir("checkpoint-escape"); + const outside = join(testRoot, "outside-task"); + const result = collectTaskArtifactPaths(repoRoot, outside, "?? outside.txt"); + expect(result.artifactPaths).toHaveLength(0); + expect(result.skippedPaths).toHaveLength(1); + }); +}); + describe("5.x: Artifact staging rejection", () => { it("5.1: path outside repo root (repo-escape) is rejected", () => { // The staging code checks: relFolder.startsWith("..") || relFolder.startsWith("/") diff --git a/templates/agents/task-worker.md b/templates/agents/task-worker.md index 48e3195c..3c3ba5f1 100644 --- a/templates/agents/task-worker.md +++ b/templates/agents/task-worker.md @@ -165,7 +165,7 @@ Git commits happen at **step boundaries**, not after every checkbox. When all checkboxes in a step are checked off, commit the implementation: ```bash -git add -A && git commit -m "feat(TASK-ID): step N implementation" +git add && git commit -m "feat(TASK-ID): step N implementation" ``` For **Review Level 0 or 1** tasks, this commit completes the step — the next @@ -189,9 +189,9 @@ commit when applicable. **Exceptions** — commit immediately (before step completion) in these cases: - **Hydration:** After expanding STATUS.md with new checkboxes, commit before - implementing: `git add -A && git commit -m "hydrate: expand Step N checkboxes"` + implementing: `git add && git commit -m "hydrate: expand Step N checkboxes"` - **REVISE response:** After adding reviewer revision items to STATUS.md: - `git add -A && git commit -m "hydrate: add R00N revision items to Step N"` + `git add && git commit -m "hydrate: add R00N revision items to Step N"` - **Wrap-up signal:** If stopping mid-step due to a wrap-up signal, commit whatever is done so far. @@ -233,7 +233,7 @@ Before implementing anything, assess whether the step needs expansion: things that need to be true when this step is done?" 4. **Commit the hydrated STATUS.md immediately** (see Checkpoint Discipline exceptions): ```bash - git add -A && git commit -m "hydrate: expand Step N checkboxes" + git add && git commit -m "hydrate: expand Step N checkboxes" ``` 5. THEN start implementing from the first unchecked item @@ -256,7 +256,7 @@ When a reviewer returns REVISE with specific feedback items: Do NOT create checkboxes for suggestions. They are advisory, not blocking. 4. **Commit the hydrated STATUS.md** (see Checkpoint Discipline exceptions): ```bash - git add -A && git commit -m "hydrate: add R00N revision items to Step N" + git add && git commit -m "hydrate: add R00N revision items to Step N" ``` 5. THEN implement the revisions, checking off each item as you go @@ -340,7 +340,7 @@ STATUS.md before the code review for that step has returned APPROVE.** check each box `[x]` in STATUS.md as you finish that item, but leave the step's `**Status:**` heading set to `🟨 In Progress`. 2. **Commit** the implementation: - `git add -A && git commit -m "feat(TASK-ID): step N implementation"` + `git add && git commit -m "feat(TASK-ID): step N implementation"` 3. **Self-check (MANDATORY — `review_step` refuses without it).** Before you ask for a review, diff your work against what YOU already wrote down. Add a `### Self-check (Step N)` section to STATUS.md directly under the step,