diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5968d98..7cb3558 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -427,7 +427,7 @@ sequenceDiagram
WF-->>DEV: { announcement: "π DEVELOPER REVIEW #42" }
```
-The issue sits in "In Review" until the heartbeat's **review pass** detects the PR has been merged, then automatically transitions to "To Test".
+The issue sits in "In Review" until the heartbeat's **review pass** detects the PR has been approved. DevClaw then auto-merges the PR and transitions to "To Test". If the merge fails (e.g. conflicts), the issue moves to "To Improve" where a developer is auto-dispatched to resolve conflicts.
### Phase 6: TESTER pickup
@@ -586,7 +586,7 @@ Every piece of data and where it lives:
β design_task β architect dispatch β
β β
β Bootstrap hook β injects role instructions into worker sessionsβ
-β Review pass β polls PR status, auto-advances In Review β
+β Review pass β polls PR status, auto-merges approved PRs β
β Config loader β three-layer merge + Zod validation β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β atomic file I/O β OpenClaw CLI (plugin shells out)
@@ -662,7 +662,7 @@ graph LR
SD[Session dispatch
create + send via CLI]
AC[Scheduling
tick queue after work_finish]
RI[Role instructions
injected via bootstrap hook]
- RV[Review polling
PR status β auto-advance]
+ RV[Review polling
PR approved β auto-merge]
A[Audit logging]
Z[Zombie cleanup]
CFG[Config validation
Zod + integrity checks]
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index aacddf4..fd16ad4 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -153,11 +153,12 @@ workflow:
type: review
label: In Review
color: "#c5def5"
- check: prMerged
+ check: prApproved
on:
APPROVED:
target: toTest
- actions: [gitPull]
+ actions: [mergePr, gitPull]
+ MERGE_FAILED: toImprove
BLOCKED: refining
done:
type: terminal
@@ -188,7 +189,7 @@ workflow:
| `queue` | Waiting for pickup. Must have a `role`. Has `priority` for ordering. |
| `active` | Worker is currently working on it. Must have a `role`. |
| `hold` | Paused, awaiting human decision. |
-| `review` | Awaiting external check (PR merged/approved). Has `check` field. |
+| `review` | Awaiting external check (PR approved/merged). Has `check` field. Heartbeat polls and auto-transitions. |
| `terminal` | Completed. No outgoing transitions. |
**Built-in actions:**
@@ -197,6 +198,7 @@ workflow:
|---|---|
| `gitPull` | Pull latest from the base branch |
| `detectPr` | Auto-detect PR URL from the issue |
+| `mergePr` | Merge the PR associated with the issue. Critical in review states (aborts on failure). |
| `closeIssue` | Close the issue |
| `reopenIssue` | Reopen the issue |
diff --git a/docs/MANAGEMENT.md b/docs/MANAGEMENT.md
index c0643b3..c4d541e 100644
--- a/docs/MANAGEMENT.md
+++ b/docs/MANAGEMENT.md
@@ -48,8 +48,8 @@ DevClaw enforces a configurable but consistent lifecycle for every task. The def
```
Planning β To Do β Doing β To Test β Testing β Done
- β In Review β (PR merged) β To Test
- β To Improve β Doing (fix cycle)
+ β In Review β (PR approved β auto-merge) β To Test
+ β To Improve β Doing (merge conflict / fix cycle)
β Refining β (human decision)
```
diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md
index bdb60c8..61093aa 100644
--- a/docs/ONBOARDING.md
+++ b/docs/ONBOARDING.md
@@ -266,7 +266,7 @@ Change which model powers each level in `workflow.yaml` β see [Configuration](
| Session management | Plugin | Creates, reuses, and dispatches to sessions via CLI. Agent never touches session tools. |
| Task completion | Plugin (`work_finish`) | Workers self-report. Scheduler dispatches next role. |
| Role instructions | Plugin (bootstrap hook) | Injected into worker sessions via `agent:bootstrap` hook at session startup |
-| Review polling | Plugin (heartbeat) | Auto-advances "In Review" issues when PR is merged |
+| Review polling | Plugin (heartbeat) | Auto-merges and advances "In Review" issues when PR is approved |
| Config validation | Plugin | Zod schemas validate `workflow.yaml` at load time |
| Audit logging | Plugin | Automatic NDJSON append per tool call |
| Zombie detection | Plugin | `health` checks active vs alive |
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 68bc809..c252e45 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -14,13 +14,13 @@ The issue lifecycle is now a configurable state machine defined in `workflow.yam
```
Planning β To Do β Doing β To Test β Testing β Done
- β In Review β (PR merged) β To Test
- β To Improve β Doing
+ β In Review β (PR approved β auto-merge) β To Test
+ β To Improve β Doing (merge conflict / fix cycle)
β Refining β (human decision)
To Design β Designing β Planning
```
-States have types (`queue`, `active`, `hold`, `review`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`).
+States have types (`queue`, `active`, `hold`, `review`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `mergePr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`).
### Three-Layer Configuration
@@ -42,7 +42,7 @@ Worker sessions receive role-specific instructions via the `agent:bootstrap` hoo
### In Review State and PR Polling
-DEVELOPER can submit a PR for human review (`result: "review"`), which transitions the issue to `In Review`. The heartbeat's review pass polls PR status via `getPrStatus()` on the provider. When the PR is merged, the issue auto-transitions to `To Test` for TESTER pickup.
+DEVELOPER can submit a PR for human review (`result: "review"`), which transitions the issue to `In Review`. The heartbeat's review pass polls PR status via `getPrStatus()` on the provider. When the PR is approved, DevClaw auto-merges via `mergePr()` and transitions to `To Test` for TESTER pickup. If the merge fails (e.g. conflicts), the issue moves to `To Improve` where a developer is auto-dispatched to resolve conflicts.
### Architect Role
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index 3f829c5..7e3a162 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -244,7 +244,7 @@ Manual trigger for heartbeat: health fix + review polling + queue dispatch. Same
**Three-pass sweep:**
1. **Health pass** β Runs `checkWorkerHealth` per project per role. Auto-fixes zombies, stale workers, orphaned state.
-2. **Review pass** β Polls PR status for issues in "In Review" state. Transitions to "To Test" when PR is merged.
+2. **Review pass** β Polls PR status for issues in "In Review" state. Auto-merges and transitions to "To Test" when PR is approved. If merge fails (conflicts), transitions to "To Improve" for developer to fix.
3. **Tick pass** β Calls `projectTick` per project. Fills free worker slots by priority (To Improve > To Test > To Do).
**Execution guards:**
diff --git a/lib/providers/github.ts b/lib/providers/github.ts
index 7677476..d3d1adc 100644
--- a/lib/providers/github.ts
+++ b/lib/providers/github.ts
@@ -152,6 +152,15 @@ export class GitHubProvider implements IssueProvider {
return { state: PrState.CLOSED, url: null };
}
+ async mergePr(issueId: number): Promise {
+ const pat = `#${issueId}`;
+ const raw = await this.gh(["pr", "list", "--state", "open", "--json", "number,title,body,url", "--limit", "20"]);
+ const prs = JSON.parse(raw) as Array<{ number: number; title: string; body: string; url: string }>;
+ const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat));
+ if (!pr) throw new Error(`No open PR found for issue #${issueId}`);
+ await this.gh(["pr", "merge", pr.url, "--merge"]);
+ }
+
async addComment(issueId: number, body: string): Promise {
await this.gh(["issue", "comment", String(issueId), "--body", body]);
}
diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts
index 603a7dc..4288b79 100644
--- a/lib/providers/gitlab.ts
+++ b/lib/providers/gitlab.ts
@@ -149,6 +149,15 @@ export class GitLabProvider implements IssueProvider {
return { state: PrState.CLOSED, url: null };
}
+ async mergePr(issueId: number): Promise {
+ const pat = `#${issueId}`;
+ const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]);
+ const mrs = JSON.parse(raw) as Array<{ iid: number; title: string; description: string }>;
+ const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat));
+ if (!mr) throw new Error(`No open MR found for issue #${issueId}`);
+ await this.glab(["mr", "merge", String(mr.iid)]);
+ }
+
async addComment(issueId: number, body: string): Promise {
// Pass message directly as argv β no shell escaping needed with spawn
await this.glab(["issue", "note", String(issueId), "--message", body]);
diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts
index d404371..aae7f1e 100644
--- a/lib/providers/provider.ts
+++ b/lib/providers/provider.ts
@@ -61,6 +61,7 @@ export interface IssueProvider {
hasMergedMR(issueId: number): Promise;
getMergedMRUrl(issueId: number): Promise;
getPrStatus(issueId: number): Promise;
+ mergePr(issueId: number): Promise;
addComment(issueId: number, body: string): Promise;
healthCheck(): Promise;
}
diff --git a/lib/services/pipeline.e2e.test.ts b/lib/services/pipeline.e2e.test.ts
index 0689291..cdc600c 100644
--- a/lib/services/pipeline.e2e.test.ts
+++ b/lib/services/pipeline.e2e.test.ts
@@ -370,10 +370,10 @@ describe("E2E pipeline", () => {
h = await createTestHarness();
});
- it("should transition In Review β To Test when PR is merged", async () => {
+ it("should auto-merge and transition In Review β To Test when PR is approved", async () => {
// Seed issue in "In Review" state
h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["In Review"] });
- h.provider.setPrStatus(60, { state: "merged", url: "https://example.com/pr/10" });
+ h.provider.setPrStatus(60, { state: "approved", url: "https://example.com/pr/10" });
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,
@@ -390,6 +390,11 @@ describe("E2E pipeline", () => {
assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`);
assert.ok(!issue.labels.includes("In Review"), "Should not have In Review");
+ // mergePr action should have been called
+ const mergeCalls = h.provider.callsTo("mergePr");
+ assert.strictEqual(mergeCalls.length, 1);
+ assert.strictEqual(mergeCalls[0].args.issueId, 60);
+
// gitPull action should have been attempted
const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git");
assert.ok(gitCmds.length > 0, "Should have run git pull");
@@ -417,8 +422,8 @@ describe("E2E pipeline", () => {
it("should handle multiple review issues in one pass", async () => {
h.provider.seedIssue({ iid: 70, title: "PR A", labels: ["In Review"] });
h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["In Review"] });
- h.provider.setPrStatus(70, { state: "merged", url: "https://example.com/pr/20" });
- h.provider.setPrStatus(71, { state: "merged", url: "https://example.com/pr/21" });
+ h.provider.setPrStatus(70, { state: "approved", url: "https://example.com/pr/20" });
+ h.provider.setPrStatus(71, { state: "approved", url: "https://example.com/pr/21" });
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,
@@ -434,6 +439,40 @@ describe("E2E pipeline", () => {
const issue71 = await h.provider.getIssue(71);
assert.ok(issue70.labels.includes("To Test"));
assert.ok(issue71.labels.includes("To Test"));
+
+ // Both should have had mergePr called
+ const mergeCalls = h.provider.callsTo("mergePr");
+ assert.strictEqual(mergeCalls.length, 2);
+ });
+
+ it("should transition In Review β To Improve when merge fails (conflicts)", async () => {
+ h.provider.seedIssue({ iid: 65, title: "Conflicting PR", labels: ["In Review"] });
+ h.provider.setPrStatus(65, { state: "approved", url: "https://example.com/pr/15" });
+ h.provider.mergePrFailures.add(65);
+
+ const transitions = await reviewPass({
+ workspaceDir: h.workspaceDir,
+ groupId: h.groupId,
+ workflow: DEFAULT_WORKFLOW,
+ provider: h.provider,
+ repoPath: "/tmp/test-repo",
+ });
+
+ assert.strictEqual(transitions, 1);
+
+ // Issue should have moved to "To Improve" (not "To Test")
+ const issue = await h.provider.getIssue(65);
+ assert.ok(issue.labels.includes("To Improve"), `Labels: ${issue.labels}`);
+ assert.ok(!issue.labels.includes("In Review"), "Should not have In Review");
+ assert.ok(!issue.labels.includes("To Test"), "Should NOT have To Test");
+
+ // mergePr should have been attempted
+ const mergeCalls = h.provider.callsTo("mergePr");
+ assert.strictEqual(mergeCalls.length, 1);
+
+ // gitPull should NOT have run (aborted before git pull)
+ const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git");
+ assert.strictEqual(gitCmds.length, 0, "Should NOT have run git pull after merge failure");
});
});
@@ -551,8 +590,8 @@ describe("E2E pipeline", () => {
let issue = await h.provider.getIssue(200);
assert.ok(issue.labels.includes("In Review"), `After review: ${issue.labels}`);
- // 4. PR gets merged β review pass picks it up
- h.provider.setPrStatus(200, { state: "merged", url: "https://example.com/pr/50" });
+ // 4. PR gets approved β review pass picks it up and auto-merges
+ h.provider.setPrStatus(200, { state: "approved", url: "https://example.com/pr/50" });
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,
diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts
index a15bc0a..01e22de 100644
--- a/lib/services/pipeline.ts
+++ b/lib/services/pipeline.ts
@@ -91,6 +91,11 @@ export async function executeCompletion(opts: {
auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
} }
break;
+ case Action.MERGE_PR:
+ try { await provider.mergePr(issueId); } catch (err) {
+ auditLog(workspaceDir, "pipeline_warning", { step: "mergePr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
+ }
+ break;
}
}
diff --git a/lib/services/review.ts b/lib/services/review.ts
index d5a0e98..c66baa1 100644
--- a/lib/services/review.ts
+++ b/lib/services/review.ts
@@ -50,8 +50,10 @@ export async function reviewPass(opts: {
if (!conditionMet) continue;
- // Find the success transition (first non-BLOCKED event)
- const successEvent = Object.keys(state.on).find((e) => e !== WorkflowEvent.BLOCKED);
+ // Find the success transition (first event that isn't BLOCKED or MERGE_FAILED)
+ const successEvent = Object.keys(state.on).find(
+ (e) => e !== WorkflowEvent.BLOCKED && e !== WorkflowEvent.MERGE_FAILED,
+ );
if (!successEvent) continue;
const transition = state.on[successEvent];
@@ -60,10 +62,41 @@ export async function reviewPass(opts: {
const targetState = workflow.states[targetKey];
if (!targetState) continue;
- // Execute transition actions
+ // Execute transition actions β mergePr is critical (aborts on failure)
+ let aborted = false;
if (actions) {
for (const action of actions) {
switch (action) {
+ case Action.MERGE_PR:
+ try {
+ await provider.mergePr(issue.iid);
+ } catch (err) {
+ // Merge failed β fire MERGE_FAILED transition (developer fixes conflicts)
+ await auditLog(workspaceDir, "review_merge_failed", {
+ groupId,
+ issueId: issue.iid,
+ from: state.label,
+ error: (err as Error).message ?? String(err),
+ });
+ const failedTransition = state.on[WorkflowEvent.MERGE_FAILED];
+ if (failedTransition) {
+ const failedKey = typeof failedTransition === "string" ? failedTransition : failedTransition.target;
+ const failedState = workflow.states[failedKey];
+ if (failedState) {
+ await provider.transitionLabel(issue.iid, state.label, failedState.label);
+ await auditLog(workspaceDir, "review_transition", {
+ groupId,
+ issueId: issue.iid,
+ from: state.label,
+ to: failedState.label,
+ reason: "merge_failed",
+ });
+ transitions++;
+ }
+ }
+ aborted = true;
+ }
+ break;
case Action.GIT_PULL:
try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ }
break;
@@ -74,9 +107,12 @@ export async function reviewPass(opts: {
await provider.reopenIssue(issue.iid);
break;
}
+ if (aborted) break;
}
}
+ if (aborted) continue; // skip normal transition, move to next issue
+
// Transition label
await provider.transitionLabel(issue.iid, state.label, targetState.label);
diff --git a/lib/templates.ts b/lib/templates.ts
index acebd23..57f4d1a 100644
--- a/lib/templates.ts
+++ b/lib/templates.ts
@@ -2,6 +2,9 @@
* Shared templates for workspace files.
* Used by setup and project_register.
*/
+import YAML from "yaml";
+import { DEFAULT_WORKFLOW } from "./workflow.js";
+import { ROLE_REGISTRY } from "./roles/registry.js";
export const DEFAULT_DEV_INSTRUCTIONS = `# DEVELOPER Worker Instructions
@@ -25,7 +28,7 @@ Read the comments carefully β they often contain clarifications, decisions, or
- **IMPORTANT:** 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" instead. DevClaw manages issue state β auto-closing bypasses QA.
- **Merge or request review:**
- Merge the PR yourself β call work_finish with result "done"
- - Leave the PR open for human review β call work_finish with result "review" (the heartbeat will auto-advance when the PR is merged)
+ - Leave the PR open for human review β call work_finish with result "review" (the heartbeat will auto-merge when approved and advance to testing)
- Clean up the worktree after merging (if you merged)
- If you discover unrelated bugs, call task_create to file them
- Do NOT call work_start, status, health, or project_register
@@ -228,9 +231,9 @@ All orchestration goes through these tools. You do NOT manually manage sessions,
\`\`\`
Planning β To Do β Doing β To Test β Testing β Done
β β
- In Review ββββββ (auto-advances when PR merged)
+ In Review ββββββ (auto-merges when PR approved)
β
- To Improve β Doing (fix cycle)
+ To Improve β Doing (merge conflict / fix cycle)
β
Refining (human decision)
@@ -262,7 +265,7 @@ All roles (Developer, Tester, Architect) use the same level scheme. Levels descr
Workers call \`work_finish\` themselves β the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle:
- Developer "done" β issue moves to "To Test" β scheduler dispatches Tester
-- Developer "review" β issue moves to "In Review" β heartbeat polls PR status β auto-advances to "To Test" when merged
+- Developer "review" β issue moves to "In Review" β heartbeat polls PR status β auto-merges and advances to "To Test" when approved (merge conflicts β "To Improve" for developer to fix)
- Tester "fail" β issue moves to "To Improve" β scheduler dispatches Developer
- Tester "pass" β Done, no further dispatch
- Tester "refine" / blocked β needs human input
@@ -292,121 +295,19 @@ export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md
Do nothing. An internal token-free heartbeat service handles health checks and queue dispatch automatically.
`;
-export const WORKFLOW_YAML_TEMPLATE = `# DevClaw workflow configuration
-# Modify values to customize. Copy to devclaw/projects//workflow.yaml for project-specific overrides.
+/**
+ * Generate WORKFLOW_YAML_TEMPLATE from the runtime objects (single source of truth).
+ */
+function buildWorkflowYaml(): string {
+ const roles: Record }> = {};
+ for (const [id, config] of Object.entries(ROLE_REGISTRY)) {
+ roles[id] = { models: { ...config.models } };
+ }
-roles:
- developer:
- models:
- junior: anthropic/claude-haiku-4-5
- medior: anthropic/claude-sonnet-4-5
- senior: anthropic/claude-opus-4-6
- tester:
- models:
- junior: anthropic/claude-haiku-4-5
- medior: anthropic/claude-sonnet-4-5
- senior: anthropic/claude-opus-4-6
- architect:
- models:
- junior: anthropic/claude-sonnet-4-5
- senior: anthropic/claude-opus-4-6
- # Disable a role entirely:
- # architect: false
+ const header =
+ "# DevClaw workflow configuration\n" +
+ "# Modify values to customize. Copy to devclaw/projects//workflow.yaml for project-specific overrides.\n\n";
+ return header + YAML.stringify({ roles, workflow: DEFAULT_WORKFLOW });
+}
-workflow:
- initial: planning
- states:
- planning:
- type: hold
- label: Planning
- color: "#95a5a6"
- on:
- APPROVE: todo
- todo:
- type: queue
- role: developer
- label: To Do
- color: "#428bca"
- priority: 1
- on:
- PICKUP: doing
- doing:
- type: active
- role: developer
- label: Doing
- color: "#f0ad4e"
- on:
- COMPLETE:
- target: toTest
- actions: [gitPull, detectPr]
- REVIEW:
- target: reviewing
- actions: [detectPr]
- BLOCKED: refining
- toTest:
- type: queue
- role: tester
- label: To Test
- color: "#5bc0de"
- priority: 2
- on:
- PICKUP: testing
- testing:
- type: active
- role: tester
- label: Testing
- color: "#9b59b6"
- on:
- PASS:
- target: done
- actions: [closeIssue]
- FAIL:
- target: toImprove
- actions: [reopenIssue]
- REFINE: refining
- BLOCKED: refining
- toImprove:
- type: queue
- role: developer
- label: To Improve
- color: "#d9534f"
- priority: 3
- on:
- PICKUP: doing
- refining:
- type: hold
- label: Refining
- color: "#f39c12"
- on:
- APPROVE: todo
- reviewing:
- type: review
- label: In Review
- color: "#c5def5"
- check: prMerged
- on:
- APPROVED:
- target: toTest
- actions: [gitPull]
- BLOCKED: refining
- done:
- type: terminal
- label: Done
- color: "#5cb85c"
- toDesign:
- type: queue
- role: architect
- label: To Design
- color: "#0075ca"
- priority: 1
- on:
- PICKUP: designing
- designing:
- type: active
- role: architect
- label: Designing
- color: "#d4c5f9"
- on:
- COMPLETE: planning
- BLOCKED: refining
-`;
+export const WORKFLOW_YAML_TEMPLATE = buildWorkflowYaml();
diff --git a/lib/testing/test-provider.ts b/lib/testing/test-provider.ts
index 17a5331..c20f6f1 100644
--- a/lib/testing/test-provider.ts
+++ b/lib/testing/test-provider.ts
@@ -21,16 +21,28 @@ import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
export type ProviderCall =
| { method: "ensureLabel"; args: { name: string; color: string } }
| { method: "ensureAllStateLabels"; args: {} }
- | { method: "createIssue"; args: { title: string; description: string; label: StateLabel; assignees?: string[] } }
+ | {
+ method: "createIssue";
+ args: {
+ title: string;
+ description: string;
+ label: StateLabel;
+ assignees?: string[];
+ };
+ }
| { method: "listIssuesByLabel"; args: { label: StateLabel } }
| { method: "getIssue"; args: { issueId: number } }
| { method: "listComments"; args: { issueId: number } }
- | { method: "transitionLabel"; args: { issueId: number; from: StateLabel; to: StateLabel } }
+ | {
+ method: "transitionLabel";
+ args: { issueId: number; from: StateLabel; to: StateLabel };
+ }
| { method: "closeIssue"; args: { issueId: number } }
| { method: "reopenIssue"; args: { issueId: number } }
| { method: "hasMergedMR"; args: { issueId: number } }
| { method: "getMergedMRUrl"; args: { issueId: number } }
| { method: "getPrStatus"; args: { issueId: number } }
+ | { method: "mergePr"; args: { issueId: number } }
| { method: "addComment"; args: { issueId: number; body: string } }
| { method: "healthCheck"; args: {} };
@@ -49,6 +61,8 @@ export class TestProvider implements IssueProvider {
prStatuses = new Map();
/** Merged MR URLs per issue. */
mergedMrUrls = new Map();
+ /** Issue IDs where mergePr should fail (simulates merge conflicts). */
+ mergePrFailures = new Set();
/** All calls, in order. */
calls: ProviderCall[] = [];
@@ -71,7 +85,8 @@ export class TestProvider implements IssueProvider {
description: overrides.description ?? "",
labels: overrides.labels ?? [],
state: overrides.state ?? "opened",
- web_url: overrides.web_url ?? `https://example.com/issues/${overrides.iid}`,
+ web_url:
+ overrides.web_url ?? `https://example.com/issues/${overrides.iid}`,
};
this.issues.set(issue.iid, issue);
if (issue.iid >= this.nextIssueId) this.nextIssueId = issue.iid + 1;
@@ -102,6 +117,7 @@ export class TestProvider implements IssueProvider {
this.labels.clear();
this.prStatuses.clear();
this.mergedMrUrls.clear();
+ this.mergePrFailures.clear();
this.calls = [];
this.nextIssueId = 1;
}
@@ -129,7 +145,10 @@ export class TestProvider implements IssueProvider {
label: StateLabel,
assignees?: string[],
): Promise {
- this.calls.push({ method: "createIssue", args: { title, description, label, assignees } });
+ this.calls.push({
+ method: "createIssue",
+ args: { title, description, label, assignees },
+ });
const iid = this.nextIssueId++;
const issue: Issue = {
iid,
@@ -210,10 +229,26 @@ export class TestProvider implements IssueProvider {
return this.prStatuses.get(issueId) ?? { state: "closed", url: null };
}
+ async mergePr(issueId: number): Promise {
+ this.calls.push({ method: "mergePr", args: { issueId } });
+ if (this.mergePrFailures.has(issueId)) {
+ throw new Error(`Merge conflict: cannot merge PR for issue #${issueId}`);
+ }
+ // Simulate successful merge β update PR status to merged
+ const existing = this.prStatuses.get(issueId);
+ if (existing) {
+ this.prStatuses.set(issueId, { state: "merged", url: existing.url });
+ }
+ }
+
async addComment(issueId: number, body: string): Promise {
this.calls.push({ method: "addComment", args: { issueId, body } });
const existing = this.comments.get(issueId) ?? [];
- existing.push({ author: "test", body, created_at: new Date().toISOString() });
+ existing.push({
+ author: "test",
+ body,
+ created_at: new Date().toISOString(),
+ });
this.comments.set(issueId, existing);
}
diff --git a/lib/workflow.ts b/lib/workflow.ts
index c8ef085..e0d46a3 100644
--- a/lib/workflow.ts
+++ b/lib/workflow.ts
@@ -39,6 +39,7 @@ export type TransitionAction = string;
export const Action = {
GIT_PULL: "gitPull",
DETECT_PR: "detectPr",
+ MERGE_PR: "mergePr",
CLOSE_ISSUE: "closeIssue",
REOPEN_ISSUE: "reopenIssue",
} as const;
@@ -56,6 +57,7 @@ export const WorkflowEvent = {
COMPLETE: "COMPLETE",
REVIEW: "REVIEW",
APPROVED: "APPROVED",
+ MERGE_FAILED: "MERGE_FAILED",
PASS: "PASS",
FAIL: "FAIL",
REFINE: "REFINE",
@@ -98,6 +100,7 @@ export type CompletionRule = {
export const DEFAULT_WORKFLOW: WorkflowConfig = {
initial: "planning",
states: {
+ // ββ Main pipeline (happy path) ββββββββββββββββββββββββββββββ
planning: {
type: StateType.HOLD,
label: "Planning",
@@ -123,6 +126,17 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
[WorkflowEvent.BLOCKED]: "refining",
},
},
+ reviewing: {
+ type: StateType.REVIEW,
+ label: "In Review",
+ color: "#c5def5",
+ check: ReviewCheck.PR_APPROVED,
+ on: {
+ [WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] },
+ [WorkflowEvent.MERGE_FAILED]: "toImprove",
+ [WorkflowEvent.BLOCKED]: "refining",
+ },
+ },
toTest: {
type: StateType.QUEUE,
role: "tester",
@@ -143,6 +157,13 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
[WorkflowEvent.BLOCKED]: "refining",
},
},
+ done: {
+ type: StateType.TERMINAL,
+ label: "Done",
+ color: "#5cb85c",
+ },
+
+ // ββ Side paths (loops back into main pipeline) ββββββββββββββ
toImprove: {
type: StateType.QUEUE,
role: "developer",
@@ -157,21 +178,8 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
color: "#f39c12",
on: { [WorkflowEvent.APPROVE]: "todo" },
},
- reviewing: {
- type: StateType.REVIEW,
- label: "In Review",
- color: "#c5def5",
- check: ReviewCheck.PR_MERGED,
- on: {
- [WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.GIT_PULL] },
- [WorkflowEvent.BLOCKED]: "refining",
- },
- },
- done: {
- type: StateType.TERMINAL,
- label: "Done",
- color: "#5cb85c",
- },
+
+ // ββ Architect track βββββββββββββββββββββββββββββββββββββββββ
toDesign: {
type: StateType.QUEUE,
role: "architect",