feat: enhance review process and role management

- Refactor reviewPass function to identify states with review checks instead of specific review types.
- Introduce review policies (HUMAN, AGENT, AUTO) to control PR review processes based on developer levels.
- Update projectTick to handle review policies and step routing labels for reviewers and testers.
- Add detailed reviewer instructions to templates for clarity on review responsibilities.
- Implement role:level label management, allowing dynamic creation of labels based on project configuration.
- Enhance task_update tool to support state and level updates, ensuring at least one parameter is provided.
- Update work_finish tool to include reviewer actions (approve, reject) in task completion.
- Modify work_start tool to utilize role-level detection for better level assignment.
- Add tests for new functionalities, including review routing and level detection from labels.
This commit is contained in:
Lauren ten Hoor
2026-02-16 18:09:53 +08:00
parent 1464fa82d2
commit d87b9f68a2
25 changed files with 1134 additions and 294 deletions

View File

@@ -138,6 +138,7 @@ function resolve(config: DevClawConfig): ResolvedConfig {
const workflow: WorkflowConfig = { const workflow: WorkflowConfig = {
initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial, initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial,
reviewPolicy: config.workflow?.reviewPolicy ?? DEFAULT_WORKFLOW.reviewPolicy,
states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states }, states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states },
}; };

View File

@@ -46,6 +46,7 @@ export function mergeConfig(
if (base.workflow || overlay.workflow) { if (base.workflow || overlay.workflow) {
merged.workflow = { merged.workflow = {
initial: overlay.workflow?.initial ?? base.workflow?.initial, initial: overlay.workflow?.initial ?? base.workflow?.initial,
reviewPolicy: overlay.workflow?.reviewPolicy ?? base.workflow?.reviewPolicy,
states: { states: {
...base.workflow?.states, ...base.workflow?.states,
...overlay.workflow?.states, ...overlay.workflow?.states,

View File

@@ -32,6 +32,7 @@ const StateConfigSchema = z.object({
const WorkflowConfigSchema = z.object({ const WorkflowConfigSchema = z.object({
initial: z.string(), initial: z.string(),
reviewPolicy: z.enum(["human", "agent", "auto"]).optional(),
states: z.record(z.string(), StateConfigSchema), states: z.record(z.string(), StateConfigSchema),
}); });

View File

@@ -16,6 +16,7 @@ import {
import { resolveModel, getFallbackEmoji } from "./roles/index.js"; import { resolveModel, getFallbackEmoji } from "./roles/index.js";
import { notify, getNotificationConfig } from "./notify.js"; import { notify, getNotificationConfig } from "./notify.js";
import { loadConfig, type ResolvedRoleConfig } from "./config/index.js"; import { loadConfig, type ResolvedRoleConfig } from "./config/index.js";
import { ReviewPolicy, resolveReviewRouting } from "./workflow.js";
export type DispatchOpts = { export type DispatchOpts = {
workspaceDir: string; workspaceDir: string;
@@ -74,6 +75,8 @@ export function buildTaskMessage(opts: {
groupId: string; groupId: string;
comments?: Array<{ author: string; body: string; created_at: string }>; comments?: Array<{ author: string; body: string; created_at: string }>;
resolvedRole?: ResolvedRoleConfig; resolvedRole?: ResolvedRoleConfig;
/** PR context for reviewer role (URL + diff) */
prContext?: { url: string; diff?: string };
}): string { }): string {
const { const {
projectName, role, issueId, issueTitle, projectName, role, issueId, issueTitle,
@@ -101,6 +104,19 @@ export function buildTaskMessage(opts: {
} }
} }
// Include PR context for reviewer role
if (opts.prContext) {
parts.push(``, `## Pull Request`, `🔗 ${opts.prContext.url}`);
if (opts.prContext.diff) {
// Truncate large diffs to avoid bloating context
const maxDiffLen = 50_000;
const diff = opts.prContext.diff.length > maxDiffLen
? opts.prContext.diff.slice(0, maxDiffLen) + "\n... (diff truncated, see PR for full changes)"
: opts.prContext.diff;
parts.push(``, `### Diff`, "```diff", diff, "```");
}
}
parts.push( parts.push(
``, ``,
`Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`, `Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`,
@@ -163,16 +179,52 @@ export async function dispatchTask(
// Fetch comments to include in task context // Fetch comments to include in task context
const comments = await provider.listComments(issueId); const comments = await provider.listComments(issueId);
// Fetch PR context for reviewer role
let prContext: { url: string; diff?: string } | undefined;
if (role === "reviewer") {
try {
const prStatus = await provider.getPrStatus(issueId);
if (prStatus.url) {
const diff = await provider.getPrDiff(issueId) ?? undefined;
prContext = { url: prStatus.url, diff };
}
} catch {
// Best-effort — reviewer can still work from issue context
}
}
const taskMessage = buildTaskMessage({ const taskMessage = buildTaskMessage({
projectName: project.name, role, issueId, projectName: project.name, role, issueId,
issueTitle, issueDescription, issueUrl, issueTitle, issueDescription, issueUrl,
repo: project.repo, baseBranch: project.baseBranch, groupId, repo: project.repo, baseBranch: project.baseBranch, groupId,
comments, resolvedRole, comments, resolvedRole, prContext,
}); });
// Step 1: Transition label (this is the commitment point) // Step 1: Transition label (this is the commitment point)
await transitionLabel(issueId, fromLabel, toLabel); await transitionLabel(issueId, fromLabel, toLabel);
// Step 1b: Apply role:level label (best-effort — failure must not abort dispatch)
try {
const issue = await provider.getIssue(issueId);
const oldRoleLabels = issue.labels.filter((l) => l.startsWith(`${role}:`));
if (oldRoleLabels.length > 0) {
await provider.removeLabels(issueId, oldRoleLabels);
}
await provider.addLabel(issueId, `${role}:${level}`);
// Step 1c: Apply review routing label when developer dispatched (best-effort)
if (role === "developer") {
const reviewLabel = resolveReviewRouting(
resolvedConfig.workflow.reviewPolicy ?? ReviewPolicy.AUTO, level,
);
const oldRouting = issue.labels.filter((l) => l.startsWith("review:"));
if (oldRouting.length > 0) await provider.removeLabels(issueId, oldRouting);
await provider.addLabel(issueId, reviewLabel);
}
} catch {
// Best-effort — label failure must not abort dispatch
}
// Step 2: Send notification early (before session dispatch which can timeout) // Step 2: Send notification early (before session dispatch which can timeout)
// This ensures users see the notification even if gateway is slow // This ensures users see the notification even if gateway is slow
const notifyConfig = getNotificationConfig(pluginConfig); const notifyConfig = getNotificationConfig(pluginConfig);

View File

@@ -6,6 +6,7 @@
* Event types: * Event types:
* - workerStart: Worker spawned/resumed for a task (→ project group) * - workerStart: Worker spawned/resumed for a task (→ project group)
* - workerComplete: Worker completed task (→ project group) * - workerComplete: Worker completed task (→ project group)
* - reviewNeeded: Issue needs review — human or agent (→ project group)
*/ */
import { log as auditLog } from "./audit.js"; import { log as auditLog } from "./audit.js";
import type { PluginRuntime } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk";
@@ -35,6 +36,16 @@ export type NotifyEvent =
result: "done" | "pass" | "fail" | "refine" | "blocked"; result: "done" | "pass" | "fail" | "refine" | "blocked";
summary?: string; summary?: string;
nextState?: string; nextState?: string;
}
| {
type: "reviewNeeded";
project: string;
groupId: string;
issueId: number;
issueUrl: string;
issueTitle: string;
routing: "human" | "agent";
prUrl?: string;
}; };
/** /**
@@ -74,6 +85,15 @@ function buildMessage(event: NotifyEvent): string {
msg += `\n🔗 ${event.issueUrl}`; msg += `\n🔗 ${event.issueUrl}`;
return msg; return msg;
} }
case "reviewNeeded": {
const icon = event.routing === "human" ? "👀" : "🤖";
const who = event.routing === "human" ? "Human review needed" : "Agent review queued";
let msg = `${icon} ${who} for #${event.issueId}: ${event.issueTitle}`;
if (event.prUrl) msg += `\n🔗 PR: ${event.prUrl}`;
msg += `\n📋 Issue: ${event.issueUrl}`;
return msg;
}
} }
} }

View File

@@ -50,6 +50,34 @@ export class GitHubProvider implements IssueProvider {
}); });
} }
/**
* Find PRs associated with an issue.
* Primary: match by head branch pattern (fix/123-, feature/123-, etc.)
* Fallback: word-boundary match on #123 in title/body.
*/
private async findPrsForIssue<T extends { title: string; body: string; headRefName?: string }>(
issueId: number,
state: "open" | "merged" | "all",
fields: string,
): Promise<T[]> {
try {
const args = ["pr", "list", "--json", fields, "--limit", "50"];
if (state !== "all") args.push("--state", state);
const raw = await this.gh(args);
if (!raw) return [];
const prs = JSON.parse(raw) as T[];
const branchPat = new RegExp(`^(?:fix|feature|chore|bugfix|hotfix)/${issueId}-`);
const titlePat = new RegExp(`\\b#${issueId}\\b`);
// Primary: match by branch name
const byBranch = prs.filter((pr) => pr.headRefName && branchPat.test(pr.headRefName));
if (byBranch.length > 0) return byBranch;
// Fallback: word-boundary match in title/body
return prs.filter((pr) => titlePat.test(pr.title) || titlePat.test(pr.body ?? ""));
} catch { return []; }
}
async ensureLabel(name: string, color: string): Promise<void> { async ensureLabel(name: string, color: string): Promise<void> {
try { await this.gh(["label", "create", name, "--color", color.replace(/^#/, "")]); } try { await this.gh(["label", "create", name, "--color", color.replace(/^#/, "")]); }
catch (err) { if (!(err as Error).message?.includes("already exists")) throw err; } catch (err) { if (!(err as Error).message?.includes("already exists")) throw err; }
@@ -102,6 +130,17 @@ export class GitHubProvider implements IssueProvider {
await this.gh(args); await this.gh(args);
} }
async addLabel(issueId: number, label: string): Promise<void> {
await this.gh(["issue", "edit", String(issueId), "--add-label", label]);
}
async removeLabels(issueId: number, labels: string[]): Promise<void> {
if (labels.length === 0) return;
const args = ["issue", "edit", String(issueId)];
for (const l of labels) args.push("--remove-label", l);
await this.gh(args);
}
async closeIssue(issueId: number): Promise<void> { await this.gh(["issue", "close", String(issueId)]); } async closeIssue(issueId: number): Promise<void> { await this.gh(["issue", "close", String(issueId)]); }
async reopenIssue(issueId: number): Promise<void> { await this.gh(["issue", "reopen", String(issueId)]); } async reopenIssue(issueId: number): Promise<void> { await this.gh(["issue", "reopen", String(issueId)]); }
@@ -113,52 +152,48 @@ export class GitHubProvider implements IssueProvider {
} }
async hasMergedMR(issueId: number): Promise<boolean> { async hasMergedMR(issueId: number): Promise<boolean> {
try { const prs = await this.findPrsForIssue(issueId, "merged", "title,body,headRefName");
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body"]); return prs.length > 0;
const prs = JSON.parse(raw) as Array<{ title: string; body: string }>;
const pat = `#${issueId}`;
return prs.some((pr) => pr.title.includes(pat) || (pr.body ?? "").includes(pat));
} catch { return false; }
} }
async getMergedMRUrl(issueId: number): Promise<string | null> { async getMergedMRUrl(issueId: number): Promise<string | null> {
try { type MergedPr = { title: string; body: string; headRefName: string; url: string; mergedAt: string };
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "number,title,body,url,mergedAt", "--limit", "20"]); const prs = await this.findPrsForIssue<MergedPr>(issueId, "merged", "title,body,headRefName,url,mergedAt");
const prs = JSON.parse(raw) as Array<{ number: number; title: string; body: string; url: string; mergedAt: string }>; if (prs.length === 0) return null;
const pat = `#${issueId}`; prs.sort((a, b) => new Date(b.mergedAt).getTime() - new Date(a.mergedAt).getTime());
return prs.find((pr) => pr.title.includes(pat) || (pr.body ?? "").includes(pat))?.url ?? null; return prs[0].url;
} catch { return null; }
} }
async getPrStatus(issueId: number): Promise<PrStatus> { async getPrStatus(issueId: number): Promise<PrStatus> {
const pat = `#${issueId}`;
// Check open PRs first // Check open PRs first
try { type OpenPr = { title: string; body: string; headRefName: string; url: string; reviewDecision: string };
const raw = await this.gh(["pr", "list", "--state", "open", "--json", "title,body,url,reviewDecision", "--limit", "20"]); const open = await this.findPrsForIssue<OpenPr>(issueId, "open", "title,body,headRefName,url,reviewDecision");
const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string; reviewDecision: string }>; if (open.length > 0) {
const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); const pr = open[0];
if (pr) {
const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN; const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN;
return { state, url: pr.url }; return { state, url: pr.url };
} }
} catch { /* continue to merged check */ }
// Check merged PRs // Check merged PRs
try { type MergedPr = { title: string; body: string; headRefName: string; url: string };
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body,url", "--limit", "20"]); const merged = await this.findPrsForIssue<MergedPr>(issueId, "merged", "title,body,headRefName,url");
const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string }>; if (merged.length > 0) return { state: PrState.MERGED, url: merged[0].url };
const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat));
if (pr) return { state: PrState.MERGED, url: pr.url };
} catch { /* ignore */ }
return { state: PrState.CLOSED, url: null }; return { state: PrState.CLOSED, url: null };
} }
async mergePr(issueId: number): Promise<void> { async mergePr(issueId: number): Promise<void> {
const pat = `#${issueId}`; type OpenPr = { title: string; body: string; headRefName: string; url: string };
const raw = await this.gh(["pr", "list", "--state", "open", "--json", "number,title,body,url", "--limit", "20"]); const prs = await this.findPrsForIssue<OpenPr>(issueId, "open", "title,body,headRefName,url");
const prs = JSON.parse(raw) as Array<{ number: number; title: string; body: string; url: string }>; if (prs.length === 0) throw new Error(`No open PR found for issue #${issueId}`);
const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); await this.gh(["pr", "merge", prs[0].url, "--merge"]);
if (!pr) throw new Error(`No open PR found for issue #${issueId}`); }
await this.gh(["pr", "merge", pr.url, "--merge"]);
async getPrDiff(issueId: number): Promise<string | null> {
type OpenPr = { title: string; body: string; headRefName: string; number: number };
const prs = await this.findPrsForIssue<OpenPr>(issueId, "open", "title,body,headRefName,number");
if (prs.length === 0) return null;
try {
return await this.gh(["pr", "diff", String(prs[0].number)]);
} catch { return null; }
} }
async addComment(issueId: number, body: string): Promise<void> { async addComment(issueId: number, body: string): Promise<void> {

View File

@@ -18,6 +18,16 @@ import {
type WorkflowConfig, type WorkflowConfig,
} from "../workflow.js"; } from "../workflow.js";
type GitLabMR = {
iid: number;
title: string;
description: string;
web_url: string;
state: string;
merged_at: string | null;
approved_by?: Array<unknown>;
};
export class GitLabProvider implements IssueProvider { export class GitLabProvider implements IssueProvider {
private repoPath: string; private repoPath: string;
private workflow: WorkflowConfig; private workflow: WorkflowConfig;
@@ -34,6 +44,15 @@ export class GitLabProvider implements IssueProvider {
}); });
} }
/** Get MRs linked to an issue via GitLab's native related_merge_requests API. */
private async getRelatedMRs(issueId: number): Promise<GitLabMR[]> {
try {
const raw = await this.glab(["api", `projects/:id/issues/${issueId}/related_merge_requests`, "--paginate"]);
if (!raw) return [];
return JSON.parse(raw) as GitLabMR[];
} catch { return []; }
}
async ensureLabel(name: string, color: string): Promise<void> { async ensureLabel(name: string, color: string): Promise<void> {
try { await this.glab(["label", "create", "--name", name, "--color", color]); } try { await this.glab(["label", "create", "--name", name, "--color", color]); }
catch (err) { const msg = (err as Error).message ?? ""; if (!msg.includes("already exists") && !msg.includes("409")) throw err; } catch (err) { const msg = (err as Error).message ?? ""; if (!msg.includes("already exists") && !msg.includes("409")) throw err; }
@@ -96,6 +115,17 @@ export class GitLabProvider implements IssueProvider {
await this.glab(args); await this.glab(args);
} }
async addLabel(issueId: number, label: string): Promise<void> {
await this.glab(["issue", "update", String(issueId), "--label", label]);
}
async removeLabels(issueId: number, labels: string[]): Promise<void> {
if (labels.length === 0) return;
const args = ["issue", "update", String(issueId)];
for (const l of labels) args.push("--unlabel", l);
await this.glab(args);
}
async closeIssue(issueId: number): Promise<void> { await this.glab(["issue", "close", String(issueId)]); } async closeIssue(issueId: number): Promise<void> { await this.glab(["issue", "close", String(issueId)]); }
async reopenIssue(issueId: number): Promise<void> { await this.glab(["issue", "reopen", String(issueId)]); } async reopenIssue(issueId: number): Promise<void> { await this.glab(["issue", "reopen", String(issueId)]); }
@@ -107,55 +137,56 @@ export class GitLabProvider implements IssueProvider {
} }
async hasMergedMR(issueId: number): Promise<boolean> { async hasMergedMR(issueId: number): Promise<boolean> {
try { const mrs = await this.getRelatedMRs(issueId);
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]); return mrs.some((mr) => mr.state === "merged");
const mrs = JSON.parse(raw) as Array<{ title: string; description: string }>;
const pat = `#${issueId}`;
return mrs.some((mr) => mr.title.includes(pat) || (mr.description ?? "").includes(pat));
} catch { return false; }
} }
async getMergedMRUrl(issueId: number): Promise<string | null> { async getMergedMRUrl(issueId: number): Promise<string | null> {
try { const mrs = await this.getRelatedMRs(issueId);
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]); const merged = mrs
const mrs = JSON.parse(raw) as Array<{ iid: number; title: string; description: string; web_url: string; merged_at: string }>; .filter((mr) => mr.state === "merged" && mr.merged_at)
const pat = `#${issueId}`; .sort((a, b) => new Date(b.merged_at!).getTime() - new Date(a.merged_at!).getTime());
const mr = mrs return merged[0]?.web_url ?? null;
.filter((mr) => mr.title.includes(pat) || (mr.description ?? "").includes(pat))
.sort((a, b) => new Date(b.merged_at).getTime() - new Date(a.merged_at).getTime())[0];
return mr?.web_url ?? null;
} catch { return null; }
} }
async getPrStatus(issueId: number): Promise<PrStatus> { async getPrStatus(issueId: number): Promise<PrStatus> {
const pat = `#${issueId}`; const mrs = await this.getRelatedMRs(issueId);
// Check open MRs first // Check open MRs first
try { const open = mrs.find((mr) => mr.state === "opened");
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]); if (open) {
const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string; approved_by?: Array<unknown> }>; // related_merge_requests doesn't populate approved_by — use dedicated approvals endpoint
const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); const approved = await this.isMrApproved(open.iid);
if (mr) { return { state: approved ? PrState.APPROVED : PrState.OPEN, url: open.web_url };
const state = mr.approved_by && mr.approved_by.length > 0 ? PrState.APPROVED : PrState.OPEN;
return { state, url: mr.web_url };
} }
} catch { /* continue to merged check */ }
// Check merged MRs // Check merged MRs
try { const merged = mrs.find((mr) => mr.state === "merged");
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]); if (merged) return { state: PrState.MERGED, url: merged.web_url };
const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string }>;
const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat));
if (mr) return { state: PrState.MERGED, url: mr.web_url };
} catch { /* ignore */ }
return { state: PrState.CLOSED, url: null }; return { state: PrState.CLOSED, url: null };
} }
/** Check if an MR is approved via the dedicated approvals endpoint. */
private async isMrApproved(mrIid: number): Promise<boolean> {
try {
const raw = await this.glab(["api", `projects/:id/merge_requests/${mrIid}/approvals`]);
const data = JSON.parse(raw) as { approved?: boolean; approvals_left?: number };
return data.approved === true || (data.approvals_left ?? 1) === 0;
} catch { return false; }
}
async mergePr(issueId: number): Promise<void> { async mergePr(issueId: number): Promise<void> {
const pat = `#${issueId}`; const mrs = await this.getRelatedMRs(issueId);
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]); const open = mrs.find((mr) => mr.state === "opened");
const mrs = JSON.parse(raw) as Array<{ iid: number; title: string; description: string }>; if (!open) throw new Error(`No open MR found for issue #${issueId}`);
const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); await this.glab(["mr", "merge", String(open.iid)]);
if (!mr) throw new Error(`No open MR found for issue #${issueId}`); }
await this.glab(["mr", "merge", String(mr.iid)]);
async getPrDiff(issueId: number): Promise<string | null> {
const mrs = await this.getRelatedMRs(issueId);
const open = mrs.find((mr) => mr.state === "opened");
if (!open) return null;
try {
return await this.glab(["mr", "diff", String(open.iid)]);
} catch { return null; }
} }
async addComment(issueId: number, body: string): Promise<void> { async addComment(issueId: number, body: string): Promise<void> {

View File

@@ -54,6 +54,8 @@ export interface IssueProvider {
getIssue(issueId: number): Promise<Issue>; getIssue(issueId: number): Promise<Issue>;
listComments(issueId: number): Promise<IssueComment[]>; listComments(issueId: number): Promise<IssueComment[]>;
transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void>; transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void>;
addLabel(issueId: number, label: string): Promise<void>;
removeLabels(issueId: number, labels: string[]): Promise<void>;
closeIssue(issueId: number): Promise<void>; closeIssue(issueId: number): Promise<void>;
reopenIssue(issueId: number): Promise<void>; reopenIssue(issueId: number): Promise<void>;
hasStateLabel(issue: Issue, expected: StateLabel): boolean; hasStateLabel(issue: Issue, expected: StateLabel): boolean;
@@ -62,6 +64,7 @@ export interface IssueProvider {
getMergedMRUrl(issueId: number): Promise<string | null>; getMergedMRUrl(issueId: number): Promise<string | null>;
getPrStatus(issueId: number): Promise<PrStatus>; getPrStatus(issueId: number): Promise<PrStatus>;
mergePr(issueId: number): Promise<void>; mergePr(issueId: number): Promise<void>;
getPrDiff(issueId: number): Promise<string | null>;
addComment(issueId: number, body: string): Promise<void>; addComment(issueId: number, body: string): Promise<void>;
healthCheck(): Promise<boolean>; healthCheck(): Promise<boolean>;
} }

View File

@@ -32,12 +32,14 @@ describe("role registry", () => {
assert.ok(ids.includes("developer")); assert.ok(ids.includes("developer"));
assert.ok(ids.includes("tester")); assert.ok(ids.includes("tester"));
assert.ok(ids.includes("architect")); assert.ok(ids.includes("architect"));
assert.ok(ids.includes("reviewer"));
}); });
it("should validate role IDs", () => { it("should validate role IDs", () => {
assert.strictEqual(isValidRole("developer"), true); assert.strictEqual(isValidRole("developer"), true);
assert.strictEqual(isValidRole("tester"), true); assert.strictEqual(isValidRole("tester"), true);
assert.strictEqual(isValidRole("architect"), true); assert.strictEqual(isValidRole("architect"), true);
assert.strictEqual(isValidRole("reviewer"), true);
assert.strictEqual(isValidRole("nonexistent"), false); assert.strictEqual(isValidRole("nonexistent"), false);
}); });
@@ -58,6 +60,7 @@ describe("levels", () => {
assert.deepStrictEqual([...getLevelsForRole("developer")], ["junior", "medior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("developer")], ["junior", "medior", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("tester")], ["junior", "medior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("tester")], ["junior", "medior", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]);
assert.deepStrictEqual([...getLevelsForRole("reviewer")], ["junior", "senior"]);
}); });
it("should return empty for unknown role", () => { it("should return empty for unknown role", () => {
@@ -185,17 +188,21 @@ describe("emoji", () => {
describe("completion results", () => { describe("completion results", () => {
it("should return valid results per role", () => { it("should return valid results per role", () => {
assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "review", "blocked"]); assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]);
assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]); assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]);
assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]); assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]);
assert.deepStrictEqual([...getCompletionResults("reviewer")], ["approve", "reject", "blocked"]);
}); });
it("should validate results", () => { it("should validate results", () => {
assert.strictEqual(isValidResult("developer", "done"), true); assert.strictEqual(isValidResult("developer", "done"), true);
assert.strictEqual(isValidResult("developer", "review"), true);
assert.strictEqual(isValidResult("developer", "pass"), false); assert.strictEqual(isValidResult("developer", "pass"), false);
assert.strictEqual(isValidResult("tester", "pass"), true); assert.strictEqual(isValidResult("tester", "pass"), true);
assert.strictEqual(isValidResult("tester", "done"), false); assert.strictEqual(isValidResult("tester", "done"), false);
assert.strictEqual(isValidResult("reviewer", "approve"), true);
assert.strictEqual(isValidResult("reviewer", "reject"), true);
assert.strictEqual(isValidResult("reviewer", "escalate"), false);
assert.strictEqual(isValidResult("reviewer", "done"), false);
}); });
}); });
@@ -205,6 +212,7 @@ describe("session key pattern", () => {
assert.ok(pattern.includes("developer")); assert.ok(pattern.includes("developer"));
assert.ok(pattern.includes("tester")); assert.ok(pattern.includes("tester"));
assert.ok(pattern.includes("architect")); assert.ok(pattern.includes("architect"));
assert.ok(pattern.includes("reviewer"));
}); });
it("should work as regex", () => { it("should work as regex", () => {
@@ -213,6 +221,7 @@ describe("session key pattern", () => {
assert.ok(regex.test("developer")); assert.ok(regex.test("developer"));
assert.ok(regex.test("tester")); assert.ok(regex.test("tester"));
assert.ok(regex.test("architect")); assert.ok(regex.test("architect"));
assert.ok(regex.test("reviewer"));
assert.ok(!regex.test("nonexistent")); assert.ok(!regex.test("nonexistent"));
}); });
}); });

View File

@@ -30,7 +30,7 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
senior: "🧠", senior: "🧠",
}, },
fallbackEmoji: "🔧", fallbackEmoji: "🔧",
completionResults: ["done", "review", "blocked"], completionResults: ["done", "blocked"],
sessionKeyPattern: "developer", sessionKeyPattern: "developer",
notifications: { onStart: true, onComplete: true }, notifications: { onStart: true, onComplete: true },
}, },
@@ -74,4 +74,23 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
sessionKeyPattern: "architect", sessionKeyPattern: "architect",
notifications: { onStart: true, onComplete: true }, notifications: { onStart: true, onComplete: true },
}, },
reviewer: {
id: "reviewer",
displayName: "REVIEWER",
levels: ["junior", "senior"],
defaultLevel: "junior",
models: {
junior: "anthropic/claude-haiku-4-5",
senior: "anthropic/claude-sonnet-4-5",
},
emoji: {
junior: "👁️",
senior: "🔬",
},
fallbackEmoji: "👁️",
completionResults: ["approve", "reject", "blocked"],
sessionKeyPattern: "reviewer",
notifications: { onStart: true, onComplete: true },
},
}; };

View File

@@ -122,16 +122,16 @@ describe("E2E bootstrap — hook injection", () => {
// Default developer instructions are scaffolded by ensureDefaultFiles // Default developer instructions are scaffolded by ensureDefaultFiles
assert.strictEqual(files.length, 1); assert.strictEqual(files.length, 1);
assert.ok(files[0].content!.includes("DEVELOPER"), "Should contain DEVELOPER heading"); assert.ok(files[0].content!.includes("DEVELOPER"), "Should contain DEVELOPER heading");
assert.ok(files[0].content!.includes("work_finish"), "Should reference work_finish"); assert.ok(files[0].content!.includes("worktree"), "Should reference git worktree workflow");
}); });
it("should NOT inject anything for unknown custom roles", async () => { it("should NOT inject anything for unknown custom roles", async () => {
h = await createTestHarness({ projectName: "custom-app" }); h = await createTestHarness({ projectName: "custom-app" });
// Simulate a session key for a custom role that has no prompt file // Simulate a session key for a custom role that has no prompt file
// This key won't parse because "reviewer" isn't in the role registry // This key won't parse because "investigator" isn't in the role registry
const files = await h.simulateBootstrap( const files = await h.simulateBootstrap(
"agent:main:subagent:custom-app-reviewer-medior", "agent:main:subagent:custom-app-investigator-medior",
); );
assert.strictEqual(files.length, 0, "Should not inject files for unknown roles"); assert.strictEqual(files.length, 0, "Should not inject files for unknown roles");

View File

@@ -13,8 +13,9 @@ import assert from "node:assert";
import { createTestHarness, type TestHarness } from "../testing/index.js"; import { createTestHarness, type TestHarness } from "../testing/index.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { executeCompletion } from "./pipeline.js"; import { executeCompletion } from "./pipeline.js";
import { projectTick } from "./tick.js";
import { reviewPass } from "./review.js"; import { reviewPass } from "./review.js";
import { DEFAULT_WORKFLOW } from "../workflow.js"; import { DEFAULT_WORKFLOW, ReviewPolicy, type WorkflowConfig } from "../workflow.js";
import { readProjects, getWorker } from "../projects.js"; import { readProjects, getWorker } from "../projects.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -147,10 +148,10 @@ describe("E2E pipeline", () => {
}); });
// ========================================================================= // =========================================================================
// Completion — developer:done // Completion — developer:done → To Review (always)
// ========================================================================= // =========================================================================
describe("executeCompletion — developer:done", () => { describe("executeCompletion — developer:done → To Review", () => {
beforeEach(async () => { beforeEach(async () => {
h = await createTestHarness({ h = await createTestHarness({
workers: { workers: {
@@ -160,9 +161,7 @@ describe("E2E pipeline", () => {
h.provider.seedIssue({ iid: 10, title: "Build feature X", labels: ["Doing"] }); h.provider.seedIssue({ iid: 10, title: "Build feature X", labels: ["Doing"] });
}); });
it("should transition Doing → To Test, deactivate worker, run gitPull+detectPr actions", async () => { it("should transition Doing → To Review", async () => {
h.provider.mergedMrUrls.set(10, "https://example.com/mr/5");
const output = await executeCompletion({ const output = await executeCompletion({
workspaceDir: h.workspaceDir, workspaceDir: h.workspaceDir,
groupId: h.groupId, groupId: h.groupId,
@@ -175,73 +174,90 @@ describe("E2E pipeline", () => {
projectName: "test-project", projectName: "test-project",
}); });
// Label transition assert.strictEqual(output.labelTransition, "Doing → To Review");
assert.strictEqual(output.labelTransition, "Doing → To Test");
assert.ok(output.announcement.includes("#10")); assert.ok(output.announcement.includes("#10"));
// Issue state
const issue = await h.provider.getIssue(10); const issue = await h.provider.getIssue(10);
assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); assert.ok(issue.labels.includes("To Review"), `Labels: ${issue.labels}`);
assert.ok(!issue.labels.includes("Doing")); assert.ok(!issue.labels.includes("Doing"));
// Worker deactivated
const data = await readProjects(h.workspaceDir); const data = await readProjects(h.workspaceDir);
const worker = getWorker(data.projects[h.groupId], "developer"); assert.strictEqual(getWorker(data.projects[h.groupId], "developer").active, false);
assert.strictEqual(worker.active, false);
// PR URL detected
assert.strictEqual(output.prUrl, "https://example.com/mr/5");
// gitPull action was executed
const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git");
assert.ok(gitCmds.length > 0, "Should have run git pull");
assert.deepStrictEqual(gitCmds[0].argv, ["git", "pull"]);
// Issue NOT closed (done goes to To Test, not Done)
assert.strictEqual(output.issueClosed, false); assert.strictEqual(output.issueClosed, false);
}); });
}); });
// ========================================================================= // =========================================================================
// Completion — developer:review // Completion — reviewer:approve / reject
// ========================================================================= // =========================================================================
describe("executeCompletion — developer:review", () => { describe("executeCompletion — reviewer", () => {
beforeEach(async () => { beforeEach(async () => {
h = await createTestHarness({ h = await createTestHarness({
workers: { workers: {
developer: { active: true, issueId: "20", level: "senior" }, reviewer: { active: true, issueId: "25", level: "junior" },
}, },
}); });
h.provider.seedIssue({ iid: 20, title: "Refactor auth", labels: ["Doing"] }); h.provider.seedIssue({ iid: 25, title: "Review PR", labels: ["Reviewing"] });
}); });
it("should transition Doing → In Review, deactivate worker", async () => { it("reviewer:approve should transition Reviewing → To Test, merge PR", async () => {
h.provider.setPrStatus(25, { state: "open", url: "https://example.com/pr/7" });
const output = await executeCompletion({ const output = await executeCompletion({
workspaceDir: h.workspaceDir, workspaceDir: h.workspaceDir,
groupId: h.groupId, groupId: h.groupId,
role: "developer", role: "reviewer",
result: "review", result: "approve",
issueId: 20, issueId: 25,
summary: "PR open for review", summary: "Code looks good",
prUrl: "https://example.com/pr/3",
provider: h.provider, provider: h.provider,
repoPath: "/tmp/test-repo", repoPath: "/tmp/test-repo",
projectName: "test-project", projectName: "test-project",
}); });
assert.strictEqual(output.labelTransition, "Doing → In Review"); assert.strictEqual(output.labelTransition, "Reviewing → To Test");
assert.ok(output.nextState.includes("review"), `nextState: ${output.nextState}`); const issue = await h.provider.getIssue(25);
assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`);
const issue = await h.provider.getIssue(20); const mergeCalls = h.provider.callsTo("mergePr");
assert.ok(issue.labels.includes("In Review"), `Labels: ${issue.labels}`); assert.strictEqual(mergeCalls.length, 1);
});
// Worker should be deactivated it("reviewer:reject should transition Reviewing → To Improve", async () => {
const data = await readProjects(h.workspaceDir); const output = await executeCompletion({
assert.strictEqual(getWorker(data.projects[h.groupId], "developer").active, false); workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "reviewer",
result: "reject",
issueId: 25,
summary: "Missing error handling",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
// Issue should NOT be closed assert.strictEqual(output.labelTransition, "Reviewing → To Improve");
assert.strictEqual(output.issueClosed, false); const issue = await h.provider.getIssue(25);
assert.ok(issue.labels.includes("To Improve"), `Labels: ${issue.labels}`);
});
it("reviewer:blocked should transition Reviewing → Refining", async () => {
const output = await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "reviewer",
result: "blocked",
issueId: 25,
summary: "Can't determine correctness",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
assert.strictEqual(output.labelTransition, "Reviewing → Refining");
const issue = await h.provider.getIssue(25);
assert.ok(issue.labels.includes("Refining"), `Labels: ${issue.labels}`);
}); });
}); });
@@ -362,7 +378,7 @@ describe("E2E pipeline", () => {
}); });
// ========================================================================= // =========================================================================
// Review pass // Review pass — heartbeat polls To Review for human path
// ========================================================================= // =========================================================================
describe("reviewPass", () => { describe("reviewPass", () => {
@@ -370,9 +386,8 @@ describe("E2E pipeline", () => {
h = await createTestHarness(); h = await createTestHarness();
}); });
it("should auto-merge and transition In Review → To Test when PR is approved", async () => { it("should auto-merge and transition To Review → To Test when PR is approved", async () => {
// Seed issue in "In Review" state h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["To Review"] });
h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["In Review"] });
h.provider.setPrStatus(60, { state: "approved", url: "https://example.com/pr/10" }); h.provider.setPrStatus(60, { state: "approved", url: "https://example.com/pr/10" });
const transitions = await reviewPass({ const transitions = await reviewPass({
@@ -385,23 +400,20 @@ describe("E2E pipeline", () => {
assert.strictEqual(transitions, 1); assert.strictEqual(transitions, 1);
// Issue should now have "To Test" label
const issue = await h.provider.getIssue(60); const issue = await h.provider.getIssue(60);
assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`);
assert.ok(!issue.labels.includes("In Review"), "Should not have In Review"); assert.ok(!issue.labels.includes("To Review"), "Should not have To Review");
// mergePr action should have been called
const mergeCalls = h.provider.callsTo("mergePr"); const mergeCalls = h.provider.callsTo("mergePr");
assert.strictEqual(mergeCalls.length, 1); assert.strictEqual(mergeCalls.length, 1);
assert.strictEqual(mergeCalls[0].args.issueId, 60); assert.strictEqual(mergeCalls[0].args.issueId, 60);
// gitPull action should have been attempted
const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git");
assert.ok(gitCmds.length > 0, "Should have run git pull"); assert.ok(gitCmds.length > 0, "Should have run git pull");
}); });
it("should NOT transition when PR is still open", async () => { it("should NOT transition when PR is still open", async () => {
h.provider.seedIssue({ iid: 61, title: "Feature Z", labels: ["In Review"] }); h.provider.seedIssue({ iid: 61, title: "Feature Z", labels: ["To Review"] });
h.provider.setPrStatus(61, { state: "open", url: "https://example.com/pr/11" }); h.provider.setPrStatus(61, { state: "open", url: "https://example.com/pr/11" });
const transitions = await reviewPass({ const transitions = await reviewPass({
@@ -414,14 +426,13 @@ describe("E2E pipeline", () => {
assert.strictEqual(transitions, 0); assert.strictEqual(transitions, 0);
// Issue should still have "In Review"
const issue = await h.provider.getIssue(61); const issue = await h.provider.getIssue(61);
assert.ok(issue.labels.includes("In Review")); assert.ok(issue.labels.includes("To Review"));
}); });
it("should handle multiple review issues in one pass", async () => { 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: 70, title: "PR A", labels: ["To Review"] });
h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["In Review"] }); h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["To Review"] });
h.provider.setPrStatus(70, { state: "approved", url: "https://example.com/pr/20" }); h.provider.setPrStatus(70, { state: "approved", url: "https://example.com/pr/20" });
h.provider.setPrStatus(71, { state: "approved", url: "https://example.com/pr/21" }); h.provider.setPrStatus(71, { state: "approved", url: "https://example.com/pr/21" });
@@ -440,13 +451,12 @@ describe("E2E pipeline", () => {
assert.ok(issue70.labels.includes("To Test")); assert.ok(issue70.labels.includes("To Test"));
assert.ok(issue71.labels.includes("To Test")); assert.ok(issue71.labels.includes("To Test"));
// Both should have had mergePr called
const mergeCalls = h.provider.callsTo("mergePr"); const mergeCalls = h.provider.callsTo("mergePr");
assert.strictEqual(mergeCalls.length, 2); assert.strictEqual(mergeCalls.length, 2);
}); });
it("should transition In Review → To Improve when merge fails (conflicts)", async () => { it("should transition To Review → To Improve when merge fails (conflicts)", async () => {
h.provider.seedIssue({ iid: 65, title: "Conflicting PR", labels: ["In Review"] }); h.provider.seedIssue({ iid: 65, title: "Conflicting PR", labels: ["To Review"] });
h.provider.setPrStatus(65, { state: "approved", url: "https://example.com/pr/15" }); h.provider.setPrStatus(65, { state: "approved", url: "https://example.com/pr/15" });
h.provider.mergePrFailures.add(65); h.provider.mergePrFailures.add(65);
@@ -460,17 +470,14 @@ describe("E2E pipeline", () => {
assert.strictEqual(transitions, 1); assert.strictEqual(transitions, 1);
// Issue should have moved to "To Improve" (not "To Test")
const issue = await h.provider.getIssue(65); const issue = await h.provider.getIssue(65);
assert.ok(issue.labels.includes("To Improve"), `Labels: ${issue.labels}`); 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 Review"), "Should not have To Review");
assert.ok(!issue.labels.includes("To Test"), "Should NOT have To Test"); assert.ok(!issue.labels.includes("To Test"), "Should NOT have To Test");
// mergePr should have been attempted
const mergeCalls = h.provider.callsTo("mergePr"); const mergeCalls = h.provider.callsTo("mergePr");
assert.strictEqual(mergeCalls.length, 1); 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"); 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"); assert.strictEqual(gitCmds.length, 0, "Should NOT have run git pull after merge failure");
}); });
@@ -481,10 +488,10 @@ describe("E2E pipeline", () => {
// ========================================================================= // =========================================================================
describe("full lifecycle", () => { describe("full lifecycle", () => {
it("developer:done → tester:pass (direct path)", async () => { it("developer:done → reviewer:approve → tester:pass (agent review path)", async () => {
h = await createTestHarness(); h = await createTestHarness();
// 1. Seed issue in To Do // 1. Seed issue
h.provider.seedIssue({ iid: 100, title: "Build dashboard", labels: ["To Do"] }); h.provider.seedIssue({ iid: 100, title: "Build dashboard", labels: ["To Do"] });
// 2. Dispatch developer // 2. Dispatch developer
@@ -505,10 +512,7 @@ describe("E2E pipeline", () => {
provider: h.provider, provider: h.provider,
}); });
let issue = await h.provider.getIssue(100); // 3. Developer done → To Review
assert.ok(issue.labels.includes("Doing"));
// 3. Developer completes → To Test
await executeCompletion({ await executeCompletion({
workspaceDir: h.workspaceDir, workspaceDir: h.workspaceDir,
groupId: h.groupId, groupId: h.groupId,
@@ -521,17 +525,37 @@ describe("E2E pipeline", () => {
projectName: "test-project", projectName: "test-project",
}); });
issue = await h.provider.getIssue(100); let issue = await h.provider.getIssue(100);
assert.ok(issue.labels.includes("To Test"), `After dev done: ${issue.labels}`); assert.ok(issue.labels.includes("To Review"), `After dev done: ${issue.labels}`);
// 4. Simulate tester dispatch (activate worker manually for completion) // 4. Reviewer dispatched → Reviewing → approve → To Test
const { activateWorker } = await import("../projects.js"); const { activateWorker } = await import("../projects.js");
await activateWorker(h.workspaceDir, h.groupId, "reviewer", {
issueId: "100", level: "junior",
});
await h.provider.transitionLabel(100, "To Review", "Reviewing");
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "reviewer",
result: "approve",
issueId: 100,
summary: "Code looks good",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
issue = await h.provider.getIssue(100);
assert.ok(issue.labels.includes("To Test"), `After reviewer approve: ${issue.labels}`);
// 5. Tester passes → Done
await activateWorker(h.workspaceDir, h.groupId, "tester", { await activateWorker(h.workspaceDir, h.groupId, "tester", {
issueId: "100", level: "medior", issueId: "100", level: "medior",
}); });
await h.provider.transitionLabel(100, "To Test", "Testing"); await h.provider.transitionLabel(100, "To Test", "Testing");
// 5. Tester passes → Done
await executeCompletion({ await executeCompletion({
workspaceDir: h.workspaceDir, workspaceDir: h.workspaceDir,
groupId: h.groupId, groupId: h.groupId,
@@ -549,13 +573,12 @@ describe("E2E pipeline", () => {
assert.strictEqual(issue.state, "closed"); assert.strictEqual(issue.state, "closed");
}); });
it("developer:review → review pass → tester:pass (review path)", async () => { it("developer:done → human review pass → tester:pass (human review path)", async () => {
h = await createTestHarness(); h = await createTestHarness();
// 1. Seed issue in To Do
h.provider.seedIssue({ iid: 200, title: "Auth refactor", labels: ["To Do"] }); h.provider.seedIssue({ iid: 200, title: "Auth refactor", labels: ["To Do"] });
// 2. Dispatch developer // 1. Dispatch developer
await dispatchTask({ await dispatchTask({
workspaceDir: h.workspaceDir, workspaceDir: h.workspaceDir,
agentId: "main", agentId: "main",
@@ -573,12 +596,12 @@ describe("E2E pipeline", () => {
provider: h.provider, provider: h.provider,
}); });
// 3. Developer finishes with "review" → In Review // 2. Developer done → To Review (same state regardless of level)
await executeCompletion({ await executeCompletion({
workspaceDir: h.workspaceDir, workspaceDir: h.workspaceDir,
groupId: h.groupId, groupId: h.groupId,
role: "developer", role: "developer",
result: "review", result: "done",
issueId: 200, issueId: 200,
summary: "PR ready for review", summary: "PR ready for review",
prUrl: "https://example.com/pr/50", prUrl: "https://example.com/pr/50",
@@ -588,9 +611,9 @@ describe("E2E pipeline", () => {
}); });
let issue = await h.provider.getIssue(200); let issue = await h.provider.getIssue(200);
assert.ok(issue.labels.includes("In Review"), `After review: ${issue.labels}`); assert.ok(issue.labels.includes("To Review"), `After dev done: ${issue.labels}`);
// 4. PR gets approved — review pass picks it up and auto-merges // 3. Human reviews PR → approved → heartbeat transitions To Review → To Test
h.provider.setPrStatus(200, { state: "approved", url: "https://example.com/pr/50" }); h.provider.setPrStatus(200, { state: "approved", url: "https://example.com/pr/50" });
const transitions = await reviewPass({ const transitions = await reviewPass({
@@ -605,7 +628,7 @@ describe("E2E pipeline", () => {
issue = await h.provider.getIssue(200); issue = await h.provider.getIssue(200);
assert.ok(issue.labels.includes("To Test"), `After review pass: ${issue.labels}`); assert.ok(issue.labels.includes("To Test"), `After review pass: ${issue.labels}`);
// 5. Tester passes → Done // 4. Tester passes → Done
const { activateWorker } = await import("../projects.js"); const { activateWorker } = await import("../projects.js");
await activateWorker(h.workspaceDir, h.groupId, "tester", { await activateWorker(h.workspaceDir, h.groupId, "tester", {
issueId: "200", level: "medior", issueId: "200", level: "medior",
@@ -629,7 +652,7 @@ describe("E2E pipeline", () => {
assert.strictEqual(issue.state, "closed"); assert.strictEqual(issue.state, "closed");
}); });
it("developer:done → tester:fail → developer:done → tester:pass (fail cycle)", async () => { it("developer:done → reviewer:reject → developer:done → reviewer:approve → tester:pass (reject cycle)", async () => {
h = await createTestHarness(); h = await createTestHarness();
h.provider.seedIssue({ iid: 300, title: "Payment flow", labels: ["To Do"] }); h.provider.seedIssue({ iid: 300, title: "Payment flow", labels: ["To Do"] });
@@ -652,7 +675,7 @@ describe("E2E pipeline", () => {
provider: h.provider, provider: h.provider,
}); });
// 2. Developer done → To Test // 2. Developer done → To Review
await executeCompletion({ await executeCompletion({
workspaceDir: h.workspaceDir, workspaceDir: h.workspaceDir,
groupId: h.groupId, groupId: h.groupId,
@@ -664,31 +687,32 @@ describe("E2E pipeline", () => {
projectName: "test-project", projectName: "test-project",
}); });
// 3. Activate tester + transition let issue = await h.provider.getIssue(300);
const { activateWorker } = await import("../projects.js"); assert.ok(issue.labels.includes("To Review"), `After dev done: ${issue.labels}`);
await activateWorker(h.workspaceDir, h.groupId, "tester", {
issueId: "300", level: "medior", // 3. Reviewer REJECTS → To Improve
}); const { activateWorker } = await import("../projects.js");
await h.provider.transitionLabel(300, "To Test", "Testing"); await activateWorker(h.workspaceDir, h.groupId, "reviewer", {
issueId: "300", level: "junior",
});
await h.provider.transitionLabel(300, "To Review", "Reviewing");
// 4. Tester FAILS → To Improve
await executeCompletion({ await executeCompletion({
workspaceDir: h.workspaceDir, workspaceDir: h.workspaceDir,
groupId: h.groupId, groupId: h.groupId,
role: "tester", role: "reviewer",
result: "fail", result: "reject",
issueId: 300, issueId: 300,
summary: "Validation broken", summary: "Missing validation",
provider: h.provider, provider: h.provider,
repoPath: "/tmp/test-repo", repoPath: "/tmp/test-repo",
projectName: "test-project", projectName: "test-project",
}); });
let issue = await h.provider.getIssue(300); issue = await h.provider.getIssue(300);
assert.ok(issue.labels.includes("To Improve"), `After fail: ${issue.labels}`); assert.ok(issue.labels.includes("To Improve"), `After reject: ${issue.labels}`);
assert.strictEqual(issue.state, "opened"); // reopened
// 5. Developer picks up again (To Improve → Doing) // 4. Developer picks up again → fixes → To Review
await dispatchTask({ await dispatchTask({
workspaceDir: h.workspaceDir, workspaceDir: h.workspaceDir,
agentId: "main", agentId: "main",
@@ -706,7 +730,6 @@ describe("E2E pipeline", () => {
provider: h.provider, provider: h.provider,
}); });
// 6. Developer fixes it → To Test
await executeCompletion({ await executeCompletion({
workspaceDir: h.workspaceDir, workspaceDir: h.workspaceDir,
groupId: h.groupId, groupId: h.groupId,
@@ -720,9 +743,30 @@ describe("E2E pipeline", () => {
}); });
issue = await h.provider.getIssue(300); issue = await h.provider.getIssue(300);
assert.ok(issue.labels.includes("To Test"), `After fix: ${issue.labels}`); assert.ok(issue.labels.includes("To Review"), `After fix: ${issue.labels}`);
// 7. Tester passes → Done // 5. Reviewer approves this time → To Test
await activateWorker(h.workspaceDir, h.groupId, "reviewer", {
issueId: "300", level: "junior",
});
await h.provider.transitionLabel(300, "To Review", "Reviewing");
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "reviewer",
result: "approve",
issueId: 300,
summary: "Looks good now",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
issue = await h.provider.getIssue(300);
assert.ok(issue.labels.includes("To Test"), `After approve: ${issue.labels}`);
// 6. Tester passes → Done
await activateWorker(h.workspaceDir, h.groupId, "tester", { await activateWorker(h.workspaceDir, h.groupId, "tester", {
issueId: "300", level: "medior", issueId: "300", level: "medior",
}); });
@@ -746,6 +790,229 @@ describe("E2E pipeline", () => {
}); });
}); });
// =========================================================================
// Review policy gating — projectTick respects reviewPolicy
// =========================================================================
describe("projectTick — reviewPolicy gating", () => {
function workflowWithPolicy(policy: ReviewPolicy): WorkflowConfig {
return { ...DEFAULT_WORKFLOW, reviewPolicy: policy };
}
it("reviewPolicy: human should skip reviewer dispatch", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 80, title: "Needs review", labels: ["To Review"] });
const result = await projectTick({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
targetRole: "reviewer",
workflow: workflowWithPolicy(ReviewPolicy.HUMAN),
provider: h.provider,
});
assert.strictEqual(result.pickups.length, 0, "Should NOT dispatch reviewer");
const reviewerSkip = result.skipped.find((s) => s.role === "reviewer");
assert.ok(reviewerSkip, "Should have skipped reviewer");
assert.ok(reviewerSkip!.reason.includes("human"), `Skip reason: ${reviewerSkip!.reason}`);
});
it("reviewPolicy: agent should dispatch reviewer", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 81, title: "Needs review", labels: ["To Review"] });
const result = await projectTick({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
agentId: "test-agent",
targetRole: "reviewer",
workflow: workflowWithPolicy(ReviewPolicy.AGENT),
provider: h.provider,
});
assert.strictEqual(result.pickups.length, 1, "Should dispatch reviewer");
assert.strictEqual(result.pickups[0].role, "reviewer");
});
it("reviewPolicy: auto should dispatch reviewer for junior-level issues", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 82, title: "Small fix", labels: ["To Review"] });
const result = await projectTick({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
agentId: "test-agent",
targetRole: "reviewer",
workflow: workflowWithPolicy(ReviewPolicy.AUTO),
provider: h.provider,
});
// Junior/medior should be dispatched under auto policy
assert.strictEqual(result.pickups.length, 1, "Should dispatch reviewer for non-senior");
});
it("reviewPolicy: auto should skip reviewer for senior-level issues (review:human label)", async () => {
h = await createTestHarness();
// dispatch applies review:human for senior developers (via resolveReviewRouting)
h.provider.seedIssue({ iid: 83, title: "Architecture rework", labels: ["To Review", "developer:senior", "review:human"] });
const result = await projectTick({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
targetRole: "reviewer",
workflow: workflowWithPolicy(ReviewPolicy.AUTO),
provider: h.provider,
});
assert.strictEqual(result.pickups.length, 0, "Should NOT dispatch reviewer for review:human");
const reviewerSkip = result.skipped.find((s) => s.role === "reviewer");
assert.ok(reviewerSkip, "Should have skipped reviewer");
assert.ok(reviewerSkip!.reason.includes("review:human"), `Skip reason: ${reviewerSkip!.reason}`);
});
it("reviewPolicy: human should still allow developer and tester dispatch", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 84, title: "Dev task", labels: ["To Do"] });
h.provider.seedIssue({ iid: 85, title: "Test task", labels: ["To Test"] });
const result = await projectTick({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
agentId: "test-agent",
workflow: workflowWithPolicy(ReviewPolicy.HUMAN),
provider: h.provider,
});
const roles = result.pickups.map((p) => p.role);
assert.ok(roles.includes("developer"), `Should dispatch developer, got: ${roles}`);
assert.ok(roles.includes("tester"), `Should dispatch tester, got: ${roles}`);
assert.ok(!roles.includes("reviewer"), "Should NOT dispatch reviewer");
});
});
// =========================================================================
// Role:level labels — dispatch applies labels, tick reads them
// =========================================================================
describe("role:level labels", () => {
it("dispatch should apply role:level label to issue", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 400, title: "Label test", labels: ["To Do"] });
await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "test-agent",
groupId: h.groupId,
project: h.project,
issueId: 400,
issueTitle: "Label test",
issueDescription: "",
issueUrl: "https://example.com/issues/400",
role: "developer",
level: "senior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
const issue = await h.provider.getIssue(400);
assert.ok(issue.labels.includes("developer:senior"), `Should have developer:senior, got: ${issue.labels}`);
assert.ok(issue.labels.includes("Doing"), "Should have Doing label");
// Senior developer dispatch should also apply review:human routing label
assert.ok(issue.labels.includes("review:human"), `Should have review:human for senior, got: ${issue.labels}`);
});
it("dispatch should apply review:agent label for non-senior developer", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 404, title: "Junior task", labels: ["To Do"] });
await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "test-agent",
groupId: h.groupId,
project: h.project,
issueId: 404,
issueTitle: "Junior task",
issueDescription: "",
issueUrl: "https://example.com/issues/404",
role: "developer",
level: "junior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
const issue = await h.provider.getIssue(404);
assert.ok(issue.labels.includes("developer:junior"), `Should have developer:junior, got: ${issue.labels}`);
assert.ok(issue.labels.includes("review:agent"), `Should have review:agent for junior, got: ${issue.labels}`);
});
it("dispatch should replace old role:level label", async () => {
h = await createTestHarness();
// Issue already has a developer:junior label from a previous dispatch
h.provider.seedIssue({ iid: 401, title: "Re-dispatch", labels: ["To Improve", "developer:junior"] });
await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "test-agent",
groupId: h.groupId,
project: h.project,
issueId: 401,
issueTitle: "Re-dispatch",
issueDescription: "",
issueUrl: "https://example.com/issues/401",
role: "developer",
level: "medior",
fromLabel: "To Improve",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
const issue = await h.provider.getIssue(401);
assert.ok(issue.labels.includes("developer:medior"), `Should have developer:medior, got: ${issue.labels}`);
assert.ok(!issue.labels.includes("developer:junior"), "Should NOT have developer:junior");
});
it("projectTick should skip reviewer when review:human label present", async () => {
h = await createTestHarness();
// review:human applied by dispatch for senior developers
h.provider.seedIssue({ iid: 402, title: "Senior review", labels: ["To Review", "developer:senior", "review:human"] });
const result = await projectTick({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
targetRole: "reviewer",
workflow: { ...DEFAULT_WORKFLOW, reviewPolicy: ReviewPolicy.AUTO },
provider: h.provider,
});
assert.strictEqual(result.pickups.length, 0, "Should NOT dispatch reviewer for review:human");
const reviewerSkip = result.skipped.find((s) => s.role === "reviewer");
assert.ok(reviewerSkip, "Should have skipped reviewer");
assert.ok(reviewerSkip!.reason.includes("review:human"), `Skip reason: ${reviewerSkip!.reason}`);
});
it("projectTick should dispatch reviewer when review:agent label present", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 403, title: "Junior fix", labels: ["To Review", "developer:junior", "review:agent"] });
const result = await projectTick({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
agentId: "test-agent",
targetRole: "reviewer",
workflow: { ...DEFAULT_WORKFLOW, reviewPolicy: ReviewPolicy.AUTO },
provider: h.provider,
});
assert.strictEqual(result.pickups.length, 1, "Should dispatch reviewer for review:agent");
assert.strictEqual(result.pickups[0].role, "reviewer");
});
});
// ========================================================================= // =========================================================================
// Provider call tracking // Provider call tracking
// ========================================================================= // =========================================================================

View File

@@ -10,6 +10,7 @@ import { runCommand } from "../run-command.js";
import { notify, getNotificationConfig } from "../notify.js"; import { notify, getNotificationConfig } from "../notify.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { loadConfig } from "../config/index.js"; import { loadConfig } from "../config/index.js";
import { detectStepRouting } from "./queue-scan.js";
import { import {
DEFAULT_WORKFLOW, DEFAULT_WORKFLOW,
Action, Action,
@@ -146,6 +147,36 @@ export async function executeCompletion(opts: {
} }
} }
// Send review routing notification when developer completes
if (role === "developer" && result === "done") {
// Re-fetch issue to get labels after transition
const updated = await provider.getIssue(issueId);
const routing = detectStepRouting(updated.labels, "review") as "human" | "agent" | null;
if (routing === "human" || routing === "agent") {
notify(
{
type: "reviewNeeded",
project: projectName,
groupId,
issueId,
issueUrl: updated.web_url,
issueTitle: updated.title,
routing,
prUrl,
},
{
workspaceDir,
config: notifyConfig,
groupId,
channel: channel ?? "telegram",
runtime,
},
).catch((err) => {
auditLog(workspaceDir, "pipeline_warning", { step: "reviewNotify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
});
}
}
// Build announcement using workflow-derived emoji // Build announcement using workflow-derived emoji
const emoji = getCompletionEmoji(role, result); const emoji = getCompletionEmoji(role, result);
const label = key.replace(":", " ").toUpperCase(); const label = key.replace(":", " ").toUpperCase();

View File

@@ -22,7 +22,16 @@ import {
export function detectLevelFromLabels(labels: string[]): string | null { export function detectLevelFromLabels(labels: string[]): string | null {
const lower = labels.map((l) => l.toLowerCase()); const lower = labels.map((l) => l.toLowerCase());
// Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior") // Priority 1: Match role:level labels (e.g., "developer:senior", "tester:junior")
for (const l of lower) {
const colon = l.indexOf(":");
if (colon === -1) continue;
const level = l.slice(colon + 1);
const all = getAllLevels();
if (all.includes(level)) return level;
}
// Priority 2: Match legacy role.level labels (e.g., "dev.senior", "qa.mid")
for (const l of lower) { for (const l of lower) {
const dot = l.indexOf("."); const dot = l.indexOf(".");
if (dot === -1) continue; if (dot === -1) continue;
@@ -37,6 +46,36 @@ export function detectLevelFromLabels(labels: string[]): string | null {
return all.find((l) => lower.includes(l)) ?? null; return all.find((l) => lower.includes(l)) ?? null;
} }
/**
* Detect role and level from colon-format labels (e.g. "developer:senior").
* Returns the first match found, or null if no role:level label exists.
*/
export function detectRoleLevelFromLabels(
labels: string[],
): { role: string; level: string } | null {
for (const label of labels) {
const colon = label.indexOf(":");
if (colon === -1) continue;
const role = label.slice(0, colon).toLowerCase();
const level = label.slice(colon + 1).toLowerCase();
const roleLevels = getLevelsForRole(role);
if (roleLevels.includes(level)) return { role, level };
}
return null;
}
/**
* Detect step routing from labels (e.g. "review:human", "test:skip").
* Returns the routing value for the given step, or null if no routing label exists.
*/
export function detectStepRouting(
labels: string[], step: string,
): string | null {
const prefix = `${step}:`;
const match = labels.find((l) => l.toLowerCase().startsWith(prefix));
return match ? match.slice(prefix.length).toLowerCase() : null;
}
/** /**
* Detect role from a label using workflow config. * Detect role from a label using workflow config.
*/ */

View File

@@ -11,7 +11,6 @@ import {
Action, Action,
ReviewCheck, ReviewCheck,
WorkflowEvent, WorkflowEvent,
StateType,
type WorkflowConfig, type WorkflowConfig,
type StateConfig, type StateConfig,
} from "../workflow.js"; } from "../workflow.js";
@@ -33,9 +32,9 @@ export async function reviewPass(opts: {
const { workspaceDir, groupId, workflow, provider, repoPath, gitPullTimeoutMs = 30_000 } = opts; const { workspaceDir, groupId, workflow, provider, repoPath, gitPullTimeoutMs = 30_000 } = opts;
let transitions = 0; let transitions = 0;
// Find all review-type states // Find all states with a review check (e.g. toReview with check: prApproved)
const reviewStates = Object.entries(workflow.states) const reviewStates = Object.entries(workflow.states)
.filter(([, s]) => s.type === StateType.REVIEW) as [string, StateConfig][]; .filter(([, s]) => s.check != null) as [string, StateConfig][];
for (const [stateKey, state] of reviewStates) { for (const [stateKey, state] of reviewStates) {
if (!state.on || !state.check) continue; if (!state.on || !state.check) continue;
@@ -50,9 +49,9 @@ export async function reviewPass(opts: {
if (!conditionMet) continue; if (!conditionMet) continue;
// Find the success transition (first event that isn't BLOCKED or MERGE_FAILED) // Find the success transition — use the APPROVED event (matches check condition)
const successEvent = Object.keys(state.on).find( const successEvent = Object.keys(state.on).find(
(e) => e !== WorkflowEvent.BLOCKED && e !== WorkflowEvent.MERGE_FAILED, (e) => e === WorkflowEvent.APPROVED,
); );
if (!successEvent) continue; if (!successEvent) continue;

View File

@@ -11,15 +11,16 @@ import { createProvider } from "../providers/index.js";
import { selectLevel } from "../model-selector.js"; import { selectLevel } from "../model-selector.js";
import { getWorker, getSessionForLevel, readProjects } from "../projects.js"; import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { roleForLevel } from "../roles/index.js"; import { getLevelsForRole } from "../roles/index.js";
import { loadConfig } from "../config/index.js"; import { loadConfig } from "../config/index.js";
import { import {
ExecutionMode, ExecutionMode,
ReviewPolicy,
getActiveLabel, getActiveLabel,
type WorkflowConfig, type WorkflowConfig,
type Role, type Role,
} from "../workflow.js"; } from "../workflow.js";
import { detectLevelFromLabels, findNextIssueForRole } from "./queue-scan.js"; import { detectRoleLevelFromLabels, detectStepRouting, findNextIssueForRole } from "./queue-scan.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// projectTick // projectTick
@@ -109,12 +110,37 @@ export async function projectTick(opts: {
continue; continue;
} }
// Review policy gate: fallback for issues dispatched before step routing labels existed
if (role === "reviewer") {
const policy = workflow.reviewPolicy ?? ReviewPolicy.AUTO;
if (policy === ReviewPolicy.HUMAN) {
skipped.push({ role, reason: "Review policy: human (heartbeat handles via PR polling)" });
continue;
}
}
const next = await findNextIssueForRole(provider, role, workflow); const next = await findNextIssueForRole(provider, role, workflow);
if (!next) continue; if (!next) continue;
const { issue, label: currentLabel } = next; const { issue, label: currentLabel } = next;
const targetLabel = getActiveLabel(workflow, role); const targetLabel = getActiveLabel(workflow, role);
// Step routing: check for review:human / review:skip / test:skip labels
if (role === "reviewer") {
const routing = detectStepRouting(issue.labels, "review");
if (routing === "human" || routing === "skip") {
skipped.push({ role, reason: `review:${routing} label` });
continue;
}
}
if (role === "tester") {
const routing = detectStepRouting(issue.labels, "test");
if (routing === "skip") {
skipped.push({ role, reason: "test:skip label" });
continue;
}
}
// Level selection: label → heuristic // Level selection: label → heuristic
const selectedLevel = resolveLevelForIssue(issue, role); const selectedLevel = resolveLevelForIssue(issue, role);
@@ -158,15 +184,25 @@ export async function projectTick(opts: {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Determine the level for an issue based on labels, role overrides, and heuristic fallback. * Determine the level for an issue based on labels and heuristic fallback.
*
* Priority:
* 1. This role's own label (e.g. tester:medior from a previous dispatch)
* 2. Inherit from another role's label (e.g. developer:medior → tester uses medior)
* 3. Heuristic fallback (first dispatch, no labels yet)
*/ */
function resolveLevelForIssue(issue: Issue, role: Role): string { function resolveLevelForIssue(issue: Issue, role: Role): string {
const labelLevel = detectLevelFromLabels(issue.labels); const roleLevel = detectRoleLevelFromLabels(issue.labels);
if (labelLevel) {
const labelRole = roleForLevel(labelLevel); // Own role label
// If label level belongs to a different role, use heuristic for correct role if (roleLevel?.role === role) return roleLevel.level;
if (labelRole && labelRole !== role) return selectLevel(issue.title, issue.description ?? "", role).level;
return labelLevel; // Inherit from another role's label if level is valid for this role
if (roleLevel) {
const levels = getLevelsForRole(role);
if (levels.includes(roleLevel.level)) return roleLevel.level;
} }
// Heuristic fallback
return selectLevel(issue.title, issue.description ?? "", role).level; return selectLevel(issue.title, issue.description ?? "", role).level;
} }

View File

@@ -26,10 +26,7 @@ Read the comments carefully — they often contain clarifications, decisions, or
- Run tests before completing - Run tests before completing
- Create an MR/PR to the base branch - Create an MR/PR to the base branch
- **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. - **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:** - **Do NOT merge the PR yourself** — leave it open for review. The system will auto-merge when approved.
- 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-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 - If you discover unrelated bugs, call task_create to file them
- Do NOT call work_start, status, health, or project_register - Do NOT call work_start, status, health, or project_register
`; `;
@@ -120,16 +117,55 @@ Your session is persistent — you may be called back for refinements.
Do NOT call work_start, status, health, or project_register. Do NOT call work_start, status, health, or project_register.
`; `;
export const DEFAULT_REVIEWER_INSTRUCTIONS = `# REVIEWER Worker Instructions
You are a code reviewer. Your job is to review the PR diff for quality, correctness, and style.
## Context You Receive
- **Issue:** the original task description and discussion
- **PR diff:** the code changes to review
- **PR URL:** link to the pull request
## Review Checklist
1. **Correctness** — Does the code do what the issue asks for?
2. **Bugs** — Any logic errors, off-by-one, null handling issues?
3. **Security** — SQL injection, XSS, hardcoded secrets, command injection?
4. **Style** — Consistent with the codebase? Readable?
5. **Tests** — Are changes tested? Any missing edge cases?
6. **Scope** — Does the PR stay within the issue scope? Any unrelated changes?
## Your Job
- Read the PR diff carefully
- Check the code against the review checklist
- Call task_comment with your review findings
- Then call work_finish with role "reviewer" and one of:
- result "approve" if the code looks good
- result "reject" with specific issues if problems found
- result "blocked" if you can't complete the review
## Important
- 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
- If you reject, list actionable items the developer must fix
- Do NOT call work_start, status, health, or project_register
`;
/** Default role instructions indexed by role ID. Used by project scaffolding. */ /** Default role instructions indexed by role ID. Used by project scaffolding. */
export const DEFAULT_ROLE_INSTRUCTIONS: Record<string, string> = { export const DEFAULT_ROLE_INSTRUCTIONS: Record<string, string> = {
developer: DEFAULT_DEV_INSTRUCTIONS, developer: DEFAULT_DEV_INSTRUCTIONS,
tester: DEFAULT_QA_INSTRUCTIONS, tester: DEFAULT_QA_INSTRUCTIONS,
architect: DEFAULT_ARCHITECT_INSTRUCTIONS, architect: DEFAULT_ARCHITECT_INSTRUCTIONS,
reviewer: DEFAULT_REVIEWER_INSTRUCTIONS,
}; };
export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw) export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw)
## If You Are a Sub-Agent (DEVELOPER/TESTER Worker) ## If You Are a Sub-Agent (DEVELOPER/TESTER/REVIEWER Worker)
Skip the orchestrator section. Follow your task message and role instructions (appended to the task message). Skip the orchestrator section. Follow your task message and role instructions (appended to the task message).
@@ -149,11 +185,12 @@ Skip the orchestrator section. Follow your task message and role instructions (a
When you are done, **call \`work_finish\` yourself** — do not just announce in text. When you are done, **call \`work_finish\` yourself** — do not just announce in text.
- **DEVELOPER done (merged):** \`work_finish({ role: "developer", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\` - **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **DEVELOPER review (PR open):** \`work_finish({ role: "developer", result: "review", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\` - **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\` - **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
- **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\` - **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
- **REVIEWER approve:** \`work_finish({ role: "reviewer", result: "approve", projectGroupId: "<from task message>", summary: "<what you checked>" })\`
- **REVIEWER reject:** \`work_finish({ role: "reviewer", result: "reject", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
- **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "<from task message>", summary: "<recommendation summary>" })\` - **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "<from task message>", summary: "<recommendation summary>" })\`
The \`projectGroupId\` is included in your task message. The \`projectGroupId\` is included in your task message.
@@ -233,17 +270,21 @@ All orchestration goes through these tools. You do NOT manually manage sessions,
### Pipeline Flow ### Pipeline Flow
\`\`\` \`\`\`
Planning → To Do → Doing → To Test → Testing → Done Planning → To Do → Doing → To Review ──┬── [agent] → Reviewing → approve → To Test → Testing → Done
↓ ↑ │ → reject → To Improve
In Review ─────┘ (auto-merges when PR approved) │ → blocked → Refining
└── [human] → PR approved → To Test (heartbeat auto-transitions)
To Improve → Doing (merge conflict / fix cycle)
Refining (human decision)
To Improve → Doing (fix cycle)
Refining (human decision)
To Design → Designing → Planning (design complete) To Design → Designing → Planning (design complete)
\`\`\` \`\`\`
Review policy (configurable per project in workflow.yaml):
- **auto** (default): junior/medior → agent review, senior → human review
- **agent**: always agent review
- **human**: always human review (stays in To Review, heartbeat polls PR)
Issue labels are the single source of truth for task state. Issue labels are the single source of truth for task state.
### Developer Assignment ### Developer Assignment
@@ -268,12 +309,15 @@ 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: 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 "done" → "To Review" → routes based on review policy:
- 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) - Agent/auto-junior: reviewer agent dispatched → "Reviewing" → approve/reject
- Tester "fail" → issue moves to "To Improve"scheduler dispatches Developer - Human/auto-senior: heartbeat polls PR status → auto-merges when approved"To Test"
- Reviewer "approve" → merges PR → "To Test" → scheduler dispatches Tester
- Reviewer "reject" → "To Improve" → scheduler dispatches Developer
- Tester "fail" → "To Improve" → scheduler dispatches Developer
- Tester "pass" → Done, no further dispatch - Tester "pass" → Done, no further dispatch
- Tester "refine" / blocked → needs human input - Tester "refine" / blocked → needs human input
- Architect "done" → issue moves to "Planning" → ready for tech lead review - Architect "done" → "Planning" → ready for tech lead review
**Always include issue URLs** in your response — these are in the \`announcement\` fields. **Always include issue URLs** in your response — these are in the \`announcement\` fields.
@@ -283,7 +327,7 @@ Workers receive role-specific instructions appended to their task message. These
### Heartbeats ### Heartbeats
**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers), review polling (auto-advancing "In Review" issues when PRs are merged), and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json. **Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers), review polling (auto-advancing "To Review" issues when PRs are approved), and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json.
### Safety ### Safety
@@ -325,7 +369,7 @@ You are a **development orchestrator** — you plan, prioritize, and dispatch. Y
- You receive requests via chat (Telegram, WhatsApp, or web) - You receive requests via chat (Telegram, WhatsApp, or web)
- You break work into issues, assign complexity levels, and dispatch workers - You break work into issues, assign complexity levels, and dispatch workers
- Workers (developer, tester, architect) do the actual work in isolated sessions - Workers (developer, reviewer, tester, architect) do the actual work in isolated sessions
- You track progress, handle failures, and keep the human informed - You track progress, handle failures, and keep the human informed
- The heartbeat runs automatically — you don't manage it - The heartbeat runs automatically — you don't manage it

View File

@@ -191,6 +191,7 @@ export async function createTestHarness(opts?: HarnessOptions): Promise<TestHarn
developer: emptyWorkerState(["junior", "medior", "senior"]), developer: emptyWorkerState(["junior", "medior", "senior"]),
tester: emptyWorkerState(["junior", "medior", "senior"]), tester: emptyWorkerState(["junior", "medior", "senior"]),
architect: emptyWorkerState(["junior", "senior"]), architect: emptyWorkerState(["junior", "senior"]),
reviewer: emptyWorkerState(["junior", "senior"]),
}; };
// Apply worker overrides // Apply worker overrides
@@ -264,6 +265,7 @@ export async function createTestHarness(opts?: HarnessOptions): Promise<TestHarn
hookCallback = cb; hookCallback = cb;
}, },
logger: { logger: {
debug() {},
info() {}, info() {},
warn() {}, warn() {},
error() {}, error() {},

View File

@@ -37,12 +37,15 @@ export type ProviderCall =
method: "transitionLabel"; method: "transitionLabel";
args: { issueId: number; from: StateLabel; to: StateLabel }; args: { issueId: number; from: StateLabel; to: StateLabel };
} }
| { method: "addLabel"; args: { issueId: number; label: string } }
| { method: "removeLabels"; args: { issueId: number; labels: string[] } }
| { method: "closeIssue"; args: { issueId: number } } | { method: "closeIssue"; args: { issueId: number } }
| { method: "reopenIssue"; args: { issueId: number } } | { method: "reopenIssue"; args: { issueId: number } }
| { method: "hasMergedMR"; args: { issueId: number } } | { method: "hasMergedMR"; args: { issueId: number } }
| { method: "getMergedMRUrl"; args: { issueId: number } } | { method: "getMergedMRUrl"; args: { issueId: number } }
| { method: "getPrStatus"; args: { issueId: number } } | { method: "getPrStatus"; args: { issueId: number } }
| { method: "mergePr"; args: { issueId: number } } | { method: "mergePr"; args: { issueId: number } }
| { method: "getPrDiff"; args: { issueId: number } }
| { method: "addComment"; args: { issueId: number; body: string } } | { method: "addComment"; args: { issueId: number; body: string } }
| { method: "healthCheck"; args: {} }; | { method: "healthCheck"; args: {} };
@@ -63,6 +66,8 @@ export class TestProvider implements IssueProvider {
mergedMrUrls = new Map<number, string>(); mergedMrUrls = new Map<number, string>();
/** Issue IDs where mergePr should fail (simulates merge conflicts). */ /** Issue IDs where mergePr should fail (simulates merge conflicts). */
mergePrFailures = new Set<number>(); mergePrFailures = new Set<number>();
/** PR diffs per issue (for reviewer tests). */
prDiffs = new Map<number, string>();
/** All calls, in order. */ /** All calls, in order. */
calls: ProviderCall[] = []; calls: ProviderCall[] = [];
@@ -118,6 +123,7 @@ export class TestProvider implements IssueProvider {
this.prStatuses.clear(); this.prStatuses.clear();
this.mergedMrUrls.clear(); this.mergedMrUrls.clear();
this.mergePrFailures.clear(); this.mergePrFailures.clear();
this.prDiffs.clear();
this.calls = []; this.calls = [];
this.nextIssueId = 1; this.nextIssueId = 1;
} }
@@ -193,6 +199,22 @@ export class TestProvider implements IssueProvider {
issue.labels.push(to); issue.labels.push(to);
} }
async addLabel(issueId: number, label: string): Promise<void> {
this.calls.push({ method: "addLabel", args: { issueId, label } });
const issue = this.issues.get(issueId);
if (issue && !issue.labels.includes(label)) {
issue.labels.push(label);
}
}
async removeLabels(issueId: number, labels: string[]): Promise<void> {
this.calls.push({ method: "removeLabels", args: { issueId, labels } });
const issue = this.issues.get(issueId);
if (issue) {
issue.labels = issue.labels.filter((l) => !labels.includes(l));
}
}
async closeIssue(issueId: number): Promise<void> { async closeIssue(issueId: number): Promise<void> {
this.calls.push({ method: "closeIssue", args: { issueId } }); this.calls.push({ method: "closeIssue", args: { issueId } });
const issue = this.issues.get(issueId); const issue = this.issues.get(issueId);
@@ -241,6 +263,11 @@ export class TestProvider implements IssueProvider {
} }
} }
async getPrDiff(issueId: number): Promise<string | null> {
this.calls.push({ method: "getPrDiff", args: { issueId } });
return this.prDiffs.get(issueId) ?? null;
}
async addComment(issueId: number, body: string): Promise<void> { async addComment(issueId: number, body: string): Promise<void> {
this.calls.push({ method: "addComment", args: { issueId, body } }); this.calls.push({ method: "addComment", args: { issueId, body } });
const existing = this.comments.get(issueId) ?? []; const existing = this.comments.get(issueId) ?? [];

View File

@@ -15,7 +15,8 @@ import { resolveRepoPath } from "../projects.js";
import { createProvider } from "../providers/index.js"; import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
import { ExecutionMode } from "../workflow.js"; import { ExecutionMode, getRoleLabels } from "../workflow.js";
import { loadConfig } from "../config/index.js";
import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js"; import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js";
import { DATA_DIR } from "../setup/migrate-layout.js"; import { DATA_DIR } from "../setup/migrate-layout.js";
@@ -141,6 +142,13 @@ export function createProjectRegisterTool() {
// 4. Create all state labels (idempotent) // 4. Create all state labels (idempotent)
await provider.ensureAllStateLabels(); await provider.ensureAllStateLabels();
// 4b. Create role:level + step routing labels (e.g. developer:junior, review:human, test:skip)
const resolvedConfig = await loadConfig(workspaceDir, name);
const roleLabels = getRoleLabels(resolvedConfig.roles);
for (const { name: labelName, color } of roleLabels) {
await provider.ensureLabel(labelName, color);
}
// 5. Add project to projects.json // 5. Add project to projects.json
// Build workers map from all registered roles // Build workers map from all registered roles
const workers: Record<string, import("../projects.js").WorkerState> = {}; const workers: Record<string, import("../projects.js").WorkerState> = {};

View File

@@ -1,64 +1,133 @@
/** /**
* Integration test for task_update tool. * Tests for task_update tool — state transitions and level overrides.
* *
* Run manually: node --loader ts-node/esm lib/tools/task-update.test.ts * Run: npx tsx --test lib/tools/task-update.test.ts
*/ */
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import { DEFAULT_WORKFLOW, getStateLabels, ReviewPolicy, resolveReviewRouting } from "../workflow.js";
import { detectLevelFromLabels, detectRoleLevelFromLabels, detectStepRouting } from "../services/queue-scan.js";
describe("task_update tool", () => { describe("task_update tool", () => {
it("has correct schema", () => { it("has correct schema", () => {
// Verify the tool signature matches requirements // state is now optional — at least one of state or level required
const requiredParams = ["projectGroupId", "issueId", "state"]; const requiredParams = ["projectGroupId", "issueId"];
const optionalParams = ["reason"]; assert.strictEqual(requiredParams.length, 2);
// Schema validation would go here in a real test
assert.ok(true, "Schema structure is valid");
}); });
it("supports all state labels", () => { it("supports all state labels", () => {
const validStates = [ const labels = getStateLabels(DEFAULT_WORKFLOW);
"Planning", assert.strictEqual(labels.length, 12);
"To Do", assert.ok(labels.includes("Planning"));
"Doing", assert.ok(labels.includes("Done"));
"To Test", assert.ok(labels.includes("To Review"));
"Testing",
"Done",
"To Improve",
"Refining",
"In Review",
];
// In a real test, we'd verify these against the tool's enum
assert.strictEqual(validStates.length, 9);
}); });
it("validates required parameters", () => { it("validates required parameters", () => {
// Test cases: // At least one of state or level required
// - Missing projectGroupId → Error
// - Missing issueId → Error
// - Missing state → Error
// - Invalid state → Error
// - Valid params → Success
assert.ok(true, "Parameter validation works"); assert.ok(true, "Parameter validation works");
}); });
it("handles same-state transitions gracefully", () => { it("handles same-state transitions gracefully", () => {
// When current state === new state, should return success without changes
assert.ok(true, "No-op transitions handled correctly"); assert.ok(true, "No-op transitions handled correctly");
}); });
it("logs to audit trail", () => { it("logs to audit trail", () => {
// Verify auditLog is called with correct parameters
assert.ok(true, "Audit logging works"); assert.ok(true, "Audit logging works");
}); });
}); });
// Test scenarios for manual verification: describe("detectLevelFromLabels — colon format", () => {
// 1. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning" }) it("should detect level from colon-format labels", () => {
// → Should transition from "To Do" to "Planning" assert.strictEqual(detectLevelFromLabels(["developer:senior", "Doing"]), "senior");
// 2. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning", reason: "Needs more discussion" }) assert.strictEqual(detectLevelFromLabels(["tester:junior", "Testing"]), "junior");
// → Should log reason in audit trail assert.strictEqual(detectLevelFromLabels(["reviewer:medior", "Reviewing"]), "medior");
// 3. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "To Do" }) });
// → Should transition back from "Planning" to "To Do"
it("should prioritize colon format over dot format", () => {
// Colon format should win since it's checked first
assert.strictEqual(detectLevelFromLabels(["developer:senior", "dev.junior"]), "senior");
});
it("should fall back to dot format", () => {
assert.strictEqual(detectLevelFromLabels(["developer.senior", "Doing"]), "senior");
});
it("should fall back to plain level name", () => {
assert.strictEqual(detectLevelFromLabels(["senior", "Doing"]), "senior");
});
it("should return null when no level found", () => {
assert.strictEqual(detectLevelFromLabels(["Doing", "bug"]), null);
});
});
describe("detectRoleLevelFromLabels", () => {
it("should detect role and level from colon-format labels", () => {
const result = detectRoleLevelFromLabels(["developer:senior", "Doing"]);
assert.deepStrictEqual(result, { role: "developer", level: "senior" });
});
it("should detect tester role", () => {
const result = detectRoleLevelFromLabels(["tester:medior", "Testing"]);
assert.deepStrictEqual(result, { role: "tester", level: "medior" });
});
it("should return null for step routing labels", () => {
// review:human is a step routing label, not a role:level label
const result = detectRoleLevelFromLabels(["review:human", "Doing"]);
assert.strictEqual(result, null);
});
it("should return null when no colon labels present", () => {
assert.strictEqual(detectRoleLevelFromLabels(["Doing", "bug"]), null);
});
});
describe("detectStepRouting", () => {
it("should detect review:human", () => {
assert.strictEqual(detectStepRouting(["review:human", "Doing"], "review"), "human");
});
it("should detect review:agent", () => {
assert.strictEqual(detectStepRouting(["review:agent", "To Review"], "review"), "agent");
});
it("should detect review:skip", () => {
assert.strictEqual(detectStepRouting(["review:skip", "To Review"], "review"), "skip");
});
it("should detect test:skip", () => {
assert.strictEqual(detectStepRouting(["test:skip", "To Test"], "test"), "skip");
});
it("should return null when no matching step label", () => {
assert.strictEqual(detectStepRouting(["developer:senior", "Doing"], "review"), null);
});
it("should be case-insensitive", () => {
assert.strictEqual(detectStepRouting(["Review:Human", "Doing"], "review"), "human");
});
});
describe("resolveReviewRouting", () => {
it("should return review:human for HUMAN policy", () => {
assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "junior"), "review:human");
assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "senior"), "review:human");
});
it("should return review:agent for AGENT policy", () => {
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "junior"), "review:agent");
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "senior"), "review:agent");
});
it("should return review:human for AUTO + senior", () => {
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "senior"), "review:human");
});
it("should return review:agent for AUTO + non-senior", () => {
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "junior"), "review:agent");
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "medior"), "review:agent");
});
});

View File

@@ -11,27 +11,30 @@ import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import type { StateLabel } from "../providers/provider.js"; import type { StateLabel } from "../providers/provider.js";
import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js"; import { DEFAULT_WORKFLOW, getStateLabels, findStateByLabel } from "../workflow.js";
import { loadConfig } from "../config/index.js";
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
export function createTaskUpdateTool(api: OpenClawPluginApi) { export function createTaskUpdateTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
name: "task_update", name: "task_update",
label: "Task Update", label: "Task Update",
description: `Change issue state programmatically. Use this when you need to update an issue's status without going through the full pickup/complete flow. description: `Change issue state and/or role:level assignment. Use this when you need to update an issue's status or override the assigned level.
Use cases: Use cases:
- Orchestrator or worker needs to change state manually - Orchestrator or worker needs to change state manually
- Manual status adjustments (e.g., Planning → To Do after approval) - Manual status adjustments (e.g., Planning → To Do after approval)
- Override the assigned level (e.g., escalate to senior for human review)
- Force human review via level change
- Failed auto-transitions that need correction - Failed auto-transitions that need correction
- Bulk state changes
Examples: Examples:
- Simple: { projectGroupId: "-123456789", issueId: 42, state: "To Do" } - State only: { projectGroupId: "-123456789", issueId: 42, state: "To Do" }
- With reason: { projectGroupId: "-123456789", issueId: 42, state: "To Do", reason: "Approved for development" }`, - Level only: { projectGroupId: "-123456789", issueId: 42, level: "senior" }
- Both: { projectGroupId: "-123456789", issueId: 42, state: "To Do", level: "senior", reason: "Escalating to senior" }`,
parameters: { parameters: {
type: "object", type: "object",
required: ["projectGroupId", "issueId", "state"], required: ["projectGroupId", "issueId"],
properties: { properties: {
projectGroupId: { projectGroupId: {
type: "string", type: "string",
@@ -46,9 +49,13 @@ Examples:
enum: getStateLabels(DEFAULT_WORKFLOW), enum: getStateLabels(DEFAULT_WORKFLOW),
description: `New state for the issue. One of: ${getStateLabels(DEFAULT_WORKFLOW).join(", ")}`, description: `New state for the issue. One of: ${getStateLabels(DEFAULT_WORKFLOW).join(", ")}`,
}, },
level: {
type: "string",
description: "Override the role:level assignment (e.g., 'senior', 'junior'). Detects role from current state label.",
},
reason: { reason: {
type: "string", type: "string",
description: "Optional audit log reason for the state change", description: "Optional audit log reason for the change",
}, },
}, },
}, },
@@ -56,41 +63,86 @@ Examples:
async execute(_id: string, params: Record<string, unknown>) { async execute(_id: string, params: Record<string, unknown>) {
const groupId = params.projectGroupId as string; const groupId = params.projectGroupId as string;
const issueId = params.issueId as number; const issueId = params.issueId as number;
const newState = params.state as StateLabel; const newState = (params.state as StateLabel) ?? undefined;
const newLevel = (params.level as string) ?? undefined;
const reason = (params.reason as string) ?? undefined; const reason = (params.reason as string) ?? undefined;
const workspaceDir = requireWorkspaceDir(ctx); const workspaceDir = requireWorkspaceDir(ctx);
if (!newState && !newLevel) {
throw new Error("At least one of 'state' or 'level' must be provided.");
}
const { project } = await resolveProject(workspaceDir, groupId); const { project } = await resolveProject(workspaceDir, groupId);
const { provider, type: providerType } = await resolveProvider(project); const { provider, type: providerType } = await resolveProvider(project);
const issue = await provider.getIssue(issueId); const issue = await provider.getIssue(issueId);
const currentState = provider.getCurrentStateLabel(issue); const currentState = provider.getCurrentStateLabel(issue);
if (!currentState) { if (!currentState) {
throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform transition.`); throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform update.`);
} }
if (currentState === newState) { let stateChanged = false;
return jsonResult({ let levelChanged = false;
success: true, issueId, state: newState, changed: false, let fromLevel: string | undefined;
message: `Issue #${issueId} is already in state "${newState}".`,
project: project.name, provider: providerType,
});
}
// Handle state transition
if (newState && currentState !== newState) {
await provider.transitionLabel(issueId, currentState, newState); await provider.transitionLabel(issueId, currentState, newState);
stateChanged = true;
}
// Handle level override
if (newLevel) {
// Detect role from current (or new) state label
const effectiveState = newState ?? currentState;
const workflow = (await loadConfig(workspaceDir, project.name)).workflow;
const stateConfig = findStateByLabel(workflow, effectiveState);
const role = stateConfig?.role;
if (!role) {
throw new Error(`Cannot determine role from state "${effectiveState}". Level can only be set on role-assigned states.`);
}
// Validate level exists for role
const resolvedConfig = await loadConfig(workspaceDir, project.name);
const roleConfig = resolvedConfig.roles[role];
if (!roleConfig || !roleConfig.levels.includes(newLevel)) {
throw new Error(`Invalid level "${newLevel}" for role "${role}". Valid levels: ${roleConfig?.levels.join(", ") ?? "none"}`);
}
// Remove old role:* labels, add new role:level
const oldRoleLabels = issue.labels.filter((l) => l.startsWith(`${role}:`));
fromLevel = oldRoleLabels[0]?.split(":")[1];
if (oldRoleLabels.length > 0) {
await provider.removeLabels(issueId, oldRoleLabels);
}
await provider.addLabel(issueId, `${role}:${newLevel}`);
levelChanged = fromLevel !== newLevel;
}
// Audit
await auditLog(workspaceDir, "task_update", { await auditLog(workspaceDir, "task_update", {
project: project.name, groupId, issueId, project: project.name, groupId, issueId,
fromState: currentState, toState: newState, ...(stateChanged ? { fromState: currentState, toState: newState } : {}),
...(levelChanged ? { fromLevel: fromLevel ?? null, toLevel: newLevel } : {}),
reason: reason ?? null, provider: providerType, reason: reason ?? null, provider: providerType,
}); });
// Build announcement
const parts: string[] = [];
if (stateChanged) parts.push(`"${currentState}" → "${newState}"`);
if (levelChanged) parts.push(`level: ${fromLevel ?? "none"}${newLevel}`);
const changeDesc = parts.join(", ");
return jsonResult({ return jsonResult({
success: true, issueId, issueTitle: issue.title, success: true, issueId, issueTitle: issue.title,
state: newState, changed: true, ...(newState ? { state: newState } : {}),
labelTransition: `${currentState}${newState}`, ...(newLevel ? { level: newLevel } : {}),
changed: stateChanged || levelChanged,
...(stateChanged ? { labelTransition: `${currentState}${newState}` } : {}),
project: project.name, provider: providerType, project: project.name, provider: providerType,
announcement: `🔄 Updated #${issueId}: "${currentState}" → "${newState}"${reason ? ` (${reason})` : ""}`, announcement: stateChanged || levelChanged
? `🔄 Updated #${issueId}: ${changeDesc}${reason ? ` (${reason})` : ""}`
: `Issue #${issueId} is already in the requested state.`,
}); });
}, },
}); });

View File

@@ -18,13 +18,13 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
name: "work_finish", name: "work_finish",
label: "Work Finish", label: "Work Finish",
description: `Complete a task: Developer done/blocked, Tester pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
parameters: { parameters: {
type: "object", type: "object",
required: ["role", "result", "projectGroupId"], required: ["role", "result", "projectGroupId"],
properties: { properties: {
role: { type: "string", enum: getAllRoleIds(), description: "Worker role" }, role: { type: "string", enum: getAllRoleIds(), description: "Worker role" },
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" }, result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked", "approve", "reject"], description: "Completion result" },
projectGroupId: { type: "string", description: "Project group ID" }, projectGroupId: { type: "string", description: "Project group ID" },
summary: { type: "string", description: "Brief summary" }, summary: { type: "string", description: "Brief summary" },
prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" }, prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" },

View File

@@ -12,8 +12,8 @@ import type { StateLabel } from "../providers/provider.js";
import { selectLevel } from "../model-selector.js"; import { selectLevel } from "../model-selector.js";
import { getWorker } from "../projects.js"; import { getWorker } from "../projects.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/queue-scan.js"; import { findNextIssue, detectRoleFromLabel, detectRoleLevelFromLabels } from "../services/queue-scan.js";
import { getAllRoleIds, isLevelForRole } from "../roles/index.js"; import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js"; import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js";
@@ -81,20 +81,16 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
// Get target label from workflow // Get target label from workflow
const targetLabel = getActiveLabel(workflow, role); const targetLabel = getActiveLabel(workflow, role);
// Select level // Select level: LLM param → own role label → inherit other role label → heuristic
let selectedLevel: string, levelReason: string, levelSource: string; let selectedLevel: string, levelReason: string, levelSource: string;
if (levelParam) { if (levelParam) {
selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm"; selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm";
} else { } else {
const labelLevel = detectLevelFromLabels(issue.labels); const roleLevel = detectRoleLevelFromLabels(issue.labels);
if (labelLevel) { if (roleLevel?.role === role) {
if (!isLevelForRole(labelLevel, role)) { selectedLevel = roleLevel.level; levelReason = `Label: "${role}:${roleLevel.level}"`; levelSource = "label";
// Label level belongs to a different role — use heuristic for this role } else if (roleLevel && getLevelsForRole(role).includes(roleLevel.level)) {
const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = roleLevel.level; levelReason = `Inherited from ${roleLevel.role}:${roleLevel.level}`; levelSource = "inherited";
selectedLevel = s.level; levelReason = `${role} overrides other role's level "${labelLevel}"`; levelSource = "role-override";
} else {
selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label";
}
} else { } else {
const s = selectLevel(issue.title, issue.description ?? "", role); const s = selectLevel(issue.title, issue.description ?? "", role);
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";

View File

@@ -19,7 +19,6 @@ export const StateType = {
ACTIVE: "active", ACTIVE: "active",
HOLD: "hold", HOLD: "hold",
TERMINAL: "terminal", TERMINAL: "terminal",
REVIEW: "review",
} as const; } as const;
export type StateType = (typeof StateType)[keyof typeof StateType]; export type StateType = (typeof StateType)[keyof typeof StateType];
@@ -30,6 +29,14 @@ export const ExecutionMode = {
} as const; } as const;
export type ExecutionMode = (typeof ExecutionMode)[keyof typeof ExecutionMode]; export type ExecutionMode = (typeof ExecutionMode)[keyof typeof ExecutionMode];
/** Review policy for PR review after developer completion. */
export const ReviewPolicy = {
HUMAN: "human",
AGENT: "agent",
AUTO: "auto",
} as const;
export type ReviewPolicy = (typeof ReviewPolicy)[keyof typeof ReviewPolicy];
/** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */ /** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */
export type Role = string; export type Role = string;
/** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */ /** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */
@@ -63,6 +70,7 @@ export const WorkflowEvent = {
REFINE: "REFINE", REFINE: "REFINE",
BLOCKED: "BLOCKED", BLOCKED: "BLOCKED",
APPROVE: "APPROVE", APPROVE: "APPROVE",
REJECT: "REJECT",
} as const; } as const;
export type TransitionTarget = string | { export type TransitionTarget = string | {
@@ -84,6 +92,7 @@ export type StateConfig = {
export type WorkflowConfig = { export type WorkflowConfig = {
initial: string; initial: string;
reviewPolicy?: ReviewPolicy;
states: Record<string, StateConfig>; states: Record<string, StateConfig>;
}; };
@@ -99,6 +108,7 @@ export type CompletionRule = {
export const DEFAULT_WORKFLOW: WorkflowConfig = { export const DEFAULT_WORKFLOW: WorkflowConfig = {
initial: "planning", initial: "planning",
reviewPolicy: ReviewPolicy.AUTO,
states: { states: {
// ── Main pipeline (happy path) ────────────────────────────── // ── Main pipeline (happy path) ──────────────────────────────
planning: { planning: {
@@ -121,19 +131,31 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
label: "Doing", label: "Doing",
color: "#f0ad4e", color: "#f0ad4e",
on: { on: {
[WorkflowEvent.COMPLETE]: { target: "toTest", actions: [Action.GIT_PULL, Action.DETECT_PR] }, [WorkflowEvent.COMPLETE]: { target: "toReview", actions: [Action.DETECT_PR] },
[WorkflowEvent.REVIEW]: { target: "reviewing", actions: [Action.DETECT_PR] },
[WorkflowEvent.BLOCKED]: "refining", [WorkflowEvent.BLOCKED]: "refining",
}, },
}, },
reviewing: { toReview: {
type: StateType.REVIEW, type: StateType.QUEUE,
label: "In Review", role: "reviewer",
color: "#c5def5", label: "To Review",
color: "#7057ff",
priority: 2,
check: ReviewCheck.PR_APPROVED, check: ReviewCheck.PR_APPROVED,
on: { on: {
[WorkflowEvent.PICKUP]: "reviewing",
[WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] }, [WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] },
[WorkflowEvent.MERGE_FAILED]: "toImprove", [WorkflowEvent.MERGE_FAILED]: "toImprove",
},
},
reviewing: {
type: StateType.ACTIVE,
role: "reviewer",
label: "Reviewing",
color: "#c5def5",
on: {
[WorkflowEvent.APPROVE]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] },
[WorkflowEvent.REJECT]: "toImprove",
[WorkflowEvent.BLOCKED]: "refining", [WorkflowEvent.BLOCKED]: "refining",
}, },
}, },
@@ -240,6 +262,83 @@ export function getLabelColors(workflow: WorkflowConfig): Record<string, string>
return colors; return colors;
} }
// ---------------------------------------------------------------------------
// Role:level labels — dynamic from config
// ---------------------------------------------------------------------------
/** Step routing label values — per-issue overrides for workflow steps. */
export const StepRouting = {
HUMAN: "human",
AGENT: "agent",
SKIP: "skip",
} as const;
export type StepRoutingValue = (typeof StepRouting)[keyof typeof StepRouting];
/** Known step routing labels (created on the provider during project registration). */
export const STEP_ROUTING_LABELS: readonly string[] = [
"review:human", "review:agent", "review:skip",
"test:skip",
];
/** Step routing label color. */
const STEP_ROUTING_COLOR = "#d93f0b";
/**
* Determine review routing label for an issue based on project policy and developer level.
* Called during developer dispatch to persist the routing decision as a label.
*/
export function resolveReviewRouting(
policy: ReviewPolicy, level: string,
): "review:human" | "review:agent" {
if (policy === ReviewPolicy.HUMAN) return "review:human";
if (policy === ReviewPolicy.AGENT) return "review:agent";
// AUTO: senior → human, else agent
return level === "senior" ? "review:human" : "review:agent";
}
/** Default colors per role for role:level labels. */
const ROLE_LABEL_COLORS: Record<string, string> = {
developer: "#0e8a16",
tester: "#5319e7",
architect: "#0075ca",
reviewer: "#d93f0b",
};
/**
* Generate all role:level label definitions from resolved config roles.
* Returns array of { name, color } for label creation (e.g. "developer:junior").
*/
export function getRoleLabels(
roles: Record<string, { levels: string[]; enabled?: boolean }>,
): Array<{ name: string; color: string }> {
const labels: Array<{ name: string; color: string }> = [];
for (const [roleId, role] of Object.entries(roles)) {
if (role.enabled === false) continue;
for (const level of role.levels) {
labels.push({
name: `${roleId}:${level}`,
color: getRoleLabelColor(roleId),
});
}
}
// Step routing labels (review:human, review:agent, test:skip, etc.)
for (const routingLabel of STEP_ROUTING_LABELS) {
labels.push({ name: routingLabel, color: STEP_ROUTING_COLOR });
}
return labels;
}
/**
* Get the label color for a role. Falls back to gray for unknown roles.
*/
export function getRoleLabelColor(role: string): string {
return ROLE_LABEL_COLORS[role] ?? "#cccccc";
}
// ---------------------------------------------------------------------------
// Queue helpers
// ---------------------------------------------------------------------------
/** /**
* Get queue labels for a role, ordered by priority (highest first). * Get queue labels for a role, ordered by priority (highest first).
*/ */
@@ -348,7 +447,6 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st
*/ */
function resultToEvent(result: string): string { function resultToEvent(result: string): string {
if (result === "done") return WorkflowEvent.COMPLETE; if (result === "done") return WorkflowEvent.COMPLETE;
if (result === "review") return WorkflowEvent.REVIEW;
return result.toUpperCase(); return result.toUpperCase();
} }
@@ -405,7 +503,6 @@ export function getNextStateDescription(
if (!targetState) return ""; if (!targetState) return "";
if (targetState.type === StateType.TERMINAL) return "Done!"; if (targetState.type === StateType.TERMINAL) return "Done!";
if (targetState.type === StateType.REVIEW) return "awaiting PR review";
if (targetState.type === StateType.HOLD) return "awaiting human decision"; if (targetState.type === StateType.HOLD) return "awaiting human decision";
if (targetState.type === StateType.QUEUE && targetState.role) { if (targetState.type === StateType.QUEUE && targetState.role) {
return `${targetState.role.toUpperCase()} queue`; return `${targetState.role.toUpperCase()} queue`;
@@ -420,11 +517,12 @@ export function getNextStateDescription(
*/ */
const RESULT_EMOJI: Record<string, string> = { const RESULT_EMOJI: Record<string, string> = {
done: "✅", done: "✅",
review: "👀",
pass: "🎉", pass: "🎉",
fail: "❌", fail: "❌",
refine: "🤔", refine: "🤔",
blocked: "🚫", blocked: "🚫",
approve: "✅",
reject: "❌",
}; };
export function getCompletionEmoji(_role: Role, result: string): string { export function getCompletionEmoji(_role: Role, result: string): string {