Skip to content
Open
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
28 changes: 14 additions & 14 deletions defaults/devclaw/prompts/developer.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,21 @@ Read the comments carefully — they often contain clarifications, decisions, or

## Workflow

### 1. Create a worktree
### 1. Adopt the canonical checkout contract

**NEVER work in the main checkout.** Create a dedicated git worktree as a sibling to the repo:
**NEVER work in the main checkout.** Your task message includes a **Canonical Checkout Contract** section with the exact required worktree path and branch.

```bash
# Example: repo is at ~/git/myproject
# Worktree goes to ~/git/myproject.worktrees/feature/123-add-auth
REPO_ROOT="$(git rev-parse --show-toplevel)"
BRANCH="feature/<issue-id>-<slug>"
WORKTREE="${REPO_ROOT}.worktrees/${BRANCH}"
git worktree add "$WORKTREE" -b "$BRANCH"
cd "$WORKTREE"
```
For normal issue work:
- branch name must be `issue/<issue-id>-<slug>`
- worktree path must be the canonical path from the task message
- for DevClaw specifically, the implementation base is `devclaw-local-dev`
- `devclaw-local-current` is the operator-managed release/local-truth branch, not your normal implementation base

If the canonical worktree already exists, verify it is on the required branch and clean before you proceed.
If it is missing, create that exact worktree and branch.
If it is dirty or mismatched and you cannot repair it deterministically, stop and call `work_finish({ role: "developer", result: "blocked", ... })`.

The `.worktrees/` directory sits NEXT TO the repo folder (not inside it). This keeps the main checkout clean for the orchestrator and other workers. If a worktree already exists from a previous task on the same branch, verify it's clean before reusing it.
Only use derived validation checkouts after the canonical issue worktree is preserved and up to date.

### 2. Implement the changes

Expand All @@ -47,7 +47,7 @@ Conventional commits: `feat:`, `fix:`, `chore:`, `refactor:`, `test:`, `docs:`

### 4. Create a Pull Request

Use `gh pr create` to open a PR against the base branch. **Do NOT use closing keywords** in the description (no "Closes #X", "Fixes #X"). Use "Addresses issue #X" instead — DevClaw manages issue lifecycle.
Use `gh pr create` to open a PR against the implementation base branch from the task message. For DevClaw implementation work, that PR must target `devclaw-local-dev`, not `devclaw-local-current`. **Do NOT use closing keywords** in the description (no "Closes #X", "Fixes #X"). Use "Addresses issue #X" instead — DevClaw manages issue lifecycle.

### Handling PR Feedback (changes requested / To Improve)

Expand Down Expand Up @@ -99,7 +99,7 @@ you MUST work on the branch explicitly mentioned in the instructions.
**The instructions will show:**
```
🔹 PR: https://github.com/.../pull/123
🔹 Branch: `feature/456-description`
🔹 Branch: `issue/456-description`
```

Use THAT branch. Do not:
Expand Down
2 changes: 2 additions & 0 deletions defaults/devclaw/prompts/reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ You are a code reviewer. Your job is to review the PR diff for quality, correctn
- **Issue:** the original task description and discussion
- **PR diff:** the code changes to review
- **PR URL:** link to the pull request
- **Canonical Checkout Contract:** the expected implementation branch/worktree identity for the issue

## Review Checklist

Expand All @@ -27,6 +28,7 @@ You are a code reviewer. Your job is to review the PR diff for quality, correctn
## Conventions

- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses the review lifecycle.
- Preserve the canonical checkout identity from the task message. If you inspect code locally, use the recorded canonical worktree unless the task explicitly allows an exception mode.
- You do NOT run code or tests — you only review the diff
- Be specific about issues: file, line, what's wrong, how to fix
- If you approve, briefly note what you checked
Expand Down
5 changes: 3 additions & 2 deletions defaults/devclaw/prompts/tester.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# TESTER Worker Instructions

You test the deployed version and inspect code on the base branch.
You test the deployed version and inspect code while preserving the canonical checkout identity from the task message.

## Your Job

- Pull latest from the base branch
- Use the canonical worktree and branch from the task message unless the task explicitly declares an exception mode such as `review/*`, `pr/*`, live self-hosting, or release flow
- Pull latest from the required base or implementation branch for that contract
- Run tests and linting
- Verify the changes address the issue requirements
- Check for regressions in related functionality
Expand Down
16 changes: 9 additions & 7 deletions dev/runbooks/developing-devclaw-with-openclaw.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ It lives under `/dev` because these rules are first-class local operating docs a

Treat these branch roles as the working contract:

- `devclaw-local-current`: local truth and day-to-day working lane
- `devclaw-local-dev`: normal project branch for day-to-day implementation work
- `devclaw-local-current`: operator-managed local truth and release branch
- `devclaw-local-stable`: local fallback lane when `devclaw-local-current` is too noisy or risky
- `issue/*`: local implementation branches for scoped work
- `issue/*`: canonical local implementation branches for scoped work, derived from `devclaw-local-dev`
- `review/*`: local review branches opened against `devclaw-local-current`
- `pr/*`: export branches prepared for upstream review

Expand All @@ -18,11 +19,12 @@ Upstream `main` is a reference point and export target. It is not the normal day
## Operating model

1. Keep local docs and operator runbooks on `devclaw-local-current`.
2. Start implementation from `devclaw-local-current` into an `issue/*` branch when you need isolated task work.
3. Land validated work back onto `devclaw-local-current` so local truth stays complete.
4. When work needs to go upstream, export it onto a matching `pr/*` branch.
5. Preserve the `/dev/` documentation changes on `devclaw-local-current` even when the upstream export omits local-only material.
6. Push runbook and workflow changes to the Git remote that tracks `devclaw-local-current` so the policy is not left only in a local checkout or an unknown branch.
2. Start ordinary implementation from `devclaw-local-dev` into a canonical `issue/*` branch/worktree when you need isolated task work.
3. Land developer PRs from `issue/*` back into `devclaw-local-dev`.
4. Let the operator/orchestrator manage promotion from `devclaw-local-dev` into `devclaw-local-current`.
5. When work needs to go upstream, export it onto a matching `pr/*` branch.
6. Preserve the `/dev/` documentation changes on `devclaw-local-current` even when the upstream export omits local-only material.
7. Push runbook and workflow changes to the Git remote that tracks `devclaw-local-current` so the policy is not left only in a local checkout or an unknown branch.

## Mandatory compliance rule

Expand Down
14 changes: 8 additions & 6 deletions docs/devclaw-self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@ That means branch switching for DevClaw development is really a **plugin source

## Recommended branch roles

These are roles, not mandatory branch names:
For DevClaw self-hosting, use this local policy:

- **clean integration branch**: the branch that tracks the normal upstream line
- **working branch**: a feature or fix branch carrying in-progress changes
- **fallback branch**: an optional known-good branch you can switch back to quickly
- **local docs branch**: an optional branch for operator runbooks that are not meant for upstream
- **implementation branch**: `devclaw-local-dev`
- **release/local-truth branch**: `devclaw-local-current`
- **ordinary issue branches**: `issue/*`, based on `devclaw-local-dev`
- **local review branches**: `review/*`
- **upstream export branches**: `pr/*`
- **fallback branch**: an optional known-good lane such as `devclaw-local-stable`

Use names that fit your repo. The workflow matters more than the naming.
Normal implementation work belongs on canonical `issue/*` worktrees derived from `devclaw-local-dev`. Live self-hosting checks and release/promotion work are explicit exception modes and do not replace the ordinary issue checkout contract.

## Safe live-switch procedure

Expand Down
30 changes: 30 additions & 0 deletions lib/checkout-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { resolveExpectedCheckoutContract } from "./checkout-contract.js";

describe("resolveExpectedCheckoutContract", () => {
it("routes normal devclaw issue work to devclaw-local-dev and issue/*", () => {
const contract = resolveExpectedCheckoutContract({
project: {
slug: "devclaw",
name: "devclaw",
repo: "/home/sai/git/devclaw.worktrees/devclaw-local-current",
groupName: "DevClaw",
deployUrl: "",
baseBranch: "devclaw-local-current",
deployBranch: "devclaw-local-current",
channels: [],
workers: {},
issueCheckouts: {},
},
issueId: 174,
issueTitle: "Implement canonical issue checkout contract",
repoPath: "/home/sai/git/devclaw.worktrees/devclaw-local-current",
role: "developer",
});

expect(contract.mode).toBe("issue");
expect(contract.baseBranch).toBe("devclaw-local-dev");
expect(contract.canonicalBranch).toBe("issue/174-implement-canonical-issue-checkout-contract");
expect(contract.canonicalWorktreePath).toContain(".worktrees/issue/174-implement-canonical-issue-checkout-contract");
});
});
170 changes: 170 additions & 0 deletions lib/checkout-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* checkout-contract.ts — Canonical checkout contract resolution and enforcement.
*/
import path from "node:path";
import type { RunCommand } from "./context.js";
import type { Project } from "./projects/types.js";

export type CheckoutMode = "issue" | "review" | "pr" | "live" | "release";
export type CheckoutStatus = "planned" | "created" | "adopted" | "missing" | "dirty" | "mismatched" | "verified";

export type CheckoutProvenance = {
verifiedAt: string;
path: string;
branch: string | null;
headSha: string | null;
clean: boolean;
status: CheckoutStatus;
details?: string;
};

export type IssueCheckoutContract = {
issueId: number;
issueTitle?: string;
mode: CheckoutMode;
repoPath: string;
canonicalBranch: string;
canonicalWorktreePath: string;
baseBranch: string;
baseWorktreePath: string;
targetRef: string;
targetSha: string | null;
requiredCleanliness: "clean" | "allow-derived-dirty";
status: CheckoutStatus;
lastVerifiedProvenance?: CheckoutProvenance;
};

export function slugifyIssueTitle(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "task";
}

export function getImplementationBaseBranch(project: Project): string {
return project.name === "devclaw" ? "devclaw-local-dev" : project.baseBranch;
}

export function getReleaseBranch(project: Project): string {
return project.name === "devclaw" ? "devclaw-local-current" : project.baseBranch;
}

export function inferCheckoutMode(role: string, issueTitle: string, prBranchName?: string): CheckoutMode {
const branch = prBranchName ?? "";
if (branch.startsWith("review/")) return "review";
if (branch.startsWith("pr/")) return "pr";
const title = issueTitle.toLowerCase();
if (title.includes("self-host") || title.includes("self host") || title.includes("live ")) return "live";
if (title.includes("release") || title.includes("promotion")) return "release";
return role === "developer" ? "issue" : "issue";
}

function deriveWorktreeRoot(repoPath: string): string {
if (repoPath.includes(".worktrees/")) {
return repoPath.slice(0, repoPath.indexOf(".worktrees/")) + ".worktrees";
}
return `${repoPath}.worktrees`;
}

function branchPath(branch: string): string {
return branch;
}

export function resolveExpectedCheckoutContract(opts: {
project: Project;
issueId: number;
issueTitle: string;
repoPath: string;
role: string;
mode?: CheckoutMode;
prBranchName?: string;
targetSha?: string | null;
}): IssueCheckoutContract {
const mode = opts.mode ?? inferCheckoutMode(opts.role, opts.issueTitle, opts.prBranchName);
const worktreeRoot = deriveWorktreeRoot(opts.repoPath);
const issueSlug = slugifyIssueTitle(opts.issueTitle);
const canonicalBranch = mode === "issue"
? `issue/${opts.issueId}-${issueSlug}`
: opts.prBranchName ?? `${mode}/${opts.issueId}-${issueSlug}`;
const implementationBaseBranch = getImplementationBaseBranch(opts.project);
const releaseBranch = getReleaseBranch(opts.project);
const baseBranch = mode === "issue" ? implementationBaseBranch : (mode === "release" || mode === "live" ? releaseBranch : canonicalBranch);
const baseWorktreePath = mode === "issue"
? path.join(worktreeRoot, implementationBaseBranch)
: mode === "release" || mode === "live"
? path.join(worktreeRoot, releaseBranch)
: path.join(worktreeRoot, branchPath(canonicalBranch));

return {
issueId: opts.issueId,
issueTitle: opts.issueTitle,
mode,
repoPath: opts.repoPath,
canonicalBranch,
canonicalWorktreePath: path.join(worktreeRoot, branchPath(canonicalBranch)),
baseBranch,
baseWorktreePath,
targetRef: mode === "issue" ? implementationBaseBranch : canonicalBranch,
targetSha: opts.targetSha ?? null,
requiredCleanliness: mode === "issue" ? "clean" : "allow-derived-dirty",
status: "planned",
};
}

async function git(runCommand: RunCommand, cwd: string, ...args: string[]): Promise<string> {
const result = await runCommand(["git", ...args], { cwd, timeoutMs: 15_000 });
return result.stdout.trim();
}

export async function inspectCheckoutContract(contract: IssueCheckoutContract, runCommand: RunCommand): Promise<CheckoutProvenance> {
const verifiedAt = new Date().toISOString();
try {
const branch = await git(runCommand, contract.canonicalWorktreePath, "branch", "--show-current");
const headSha = await git(runCommand, contract.canonicalWorktreePath, "rev-parse", "HEAD");
const statusOut = await git(runCommand, contract.canonicalWorktreePath, "status", "--porcelain");
const clean = statusOut.length === 0;
const branchOk = branch === contract.canonicalBranch;
const status: CheckoutStatus = !branchOk ? "mismatched" : (!clean && contract.requiredCleanliness === "clean") ? "dirty" : "verified";
return {
verifiedAt,
path: contract.canonicalWorktreePath,
branch: branch || null,
headSha: headSha || null,
clean,
status,
details: branchOk ? undefined : `expected ${contract.canonicalBranch}, got ${branch || "(detached)"}`,
};
} catch (error) {
return {
verifiedAt,
path: contract.canonicalWorktreePath,
branch: null,
headSha: null,
clean: false,
status: "missing",
details: error instanceof Error ? error.message : String(error),
};
}
}

export async function ensureCheckoutContract(contract: IssueCheckoutContract, runCommand: RunCommand): Promise<IssueCheckoutContract> {
let provenance = await inspectCheckoutContract(contract, runCommand);
if (provenance.status === "missing" && contract.mode === "issue") {
await runCommand(["git", "worktree", "add", contract.canonicalWorktreePath, "-B", contract.canonicalBranch, contract.baseBranch], {
cwd: contract.repoPath,
timeoutMs: 30_000,
});
provenance = await inspectCheckoutContract(contract, runCommand);
return { ...contract, status: provenance.status === "verified" ? "created" : provenance.status, lastVerifiedProvenance: provenance, targetSha: provenance.headSha };
}
return { ...contract, status: provenance.status === "verified" ? "adopted" : provenance.status, lastVerifiedProvenance: provenance, targetSha: provenance.headSha };
}

export function renderCheckoutRecoveryGuidance(contract: IssueCheckoutContract): string[] {
return [
"",
"### Checkout Recovery",
`- Required path: \`${contract.canonicalWorktreePath}\``,
`- Required branch: \`${contract.canonicalBranch}\``,
`- Base branch: \`${contract.baseBranch}\``,
"- If the path is missing, recreate it with the exact canonical branch/worktree.",
"- If the worktree is dirty or on the wrong branch, stop and call work_finish with result \"blocked\" unless you can repair it deterministically.",
];
}
22 changes: 20 additions & 2 deletions lib/dispatch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ import {
updateSlot,
getRoleWorker,
emptySlot,
upsertIssueCheckout,
resolveRepoPath,
} from "../projects/index.js";
import { resolveModel } from "../roles/index.js";
import { notify, getNotificationConfig } from "./notify.js";
import { loadConfig, type ResolvedRoleConfig } from "../config/index.js";
import { ReviewPolicy, TestPolicy, resolveReviewRouting, resolveTestRouting, resolveNotifyChannel, isFeedbackState, hasReviewCheck, producesReviewableWork, hasTestPhase, detectOwner, getOwnerLabel, OWNER_LABEL_COLOR, getRoleLabelColor, STEP_ROUTING_COLOR, getStateLabels } from "../workflow/index.js";
import { fetchPrFeedback, fetchPrContext, type PrFeedback, type PrContext } from "./pr-context.js";
import { ensureCheckoutContract, resolveExpectedCheckoutContract } from "../checkout-contract.js";
import { formatAttachmentsForTask } from "./attachments.js";
import { loadRoleInstructions } from "./bootstrap-hook.js";
import { slotName } from "../names.js";
Expand Down Expand Up @@ -162,6 +165,21 @@ export async function dispatchTask(
const prContext = hasReviewCheck(workflow, role)
? await fetchPrContext(provider, issueId) : undefined;

const repoPath = resolveRepoPath(project.repo);
const checkoutContract = await ensureCheckoutContract(
resolveExpectedCheckoutContract({
project,
issueId,
issueTitle,
repoPath,
role,
prBranchName: prFeedback?.branchName,
targetSha: prContext?.diff ? undefined : null,
}),
rc,
);
await upsertIssueCheckout(workspaceDir, project.slug, checkoutContract);

// Fetch attachment context (best-effort — never blocks dispatch)
let attachmentContext: string | undefined;
try {
Expand All @@ -175,13 +193,13 @@ export async function dispatchTask(
projectName: project.name, channelId: primaryChannelId, role, issueId,
issueTitle, issueUrl,
repo: project.repo, baseBranch: project.baseBranch,
resolvedRole, prFeedback,
resolvedRole, prFeedback, checkoutContract,
})
: buildTaskMessage({
projectName: project.name, channelId: primaryChannelId, role, issueId,
issueTitle, issueDescription, issueUrl,
repo: project.repo, baseBranch: project.baseBranch,
comments, resolvedRole, prContext, prFeedback, attachmentContext,
comments, resolvedRole, prContext, prFeedback, checkoutContract, attachmentContext,
});

// Load role-specific instructions to inject into the worker's system prompt
Expand Down
Loading