From a9e5b9ef3e9e91084de87945b1d53fedc8041fed Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor <32955832+laurentenhoor@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:20:27 +0800 Subject: [PATCH] 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 --- README.md | 36 +++++++- index.ts | 10 ++- lib/task-managers/github.ts | 20 +++++ lib/task-managers/gitlab.ts | 25 ++++++ lib/task-managers/task-manager.ts | 3 + lib/tools/task-comment.ts | 134 +++++++++++++++++++++++++++ lib/tools/task-update.ts | 144 ++++++++++++++++++++++++++++++ 7 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 lib/tools/task-comment.ts create mode 100644 lib/tools/task-update.ts diff --git a/README.md b/README.md index 6419cef..0473bc9 100644 --- a/README.md +++ b/README.md @@ -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. -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 @@ -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 "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` 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", "task_pickup", "task_complete", + "task_update", + "task_comment", "task_create", "queue_status", "session_health", diff --git a/index.ts b/index.ts index cc43f11..fd530d5 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { createTaskPickupTool } from "./lib/tools/task-pickup.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 { createSessionHealthTool } from "./lib/tools/session-health.js"; import { createProjectRegisterTool } from "./lib/tools/project-register.js"; @@ -72,6 +74,12 @@ const plugin = { api.registerTool(createTaskCompleteTool(api), { names: ["task_complete"], }); + api.registerTool(createTaskUpdateTool(api), { + names: ["task_update"], + }); + api.registerTool(createTaskCommentTool(api), { + names: ["task_comment"], + }); api.registerTool(createQueueStatusTool(api), { names: ["queue_status"], }); @@ -106,7 +114,7 @@ const plugin = { }); api.logger.info( - "DevClaw plugin registered (11 tools, 1 CLI command)", + "DevClaw plugin registered (13 tools, 1 CLI command)", ); }, }; diff --git a/lib/task-managers/github.ts b/lib/task-managers/github.ts index cc833cb..4529542 100644 --- a/lib/task-managers/github.ts +++ b/lib/task-managers/github.ts @@ -197,6 +197,26 @@ export class GitHubProvider implements TaskManager { } } + async addComment(issueId: number, body: string): Promise { + // 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 { try { await this.gh(["auth", "status"]); diff --git a/lib/task-managers/gitlab.ts b/lib/task-managers/gitlab.ts index 3c3bec0..8a6c6be 100644 --- a/lib/task-managers/gitlab.ts +++ b/lib/task-managers/gitlab.ts @@ -160,6 +160,31 @@ export class GitLabProvider implements TaskManager { } } + async addComment(issueId: number, body: string): Promise { + // 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 { try { await this.glab(["auth", "status"]); diff --git a/lib/task-managers/task-manager.ts b/lib/task-managers/task-manager.ts index 614866a..8b4195f 100644 --- a/lib/task-managers/task-manager.ts +++ b/lib/task-managers/task-manager.ts @@ -75,6 +75,9 @@ export interface TaskManager { /** Check if any merged MR/PR exists for a specific issue. */ hasMergedMR(issueId: number): Promise; + /** Add a comment to an issue. */ + addComment(issueId: number, body: string): Promise; + /** Verify the task manager is working (CLI available, auth valid, repo accessible). */ healthCheck(): Promise; } diff --git a/lib/tools/task-comment.ts b/lib/tools/task-comment.ts new file mode 100644 index 0000000..794b8fc --- /dev/null +++ b/lib/tools/task-comment.ts @@ -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) { + 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 = { + 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); + }, + }); +} diff --git a/lib/tools/task-update.ts b/lib/tools/task-update.ts new file mode 100644 index 0000000..62d4cb9 --- /dev/null +++ b/lib/tools/task-update.ts @@ -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) { + 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); + }, + }); +}