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:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.js
|
||||||
|
*.js.map
|
||||||
|
*.d.ts
|
||||||
|
!openclaw.plugin.json
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
72
README.md
Normal 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
33
index.ts
Normal 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
29
lib/audit.ts
Normal 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
182
lib/gitlab.ts
Normal 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
92
lib/model-selector.ts
Normal 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
131
lib/projects.ts
Normal 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
111
lib/tools/queue-status.ts
Normal 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
189
lib/tools/session-health.ts
Normal 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
198
lib/tools/task-complete.ts
Normal 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
196
lib/tools/task-pickup.ts
Normal 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
22
openclaw.plugin.json
Normal 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
31
package.json
Normal 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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user