feat: add task_update and task_comment tools (#33)

Closes #26

This PR adds two new DevClaw tools for better task lifecycle management:

- task_update: Change issue state programmatically without full pickup/complete flow
- task_comment: Add review comments or notes to issues with optional role attribution
This commit is contained in:
Lauren ten Hoor
2026-02-10 09:20:27 +08:00
committed by GitHub
parent 78bc95efb3
commit a9e5b9ef3e
7 changed files with 370 additions and 2 deletions

View File

@@ -20,7 +20,7 @@ DevClaw fills that gap with guardrails. It gives the orchestrator atomic tools t
One orchestrator agent manages all your projects. It reads task backlogs, creates issues, decides priorities, and delegates work. For each task, DevClaw assigns a developer from your **team** — a junior, medior, or senior dev writes the code, then a QA engineer reviews it. Every Telegram/WhatsApp group is a separate project — the orchestrator keeps them completely isolated while managing them all from a single process. One orchestrator agent manages all your projects. It reads task backlogs, creates issues, decides priorities, and delegates work. For each task, DevClaw assigns a developer from your **team** — a junior, medior, or senior dev writes the code, then a QA engineer reviews it. Every Telegram/WhatsApp group is a separate project — the orchestrator keeps them completely isolated while managing them all from a single process.
DevClaw gives the orchestrator seven tools that replace hundreds of lines of manual orchestration logic. Instead of following a 10-step checklist per task (fetch issue, check labels, pick model, check for existing session, transition label, dispatch task, update state, log audit event...), it calls `task_pickup` and the plugin handles everything atomically — including session dispatch. Workers call `task_complete` themselves for atomic state updates, and can file follow-up issues via `task_create`. DevClaw gives the orchestrator nine tools that replace hundreds of lines of manual orchestration logic. Instead of following a 10-step checklist per task (fetch issue, check labels, pick model, check for existing session, transition label, dispatch task, update state, log audit event...), it calls `task_pickup` and the plugin handles everything atomically — including session dispatch. Workers call `task_complete` themselves for atomic state updates, and can file follow-up issues via `task_create`.
## Developer tiers ## Developer tiers
@@ -253,6 +253,38 @@ Complete a task with one of four results. Called by workers (DEV/QA sub-agent se
- **QA "fail"** — Moves label `Testing``To Improve`, reopens issue. If `autoChain` enabled, automatically dispatches DEV fix (reuses previous DEV tier). - **QA "fail"** — Moves label `Testing``To Improve`, reopens issue. If `autoChain` enabled, automatically dispatches DEV fix (reuses previous DEV tier).
- **QA "refine"** — Moves label `Testing``Refining`, awaits human decision - **QA "refine"** — Moves label `Testing``Refining`, awaits human decision
### `task_update`
Change an issue's state label programmatically without going through the full pickup/complete flow.
**Parameters:**
- `projectGroupId` (string, required) — Telegram/WhatsApp group ID
- `issueId` (number, required) — Issue ID to update
- `state` (string, required) — New state label (Planning, To Do, Doing, To Test, Testing, Done, To Improve, Refining)
- `reason` (string, optional) — Audit log reason for the change
**Use cases:**
- Manual state adjustments (e.g., Planning → To Do after approval)
- Failed auto-transitions that need correction
- Bulk state changes by orchestrator
### `task_comment`
Add a comment to an issue for feedback, notes, or discussion.
**Parameters:**
- `projectGroupId` (string, required) — Telegram/WhatsApp group ID
- `issueId` (number, required) — Issue ID to comment on
- `body` (string, required) — Comment body in markdown
- `authorRole` ("dev" | "qa" | "orchestrator", optional) — Attribution role
**Use cases:**
- QA adds review feedback without blocking pass/fail
- DEV posts implementation notes or progress updates
- Orchestrator adds summary comments
### `task_create` ### `task_create`
Create a new issue in the project's issue tracker. Used by workers to file follow-up bugs, or by the orchestrator to create tasks from chat. Create a new issue in the project's issue tracker. Used by workers to file follow-up bugs, or by the orchestrator to create tasks from chat.
@@ -379,6 +411,8 @@ Restrict tools to your orchestrator agent only:
"devclaw_setup", "devclaw_setup",
"task_pickup", "task_pickup",
"task_complete", "task_complete",
"task_update",
"task_comment",
"task_create", "task_create",
"queue_status", "queue_status",
"session_health", "session_health",

View File

@@ -1,6 +1,8 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { createTaskPickupTool } from "./lib/tools/task-pickup.js"; import { createTaskPickupTool } from "./lib/tools/task-pickup.js";
import { createTaskCompleteTool } from "./lib/tools/task-complete.js"; import { createTaskCompleteTool } from "./lib/tools/task-complete.js";
import { createTaskUpdateTool } from "./lib/tools/task-update.js";
import { createTaskCommentTool } from "./lib/tools/task-comment.js";
import { createQueueStatusTool } from "./lib/tools/queue-status.js"; import { createQueueStatusTool } from "./lib/tools/queue-status.js";
import { createSessionHealthTool } from "./lib/tools/session-health.js"; import { createSessionHealthTool } from "./lib/tools/session-health.js";
import { createProjectRegisterTool } from "./lib/tools/project-register.js"; import { createProjectRegisterTool } from "./lib/tools/project-register.js";
@@ -72,6 +74,12 @@ const plugin = {
api.registerTool(createTaskCompleteTool(api), { api.registerTool(createTaskCompleteTool(api), {
names: ["task_complete"], names: ["task_complete"],
}); });
api.registerTool(createTaskUpdateTool(api), {
names: ["task_update"],
});
api.registerTool(createTaskCommentTool(api), {
names: ["task_comment"],
});
api.registerTool(createQueueStatusTool(api), { api.registerTool(createQueueStatusTool(api), {
names: ["queue_status"], names: ["queue_status"],
}); });
@@ -106,7 +114,7 @@ const plugin = {
}); });
api.logger.info( api.logger.info(
"DevClaw plugin registered (11 tools, 1 CLI command)", "DevClaw plugin registered (13 tools, 1 CLI command)",
); );
}, },
}; };

View File

@@ -197,6 +197,26 @@ export class GitHubProvider implements TaskManager {
} }
} }
async addComment(issueId: number, body: string): Promise<void> {
// Write body to temp file to preserve newlines
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);
await writeFile(tempFile, body, "utf-8");
try {
await this.gh([
"issue", "comment", String(issueId),
"--body-file", tempFile,
]);
} finally {
// Clean up temp file
try {
await unlink(tempFile);
} catch {
// Ignore cleanup errors
}
}
}
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {
try { try {
await this.gh(["auth", "status"]); await this.gh(["auth", "status"]);

View File

@@ -160,6 +160,31 @@ export class GitLabProvider implements TaskManager {
} }
} }
async addComment(issueId: number, body: string): Promise<void> {
// Write body to temp file to preserve newlines
const tempFile = join(tmpdir(), `devclaw-comment-${Date.now()}.md`);
await writeFile(tempFile, body, "utf-8");
try {
const { exec } = await import("node:child_process");
const { promisify } = await import("node:util");
const execAsync = promisify(exec);
const cmd = `glab issue note ${issueId} --message "$(cat ${tempFile})"`;
await execAsync(cmd, {
cwd: this.repoPath,
timeout: 30_000,
});
} finally {
// Clean up temp file
try {
await unlink(tempFile);
} catch {
// Ignore cleanup errors
}
}
}
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {
try { try {
await this.glab(["auth", "status"]); await this.glab(["auth", "status"]);

View File

@@ -75,6 +75,9 @@ export interface TaskManager {
/** Check if any merged MR/PR exists for a specific issue. */ /** Check if any merged MR/PR exists for a specific issue. */
hasMergedMR(issueId: number): Promise<boolean>; hasMergedMR(issueId: number): Promise<boolean>;
/** Add a comment to an issue. */
addComment(issueId: number, body: string): Promise<void>;
/** Verify the task manager is working (CLI available, auth valid, repo accessible). */ /** Verify the task manager is working (CLI available, auth valid, repo accessible). */
healthCheck(): Promise<boolean>; healthCheck(): Promise<boolean>;
} }

134
lib/tools/task-comment.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* task_comment — Add review comments or notes to an issue.
*
* Use cases:
* - QA worker adds review feedback without blocking pass/fail
* - DEV worker posts implementation notes
* - Orchestrator adds summary comments
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { readProjects } from "../projects.js";
import { createProvider } from "../task-managers/index.js";
import { log as auditLog } from "../audit.js";
/** Valid author roles for attribution */
const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const;
type AuthorRole = (typeof AUTHOR_ROLES)[number];
export function createTaskCommentTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
name: "task_comment",
label: "Task Comment",
description: `Add a comment to an issue. Use this for review feedback, implementation notes, or any discussion that doesn't require a state change.
Use cases:
- QA adds review feedback without blocking pass/fail
- DEV posts implementation notes or progress updates
- Orchestrator adds summary comments
- Cross-referencing related issues or PRs
Examples:
- Simple: { projectGroupId: "-123456789", issueId: 42, body: "Found an edge case with null inputs" }
- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "qa" }
- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "dev" }`,
parameters: {
type: "object",
required: ["projectGroupId", "issueId", "body"],
properties: {
projectGroupId: {
type: "string",
description: "Telegram/WhatsApp group ID (key in projects.json)",
},
issueId: {
type: "number",
description: "Issue ID to comment on",
},
body: {
type: "string",
description: "Comment body in markdown. Supports GitHub-flavored markdown.",
},
authorRole: {
type: "string",
enum: AUTHOR_ROLES,
description: `Optional role attribution for the comment. One of: ${AUTHOR_ROLES.join(", ")}`,
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const groupId = params.projectGroupId as string;
const issueId = params.issueId as number;
const body = params.body as string;
const authorRole = (params.authorRole as AuthorRole) ?? undefined;
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
// Validate body is not empty
if (!body || body.trim().length === 0) {
throw new Error("Comment body cannot be empty.");
}
// 1. Resolve project
const data = await readProjects(workspaceDir);
const project = data.projects[groupId];
if (!project) {
throw new Error(
`Project not found for groupId ${groupId}. Run project_register first.`,
);
}
// 2. Create provider
const { provider, type: providerType } = createProvider({
repo: project.repo,
});
// 3. Fetch issue to verify it exists and get title
const issue = await provider.getIssue(issueId);
// 4. Prepare comment body with optional attribution header
let commentBody = body;
if (authorRole) {
const roleEmoji: Record<AuthorRole, string> = {
dev: "👨‍💻",
qa: "🔍",
orchestrator: "🎛️",
};
commentBody = `${roleEmoji[authorRole]} **${authorRole.toUpperCase()}**: ${body}`;
}
// 5. Add the comment
await provider.addComment(issueId, commentBody);
// 6. Audit log
await auditLog(workspaceDir, "task_comment", {
project: project.name,
groupId,
issueId,
authorRole: authorRole ?? null,
bodyPreview: body.slice(0, 100) + (body.length > 100 ? "..." : ""),
provider: providerType,
});
// 7. Build response
const result = {
success: true,
issueId,
issueTitle: issue.title,
issueUrl: issue.web_url,
commentAdded: true,
authorRole: authorRole ?? null,
bodyLength: body.length,
project: project.name,
provider: providerType,
announcement: `💬 Comment added to #${issueId}${authorRole ? ` by ${authorRole.toUpperCase()}` : ""}`,
};
return jsonResult(result);
},
});
}

144
lib/tools/task-update.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* task_update — Change issue state programmatically.
*
* Use cases:
* - Orchestrator or worker needs to change state without full pickup/complete flow
* - Manual status adjustments (e.g., Planning → To Do after approval)
* - Failed auto-transitions that need correction
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { readProjects } from "../projects.js";
import { createProvider } from "../task-managers/index.js";
import { log as auditLog } from "../audit.js";
import type { StateLabel } from "../task-managers/task-manager.js";
const STATE_LABELS: StateLabel[] = [
"Planning",
"To Do",
"Doing",
"To Test",
"Testing",
"Done",
"To Improve",
"Refining",
];
export function createTaskUpdateTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
name: "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.
Use cases:
- Orchestrator or worker needs to change state manually
- Manual status adjustments (e.g., Planning → To Do after approval)
- Failed auto-transitions that need correction
- Bulk state changes
Examples:
- Simple: { projectGroupId: "-123456789", issueId: 42, state: "To Do" }
- With reason: { projectGroupId: "-123456789", issueId: 42, state: "To Do", reason: "Approved for development" }`,
parameters: {
type: "object",
required: ["projectGroupId", "issueId", "state"],
properties: {
projectGroupId: {
type: "string",
description: "Telegram/WhatsApp group ID (key in projects.json)",
},
issueId: {
type: "number",
description: "Issue ID to update",
},
state: {
type: "string",
enum: STATE_LABELS,
description: `New state for the issue. One of: ${STATE_LABELS.join(", ")}`,
},
reason: {
type: "string",
description: "Optional audit log reason for the state change",
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const groupId = params.projectGroupId as string;
const issueId = params.issueId as number;
const newState = params.state as StateLabel;
const reason = (params.reason as string) ?? undefined;
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
// 1. Resolve project
const data = await readProjects(workspaceDir);
const project = data.projects[groupId];
if (!project) {
throw new Error(
`Project not found for groupId ${groupId}. Run project_register first.`,
);
}
// 2. Create provider
const { provider, type: providerType } = createProvider({
repo: project.repo,
});
// 3. Fetch current issue to get current state
const issue = await provider.getIssue(issueId);
const currentState = provider.getCurrentStateLabel(issue);
if (!currentState) {
throw new Error(
`Issue #${issueId} has no recognized state label. Cannot perform transition.`,
);
}
if (currentState === newState) {
return jsonResult({
success: true,
issueId,
state: newState,
changed: false,
message: `Issue #${issueId} is already in state "${newState}".`,
project: project.name,
provider: providerType,
});
}
// 4. Perform the transition
await provider.transitionLabel(issueId, currentState, newState);
// 5. Audit log
await auditLog(workspaceDir, "task_update", {
project: project.name,
groupId,
issueId,
fromState: currentState,
toState: newState,
reason: reason ?? null,
provider: providerType,
});
// 6. Build response
const result = {
success: true,
issueId,
issueTitle: issue.title,
state: newState,
changed: true,
labelTransition: `${currentState}${newState}`,
project: project.name,
provider: providerType,
announcement: `🔄 Updated #${issueId}: "${currentState}" → "${newState}"${reason ? ` (${reason})` : ""}`,
};
return jsonResult(result);
},
});
}