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 <noreply@anthropic.com>
This commit is contained in:
Lauren ten Hoor
2026-02-08 15:26:29 +08:00
commit 9ace15dad5
14 changed files with 1313 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
*.js
*.js.map
*.d.ts
!openclaw.plugin.json

21
LICENSE Normal file
View File

@@ -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.

72
README.md Normal file
View File

@@ -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

33
index.ts Normal file
View File

@@ -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;

29
lib/audit.ts Normal file
View File

@@ -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<string, unknown>,
): Promise<void> {
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
}
}

182
lib/gitlab.ts Normal file
View File

@@ -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<string> {
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<GitLabIssue> {
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<GitLabIssue[]> {
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<void> {
await glab(
[
"issue",
"update",
String(issueId),
"--unlabel",
from,
"--label",
to,
],
opts,
);
}
/**
* Close an issue.
*/
export async function closeIssue(
issueId: number,
opts: GlabOptions,
): Promise<void> {
await glab(["issue", "close", String(issueId)], opts);
}
/**
* Reopen an issue.
*/
export async function reopenIssue(
issueId: number,
opts: GlabOptions,
): Promise<void> {
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<boolean> {
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;
}

92
lib/model-selector.ts Normal file
View File

@@ -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",
};
}

131
lib/projects.ts Normal file
View File

@@ -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<string, Project>;
};
function projectsPath(workspaceDir: string): string {
return path.join(workspaceDir, "memory", "projects.json");
}
export async function readProjects(workspaceDir: string): Promise<ProjectsData> {
const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8");
return JSON.parse(raw) as ProjectsData;
}
export async function writeProjects(
workspaceDir: string,
data: ProjectsData,
): Promise<void> {
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<WorkerState>,
): Promise<ProjectsData> {
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<ProjectsData> {
const updates: Partial<WorkerState> = {
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<ProjectsData> {
return updateWorker(workspaceDir, groupId, role, {
active: false,
issueId: null,
});
}

111
lib/tools/queue-status.ts Normal file
View File

@@ -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<string, unknown>) {
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<string, unknown>)?.glabPath as string | undefined;
const projects: Array<Record<string, unknown>> = [];
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<string, Array<{ id: number; title: string }>> = {};
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<string, unknown[]>).toImprove?.length ?? 0),
0,
),
totalToTest: projects.reduce(
(sum, p) => sum + ((p.queue as Record<string, unknown[]>).toTest?.length ?? 0),
0,
),
totalToDo: projects.reduce(
(sum, p) => sum + ((p.queue as Record<string, unknown[]>).toDo?.length ?? 0),
0,
),
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ projects }, null, 2),
},
],
};
},
});
}

189
lib/tools/session-health.ts Normal file
View File

@@ -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<string, unknown>) {
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<string, unknown>)?.glabPath as string | undefined;
const issues: Array<Record<string, unknown>> = [];
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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) }],
};
},
});
}

198
lib/tools/task-complete.ts Normal file
View File

@@ -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<string, unknown>) {
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<string, unknown>)?.glabPath as string | undefined,
repoPath,
};
const output: Record<string, unknown> = {
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) }],
};
},
});
}

196
lib/tools/task-pickup.ts Normal file
View File

@@ -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<string, unknown>) {
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<string, unknown>)?.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<string, unknown> = {
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) }],
};
},
});
}

22
openclaw.plugin.json Normal file
View File

@@ -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."
}
}
}
}

31
package.json Normal file
View File

@@ -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:*"
}
}