feat: add TypeScript support and shared types

- Added TypeScript configuration file (tsconfig.json) with strict settings.
- Introduced devDependencies for TypeScript in package.json.
- Added scripts for type checking and watching for changes.
- Created a new types file (lib/types.ts) defining shared types for the DevClaw plugin.
This commit is contained in:
Lauren ten Hoor
2026-02-09 14:27:13 +08:00
parent b5bcd313e8
commit 32eb079521
12 changed files with 282 additions and 1694 deletions

View File

@@ -5,26 +5,24 @@
* state update (activateWorker), and audit logging.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { log as auditLog } from "./audit.js";
import {
type Project,
type WorkerState,
getWorker,
getSessionForModel,
activateWorker,
getSessionForModel,
getWorker,
} from "./projects.js";
import { selectModel } from "./model-selector.js";
import { log as auditLog } from "./audit.js";
import { resolveModel, TIER_EMOJI, isTier } from "./tiers.js";
import { TIER_EMOJI, isTier, resolveModel } from "./tiers.js";
const execFileAsync = promisify(execFile);
export type DispatchOpts = {
workspaceDir: string;
agentId: string;
agentId?: string;
groupId: string;
project: Project;
issueId: number;
@@ -69,12 +67,33 @@ async function buildTaskMessage(opts: {
baseBranch: string;
groupId: string;
}): Promise<string> {
const { workspaceDir, projectName, role, issueId, issueTitle, issueDescription, issueUrl, repo, baseBranch, groupId } = opts;
const {
workspaceDir,
projectName,
role,
issueId,
issueTitle,
issueDescription,
issueUrl,
repo,
baseBranch,
groupId,
} = opts;
// Read role-specific instructions
let roleInstructions = "";
const projectRoleFile = path.join(workspaceDir, "roles", projectName, `${role}.md`);
const defaultRoleFile = path.join(workspaceDir, "roles", "default", `${role}.md`);
const projectRoleFile = path.join(
workspaceDir,
"roles",
projectName,
`${role}.md`,
);
const defaultRoleFile = path.join(
workspaceDir,
"roles",
"default",
`${role}.md`,
);
try {
roleInstructions = await fs.readFile(projectRoleFile, "utf-8");
} catch {
@@ -110,11 +129,23 @@ async function buildTaskMessage(opts: {
* (with label rollback). Logs warning on state update failure
* (dispatch succeeded, session IS running).
*/
export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult> {
export async function dispatchTask(
opts: DispatchOpts,
): Promise<DispatchResult> {
const {
workspaceDir, agentId, groupId, project, issueId,
issueTitle, issueDescription, issueUrl,
role, modelAlias, fromLabel, toLabel, transitionLabel,
workspaceDir,
agentId,
groupId,
project,
issueId,
issueTitle,
issueDescription,
issueUrl,
role,
modelAlias,
fromLabel,
toLabel,
transitionLabel,
pluginConfig,
} = opts;
@@ -146,18 +177,25 @@ export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult>
try {
if (sessionAction === "spawn") {
sessionKey = `agent:${agentId}:subagent:${randomUUID()}`;
await execFileAsync("openclaw", [
"gateway", "call", "sessions.patch",
"--data", JSON.stringify({ key: sessionKey, model: fullModel }),
], { timeout: 30_000 });
sessionKey = `agent:${agentId ?? "unknown"}:subagent:${randomUUID()}`;
await execFileAsync(
"openclaw",
[
"gateway",
"call",
"sessions.patch",
"--data",
JSON.stringify({ key: sessionKey, model: fullModel }),
],
{ timeout: 30_000 },
);
}
await execFileAsync("openclaw", [
"agent",
"--session-id", sessionKey!,
"--message", taskMessage,
], { timeout: 60_000 });
await execFileAsync(
"openclaw",
["agent", "--session-id", sessionKey!, "--message", taskMessage],
{ timeout: 60_000 },
);
dispatched = true;
@@ -224,7 +262,9 @@ export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult>
// Build announcement
const emoji = isTier(modelAlias)
? TIER_EMOJI[modelAlias]
: (role === "qa" ? "🔍" : "🔧");
: role === "qa"
? "🔍"
: "🔧";
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${modelAlias}) for #${issueId}: ${issueTitle}`;

View File

@@ -4,13 +4,16 @@
* Creates a new agent (optional), configures model tiers,
* and writes workspace files (AGENTS.md, HEARTBEAT.md, roles, memory).
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { runSetup } from "../setup.js";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
export function createSetupTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
return (ctx: ToolContext) => ({
name: "devclaw_setup",
label: "DevClaw Setup",
description: `Set up DevClaw in an agent's workspace. Creates AGENTS.md, HEARTBEAT.md, role templates, memory/projects.json, and writes model tier config to openclaw.json. Optionally creates a new agent. Backs up existing files before overwriting.`,
parameters: {
type: "object",
@@ -69,16 +72,11 @@ export function createSetupTool(api: OpenClawPluginApi) {
` 3. Create your first issue and pick it up`,
);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
...result,
summary: lines.join("\n"),
}, null, 2),
}],
};
return jsonResult({
success: true,
...result,
summary: lines.join("\n"),
});
},
});
}

View File

@@ -6,7 +6,9 @@
*
* Replaces the manual steps of running glab/gh label create + editing projects.json.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
@@ -67,8 +69,9 @@ async function scaffoldRoleFiles(workspaceDir: string, projectName: string): Pro
}
export function createProjectRegisterTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
return (ctx: ToolContext) => ({
name: "project_register",
label: "Project Register",
description: `Register a new project with DevClaw. Creates all required state labels (idempotent) and adds the project to projects.json. One-time setup per project. Auto-detects GitHub/GitLab from git remote.`,
parameters: {
type: "object",
@@ -186,22 +189,17 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
const rolesNote = rolesCreated ? " Role files scaffolded." : "";
const announcement = `📋 Project "${name}" registered for group ${groupName}. Labels created.${rolesNote} Ready for tasks.`;
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
project: name,
groupId,
repo,
baseBranch,
deployBranch,
labelsCreated: 8,
rolesScaffolded: rolesCreated,
announcement,
}, null, 2),
}],
};
return jsonResult({
success: true,
project: name,
groupId,
repo,
baseBranch,
deployBranch,
labelsCreated: 8,
rolesScaffolded: rolesCreated,
announcement,
});
},
});
}

View File

@@ -3,14 +3,17 @@
*
* Replaces manual GitLab scanning in HEARTBEAT.md.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
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) => ({
return (ctx: ToolContext) => ({
name: "queue_status",
label: "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",
@@ -98,14 +101,7 @@ export function createQueueStatusTool(api: OpenClawPluginApi) {
),
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ projects }, null, 2),
},
],
};
return jsonResult({ projects });
},
});
}

View File

@@ -4,14 +4,17 @@
* Detects zombie sessions (active=true but session dead) and stale workers.
* Checks the sessions map for each worker's current model.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { readProjects, updateWorker, getSessionForModel } 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) => ({
return (ctx: ToolContext) => ({
name: "session_health",
label: "Session Health",
description: `Check session state consistency across all projects. Detects: active workers with no session in their sessions map, 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",
@@ -189,9 +192,7 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
: undefined,
};
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
};
return jsonResult(result);
},
});
}

View File

@@ -8,45 +8,59 @@
* - DEV "done" → automatically dispatches QA (qa tier)
* - QA "fail" → automatically dispatches DEV fix (reuses previous DEV tier)
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import {
readProjects,
getProject,
getWorker,
getSessionForModel,
deactivateWorker,
} from "../projects.js";
import {
getIssue,
transitionLabel,
closeIssue,
reopenIssue,
resolveRepoPath,
type StateLabel,
} from "../gitlab.js";
import { log as auditLog } from "../audit.js";
import { dispatchTask } from "../dispatch.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import { log as auditLog } from "../audit.js";
import { dispatchTask } from "../dispatch.js";
import {
closeIssue,
getIssue,
reopenIssue,
resolveRepoPath,
transitionLabel,
type StateLabel,
} from "../gitlab.js";
import {
deactivateWorker,
getProject,
getSessionForModel,
getWorker,
readProjects,
} from "../projects.js";
import type { ToolContext } from "../types.js";
const execFileAsync = promisify(execFile);
export function createTaskCompleteTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
return (ctx: ToolContext) => ({
name: "task_complete",
label: "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. If the project has autoChain enabled, automatically dispatches the next step (DEV done → QA, QA fail → DEV fix).`,
parameters: {
type: "object",
required: ["role", "result", "projectGroupId"],
properties: {
role: { type: "string", enum: ["dev", "qa"], description: "Worker role completing the task" },
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)',
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",
},
projectGroupId: { type: "string", description: "Telegram group ID (key in projects.json)" },
summary: { type: "string", description: "Brief summary for Telegram announcement" },
},
},
@@ -63,10 +77,14 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
// Validate result matches role
if (role === "dev" && result !== "done") {
throw new Error(`DEV can only complete with result "done", got "${result}"`);
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".`);
throw new Error(
`QA cannot use result "done". Use "pass", "fail", or "refine".`,
);
}
// Resolve project
@@ -83,14 +101,20 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
);
}
const issueId = worker.issueId ? Number(worker.issueId.split(",")[0]) : null;
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}`);
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,
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as
| string
| undefined,
repoPath,
};
@@ -106,7 +130,10 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
// === DEV DONE ===
if (role === "dev" && result === "done") {
try {
await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 });
await execFileAsync("git", ["pull"], {
cwd: repoPath,
timeout: 30_000,
});
output.gitPull = "success";
} catch (err) {
output.gitPull = `warning: ${(err as Error).message}`;
@@ -120,7 +147,9 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
if (project.autoChain) {
try {
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
const pluginConfig = api.pluginConfig as
| Record<string, unknown>
| undefined;
const issue = await getIssue(issueId, glabOpts);
const chainResult = await dispatchTask({
workspaceDir,
@@ -136,7 +165,12 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
fromLabel: "To Test",
toLabel: "Testing",
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
transitionLabel(
id,
from as StateLabel,
to as StateLabel,
glabOpts,
),
pluginConfig,
});
output.autoChain = {
@@ -147,7 +181,10 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
announcement: chainResult.announcement,
};
} catch (err) {
output.autoChain = { dispatched: false, error: (err as Error).message };
output.autoChain = {
dispatched: false,
error: (err as Error).message,
};
}
} else {
output.nextAction = "qa_pickup";
@@ -173,7 +210,9 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
const devWorker = getWorker(project, "dev");
const devModel = devWorker.model;
const devSessionKey = devModel ? getSessionForModel(devWorker, devModel) : null;
const devSessionKey = devModel
? getSessionForModel(devWorker, devModel)
: null;
output.labelTransition = "Testing → To Improve";
output.issueReopened = true;
@@ -183,7 +222,9 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
if (project.autoChain && devModel) {
try {
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
const pluginConfig = api.pluginConfig as
| Record<string, unknown>
| undefined;
const issue = await getIssue(issueId, glabOpts);
const chainResult = await dispatchTask({
workspaceDir,
@@ -199,7 +240,12 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
fromLabel: "To Improve",
toLabel: "Doing",
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
transitionLabel(
id,
from as StateLabel,
to as StateLabel,
glabOpts,
),
pluginConfig,
});
output.autoChain = {
@@ -210,7 +256,10 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
announcement: chainResult.announcement,
};
} catch (err) {
output.autoChain = { dispatched: false, error: (err as Error).message };
output.autoChain = {
dispatched: false,
error: (err as Error).message,
};
}
} else {
output.nextAction = "dev_fix";
@@ -238,9 +287,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
autoChain: output.autoChain ?? null,
});
return {
content: [{ type: "text" as const, text: JSON.stringify(output, null, 2) }],
};
return jsonResult(output);
},
});
}

View File

@@ -9,8 +9,11 @@
* - A sub-agent finds a bug and needs to file a follow-up issue
* - Breaking down an epic into smaller tasks
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { readProjects, resolveRepoPath } from "../projects.js";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { readProjects } from "../projects.js";
import { resolveRepoPath } from "../gitlab.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js";
import type { StateLabel } from "../issue-provider.js";
@@ -27,8 +30,9 @@ const STATE_LABELS: StateLabel[] = [
];
export function createTaskCreateTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
return (ctx: ToolContext) => ({
name: "task_create",
label: "Task Create",
description: `Create a new task (issue) in the project's issue tracker. Use this to file bugs, features, or tasks from chat.
Examples:
@@ -132,12 +136,7 @@ The issue is created with a state label (defaults to "Planning"). Returns the cr
: `📋 Created #${issue.iid}: "${title}" (${label}).${hasBody ? " With detailed description." : ""} Ready for pickup when needed.`,
};
return {
content: [{
type: "text" as const,
text: JSON.stringify(result, null, 2),
}],
};
return jsonResult(result);
},
});
}

View File

@@ -8,35 +8,44 @@
* Model selection is LLM-based: the orchestrator passes a `model` param.
* A keyword heuristic is used as fallback if no model is specified.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { readProjects, getProject, getWorker } from "../projects.js";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import { dispatchTask } from "../dispatch.js";
import {
getIssue,
getCurrentStateLabel,
transitionLabel,
getIssue,
resolveRepoPath,
transitionLabel,
type StateLabel,
} from "../gitlab.js";
import { selectModel } from "../model-selector.js";
import { dispatchTask } from "../dispatch.js";
import { getProject, getWorker, readProjects } from "../projects.js";
import type { ToolContext } from "../types.js";
export function createTaskPickupTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
return (ctx: ToolContext) => ({
name: "task_pickup",
label: "Task Pickup",
description: `Pick up a task from the issue queue for a DEV or QA worker. Handles everything end-to-end: label transition, tier assignment, session creation/reuse, task dispatch, state update, and audit logging. The orchestrator should analyze the issue and pass the appropriate developer tier. Returns an announcement for the agent to post — no further session actions needed.`,
parameters: {
type: "object",
required: ["issueId", "role", "projectGroupId"],
properties: {
issueId: { type: "number", description: "Issue ID to pick up" },
role: { type: "string", enum: ["dev", "qa"], description: "Worker role: dev or qa" },
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.",
description:
"Telegram group ID (key in projects.json). Required — pass the group ID from the current conversation.",
},
model: {
type: "string",
description: "Developer tier (junior, medior, senior, qa). The orchestrator should evaluate the task complexity and choose the right tier. Falls back to keyword heuristic if omitted.",
description:
"Developer tier (junior, medior, senior, qa). The orchestrator should evaluate the task complexity and choose the right tier. Falls back to keyword heuristic if omitted.",
},
},
},
@@ -72,7 +81,9 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
// 3. Fetch issue and verify state
const repoPath = resolveRepoPath(project.repo);
const glabOpts = {
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined,
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as
| string
| undefined,
repoPath,
};
@@ -100,14 +111,20 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
modelReason = "LLM-selected by orchestrator";
modelSource = "llm";
} else {
const selected = selectModel(issue.title, issue.description ?? "", role);
const selected = selectModel(
issue.title,
issue.description ?? "",
role,
);
modelAlias = selected.tier;
modelReason = selected.reason;
modelSource = "heuristic";
}
// 5. Dispatch via shared logic
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
const pluginConfig = api.pluginConfig as
| Record<string, unknown>
| undefined;
const dispatchResult = await dispatchTask({
workspaceDir,
agentId: ctx.agentId,
@@ -147,9 +164,7 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
result.tokensSavedEstimate = "~50K (session reuse)";
}
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
};
return jsonResult(result);
},
});
}

17
lib/types.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Shared types for the DevClaw plugin.
*
* OpenClawPluginToolContext is declared in the plugin-sdk but not exported.
* We define a compatible local type for use in tool factory functions.
*/
export type ToolContext = {
config?: Record<string, unknown>;
workspaceDir?: string;
agentDir?: string;
agentId?: string;
sessionKey?: string;
messageChannel?: string;
agentAccountId?: string;
sandboxed?: boolean;
};