From 9ace15dad5083af0f2e7c861862fd44ce6d6dc31 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Sun, 8 Feb 2026 15:26:29 +0800 Subject: [PATCH] Initial commit: DevClaw OpenClaw plugin Multi-project dev/qa pipeline orchestration with 4 agent tools: - task_pickup: atomic task pickup with model selection and session reuse - task_complete: DEV done, QA pass/fail/refine with label transitions - queue_status: task queue and worker status across projects - session_health: zombie detection and state consistency checks Co-Authored-By: Claude Opus 4.6 --- .gitignore | 6 ++ LICENSE | 21 ++++ README.md | 72 +++++++++++++ index.ts | 33 ++++++ lib/audit.ts | 29 ++++++ lib/gitlab.ts | 182 +++++++++++++++++++++++++++++++++ lib/model-selector.ts | 92 +++++++++++++++++ lib/projects.ts | 131 ++++++++++++++++++++++++ lib/tools/queue-status.ts | 111 ++++++++++++++++++++ lib/tools/session-health.ts | 189 ++++++++++++++++++++++++++++++++++ lib/tools/task-complete.ts | 198 ++++++++++++++++++++++++++++++++++++ lib/tools/task-pickup.ts | 196 +++++++++++++++++++++++++++++++++++ openclaw.plugin.json | 22 ++++ package.json | 31 ++++++ 14 files changed, 1313 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.ts create mode 100644 lib/audit.ts create mode 100644 lib/gitlab.ts create mode 100644 lib/model-selector.ts create mode 100644 lib/projects.ts create mode 100644 lib/tools/queue-status.ts create mode 100644 lib/tools/session-health.ts create mode 100644 lib/tools/task-complete.ts create mode 100644 lib/tools/task-pickup.ts create mode 100644 openclaw.plugin.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6b02ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.js +*.js.map +*.d.ts +!openclaw.plugin.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b7ead58 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 laurentenhoor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f16622 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# DevClaw + +Multi-project dev/qa pipeline orchestration plugin for [OpenClaw](https://openclaw.ai). + +Replaces manual orchestration steps with atomic agent tools. Instead of 10+ error-prone manual steps per task, the agent calls a single tool that handles GitLab labels, state management, model selection, and audit logging atomically. + +## Tools + +| Tool | Description | +|------|-------------| +| `task_pickup` | Pick up a task from the GitLab queue. Handles label transition, model selection, state update, and session reuse detection. | +| `task_complete` | Complete a task (DEV done, QA pass/fail/refine). Handles label transition, issue close/reopen, and fix cycle preparation. | +| `queue_status` | Show task queue counts and worker status across all projects. | +| `session_health` | Detect zombie sessions, stale workers, and state mismatches. Auto-fix with `autoFix: true`. | + +## Installation + +```bash +# Local development (link from extensions directory) +openclaw plugins install -l ~/.openclaw/extensions/devclaw + +# From npm (future) +openclaw plugins install @openclaw/devclaw +``` + +## Configuration + +Optional plugin config in `openclaw.json`: + +```json +{ + "plugins": { + "entries": { + "devclaw": { + "config": { + "glabPath": "/usr/local/bin/glab", + "modelSelection": { + "enabled": true, + "analyzerModel": "anthropic/claude-haiku-4-5" + } + } + } + } + } +} +``` + +Restrict tools to your orchestrator agent: + +```json +{ + "agents": { + "list": [{ + "id": "henk-development", + "tools": { + "allow": ["task_pickup", "task_complete", "queue_status", "session_health"] + } + }] + } +} +``` + +## Requirements + +- OpenClaw >= 0.x +- Node.js >= 20 +- `glab` CLI installed and authenticated +- `memory/projects.json` in the orchestrator agent's workspace + +## License + +MIT diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..ef7c586 --- /dev/null +++ b/index.ts @@ -0,0 +1,33 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { createTaskPickupTool } from "./lib/tools/task-pickup.js"; +import { createTaskCompleteTool } from "./lib/tools/task-complete.js"; +import { createQueueStatusTool } from "./lib/tools/queue-status.js"; +import { createSessionHealthTool } from "./lib/tools/session-health.js"; + +const plugin = { + id: "devclaw", + name: "DevClaw", + description: + "Multi-project dev/qa pipeline orchestration with GitLab integration, model selection, and audit logging.", + configSchema: {}, + + register(api: OpenClawPluginApi) { + // Agent tools (primary interface — agent calls these directly) + api.registerTool(createTaskPickupTool(api), { + names: ["task_pickup"], + }); + api.registerTool(createTaskCompleteTool(api), { + names: ["task_complete"], + }); + api.registerTool(createQueueStatusTool(api), { + names: ["queue_status"], + }); + api.registerTool(createSessionHealthTool(api), { + names: ["session_health"], + }); + + api.logger.info("DevClaw plugin registered (4 tools)"); + }, +}; + +export default plugin; diff --git a/lib/audit.ts b/lib/audit.ts new file mode 100644 index 0000000..3741c36 --- /dev/null +++ b/lib/audit.ts @@ -0,0 +1,29 @@ +/** + * Append-only NDJSON audit logging. + * Every tool call automatically logs — no manual action needed from agents. + */ +import { appendFile, mkdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; + +export async function log( + workspaceDir: string, + event: string, + data: Record, +): Promise { + const filePath = join(workspaceDir, "memory", "audit.log"); + const entry = JSON.stringify({ + ts: new Date().toISOString(), + event, + ...data, + }); + try { + await appendFile(filePath, entry + "\n"); + } catch (err: unknown) { + // If directory doesn't exist, create it and retry + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + await mkdir(dirname(filePath), { recursive: true }); + await appendFile(filePath, entry + "\n"); + } + // Audit logging should never break the tool — silently ignore other errors + } +} diff --git a/lib/gitlab.ts b/lib/gitlab.ts new file mode 100644 index 0000000..c127c04 --- /dev/null +++ b/lib/gitlab.ts @@ -0,0 +1,182 @@ +/** + * GitLab wrapper using glab CLI. + * Handles label transitions, issue fetching, and MR verification. + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +// State labels — each issue has exactly ONE at a time +const STATE_LABELS = [ + "Planning", + "To Do", + "Doing", + "To Test", + "Testing", + "Done", + "To Improve", + "Refining", +] as const; + +export type StateLabel = (typeof STATE_LABELS)[number]; + +type GlabOptions = { + glabPath?: string; + repoPath: string; +}; + +async function glab( + args: string[], + opts: GlabOptions, +): Promise { + const bin = opts.glabPath ?? "glab"; + const { stdout } = await execFileAsync(bin, args, { + cwd: opts.repoPath, + timeout: 30_000, + }); + return stdout.trim(); +} + +export type GitLabIssue = { + iid: number; + title: string; + description: string; + labels: string[]; + state: string; + web_url: string; +}; + +/** + * Fetch a single issue by ID. + */ +export async function getIssue( + issueId: number, + opts: GlabOptions, +): Promise { + const raw = await glab( + ["issue", "view", String(issueId), "--output", "json"], + opts, + ); + return JSON.parse(raw) as GitLabIssue; +} + +/** + * List issues with a specific label. + */ +export async function listIssuesByLabel( + label: StateLabel, + opts: GlabOptions, +): Promise { + try { + const raw = await glab( + ["issue", "list", "--label", label, "--output", "json"], + opts, + ); + return JSON.parse(raw) as GitLabIssue[]; + } catch { + // glab returns error when no issues found + return []; + } +} + +/** + * Transition an issue from one state label to another. + * Uses --unlabel + --label to ensure only one state label at a time. + */ +export async function transitionLabel( + issueId: number, + from: StateLabel, + to: StateLabel, + opts: GlabOptions, +): Promise { + await glab( + [ + "issue", + "update", + String(issueId), + "--unlabel", + from, + "--label", + to, + ], + opts, + ); +} + +/** + * Close an issue. + */ +export async function closeIssue( + issueId: number, + opts: GlabOptions, +): Promise { + await glab(["issue", "close", String(issueId)], opts); +} + +/** + * Reopen an issue. + */ +export async function reopenIssue( + issueId: number, + opts: GlabOptions, +): Promise { + await glab(["issue", "reopen", String(issueId)], opts); +} + +/** + * Check if the current state label on an issue matches expected. + */ +export function hasStateLabel( + issue: GitLabIssue, + expected: StateLabel, +): boolean { + return issue.labels.includes(expected); +} + +/** + * Get the current state label of an issue (first match from STATE_LABELS). + */ +export function getCurrentStateLabel( + issue: GitLabIssue, +): StateLabel | null { + for (const label of STATE_LABELS) { + if (issue.labels.includes(label)) { + return label; + } + } + return null; +} + +/** + * Check if any merged MR exists for a specific issue. + */ +export async function hasMergedMR( + issueId: number, + opts: GlabOptions, +): Promise { + try { + const raw = await glab( + ["mr", "list", "--output", "json", "--state", "merged"], + opts, + ); + const mrs = JSON.parse(raw) as Array<{ title: string; description: string }>; + const pattern = `#${issueId}`; + return mrs.some( + (mr) => + mr.title.includes(pattern) || (mr.description ?? "").includes(pattern), + ); + } catch { + return false; + } +} + +/** + * Resolve the repo path from projects.json repo field (handles ~/). + */ +export function resolveRepoPath(repoField: string): string { + if (repoField.startsWith("~/")) { + return repoField.replace("~", process.env.HOME ?? "/home/lauren"); + } + return repoField; +} diff --git a/lib/model-selector.ts b/lib/model-selector.ts new file mode 100644 index 0000000..0470ec8 --- /dev/null +++ b/lib/model-selector.ts @@ -0,0 +1,92 @@ +/** + * Model selection for dev/qa tasks. + * MVP: Simple heuristic-based selection. LLM-based analysis can be added later. + */ + +export type ModelRecommendation = { + model: string; + alias: string; + reason: string; +}; + +// Keywords that indicate simple tasks +const SIMPLE_KEYWORDS = [ + "typo", + "fix typo", + "rename", + "update text", + "change color", + "minor", + "small", + "css", + "style", + "copy", + "wording", +]; + +// Keywords that indicate complex tasks +const COMPLEX_KEYWORDS = [ + "architect", + "refactor", + "redesign", + "system-wide", + "migration", + "database schema", + "security", + "performance", + "infrastructure", + "multi-service", +]; + +/** + * Select appropriate model based on task description. + * + * Model tiers: + * - haiku: very simple (typos, single-file fixes, CSS tweaks) + * - grok: default QA (code inspection, validation, test runs) + * - sonnet: default DEV (features, bug fixes, multi-file changes) + * - opus: deep/architectural (system-wide refactoring, novel design) + */ +export function selectModel( + issueTitle: string, + issueDescription: string, + role: "dev" | "qa", +): ModelRecommendation { + if (role === "qa") { + return { + model: "github-copilot/grok-code-fast-1", + alias: "grok", + reason: "Default QA model for code inspection and validation", + }; + } + + const text = `${issueTitle} ${issueDescription}`.toLowerCase(); + const wordCount = text.split(/\s+/).length; + + // Check for simple task indicators + const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw)); + if (isSimple && wordCount < 100) { + return { + model: "anthropic/claude-haiku-4-5", + alias: "haiku", + reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`, + }; + } + + // Check for complex task indicators + const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); + if (isComplex || wordCount > 500) { + return { + model: "anthropic/claude-opus-4-5", + alias: "opus", + reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`, + }; + } + + // Default: sonnet for standard dev work + return { + model: "anthropic/claude-sonnet-4-5", + alias: "sonnet", + reason: "Standard dev task — multi-file changes, features, bug fixes", + }; +} diff --git a/lib/projects.ts b/lib/projects.ts new file mode 100644 index 0000000..23ad8d2 --- /dev/null +++ b/lib/projects.ts @@ -0,0 +1,131 @@ +/** + * Atomic projects.json read/write operations. + * All state mutations go through this module to prevent corruption. + */ +import fs from "node:fs/promises"; +import path from "node:path"; + +export type WorkerState = { + active: boolean; + sessionId: string | null; + issueId: string | null; + startTime: string | null; + model: string | null; +}; + +export type Project = { + name: string; + repo: string; + groupName: string; + deployUrl: string; + baseBranch: string; + deployBranch: string; + dev: WorkerState; + qa: WorkerState; +}; + +export type ProjectsData = { + projects: Record; +}; + +function projectsPath(workspaceDir: string): string { + return path.join(workspaceDir, "memory", "projects.json"); +} + +export async function readProjects(workspaceDir: string): Promise { + const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8"); + return JSON.parse(raw) as ProjectsData; +} + +export async function writeProjects( + workspaceDir: string, + data: ProjectsData, +): Promise { + const filePath = projectsPath(workspaceDir); + // Write to temp file first, then rename for atomicity + const tmpPath = filePath + ".tmp"; + await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8"); + await fs.rename(tmpPath, filePath); +} + +export function getProject( + data: ProjectsData, + groupId: string, +): Project | undefined { + return data.projects[groupId]; +} + +export function getWorker( + project: Project, + role: "dev" | "qa", +): WorkerState { + return project[role]; +} + +/** + * Update worker state for a project. Only provided fields are updated. + * This prevents accidentally nulling out fields that should be preserved. + */ +export async function updateWorker( + workspaceDir: string, + groupId: string, + role: "dev" | "qa", + updates: Partial, +): Promise { + const data = await readProjects(workspaceDir); + const project = data.projects[groupId]; + if (!project) { + throw new Error(`Project not found for groupId: ${groupId}`); + } + + const worker = project[role]; + project[role] = { ...worker, ...updates }; + + await writeProjects(workspaceDir, data); + return data; +} + +/** + * Mark a worker as active with a new task. + * Sets active=true, issueId, model. Preserves sessionId and startTime if reusing. + */ +export async function activateWorker( + workspaceDir: string, + groupId: string, + role: "dev" | "qa", + params: { + issueId: string; + model: string; + sessionId?: string; + startTime?: string; + }, +): Promise { + const updates: Partial = { + active: true, + issueId: params.issueId, + model: params.model, + }; + // Only set sessionId and startTime if provided (new spawn) + if (params.sessionId !== undefined) { + updates.sessionId = params.sessionId; + } + if (params.startTime !== undefined) { + updates.startTime = params.startTime; + } + return updateWorker(workspaceDir, groupId, role, updates); +} + +/** + * Mark a worker as inactive after task completion. + * Clears issueId and active, PRESERVES sessionId, model, startTime for reuse. + */ +export async function deactivateWorker( + workspaceDir: string, + groupId: string, + role: "dev" | "qa", +): Promise { + return updateWorker(workspaceDir, groupId, role, { + active: false, + issueId: null, + }); +} diff --git a/lib/tools/queue-status.ts b/lib/tools/queue-status.ts new file mode 100644 index 0000000..8744bcb --- /dev/null +++ b/lib/tools/queue-status.ts @@ -0,0 +1,111 @@ +/** + * queue_status — Show task queue and worker status across projects. + * + * Replaces manual GitLab scanning in HEARTBEAT.md. + */ +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; +import { readProjects, getProject } from "../projects.js"; +import { listIssuesByLabel, resolveRepoPath, type StateLabel } from "../gitlab.js"; +import { log as auditLog } from "../audit.js"; + +export function createQueueStatusTool(api: OpenClawPluginApi) { + return (ctx: OpenClawPluginToolContext) => ({ + name: "queue_status", + description: `Show task queue counts and worker status for all projects (or a specific project). Returns To Improve, To Test, To Do issue counts and active DEV/QA session state.`, + parameters: { + type: "object", + properties: { + projectGroupId: { + type: "string", + description: "Specific project group ID to check. Omit to check all projects.", + }, + }, + }, + + async execute(_id: string, params: Record) { + const groupId = params.projectGroupId as string | undefined; + const workspaceDir = ctx.workspaceDir; + + if (!workspaceDir) { + throw new Error("No workspace directory available in tool context"); + } + + const data = await readProjects(workspaceDir); + const projectIds = groupId + ? [groupId] + : Object.keys(data.projects); + + const glabPath = (api.pluginConfig as Record)?.glabPath as string | undefined; + const projects: Array> = []; + + for (const pid of projectIds) { + const project = getProject(data, pid); + if (!project) continue; + + const repoPath = resolveRepoPath(project.repo); + const glabOpts = { glabPath, repoPath }; + + // Fetch queue counts from GitLab + const queueLabels: StateLabel[] = ["To Improve", "To Test", "To Do"]; + const queue: Record> = {}; + + for (const label of queueLabels) { + try { + const issues = await listIssuesByLabel(label, glabOpts); + queue[label] = issues.map((i) => ({ id: i.iid, title: i.title })); + } catch { + queue[label] = []; + } + } + + projects.push({ + name: project.name, + groupId: pid, + dev: { + active: project.dev.active, + sessionId: project.dev.sessionId, + issueId: project.dev.issueId, + model: project.dev.model, + }, + qa: { + active: project.qa.active, + sessionId: project.qa.sessionId, + issueId: project.qa.issueId, + model: project.qa.model, + }, + queue: { + toImprove: queue["To Improve"], + toTest: queue["To Test"], + toDo: queue["To Do"], + }, + }); + } + + // Audit log + await auditLog(workspaceDir, "queue_status", { + projectCount: projects.length, + totalToImprove: projects.reduce( + (sum, p) => sum + ((p.queue as Record).toImprove?.length ?? 0), + 0, + ), + totalToTest: projects.reduce( + (sum, p) => sum + ((p.queue as Record).toTest?.length ?? 0), + 0, + ), + totalToDo: projects.reduce( + (sum, p) => sum + ((p.queue as Record).toDo?.length ?? 0), + 0, + ), + }); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ projects }, null, 2), + }, + ], + }; + }, + }); +} diff --git a/lib/tools/session-health.ts b/lib/tools/session-health.ts new file mode 100644 index 0000000..b8aab55 --- /dev/null +++ b/lib/tools/session-health.ts @@ -0,0 +1,189 @@ +/** + * session_health — Check and fix session state consistency. + * + * Detects zombie sessions (active=true but session dead) and stale workers. + * Replaces manual HEARTBEAT.md step 1. + * + * NOTE: This tool checks projects.json state only. The agent should verify + * session liveness via sessions_list and pass the results. The tool cannot + * call sessions_list directly (it's an agent-level tool). + */ +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; +import { readProjects, updateWorker } from "../projects.js"; +import { transitionLabel, resolveRepoPath, type StateLabel } from "../gitlab.js"; +import { log as auditLog } from "../audit.js"; + +export function createSessionHealthTool(api: OpenClawPluginApi) { + return (ctx: OpenClawPluginToolContext) => ({ + name: "session_health", + description: `Check session state consistency across all projects. Detects: active workers with dead sessions, stale workers (>2 hours), and state mismatches. With autoFix=true, clears zombie states and reverts GitLab labels. Pass activeSessions (from sessions_list) so the tool can verify liveness.`, + parameters: { + type: "object", + properties: { + autoFix: { + type: "boolean", + description: "Automatically fix zombie sessions and stale active flags. Default: false.", + }, + activeSessions: { + type: "array", + items: { type: "string" }, + description: "List of currently alive session IDs from sessions_list. Used to detect zombies.", + }, + }, + }, + + async execute(_id: string, params: Record) { + const autoFix = (params.autoFix as boolean) ?? false; + const activeSessions = (params.activeSessions as string[]) ?? []; + const workspaceDir = ctx.workspaceDir; + + if (!workspaceDir) { + throw new Error("No workspace directory available in tool context"); + } + + const data = await readProjects(workspaceDir); + const glabPath = (api.pluginConfig as Record)?.glabPath as string | undefined; + + const issues: Array> = []; + let fixesApplied = 0; + + for (const [groupId, project] of Object.entries(data.projects)) { + const repoPath = resolveRepoPath(project.repo); + const glabOpts = { glabPath, repoPath }; + + for (const role of ["dev", "qa"] as const) { + const worker = project[role]; + + // Check 1: Active but no sessionId + if (worker.active && !worker.sessionId) { + const issue: Record = { + type: "active_no_session", + severity: "critical", + project: project.name, + groupId, + role, + message: `${role.toUpperCase()} marked active but has no sessionId`, + }; + + if (autoFix) { + await updateWorker(workspaceDir, groupId, role, { + active: false, + issueId: null, + }); + issue.fixed = true; + fixesApplied++; + } + issues.push(issue); + } + + // Check 2: Active with sessionId but session is dead (zombie) + if ( + worker.active && + worker.sessionId && + activeSessions.length > 0 && + !activeSessions.includes(worker.sessionId) + ) { + const issue: Record = { + type: "zombie_session", + severity: "critical", + project: project.name, + groupId, + role, + sessionId: worker.sessionId, + message: `${role.toUpperCase()} session ${worker.sessionId} not found in active sessions`, + }; + + if (autoFix) { + // Revert GitLab label + const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test"; + const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; + try { + if (worker.issueId) { + const primaryIssueId = Number(worker.issueId.split(",")[0]); + await transitionLabel(primaryIssueId, currentLabel, revertLabel, glabOpts); + issue.labelReverted = `${currentLabel} → ${revertLabel}`; + } + } catch { + issue.labelRevertFailed = true; + } + + await updateWorker(workspaceDir, groupId, role, { + active: false, + issueId: null, + }); + issue.fixed = true; + fixesApplied++; + } + issues.push(issue); + } + + // Check 3: Active for >2 hours (stale) + if (worker.active && worker.startTime) { + const startMs = new Date(worker.startTime).getTime(); + const nowMs = Date.now(); + const hoursActive = (nowMs - startMs) / (1000 * 60 * 60); + + if (hoursActive > 2) { + issues.push({ + type: "stale_worker", + severity: "warning", + project: project.name, + groupId, + role, + hoursActive: Math.round(hoursActive * 10) / 10, + sessionId: worker.sessionId, + issueId: worker.issueId, + message: `${role.toUpperCase()} has been active for ${Math.round(hoursActive * 10) / 10}h — may need attention`, + }); + } + } + + // Check 4: Inactive but still has issueId (should have been cleared) + if (!worker.active && worker.issueId) { + const issue: Record = { + type: "inactive_with_issue", + severity: "warning", + project: project.name, + groupId, + role, + issueId: worker.issueId, + message: `${role.toUpperCase()} inactive but still has issueId "${worker.issueId}"`, + }; + + if (autoFix) { + await updateWorker(workspaceDir, groupId, role, { + issueId: null, + }); + issue.fixed = true; + fixesApplied++; + } + issues.push(issue); + } + } + } + + // Audit log + await auditLog(workspaceDir, "health_check", { + projectsScanned: Object.keys(data.projects).length, + issuesFound: issues.length, + fixesApplied, + autoFix, + activeSessionsProvided: activeSessions.length > 0, + }); + + const result = { + healthy: issues.length === 0, + issuesFound: issues.length, + fixesApplied, + issues, + note: activeSessions.length === 0 + ? "No activeSessions provided — zombie detection skipped. Call sessions_list and pass the result for full health check." + : undefined, + }; + + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }); +} diff --git a/lib/tools/task-complete.ts b/lib/tools/task-complete.ts new file mode 100644 index 0000000..14f9e13 --- /dev/null +++ b/lib/tools/task-complete.ts @@ -0,0 +1,198 @@ +/** + * task_complete — Atomically complete a task (DEV done, QA pass/fail/refine). + * + * Handles: validation, GitLab label transition, projects.json state update, + * issue close/reopen, and audit logging. + */ +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; +import { + readProjects, + getProject, + getWorker, + deactivateWorker, + activateWorker, +} from "../projects.js"; +import { + getIssue, + transitionLabel, + closeIssue, + reopenIssue, + resolveRepoPath, + type StateLabel, +} from "../gitlab.js"; +import { selectModel } from "../model-selector.js"; +import { log as auditLog } from "../audit.js"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export function createTaskCompleteTool(api: OpenClawPluginApi) { + return (ctx: OpenClawPluginToolContext) => ({ + name: "task_complete", + description: `Complete a task: DEV done, QA pass, QA fail, or QA refine. Atomically handles: label transition, projects.json update, issue close/reopen, and audit logging. For QA fail, also prepares DEV session instructions for the fix cycle.`, + parameters: { + type: "object", + required: ["role", "result", "projectGroupId"], + properties: { + role: { type: "string", enum: ["dev", "qa"], description: "Worker role completing the task" }, + result: { + type: "string", + enum: ["done", "pass", "fail", "refine"], + description: 'Completion result: "done" (DEV finished), "pass" (QA approved), "fail" (QA found issues), "refine" (needs human input)', + }, + projectGroupId: { type: "string", description: "Telegram group ID (key in projects.json)" }, + summary: { type: "string", description: "Brief summary for Telegram announcement" }, + }, + }, + + async execute(_id: string, params: Record) { + const role = params.role as "dev" | "qa"; + const result = params.result as "done" | "pass" | "fail" | "refine"; + const groupId = params.projectGroupId as string; + const summary = params.summary as string | undefined; + const workspaceDir = ctx.workspaceDir; + + if (!workspaceDir) { + throw new Error("No workspace directory available in tool context"); + } + + // Validate result matches role + if (role === "dev" && result !== "done") { + throw new Error(`DEV can only complete with result "done", got "${result}"`); + } + if (role === "qa" && result === "done") { + throw new Error(`QA cannot use result "done". Use "pass", "fail", or "refine".`); + } + + // Resolve project + const data = await readProjects(workspaceDir); + const project = getProject(data, groupId); + if (!project) { + throw new Error(`Project not found for groupId: ${groupId}`); + } + + const worker = getWorker(project, role); + if (!worker.active) { + throw new Error( + `${role.toUpperCase()} worker is not active on ${project.name}. Nothing to complete.`, + ); + } + + const issueId = worker.issueId ? Number(worker.issueId.split(",")[0]) : null; + if (!issueId) { + throw new Error(`No issueId found for active ${role.toUpperCase()} worker on ${project.name}`); + } + + const repoPath = resolveRepoPath(project.repo); + const glabOpts = { + glabPath: (api.pluginConfig as Record)?.glabPath as string | undefined, + repoPath, + }; + + const output: Record = { + success: true, + project: project.name, + groupId, + issueId, + role, + result, + }; + + // === DEV DONE === + if (role === "dev" && result === "done") { + // Pull latest on the project repo + try { + await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 }); + output.gitPull = "success"; + } catch (err) { + output.gitPull = `warning: ${(err as Error).message}`; + } + + // Deactivate DEV (preserves sessionId, model, startTime) + await deactivateWorker(workspaceDir, groupId, "dev"); + + // Transition label: Doing → To Test + await transitionLabel(issueId, "Doing", "To Test", glabOpts); + + output.labelTransition = "Doing → To Test"; + output.announcement = `✅ DEV done #${issueId}${summary ? ` — ${summary}` : ""}. Moved to QA queue.`; + } + + // === QA PASS === + if (role === "qa" && result === "pass") { + // Deactivate QA + await deactivateWorker(workspaceDir, groupId, "qa"); + + // Transition label: Testing → Done, close issue + await transitionLabel(issueId, "Testing", "Done", glabOpts); + await closeIssue(issueId, glabOpts); + + output.labelTransition = "Testing → Done"; + output.issueClosed = true; + output.announcement = `🎉 QA PASS #${issueId}${summary ? ` — ${summary}` : ""}. Issue closed.`; + } + + // === QA FAIL === + if (role === "qa" && result === "fail") { + // Deactivate QA + await deactivateWorker(workspaceDir, groupId, "qa"); + + // Transition label: Testing → To Improve, reopen issue + await transitionLabel(issueId, "Testing", "To Improve", glabOpts); + await reopenIssue(issueId, glabOpts); + + // Prepare DEV fix cycle + const issue = await getIssue(issueId, glabOpts); + const devModel = selectModel(issue.title, issue.description ?? "", "dev"); + const devWorker = getWorker(project, "dev"); + + output.labelTransition = "Testing → To Improve"; + output.issueReopened = true; + output.announcement = `❌ QA FAIL #${issueId}${summary ? ` — ${summary}` : ""}. Sent back to DEV.`; + + // If DEV session exists, prepare reuse instructions + if (devWorker.sessionId) { + output.devFixInstructions = + `Send QA feedback to existing DEV session ${devWorker.sessionId}. ` + + `If model "${devModel.alias}" differs from "${devWorker.model}", call sessions.patch first. ` + + `Then sessions_send with QA failure details. ` + + `DEV will pick up from To Improve → Doing automatically.`; + output.devSessionId = devWorker.sessionId; + output.devModel = devModel.alias; + } else { + output.devFixInstructions = + `No existing DEV session. Spawn new DEV worker with model "${devModel.alias}" to fix #${issueId}.`; + output.devModel = devModel.alias; + } + } + + // === QA REFINE === + if (role === "qa" && result === "refine") { + // Deactivate QA + await deactivateWorker(workspaceDir, groupId, "qa"); + + // Transition label: Testing → Refining + await transitionLabel(issueId, "Testing", "Refining", glabOpts); + + output.labelTransition = "Testing → Refining"; + output.announcement = `🤔 QA REFINE #${issueId}${summary ? ` — ${summary}` : ""}. Awaiting human decision.`; + } + + // Audit log + await auditLog(workspaceDir, "task_complete", { + project: project.name, + groupId, + issue: issueId, + role, + result, + summary: summary ?? null, + labelTransition: output.labelTransition, + }); + + return { + content: [{ type: "text" as const, text: JSON.stringify(output, null, 2) }], + }; + }, + }); +} diff --git a/lib/tools/task-pickup.ts b/lib/tools/task-pickup.ts new file mode 100644 index 0000000..cd93f3e --- /dev/null +++ b/lib/tools/task-pickup.ts @@ -0,0 +1,196 @@ +/** + * task_pickup — Atomically pick up a task from the GitLab queue. + * + * Handles: validation, model selection, GitLab label transition, + * projects.json state update, and audit logging. + * + * Returns structured instructions for the agent to spawn/send a session. + */ +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; +import { + readProjects, + getProject, + getWorker, + activateWorker, +} from "../projects.js"; +import { + getIssue, + getCurrentStateLabel, + transitionLabel, + resolveRepoPath, + type StateLabel, +} from "../gitlab.js"; +import { selectModel } from "../model-selector.js"; +import { log as auditLog } from "../audit.js"; + +export function createTaskPickupTool(api: OpenClawPluginApi) { + return (ctx: OpenClawPluginToolContext) => ({ + name: "task_pickup", + description: `Pick up a task from the GitLab queue for a DEV or QA worker. Atomically handles: label transition, model selection, projects.json update, and audit logging. Returns session action instructions (spawn or send) for the agent to execute.`, + parameters: { + type: "object", + required: ["issueId", "role", "projectGroupId"], + properties: { + issueId: { type: "number", description: "GitLab issue ID to pick up" }, + role: { type: "string", enum: ["dev", "qa"], description: "Worker role: dev or qa" }, + projectGroupId: { + type: "string", + description: "Telegram group ID (key in projects.json). Required — pass the group ID from the current conversation.", + }, + modelOverride: { + type: "string", + description: "Force a specific model alias (e.g. haiku, sonnet, opus, grok). Overrides automatic selection.", + }, + }, + }, + + async execute(_id: string, params: Record) { + const issueId = params.issueId as number; + const role = params.role as "dev" | "qa"; + const groupId = params.projectGroupId as string; + const modelOverride = params.modelOverride 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 = getProject(data, groupId); + if (!project) { + throw new Error( + `Project not found for groupId: ${groupId}. Available: ${Object.keys(data.projects).join(", ")}`, + ); + } + + // 2. Check no active worker for this role + const worker = getWorker(project, role); + if (worker.active) { + throw new Error( + `${role.toUpperCase()} worker already active on ${project.name} (issue: ${worker.issueId}, session: ${worker.sessionId}). Complete current task first.`, + ); + } + + // 3. Fetch issue from GitLab and verify state + const repoPath = resolveRepoPath(project.repo); + const glabOpts = { + glabPath: (api.pluginConfig as Record)?.glabPath as string | undefined, + repoPath, + }; + + const issue = await getIssue(issueId, glabOpts); + const currentLabel = getCurrentStateLabel(issue); + + // Validate label matches expected state for the role + const validLabelsForDev: StateLabel[] = ["To Do", "To Improve"]; + const validLabelsForQa: StateLabel[] = ["To Test"]; + const validLabels = role === "dev" ? validLabelsForDev : validLabelsForQa; + + if (!currentLabel || !validLabels.includes(currentLabel)) { + throw new Error( + `Issue #${issueId} has label "${currentLabel ?? "none"}" but expected one of: ${validLabels.join(", ")}. Cannot pick up for ${role.toUpperCase()}.`, + ); + } + + // 4. Select model + const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; + let selectedModel = selectModel(issue.title, issue.description ?? "", role); + if (modelOverride) { + selectedModel = { + model: modelOverride, + alias: modelOverride, + reason: `User override: ${modelOverride}`, + }; + } + + // 5. Determine session action (spawn vs reuse) + const existingSessionId = worker.sessionId; + const sessionAction = existingSessionId ? "send" : "spawn"; + + // 6. Transition GitLab label + await transitionLabel(issueId, currentLabel, targetLabel, glabOpts); + + // 7. Update projects.json + const now = new Date().toISOString(); + if (sessionAction === "spawn") { + // New spawn — agent will provide sessionId after spawning + await activateWorker(workspaceDir, groupId, role, { + issueId: String(issueId), + model: selectedModel.alias, + startTime: now, + }); + } else { + // Reuse existing session — preserve sessionId and startTime + await activateWorker(workspaceDir, groupId, role, { + issueId: String(issueId), + model: selectedModel.alias, + }); + } + + // 8. Audit log + await auditLog(workspaceDir, "task_pickup", { + project: project.name, + groupId, + issue: issueId, + issueTitle: issue.title, + role, + model: selectedModel.alias, + modelReason: selectedModel.reason, + sessionAction, + sessionId: existingSessionId, + labelTransition: `${currentLabel} → ${targetLabel}`, + }); + + await auditLog(workspaceDir, "model_selection", { + issue: issueId, + role, + selected: selectedModel.alias, + fullModel: selectedModel.model, + reason: selectedModel.reason, + override: modelOverride ?? null, + }); + + // 9. Build announcement and session instructions + const emoji = role === "dev" + ? (selectedModel.alias === "haiku" ? "⚡" : selectedModel.alias === "opus" ? "🧠" : "🔧") + : "🔍"; + + const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending"; + const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${selectedModel.alias}) for #${issueId}: ${issue.title}`; + + const result: Record = { + success: true, + project: project.name, + groupId, + issueId, + issueTitle: issue.title, + role, + model: selectedModel.alias, + fullModel: selectedModel.model, + modelReason: selectedModel.reason, + sessionAction, + announcement, + labelTransition: `${currentLabel} → ${targetLabel}`, + }; + + if (sessionAction === "send") { + result.sessionId = existingSessionId; + result.instructions = + `Session reuse: send new task to existing session ${existingSessionId}. ` + + `If model "${selectedModel.alias}" differs from current session model, call sessions.patch first to update the model. ` + + `Then call sessions_send with the task description. ` + + `After spawning/sending, update projects.json sessionId if it changed.`; + result.tokensSavedEstimate = "~50K (session reuse)"; + } else { + result.instructions = + `New session: call sessions_spawn with model "${selectedModel.model}" for this ${role.toUpperCase()} task. ` + + `After spawn completes, call task_pickup_confirm with the returned sessionId to update projects.json.`; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }); +} diff --git a/openclaw.plugin.json b/openclaw.plugin.json new file mode 100644 index 0000000..48cf3bd --- /dev/null +++ b/openclaw.plugin.json @@ -0,0 +1,22 @@ +{ + "id": "devclaw", + "name": "DevClaw", + "description": "Multi-project dev/qa pipeline orchestration for OpenClaw. Atomic task pickup, completion, queue status, and session health tools.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "modelSelection": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "analyzerModel": { "type": "string" } + } + }, + "glabPath": { + "type": "string", + "description": "Path to glab CLI binary. Defaults to 'glab' on PATH." + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6790a63 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "@openclaw/devclaw", + "version": "0.1.0", + "description": "Multi-project dev/qa pipeline orchestration for OpenClaw", + "type": "module", + "license": "MIT", + "author": "laurentenhoor", + "repository": { + "type": "git", + "url": "https://github.com/laurentenhoor/devclaw.git" + }, + "keywords": [ + "openclaw", + "openclaw-plugin", + "dev-pipeline", + "orchestration", + "gitlab", + "multi-project" + ], + "engines": { + "node": ">=20" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + }, + "devDependencies": { + "openclaw": "workspace:*" + } +}