Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion extensions/taskplane/completion-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
75 changes: 71 additions & 4 deletions extensions/taskplane/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion extensions/taskplane/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
29 changes: 29 additions & 0 deletions extensions/tests/status-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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("/")
Expand Down
12 changes: 6 additions & 6 deletions templates/agents/task-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <files you changed> && git commit -m "feat(TASK-ID): step N implementation"
```

For **Review Level 0 or 1** tasks, this commit completes the step — the next
Expand All @@ -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 <files you changed> && 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 <files you changed> && 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.

Expand Down Expand Up @@ -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 <files you changed> && git commit -m "hydrate: expand Step N checkboxes"
```
5. THEN start implementing from the first unchecked item

Expand All @@ -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 <files you changed> && git commit -m "hydrate: add R00N revision items to Step N"
```
5. THEN implement the revisions, checking off each item as you go

Expand Down Expand Up @@ -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 <files you changed> && 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,
Expand Down