Refactor setup and tool helpers for improved modularity and clarity

- Moved setup logic into dedicated files: agent.ts, config.ts, index.ts, workspace.ts.
- Introduced tool-helpers.ts for shared functions across tools, reducing boilerplate.
- Updated tools (status, task-comment, task-create, task-update, work-finish, work-start) to utilize new helper functions for workspace resolution and provider creation.
- Enhanced error handling and context detection in tools.
- Improved project resolution logic to streamline tool execution.
- Added new functionality for agent creation and configuration management in setup.
This commit is contained in:
Lauren ten Hoor
2026-02-10 22:51:35 +08:00
parent 55b062ac76
commit 70af40e986
22 changed files with 768 additions and 953 deletions

View File

@@ -4,7 +4,7 @@
* Uses Commander.js (provided by OpenClaw plugin SDK context). * Uses Commander.js (provided by OpenClaw plugin SDK context).
*/ */
import type { Command } from "commander"; import type { Command } from "commander";
import { runSetup } from "./setup.js"; import { runSetup } from "./setup/index.js";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js"; import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js";
/** /**

View File

@@ -12,10 +12,10 @@ import { log as auditLog } from "./audit.js";
import { import {
type Project, type Project,
activateWorker, activateWorker,
getSessionForModel, getSessionForTier,
getWorker, getWorker,
} from "./projects.js"; } from "./projects.js";
import { TIER_EMOJI, isTier, resolveModel } from "./tiers.js"; import { TIER_EMOJI, isTier, resolveTierToModel } from "./tiers.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -30,12 +30,12 @@ export type DispatchOpts = {
issueUrl: string; issueUrl: string;
role: "dev" | "qa"; role: "dev" | "qa";
/** Developer tier (junior, medior, senior, qa) or raw model ID */ /** Developer tier (junior, medior, senior, qa) or raw model ID */
modelAlias: string; tier: string;
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */ /** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
fromLabel: string; fromLabel: string;
/** Label to transition TO (e.g. "Doing", "Testing") */ /** Label to transition TO (e.g. "Doing", "Testing") */
toLabel: string; toLabel: string;
/** Function to transition labels (injected to avoid gitlab.ts dependency) */ /** Function to transition labels (injected to avoid provider dependency) */
transitionLabel: (issueId: number, from: string, to: string) => Promise<void>; transitionLabel: (issueId: number, from: string, to: string) => Promise<void>;
/** Plugin config for model resolution */ /** Plugin config for model resolution */
pluginConfig?: Record<string, unknown>; pluginConfig?: Record<string, unknown>;
@@ -46,8 +46,8 @@ export type DispatchOpts = {
export type DispatchResult = { export type DispatchResult = {
sessionAction: "spawn" | "send"; sessionAction: "spawn" | "send";
sessionKey: string; sessionKey: string;
modelAlias: string; tier: string;
fullModel: string; model: string;
announcement: string; announcement: string;
}; };
@@ -69,43 +69,12 @@ export async function buildTaskMessage(opts: {
groupId: string; groupId: string;
}): Promise<string> { }): Promise<string> {
const { const {
workspaceDir, workspaceDir, projectName, role, issueId, issueTitle,
projectName, issueDescription, issueUrl, repo, baseBranch, groupId,
role,
issueId,
issueTitle,
issueDescription,
issueUrl,
repo,
baseBranch,
groupId,
} = opts; } = opts;
// Read role-specific instructions const roleInstructions = await loadRoleInstructions(workspaceDir, projectName, role);
let roleInstructions = "";
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 {
try {
roleInstructions = await fs.readFile(defaultRoleFile, "utf-8");
} catch {
// No role instructions — that's fine
}
}
// Build available results based on role
const availableResults = const availableResults =
role === "dev" role === "dev"
? '"done" (completed successfully) or "blocked" (cannot complete, need help)' ? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
@@ -125,11 +94,8 @@ export async function buildTaskMessage(opts: {
parts.push(``, `---`, ``, roleInstructions.trim()); parts.push(``, `---`, ``, roleInstructions.trim());
} }
// Mandatory completion contract
parts.push( parts.push(
``, ``, `---`, ``,
`---`,
``,
`## MANDATORY: Task Completion`, `## MANDATORY: Task Completion`,
``, ``,
`When you finish this task, you MUST call \`work_finish\` with:`, `When you finish this task, you MUST call \`work_finish\` with:`,
@@ -147,188 +113,172 @@ export async function buildTaskMessage(opts: {
} }
/** /**
* Dispatch a task to a worker session. Handles session spawn/reuse, * Dispatch a task to a worker session.
* CLI dispatch, state update, and audit logging.
* *
* Returns dispatch result on success. Throws on dispatch failure * Flow: resolve model → build message → transition label → spawn/send session
* (with label rollback). Logs warning on state update failure * → update worker state → audit → build announcement.
* (dispatch succeeded, session IS running). *
* On dispatch failure: rolls back label transition.
* On state update failure after dispatch: logs warning (session IS running).
*/ */
export async function dispatchTask( export async function dispatchTask(
opts: DispatchOpts, opts: DispatchOpts,
): Promise<DispatchResult> { ): Promise<DispatchResult> {
const { const {
workspaceDir, workspaceDir, agentId, groupId, project, issueId, issueTitle,
agentId, issueDescription, issueUrl, role, tier, fromLabel, toLabel,
groupId, transitionLabel, pluginConfig,
project,
issueId,
issueTitle,
issueDescription,
issueUrl,
role,
modelAlias,
fromLabel,
toLabel,
transitionLabel,
pluginConfig,
} = opts; } = opts;
const fullModel = resolveModel(modelAlias, pluginConfig); const model = resolveTierToModel(tier, pluginConfig);
const worker = getWorker(project, role); const worker = getWorker(project, role);
const existingSessionKey = getSessionForModel(worker, modelAlias); const existingSessionKey = getSessionForTier(worker, tier);
const sessionAction = existingSessionKey ? "send" : "spawn"; const sessionAction = existingSessionKey ? "send" : "spawn";
// Build task message with role instructions
const taskMessage = await buildTaskMessage({ const taskMessage = await buildTaskMessage({
workspaceDir, workspaceDir, projectName: project.name, role, issueId,
projectName: project.name, issueTitle, issueDescription, issueUrl,
role, repo: project.repo, baseBranch: project.baseBranch, groupId,
issueId,
issueTitle,
issueDescription,
issueUrl,
repo: project.repo,
baseBranch: project.baseBranch,
groupId,
}); });
// Transition label
await transitionLabel(issueId, fromLabel, toLabel); await transitionLabel(issueId, fromLabel, toLabel);
// Dispatch
let sessionKey = existingSessionKey; let sessionKey = existingSessionKey;
let dispatched = false; let dispatched = false;
try { try {
if (sessionAction === "spawn") { sessionKey = await ensureSession(sessionAction, sessionKey, {
sessionKey = `agent:${agentId ?? "unknown"}:subagent:${project.name}-${role}-${modelAlias}`; agentId, projectName: project.name, role, tier, model,
await execFileAsync( });
"openclaw",
[ await sendToAgent(sessionKey!, taskMessage, {
"gateway", agentId, projectName: project.name, issueId, role,
"call", orchestratorSessionKey: opts.sessionKey,
"sessions.patch",
"--params",
JSON.stringify({ key: sessionKey, model: fullModel }),
],
{ timeout: 30_000 },
);
}
// Dispatch via `gateway call agent --expect-final` as a detached background process.
// Without --expect-final the gateway accepts but never processes the request.
// Running with --expect-final in a detached process ensures the agent runs
// while work_start returns immediately.
// Using the gateway RPC (not `openclaw agent` CLI) lets us set lane, spawnedBy,
// and deliver — matching the official sessions_spawn internals.
const orchestratorSessionKey = opts.sessionKey;
const gatewayParams = JSON.stringify({
idempotencyKey: `devclaw-${project.name}-${issueId}-${role}-${Date.now()}`,
agentId: agentId ?? "devclaw",
sessionKey: sessionKey!,
message: taskMessage,
deliver: false,
lane: "subagent",
...(orchestratorSessionKey
? { spawnedBy: orchestratorSessionKey }
: {}),
}); });
const child = spawn(
"openclaw",
[
"gateway",
"call",
"agent",
"--params",
gatewayParams,
"--expect-final",
"--json",
],
{ detached: true, stdio: "ignore" },
);
child.unref();
dispatched = true; dispatched = true;
// Update state await recordWorkerState(workspaceDir, groupId, role, {
const now = new Date().toISOString(); issueId, tier, sessionKey: sessionKey!, sessionAction,
if (sessionAction === "spawn") {
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: modelAlias,
sessionKey: sessionKey!,
startTime: now,
}); });
} else {
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: modelAlias,
});
}
} catch (err) { } catch (err) {
if (dispatched) { if (dispatched) {
// State update failed but session IS running — log warning, don't rollback
await auditLog(workspaceDir, "work_start", { await auditLog(workspaceDir, "work_start", {
project: project.name, project: project.name, groupId, issue: issueId, role,
groupId,
issue: issueId,
role,
warning: "State update failed after successful dispatch", warning: "State update failed after successful dispatch",
error: (err as Error).message, error: (err as Error).message, sessionKey,
sessionKey,
}); });
// Re-throw so caller knows state update failed
throw new Error( throw new Error(
`State update failed after successful session dispatch: ${(err as Error).message}. Session is running but projects.json was not updated.`, `State update failed after successful session dispatch: ${(err as Error).message}. Session is running but projects.json was not updated.`,
); );
} else {
// Dispatch failed — rollback label
try {
await transitionLabel(issueId, toLabel, fromLabel);
} catch {
// Best-effort rollback
} }
try { await transitionLabel(issueId, toLabel, fromLabel); } catch { /* best-effort rollback */ }
throw new Error( throw new Error(
`Session dispatch failed: ${(err as Error).message}. Label reverted to "${fromLabel}".`, `Session dispatch failed: ${(err as Error).message}. Label reverted to "${fromLabel}".`,
); );
} }
await auditDispatch(workspaceDir, {
project: project.name, groupId, issueId, issueTitle,
role, tier, model, sessionAction, sessionKey: sessionKey!,
fromLabel, toLabel,
});
const announcement = buildAnnouncement(tier, role, sessionAction, issueId, issueTitle, issueUrl);
return { sessionAction, sessionKey: sessionKey!, tier, model, announcement };
} }
// Audit // ---------------------------------------------------------------------------
await auditLog(workspaceDir, "work_start", { // Private helpers — exist so dispatchTask reads as a sequence of steps
project: project.name, // ---------------------------------------------------------------------------
groupId,
issue: issueId, async function loadRoleInstructions(
issueTitle, workspaceDir: string, projectName: string, role: "dev" | "qa",
role, ): Promise<string> {
tier: modelAlias, const projectFile = path.join(workspaceDir, "roles", projectName, `${role}.md`);
sessionAction, const defaultFile = path.join(workspaceDir, "roles", "default", `${role}.md`);
try { return await fs.readFile(projectFile, "utf-8"); } catch { /* fallback */ }
try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* none */ }
return "";
}
async function ensureSession(
action: "spawn" | "send",
existingKey: string | null,
opts: { agentId?: string; projectName: string; role: string; tier: string; model: string },
): Promise<string> {
if (action === "send") return existingKey!;
const sessionKey = `agent:${opts.agentId ?? "unknown"}:subagent:${opts.projectName}-${opts.role}-${opts.tier}`;
await execFileAsync(
"openclaw",
["gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model: opts.model })],
{ timeout: 30_000 },
);
return sessionKey;
}
function sendToAgent(
sessionKey: string, taskMessage: string,
opts: { agentId?: string; projectName: string; issueId: number; role: string; orchestratorSessionKey?: string },
): void {
const gatewayParams = JSON.stringify({
idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${Date.now()}`,
agentId: opts.agentId ?? "devclaw",
sessionKey, sessionKey,
labelTransition: `${fromLabel}${toLabel}`, message: taskMessage,
deliver: false,
lane: "subagent",
...(opts.orchestratorSessionKey ? { spawnedBy: opts.orchestratorSessionKey } : {}),
}); });
const child = spawn(
await auditLog(workspaceDir, "model_selection", { "openclaw",
issue: issueId, ["gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
role, { detached: true, stdio: "ignore" },
tier: modelAlias, );
fullModel, child.unref();
}); }
// Build announcement async function recordWorkerState(
const emoji = isTier(modelAlias) workspaceDir: string, groupId: string, role: "dev" | "qa",
? TIER_EMOJI[modelAlias] opts: { issueId: number; tier: string; sessionKey: string; sessionAction: "spawn" | "send" },
: role === "qa" ): Promise<void> {
? "🔍" const params: { issueId: string; tier: string; sessionKey?: string; startTime?: string } = {
: "🔧"; issueId: String(opts.issueId), tier: opts.tier,
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending"; };
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${modelAlias}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`; if (opts.sessionAction === "spawn") {
params.sessionKey = opts.sessionKey;
return { params.startTime = new Date().toISOString();
sessionAction, }
sessionKey: sessionKey!, await activateWorker(workspaceDir, groupId, role, params);
modelAlias, }
fullModel,
announcement, async function auditDispatch(
}; workspaceDir: string,
opts: {
project: string; groupId: string; issueId: number; issueTitle: string;
role: string; tier: string; model: string; sessionAction: string;
sessionKey: string; fromLabel: string; toLabel: string;
},
): Promise<void> {
await auditLog(workspaceDir, "work_start", {
project: opts.project, groupId: opts.groupId,
issue: opts.issueId, issueTitle: opts.issueTitle,
role: opts.role, tier: opts.tier,
sessionAction: opts.sessionAction, sessionKey: opts.sessionKey,
labelTransition: `${opts.fromLabel}${opts.toLabel}`,
});
await auditLog(workspaceDir, "model_selection", {
issue: opts.issueId, role: opts.role, tier: opts.tier, model: opts.model,
});
}
function buildAnnouncement(
tier: string, role: string, sessionAction: "spawn" | "send",
issueId: number, issueTitle: string, issueUrl: string,
): string {
const emoji = isTier(tier) ? TIER_EMOJI[tier] : role === "qa" ? "🔍" : "🔧";
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${tier}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
} }

View File

@@ -4,7 +4,7 @@
* Returns tier names (junior, medior, senior, qa) instead of model aliases. * Returns tier names (junior, medior, senior, qa) instead of model aliases.
*/ */
export type ModelRecommendation = { export type TierRecommendation = {
tier: string; tier: string;
reason: string; reason: string;
}; };
@@ -47,11 +47,11 @@ const COMPLEX_KEYWORDS = [
* - senior: deep/architectural (system-wide refactoring, novel design) * - senior: deep/architectural (system-wide refactoring, novel design)
* - qa: all QA tasks (code inspection, validation, test runs) * - qa: all QA tasks (code inspection, validation, test runs)
*/ */
export function selectModel( export function selectTier(
issueTitle: string, issueTitle: string,
issueDescription: string, issueDescription: string,
role: "dev" | "qa", role: "dev" | "qa",
): ModelRecommendation { ): TierRecommendation {
if (role === "qa") { if (role === "qa") {
return { return {
tier: "qa", tier: "qa",

View File

@@ -33,7 +33,7 @@ export type NotifyEvent =
issueTitle: string; issueTitle: string;
issueUrl: string; issueUrl: string;
role: "dev" | "qa"; role: "dev" | "qa";
model: string; tier: string;
sessionAction: "spawn" | "send"; sessionAction: "spawn" | "send";
} }
| { | {
@@ -67,7 +67,7 @@ function buildMessage(event: NotifyEvent): string {
switch (event.type) { switch (event.type) {
case "workerStart": { case "workerStart": {
const action = event.sessionAction === "spawn" ? "🚀 Started" : "▶️ Resumed"; const action = event.sessionAction === "spawn" ? "🚀 Started" : "▶️ Resumed";
return `${action} ${event.role.toUpperCase()} (${event.model}) on #${event.issueId}: ${event.issueTitle}\n🔗 ${event.issueUrl}`; return `${action} ${event.role.toUpperCase()} (${event.tier}) on #${event.issueId}: ${event.issueTitle}\n🔗 ${event.issueUrl}`;
} }
case "workerComplete": { case "workerComplete": {

View File

@@ -11,7 +11,7 @@ export type WorkerState = {
active: boolean; active: boolean;
issueId: string | null; issueId: string | null;
startTime: string | null; startTime: string | null;
model: string | null; tier: string | null;
sessions: Record<string, string | null>; sessions: Record<string, string | null>;
}; };
@@ -39,20 +39,22 @@ export type ProjectsData = {
/** /**
* Migrate old WorkerState schema to current format. * Migrate old WorkerState schema to current format.
* *
* Handles two migrations: * Handles three migrations:
* 1. Old sessionId field → sessions map (pre-sessions era) * 1. Old sessionId field → sessions map (pre-sessions era)
* 2. Model-alias session keys → tier-name keys (haiku→junior, sonnet→medior, etc.) * 2. Model-alias session keys → tier-name keys (haiku→junior, sonnet→medior, etc.)
* 3. Old "model" field name → "tier" field name
*/ */
function migrateWorkerState(worker: Record<string, unknown>): WorkerState { function migrateWorkerState(worker: Record<string, unknown>): WorkerState {
// Read tier from either "tier" (new) or "model" (old) field
const rawTier = (worker.tier ?? worker.model) as string | null;
// Migration 1: old sessionId field → sessions map // Migration 1: old sessionId field → sessions map
if (!worker.sessions || typeof worker.sessions !== "object") { if (!worker.sessions || typeof worker.sessions !== "object") {
const sessionId = worker.sessionId as string | null; const sessionId = worker.sessionId as string | null;
const model = worker.model as string | null;
const sessions: Record<string, string | null> = {}; const sessions: Record<string, string | null> = {};
if (sessionId && model) { if (sessionId && rawTier) {
// Apply tier migration to the model key too const tierKey = TIER_MIGRATION[rawTier] ?? rawTier;
const tierKey = TIER_MIGRATION[model] ?? model;
sessions[tierKey] = sessionId; sessions[tierKey] = sessionId;
} }
@@ -60,7 +62,7 @@ function migrateWorkerState(worker: Record<string, unknown>): WorkerState {
active: worker.active as boolean, active: worker.active as boolean,
issueId: worker.issueId as string | null, issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null, startTime: worker.startTime as string | null,
model: model ? (TIER_MIGRATION[model] ?? model) : null, tier: rawTier ? (TIER_MIGRATION[rawTier] ?? rawTier) : null,
sessions, sessions,
}; };
} }
@@ -75,17 +77,23 @@ function migrateWorkerState(worker: Record<string, unknown>): WorkerState {
const newKey = TIER_MIGRATION[key] ?? key; const newKey = TIER_MIGRATION[key] ?? key;
newSessions[newKey] = value; newSessions[newKey] = value;
} }
const model = worker.model as string | null;
return { return {
active: worker.active as boolean, active: worker.active as boolean,
issueId: worker.issueId as string | null, issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null, startTime: worker.startTime as string | null,
model: model ? (TIER_MIGRATION[model] ?? model) : null, tier: rawTier ? (TIER_MIGRATION[rawTier] ?? rawTier) : null,
sessions: newSessions, sessions: newSessions,
}; };
} }
return worker as unknown as WorkerState; // Migration 3: "model" field → "tier" field (already handled by rawTier above)
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
tier: rawTier ? (TIER_MIGRATION[rawTier] ?? rawTier) : null,
sessions: oldSessions,
};
} }
/** /**
@@ -93,14 +101,14 @@ function migrateWorkerState(worker: Record<string, unknown>): WorkerState {
*/ */
export function emptyWorkerState(tiers: string[]): WorkerState { export function emptyWorkerState(tiers: string[]): WorkerState {
const sessions: Record<string, string | null> = {}; const sessions: Record<string, string | null> = {};
for (const tier of tiers) { for (const t of tiers) {
sessions[tier] = null; sessions[t] = null;
} }
return { return {
active: false, active: false,
issueId: null, issueId: null,
startTime: null, startTime: null,
model: null, tier: null,
sessions, sessions,
}; };
} }
@@ -108,7 +116,7 @@ export function emptyWorkerState(tiers: string[]): WorkerState {
/** /**
* Get session key for a specific tier from a worker's sessions map. * Get session key for a specific tier from a worker's sessions map.
*/ */
export function getSessionForModel( export function getSessionForTier(
worker: WorkerState, worker: WorkerState,
tier: string, tier: string,
): string | null { ): string | null {
@@ -123,7 +131,6 @@ export async function readProjects(workspaceDir: string): Promise<ProjectsData>
const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8"); const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8");
const data = JSON.parse(raw) as ProjectsData; const data = JSON.parse(raw) as ProjectsData;
// Migrate any old-schema or missing fields transparently
for (const project of Object.values(data.projects)) { for (const project of Object.values(data.projects)) {
project.dev = project.dev project.dev = project.dev
? migrateWorkerState(project.dev as unknown as Record<string, unknown>) ? migrateWorkerState(project.dev as unknown as Record<string, unknown>)
@@ -144,7 +151,6 @@ export async function writeProjects(
data: ProjectsData, data: ProjectsData,
): Promise<void> { ): Promise<void> {
const filePath = projectsPath(workspaceDir); const filePath = projectsPath(workspaceDir);
// Write to temp file first, then rename for atomicity
const tmpPath = filePath + ".tmp"; const tmpPath = filePath + ".tmp";
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8"); await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
await fs.rename(tmpPath, filePath); await fs.rename(tmpPath, filePath);
@@ -166,12 +172,7 @@ export function getWorker(
/** /**
* Update worker state for a project. Only provided fields are updated. * Update worker state for a project. Only provided fields are updated.
* This prevents accidentally nulling out fields that should be preserved. * Sessions are merged (not replaced) when both existing and new sessions are present.
*
* Session Preservation:
* - If updates.sessions is provided, it's merged with existing sessions (new keys added/updated, existing keys preserved)
* - If updates.sessions is NOT provided, existing sessions are preserved via spread operator
* - Sessions should NEVER be accidentally cleared during state updates
*/ */
export async function updateWorker( export async function updateWorker(
workspaceDir: string, workspaceDir: string,
@@ -187,13 +188,10 @@ export async function updateWorker(
const worker = project[role]; const worker = project[role];
// Merge sessions maps if both exist
// This ensures we preserve existing sessions while adding/updating new ones
if (updates.sessions && worker.sessions) { if (updates.sessions && worker.sessions) {
updates.sessions = { ...worker.sessions, ...updates.sessions }; updates.sessions = { ...worker.sessions, ...updates.sessions };
} }
// Spread worker first, then updates - this preserves any fields not in updates
project[role] = { ...worker, ...updates }; project[role] = { ...worker, ...updates };
await writeProjects(workspaceDir, data); await writeProjects(workspaceDir, data);
@@ -202,22 +200,7 @@ export async function updateWorker(
/** /**
* Mark a worker as active with a new task. * Mark a worker as active with a new task.
* Sets active=true, issueId, model (tier). Stores session key in sessions[tier]. * Stores session key in sessions[tier] when a new session is spawned.
*
* Session Handling:
* - If sessionKey is provided: new session spawned, stored in sessions[model]
* - If sessionKey is omitted: existing session reused (sessions map preserved)
* - Other tier sessions in the sessions map are ALWAYS preserved
*
* Example flow:
* 1. First senior task: activateWorker({model: "senior", sessionKey: "abc"})
* → sessions = {junior: null, medior: null, senior: "abc"}
* 2. Task completes: deactivateWorker()
* → sessions = {junior: null, medior: null, senior: "abc"} (preserved!)
* 3. Next senior task: activateWorker({model: "senior"}) [no sessionKey]
* → sessions = {junior: null, medior: null, senior: "abc"} (reused!)
* 4. Medior task: activateWorker({model: "medior", sessionKey: "xyz"})
* → sessions = {junior: null, medior: "xyz", senior: "abc"} (both preserved!)
*/ */
export async function activateWorker( export async function activateWorker(
workspaceDir: string, workspaceDir: string,
@@ -225,7 +208,7 @@ export async function activateWorker(
role: "dev" | "qa", role: "dev" | "qa",
params: { params: {
issueId: string; issueId: string;
model: string; tier: string;
sessionKey?: string; sessionKey?: string;
startTime?: string; startTime?: string;
}, },
@@ -233,12 +216,10 @@ export async function activateWorker(
const updates: Partial<WorkerState> = { const updates: Partial<WorkerState> = {
active: true, active: true,
issueId: params.issueId, issueId: params.issueId,
model: params.model, tier: params.tier,
}; };
// Store session key in the sessions map for this tier (if new spawn)
// If sessionKey is omitted, existing sessions are preserved via updateWorker
if (params.sessionKey !== undefined) { if (params.sessionKey !== undefined) {
updates.sessions = { [params.model]: params.sessionKey }; updates.sessions = { [params.tier]: params.sessionKey };
} }
if (params.startTime !== undefined) { if (params.startTime !== undefined) {
updates.startTime = params.startTime; updates.startTime = params.startTime;
@@ -248,58 +229,21 @@ export async function activateWorker(
/** /**
* Mark a worker as inactive after task completion. * Mark a worker as inactive after task completion.
* Clears issueId and active, PRESERVES sessions map, model, startTime for reuse. * Preserves sessions map and tier for reuse via updateWorker's spread.
*
* IMPORTANT: This function MUST preserve the sessions map to enable session reuse
* across multiple tasks of the same tier. Do NOT pass `sessions` in the updates
* object, as this would overwrite the existing sessions.
*/ */
export async function deactivateWorker( export async function deactivateWorker(
workspaceDir: string, workspaceDir: string,
groupId: string, groupId: string,
role: "dev" | "qa", role: "dev" | "qa",
): Promise<ProjectsData> { ): Promise<ProjectsData> {
// Read current state to verify sessions will be preserved return updateWorker(workspaceDir, groupId, role, {
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];
const sessionsBefore = worker.sessions;
// Update worker state (active=false, issueId=null)
// Sessions are preserved via spread operator in updateWorker
const result = await updateWorker(workspaceDir, groupId, role, {
active: false, active: false,
issueId: null, issueId: null,
// Explicitly DO NOT set sessions here to preserve them
}); });
// Defensive verification: ensure sessions were not accidentally cleared
const updatedWorker = result.projects[groupId][role];
const sessionsAfter = updatedWorker.sessions;
// Verify sessions map was preserved
if (sessionsBefore && sessionsAfter) {
for (const [tier, sessionKey] of Object.entries(sessionsBefore)) {
if (sessionKey !== null && sessionsAfter[tier] !== sessionKey) {
throw new Error(
`BUG: Session for tier "${tier}" was lost during deactivateWorker! ` +
`Before: ${sessionKey}, After: ${sessionsAfter[tier]}. ` +
`This should never happen - sessions must persist for reuse.`
);
}
}
}
return result;
} }
/** /**
* Resolve repo path from projects.json repo field (handles ~/ expansion). * Resolve repo path from projects.json repo field (handles ~/ expansion).
* Uses os.homedir() for cross-platform home directory resolution.
*/ */
export function resolveRepoPath(repoField: string): string { export function resolveRepoPath(repoField: string): string {
if (repoField.startsWith("~/")) { if (repoField.startsWith("~/")) {

View File

@@ -6,7 +6,7 @@
*/ */
import type { StateLabel } from "../providers/provider.js"; import type { StateLabel } from "../providers/provider.js";
import { import {
getSessionForModel, getSessionForTier,
getWorker, getWorker,
updateWorker, updateWorker,
type Project, type Project,
@@ -19,7 +19,7 @@ export type HealthIssue = {
groupId: string; groupId: string;
role: "dev" | "qa"; role: "dev" | "qa";
message: string; message: string;
model?: string | null; tier?: string | null;
sessionKey?: string | null; sessionKey?: string | null;
hoursActive?: number; hoursActive?: number;
issueId?: string | null; issueId?: string | null;
@@ -46,7 +46,7 @@ export async function checkWorkerHealth(opts: {
const { workspaceDir, groupId, project, role, activeSessions, autoFix, provider } = opts; const { workspaceDir, groupId, project, role, activeSessions, autoFix, provider } = opts;
const fixes: HealthFix[] = []; const fixes: HealthFix[] = [];
const worker = getWorker(project, role); const worker = getWorker(project, role);
const sessionKey = worker.model ? getSessionForModel(worker, worker.model) : null; const sessionKey = worker.tier ? getSessionForTier(worker, worker.tier) : null;
const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test"; const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test";
const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
@@ -62,14 +62,14 @@ export async function checkWorkerHealth(opts: {
} }
} }
// Check 1: Active but no session key for current model // Check 1: Active but no session key for current tier
if (worker.active && !sessionKey) { if (worker.active && !sessionKey) {
const fix: HealthFix = { const fix: HealthFix = {
issue: { issue: {
type: "active_no_session", severity: "critical", type: "active_no_session", severity: "critical",
project: project.name, groupId, role, project: project.name, groupId, role,
model: worker.model, tier: worker.tier,
message: `${role.toUpperCase()} active but no session for model "${worker.model}"`, message: `${role.toUpperCase()} active but no session for tier "${worker.tier}"`,
}, },
fixed: false, fixed: false,
}; };
@@ -86,7 +86,7 @@ export async function checkWorkerHealth(opts: {
issue: { issue: {
type: "zombie_session", severity: "critical", type: "zombie_session", severity: "critical",
project: project.name, groupId, role, project: project.name, groupId, role,
sessionKey, model: worker.model, sessionKey, tier: worker.tier,
message: `${role.toUpperCase()} session not in active sessions list`, message: `${role.toUpperCase()} session not in active sessions list`,
}, },
fixed: false, fixed: false,
@@ -94,7 +94,7 @@ export async function checkWorkerHealth(opts: {
if (autoFix) { if (autoFix) {
await revertIssueLabel(fix); await revertIssueLabel(fix);
const sessions = { ...worker.sessions }; const sessions = { ...worker.sessions };
if (worker.model) sessions[worker.model] = null; if (worker.tier) sessions[worker.tier] = null;
await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null, sessions }); await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null, sessions });
fix.fixed = true; fix.fixed = true;
} }

View File

@@ -7,10 +7,10 @@
import type { Issue, StateLabel } from "../providers/provider.js"; import type { Issue, StateLabel } from "../providers/provider.js";
import type { IssueProvider } from "../providers/provider.js"; import type { IssueProvider } from "../providers/provider.js";
import { createProvider } from "../providers/index.js"; import { createProvider } from "../providers/index.js";
import { selectModel } from "../model-selector.js"; import { selectTier } from "../model-selector.js";
import { getWorker, getSessionForModel, readProjects } from "../projects.js"; import { getWorker, getSessionForTier, readProjects } from "../projects.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import type { Tier } from "../tiers.js"; import { ALL_TIERS, type Tier } from "../tiers.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared constants + helpers (used by tick, work-start, auto-pickup) // Shared constants + helpers (used by tick, work-start, auto-pickup)
@@ -20,11 +20,9 @@ export const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
export const QA_LABELS: StateLabel[] = ["To Test"]; export const QA_LABELS: StateLabel[] = ["To Test"];
export const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"]; export const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
export function detectTierFromLabels(labels: string[]): Tier | null { export function detectTierFromLabels(labels: string[]): Tier | null {
const lower = labels.map((l) => l.toLowerCase()); const lower = labels.map((l) => l.toLowerCase());
return TIER_LABELS.find((t) => lower.includes(t)) ?? null; return (ALL_TIERS as readonly string[]).find((t) => lower.includes(t)) as Tier | undefined ?? null;
} }
export function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null { export function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null {
@@ -78,7 +76,7 @@ export type TickAction = {
issueId: number; issueId: number;
issueTitle: string; issueTitle: string;
role: "dev" | "qa"; role: "dev" | "qa";
model: string; tier: string;
sessionAction: "spawn" | "send"; sessionAction: "spawn" | "send";
announcement: string; announcement: string;
}; };
@@ -145,21 +143,13 @@ export async function projectTick(opts: {
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
// Tier selection: label → heuristic // Tier selection: label → heuristic
let modelAlias: string; const selectedTier = resolveTierForIssue(issue, role);
const tier = detectTierFromLabels(issue.labels);
if (tier) {
if (role === "qa" && tier !== "qa") modelAlias = "qa";
else if (role === "dev" && tier === "qa") modelAlias = selectModel(issue.title, issue.description ?? "", role).tier;
else modelAlias = tier;
} else {
modelAlias = selectModel(issue.title, issue.description ?? "", role).tier;
}
if (dryRun) { if (dryRun) {
pickups.push({ pickups.push({
project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,
role, model: modelAlias, role, tier: selectedTier,
sessionAction: getSessionForModel(worker, modelAlias) ? "send" : "spawn", sessionAction: getSessionForTier(worker, selectedTier) ? "send" : "spawn",
announcement: `[DRY RUN] Would pick up #${issue.iid}`, announcement: `[DRY RUN] Would pick up #${issue.iid}`,
}); });
} else { } else {
@@ -167,13 +157,13 @@ export async function projectTick(opts: {
const dr = await dispatchTask({ const dr = await dispatchTask({
workspaceDir, agentId, groupId, project: fresh, issueId: issue.iid, workspaceDir, agentId, groupId, project: fresh, issueId: issue.iid,
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url, issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
role, modelAlias, fromLabel: currentLabel, toLabel: targetLabel, role, tier: selectedTier, fromLabel: currentLabel, toLabel: targetLabel,
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
pluginConfig, sessionKey, pluginConfig, sessionKey,
}); });
pickups.push({ pickups.push({
project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,
role, model: dr.modelAlias, sessionAction: dr.sessionAction, announcement: dr.announcement, role, tier: dr.tier, sessionAction: dr.sessionAction, announcement: dr.announcement,
}); });
} catch (err) { } catch (err) {
skipped.push({ role, reason: `Dispatch failed: ${(err as Error).message}` }); skipped.push({ role, reason: `Dispatch failed: ${(err as Error).message}` });
@@ -185,3 +175,20 @@ export async function projectTick(opts: {
return { pickups, skipped }; return { pickups, skipped };
} }
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/**
* Determine the tier for an issue based on labels, role overrides, and heuristic fallback.
*/
function resolveTierForIssue(issue: Issue, role: "dev" | "qa"): string {
const labelTier = detectTierFromLabels(issue.labels);
if (labelTier) {
if (role === "qa" && labelTier !== "qa") return "qa";
if (role === "dev" && labelTier === "qa") return selectTier(issue.title, issue.description ?? "", role).tier;
return labelTier;
}
return selectTier(issue.title, issue.description ?? "", role).tier;
}

View File

@@ -1,375 +0,0 @@
/**
* setup.ts — Shared setup logic for DevClaw onboarding.
*
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
* Handles: agent creation, model configuration, workspace file writes.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import path from "node:path";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js";
import {
AGENTS_MD_TEMPLATE,
HEARTBEAT_MD_TEMPLATE,
DEFAULT_DEV_INSTRUCTIONS,
DEFAULT_QA_INSTRUCTIONS,
} from "./templates.js";
import { migrateChannelBinding } from "./binding-manager.js";
const execFileAsync = promisify(execFile);
export type SetupOpts = {
/** Create a new agent with this name. Mutually exclusive with agentId. */
newAgentName?: string;
/** Channel binding for new agent. Only used when newAgentName is set. */
channelBinding?: "telegram" | "whatsapp" | null;
/** Migrate channel binding from this agent ID. Only used when newAgentName and channelBinding are set. */
migrateFrom?: string;
/** Use an existing agent by ID. Mutually exclusive with newAgentName. */
agentId?: string;
/** Override workspace path (auto-detected from agent if not given). */
workspacePath?: string;
/** Model overrides per tier. Missing tiers use defaults. */
models?: Partial<Record<Tier, string>>;
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
projectExecution?: "parallel" | "sequential";
};
export type SetupResult = {
agentId: string;
agentCreated: boolean;
workspacePath: string;
models: Record<Tier, string>;
filesWritten: string[];
warnings: string[];
bindingMigrated?: {
from: string;
channel: "telegram" | "whatsapp";
};
};
/**
* Run the full DevClaw setup.
*
* 1. Create agent (optional) or resolve existing workspace
* 2. Merge model config and write to openclaw.json
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, roles, memory)
*/
export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
const warnings: string[] = [];
const filesWritten: string[] = [];
let agentId: string;
let agentCreated = false;
let workspacePath: string;
let bindingMigrated: SetupResult["bindingMigrated"];
// --- Step 1: Agent ---
if (opts.newAgentName) {
const result = await createAgent(opts.newAgentName, opts.channelBinding);
agentId = result.agentId;
workspacePath = result.workspacePath;
agentCreated = true;
// --- Step 1b: Migration (if requested) ---
if (opts.migrateFrom && opts.channelBinding) {
try {
await migrateChannelBinding(
opts.channelBinding,
opts.migrateFrom,
agentId,
);
bindingMigrated = {
from: opts.migrateFrom,
channel: opts.channelBinding,
};
} catch (err) {
warnings.push(
`Failed to migrate binding from "${opts.migrateFrom}": ${(err as Error).message}`,
);
}
}
} else if (opts.agentId) {
agentId = opts.agentId;
workspacePath = opts.workspacePath ?? await resolveWorkspacePath(agentId);
} else if (opts.workspacePath) {
agentId = "unknown";
workspacePath = opts.workspacePath;
} else {
throw new Error(
"Setup requires either newAgentName, agentId, or workspacePath",
);
}
// --- Step 2: Models ---
const models = { ...DEFAULT_MODELS };
if (opts.models) {
for (const [tier, model] of Object.entries(opts.models)) {
if (model && (ALL_TIERS as readonly string[]).includes(tier)) {
models[tier as Tier] = model;
}
}
}
// Write plugin config to openclaw.json (includes agentId in devClawAgentIds)
await writePluginConfig(models, agentId, opts.projectExecution);
// --- Step 3: Workspace files ---
// AGENTS.md (backup existing)
const agentsMdPath = path.join(workspacePath, "AGENTS.md");
await backupAndWrite(agentsMdPath, AGENTS_MD_TEMPLATE);
filesWritten.push("AGENTS.md");
// HEARTBEAT.md
const heartbeatPath = path.join(workspacePath, "HEARTBEAT.md");
await backupAndWrite(heartbeatPath, HEARTBEAT_MD_TEMPLATE);
filesWritten.push("HEARTBEAT.md");
// roles/default/dev.md and qa.md
const rolesDefaultDir = path.join(workspacePath, "roles", "default");
await fs.mkdir(rolesDefaultDir, { recursive: true });
const devRolePath = path.join(rolesDefaultDir, "dev.md");
const qaRolePath = path.join(rolesDefaultDir, "qa.md");
if (!await fileExists(devRolePath)) {
await fs.writeFile(devRolePath, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
filesWritten.push("roles/default/dev.md");
}
if (!await fileExists(qaRolePath)) {
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
filesWritten.push("roles/default/qa.md");
}
// memory/projects.json
const memoryDir = path.join(workspacePath, "memory");
await fs.mkdir(memoryDir, { recursive: true });
const projectsJsonPath = path.join(memoryDir, "projects.json");
if (!await fileExists(projectsJsonPath)) {
await fs.writeFile(
projectsJsonPath,
JSON.stringify({ projects: {} }, null, 2) + "\n",
"utf-8",
);
filesWritten.push("memory/projects.json");
}
return {
agentId,
agentCreated,
workspacePath,
models,
filesWritten,
warnings,
bindingMigrated,
};
}
/**
* Create a new agent via `openclaw agents add`.
*/
async function createAgent(
name: string,
channelBinding?: "telegram" | "whatsapp" | null,
): Promise<{ agentId: string; workspacePath: string }> {
// Generate ID from name (lowercase, hyphenated)
const agentId = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const workspacePath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
`workspace-${agentId}`,
);
const args = [
"agents",
"add",
agentId,
"--workspace",
workspacePath,
"--non-interactive",
];
// Add --bind if specified
if (channelBinding) {
args.push("--bind", channelBinding);
}
try {
await execFileAsync("openclaw", args, { timeout: 30_000 });
} catch (err) {
throw new Error(
`Failed to create agent "${name}": ${(err as Error).message}`,
);
}
// openclaw agents add creates a .git dir and BOOTSTRAP.md in the workspace — remove them
const gitDir = path.join(workspacePath, ".git");
const bootstrapFile = path.join(workspacePath, "BOOTSTRAP.md");
try {
await fs.rm(gitDir, { recursive: true });
} catch {
// May not exist — that's fine
}
try {
await fs.unlink(bootstrapFile);
} catch {
// May not exist — that's fine
}
// Update agent's display name in openclaw.json if different from ID
if (name !== agentId) {
try {
const configPath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
"openclaw.json",
);
const configContent = await fs.readFile(configPath, "utf-8");
const config = JSON.parse(configContent);
// Find the newly created agent and update its name
const agent = config.agents?.list?.find((a: { id: string }) => a.id === agentId);
if (agent) {
agent.name = name;
await fs.writeFile(
configPath,
JSON.stringify(config, null, 2) + "\n",
"utf-8",
);
}
} catch (err) {
// Non-fatal - agent was created successfully, just couldn't update display name
console.warn(`Warning: Could not update display name: ${(err as Error).message}`);
}
}
return { agentId, workspacePath };
}
/**
* Resolve workspace path from an agent ID by reading openclaw.json.
*/
async function resolveWorkspacePath(agentId: string): Promise<string> {
const configPath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
"openclaw.json",
);
const raw = await fs.readFile(configPath, "utf-8");
const config = JSON.parse(raw);
const agent = config.agents?.list?.find(
(a: { id: string }) => a.id === agentId,
);
if (!agent?.workspace) {
throw new Error(
`Agent "${agentId}" not found in openclaw.json or has no workspace configured.`,
);
}
return agent.workspace;
}
/**
* Write DevClaw model tier config and devClawAgentIds to openclaw.json plugins section.
* Also adds tool restrictions (deny sessions_spawn, sessions_send) to DevClaw agents.
* This prevents workers from spawning sub-agents or messaging other sessions directly.
* Configures subagent cleanup interval to keep development sessions alive.
* Read-modify-write to preserve existing config.
*/
async function writePluginConfig(
models: Record<Tier, string>,
agentId?: string,
projectExecution?: "parallel" | "sequential",
): Promise<void> {
const configPath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
"openclaw.json",
);
const raw = await fs.readFile(configPath, "utf-8");
const config = JSON.parse(raw);
// Ensure plugins.entries.devclaw.config exists
if (!config.plugins) config.plugins = {};
if (!config.plugins.entries) config.plugins.entries = {};
if (!config.plugins.entries.devclaw) config.plugins.entries.devclaw = {};
if (!config.plugins.entries.devclaw.config)
config.plugins.entries.devclaw.config = {};
// Write models
config.plugins.entries.devclaw.config.models = { ...models };
// Write projectExecution if specified
if (projectExecution) {
config.plugins.entries.devclaw.config.projectExecution = projectExecution;
}
// Configure subagent cleanup interval to 30 days (43200 minutes)
// This keeps development sessions alive during active development
if (!config.agents) config.agents = {};
if (!config.agents.defaults) config.agents.defaults = {};
if (!config.agents.defaults.subagents) config.agents.defaults.subagents = {};
config.agents.defaults.subagents.archiveAfterMinutes = 43200;
// Write/update devClawAgentIds
if (agentId) {
const existing = config.plugins.entries.devclaw.config.devClawAgentIds ?? [];
if (!existing.includes(agentId)) {
config.plugins.entries.devclaw.config.devClawAgentIds = [...existing, agentId];
}
// Add tool restrictions to the agent
// Workers shouldn't spawn sub-agents or message other sessions directly
// All coordination should go through DevClaw tools (work_start, work_finish, etc.)
const agent = config.agents?.list?.find((a: { id: string }) => a.id === agentId);
if (agent) {
if (!agent.tools) {
agent.tools = {};
}
agent.tools.deny = ["sessions_spawn", "sessions_send"];
// Clear any conflicting allow list
delete agent.tools.allow;
}
}
// Atomic write
const tmpPath = configPath + ".tmp";
await fs.writeFile(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
await fs.rename(tmpPath, configPath);
}
/**
* Backup existing file (if any) and write new content.
*/
async function backupAndWrite(
filePath: string,
content: string,
): Promise<void> {
try {
await fs.access(filePath);
// File exists — backup
const bakPath = filePath + ".bak";
await fs.copyFile(filePath, bakPath);
} catch {
// File doesn't exist — ensure directory
await fs.mkdir(path.dirname(filePath), { recursive: true });
}
await fs.writeFile(filePath, content, "utf-8");
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

87
lib/setup/agent.ts Normal file
View File

@@ -0,0 +1,87 @@
/**
* setup/agent.ts — Agent creation and workspace resolution.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import path from "node:path";
const execFileAsync = promisify(execFile);
function openclawConfigPath(): string {
return path.join(process.env.HOME ?? "/home/lauren", ".openclaw", "openclaw.json");
}
/**
* Create a new agent via `openclaw agents add`.
* Cleans up .git and BOOTSTRAP.md from the workspace, updates display name.
*/
export async function createAgent(
name: string,
channelBinding?: "telegram" | "whatsapp" | null,
): Promise<{ agentId: string; workspacePath: string }> {
const agentId = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const workspacePath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
`workspace-${agentId}`,
);
const args = ["agents", "add", agentId, "--workspace", workspacePath, "--non-interactive"];
if (channelBinding) args.push("--bind", channelBinding);
try {
await execFileAsync("openclaw", args, { timeout: 30_000 });
} catch (err) {
throw new Error(`Failed to create agent "${name}": ${(err as Error).message}`);
}
await cleanupWorkspace(workspacePath);
await updateAgentDisplayName(agentId, name);
return { agentId, workspacePath };
}
/**
* Resolve workspace path from an agent ID by reading openclaw.json.
*/
export async function resolveWorkspacePath(agentId: string): Promise<string> {
const raw = await fs.readFile(openclawConfigPath(), "utf-8");
const config = JSON.parse(raw);
const agent = config.agents?.list?.find((a: { id: string }) => a.id === agentId);
if (!agent?.workspace) {
throw new Error(`Agent "${agentId}" not found in openclaw.json or has no workspace configured.`);
}
return agent.workspace;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
async function cleanupWorkspace(workspacePath: string): Promise<void> {
// openclaw agents add creates a .git dir and BOOTSTRAP.md — remove them
try { await fs.rm(path.join(workspacePath, ".git"), { recursive: true }); } catch { /* may not exist */ }
try { await fs.unlink(path.join(workspacePath, "BOOTSTRAP.md")); } catch { /* may not exist */ }
}
async function updateAgentDisplayName(agentId: string, name: string): Promise<void> {
if (name === agentId) return;
try {
const configPath = openclawConfigPath();
const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
const agent = config.agents?.list?.find((a: { id: string }) => a.id === agentId);
if (agent) {
agent.name = name;
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
}
} catch (err) {
console.warn(`Warning: Could not update display name: ${(err as Error).message}`);
}
}

88
lib/setup/config.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* setup/config.ts — Plugin config writer (openclaw.json).
*
* Handles: model tier config, devClawAgentIds, tool restrictions, subagent cleanup.
*/
import fs from "node:fs/promises";
import path from "node:path";
import type { Tier } from "../tiers.js";
function openclawConfigPath(): string {
return path.join(process.env.HOME ?? "/home/lauren", ".openclaw", "openclaw.json");
}
/**
* Write DevClaw model tier config and devClawAgentIds to openclaw.json plugins section.
*
* Also configures:
* - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents
* - Subagent cleanup interval (30 days) to keep development sessions alive
*
* Read-modify-write to preserve existing config.
*/
export async function writePluginConfig(
models: Record<Tier, string>,
agentId?: string,
projectExecution?: "parallel" | "sequential",
): Promise<void> {
const configPath = openclawConfigPath();
const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
ensurePluginStructure(config);
config.plugins.entries.devclaw.config.models = { ...models };
if (projectExecution) {
config.plugins.entries.devclaw.config.projectExecution = projectExecution;
}
configureSubagentCleanup(config);
if (agentId) {
addDevClawAgentId(config, agentId);
addToolRestrictions(config, agentId);
}
const tmpPath = configPath + ".tmp";
await fs.writeFile(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
await fs.rename(tmpPath, configPath);
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
function ensurePluginStructure(config: Record<string, unknown>): void {
if (!config.plugins) config.plugins = {};
const plugins = config.plugins as Record<string, unknown>;
if (!plugins.entries) plugins.entries = {};
const entries = plugins.entries as Record<string, unknown>;
if (!entries.devclaw) entries.devclaw = {};
const devclaw = entries.devclaw as Record<string, unknown>;
if (!devclaw.config) devclaw.config = {};
}
function configureSubagentCleanup(config: Record<string, unknown>): void {
if (!config.agents) config.agents = {};
const agents = config.agents as Record<string, unknown>;
if (!agents.defaults) agents.defaults = {};
const defaults = agents.defaults as Record<string, unknown>;
if (!defaults.subagents) defaults.subagents = {};
(defaults.subagents as Record<string, unknown>).archiveAfterMinutes = 43200;
}
function addDevClawAgentId(config: Record<string, unknown>, agentId: string): void {
const devclaw = (config as any).plugins.entries.devclaw.config;
const existing: string[] = devclaw.devClawAgentIds ?? [];
if (!existing.includes(agentId)) {
devclaw.devClawAgentIds = [...existing, agentId];
}
}
function addToolRestrictions(config: Record<string, unknown>, agentId: string): void {
const agent = (config as any).agents?.list?.find((a: { id: string }) => a.id === agentId);
if (agent) {
if (!agent.tools) agent.tools = {};
agent.tools.deny = ["sessions_spawn", "sessions_send"];
delete agent.tools.allow;
}
}

120
lib/setup/index.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* setup/index.ts — DevClaw setup orchestrator.
*
* Coordinates: agent creation → model config → workspace scaffolding.
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
*/
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
import { migrateChannelBinding } from "../binding-manager.js";
import { createAgent, resolveWorkspacePath } from "./agent.js";
import { writePluginConfig } from "./config.js";
import { scaffoldWorkspace } from "./workspace.js";
export type SetupOpts = {
/** Create a new agent with this name. Mutually exclusive with agentId. */
newAgentName?: string;
/** Channel binding for new agent. Only used when newAgentName is set. */
channelBinding?: "telegram" | "whatsapp" | null;
/** Migrate channel binding from this agent ID. Only used when newAgentName and channelBinding are set. */
migrateFrom?: string;
/** Use an existing agent by ID. Mutually exclusive with newAgentName. */
agentId?: string;
/** Override workspace path (auto-detected from agent if not given). */
workspacePath?: string;
/** Model overrides per tier. Missing tiers use defaults. */
models?: Partial<Record<Tier, string>>;
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
projectExecution?: "parallel" | "sequential";
};
export type SetupResult = {
agentId: string;
agentCreated: boolean;
workspacePath: string;
models: Record<Tier, string>;
filesWritten: string[];
warnings: string[];
bindingMigrated?: {
from: string;
channel: "telegram" | "whatsapp";
};
};
/**
* Run the full DevClaw setup.
*
* 1. Create agent (optional) or resolve existing workspace
* 2. Merge model config and write to openclaw.json
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, roles, memory)
*/
export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
const warnings: string[] = [];
const { agentId, workspacePath, agentCreated, bindingMigrated } =
await resolveOrCreateAgent(opts, warnings);
const models = buildModelConfig(opts.models);
await writePluginConfig(models, agentId, opts.projectExecution);
const filesWritten = await scaffoldWorkspace(workspacePath);
return { agentId, agentCreated, workspacePath, models, filesWritten, warnings, bindingMigrated };
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
async function resolveOrCreateAgent(
opts: SetupOpts,
warnings: string[],
): Promise<{
agentId: string;
workspacePath: string;
agentCreated: boolean;
bindingMigrated?: SetupResult["bindingMigrated"];
}> {
if (opts.newAgentName) {
const { agentId, workspacePath } = await createAgent(opts.newAgentName, opts.channelBinding);
const bindingMigrated = await tryMigrateBinding(opts, agentId, warnings);
return { agentId, workspacePath, agentCreated: true, bindingMigrated };
}
if (opts.agentId) {
const workspacePath = opts.workspacePath ?? await resolveWorkspacePath(opts.agentId);
return { agentId: opts.agentId, workspacePath, agentCreated: false };
}
if (opts.workspacePath) {
return { agentId: "unknown", workspacePath: opts.workspacePath, agentCreated: false };
}
throw new Error("Setup requires either newAgentName, agentId, or workspacePath");
}
async function tryMigrateBinding(
opts: SetupOpts,
agentId: string,
warnings: string[],
): Promise<SetupResult["bindingMigrated"]> {
if (!opts.migrateFrom || !opts.channelBinding) return undefined;
try {
await migrateChannelBinding(opts.channelBinding, opts.migrateFrom, agentId);
return { from: opts.migrateFrom, channel: opts.channelBinding };
} catch (err) {
warnings.push(`Failed to migrate binding from "${opts.migrateFrom}": ${(err as Error).message}`);
return undefined;
}
}
function buildModelConfig(overrides?: Partial<Record<Tier, string>>): Record<Tier, string> {
const models = { ...DEFAULT_MODELS };
if (overrides) {
for (const [tier, model] of Object.entries(overrides)) {
if (model && (ALL_TIERS as readonly string[]).includes(tier)) {
models[tier as Tier] = model;
}
}
}
return models;
}

79
lib/setup/workspace.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* setup/workspace.ts — Workspace file scaffolding.
*
* Writes AGENTS.md, HEARTBEAT.md, default role instructions, and projects.json.
*/
import fs from "node:fs/promises";
import path from "node:path";
import {
AGENTS_MD_TEMPLATE,
HEARTBEAT_MD_TEMPLATE,
DEFAULT_DEV_INSTRUCTIONS,
DEFAULT_QA_INSTRUCTIONS,
} from "../templates.js";
/**
* Write all workspace files for a DevClaw agent.
* Returns the list of files that were written (skips files that already exist).
*/
export async function scaffoldWorkspace(workspacePath: string): Promise<string[]> {
const filesWritten: string[] = [];
// AGENTS.md (backup existing)
await backupAndWrite(path.join(workspacePath, "AGENTS.md"), AGENTS_MD_TEMPLATE);
filesWritten.push("AGENTS.md");
// HEARTBEAT.md
await backupAndWrite(path.join(workspacePath, "HEARTBEAT.md"), HEARTBEAT_MD_TEMPLATE);
filesWritten.push("HEARTBEAT.md");
// roles/default/dev.md and qa.md
const rolesDir = path.join(workspacePath, "roles", "default");
await fs.mkdir(rolesDir, { recursive: true });
const devRolePath = path.join(rolesDir, "dev.md");
if (!await fileExists(devRolePath)) {
await fs.writeFile(devRolePath, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
filesWritten.push("roles/default/dev.md");
}
const qaRolePath = path.join(rolesDir, "qa.md");
if (!await fileExists(qaRolePath)) {
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
filesWritten.push("roles/default/qa.md");
}
// memory/projects.json
const memoryDir = path.join(workspacePath, "memory");
await fs.mkdir(memoryDir, { recursive: true });
const projectsJsonPath = path.join(memoryDir, "projects.json");
if (!await fileExists(projectsJsonPath)) {
await fs.writeFile(projectsJsonPath, JSON.stringify({ projects: {} }, null, 2) + "\n", "utf-8");
filesWritten.push("memory/projects.json");
}
return filesWritten;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
async function backupAndWrite(filePath: string, content: string): Promise<void> {
try {
await fs.access(filePath);
await fs.copyFile(filePath, filePath + ".bak");
} catch {
await fs.mkdir(path.dirname(filePath), { recursive: true });
}
await fs.writeFile(filePath, content, "utf-8");
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

View File

@@ -46,7 +46,7 @@ export function isDevTier(value: string): value is DevTier {
* 2. DEFAULT_MODELS (hardcoded defaults) * 2. DEFAULT_MODELS (hardcoded defaults)
* 3. Treat input as raw model ID (passthrough for non-tier values) * 3. Treat input as raw model ID (passthrough for non-tier values)
*/ */
export function resolveModel( export function resolveTierToModel(
tier: string, tier: string,
pluginConfig?: Record<string, unknown>, pluginConfig?: Record<string, unknown>,
): string { ): string {

79
lib/tool-helpers.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* tool-helpers.ts — Shared resolution helpers for tool execute() functions.
*
* Eliminates repeated boilerplate across tools: workspace validation,
* context detection, project resolution, provider creation.
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "./types.js";
import type { InteractionContext } from "./context-guard.js";
import { detectContext, generateGuardrails } from "./context-guard.js";
import { readProjects, getProject, type Project, type ProjectsData } from "./projects.js";
import { createProvider, type ProviderWithType } from "./providers/index.js";
/**
* Require workspaceDir from context or throw a clear error.
*/
export function requireWorkspaceDir(ctx: ToolContext): string {
if (!ctx.workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
return ctx.workspaceDir;
}
/**
* Detect interaction context (via-agent, direct, or group).
* Extracts devClawAgentIds from plugin config automatically.
*/
export async function resolveContext(
ctx: ToolContext,
api: OpenClawPluginApi,
): Promise<InteractionContext> {
const devClawAgentIds =
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? [];
return detectContext(ctx, devClawAgentIds);
}
/**
* Resolve project by groupId, throw if not found.
*/
export async function resolveProject(
workspaceDir: string,
groupId: string,
): Promise<{ data: ProjectsData; project: Project }> {
const data = await readProjects(workspaceDir);
const project = getProject(data, groupId);
if (!project) {
throw new Error(`Project not found for groupId ${groupId}. Run project_register first.`);
}
return { data, project };
}
/**
* Create an issue provider for a project.
*/
export function resolveProvider(project: Project): ProviderWithType {
return createProvider({ repo: project.repo });
}
/**
* Return a standard "group-only" error response for tools restricted to group chats.
*/
export function groupOnlyError(toolName: string, context: InteractionContext) {
return jsonResult({
success: false,
error: `${toolName} can only be used in project group chats.`,
recommendation: context.type === "via-agent"
? "Use onboard instead for setup."
: "Use the relevant project group.",
contextGuidance: generateGuardrails(context),
});
}
/**
* Get plugin config as a typed record (or undefined).
*/
export function getPluginConfig(api: OpenClawPluginApi): Record<string, unknown> | undefined {
return api.pluginConfig as Record<string, unknown> | undefined;
}

View File

@@ -7,13 +7,12 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { createProvider } from "../providers/index.js";
import { readProjects } from "../projects.js"; import { readProjects } from "../projects.js";
import { detectContext } from "../context-guard.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { notify, getNotificationConfig } from "../notify.js"; import { notify, getNotificationConfig } from "../notify.js";
import { checkWorkerHealth, type HealthFix } from "../services/health.js"; import { checkWorkerHealth, type HealthFix } from "../services/health.js";
import { projectTick, type TickAction } from "../services/tick.js"; import { projectTick, type TickAction } from "../services/tick.js";
import { requireWorkspaceDir, resolveContext, resolveProvider, getPluginConfig } from "../tool-helpers.js";
type ExecutionMode = "parallel" | "sequential"; type ExecutionMode = "parallel" | "sequential";
@@ -37,10 +36,9 @@ export function createAutoPickupTool(api: OpenClawPluginApi) {
const dryRun = (params.dryRun as boolean) ?? false; const dryRun = (params.dryRun as boolean) ?? false;
const maxPickups = params.maxPickups as number | undefined; const maxPickups = params.maxPickups as number | undefined;
const activeSessions = (params.activeSessions as string[]) ?? []; const activeSessions = (params.activeSessions as string[]) ?? [];
const workspaceDir = ctx.workspaceDir; const workspaceDir = requireWorkspaceDir(ctx);
if (!workspaceDir) throw new Error("No workspace directory available");
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined; const pluginConfig = getPluginConfig(api);
const projectExecution: ExecutionMode = (pluginConfig?.projectExecution as ExecutionMode) ?? "parallel"; const projectExecution: ExecutionMode = (pluginConfig?.projectExecution as ExecutionMode) ?? "parallel";
const data = await readProjects(workspaceDir); const data = await readProjects(workspaceDir);
@@ -59,7 +57,7 @@ export function createAutoPickupTool(api: OpenClawPluginApi) {
// Pass 1: health checks // Pass 1: health checks
for (const [groupId, project] of projectEntries) { for (const [groupId, project] of projectEntries) {
const { provider } = createProvider({ repo: project.repo }); const { provider } = resolveProvider(project);
for (const role of ["dev", "qa"] as const) { for (const role of ["dev", "qa"] as const) {
const fixes = await checkWorkerHealth({ workspaceDir, groupId, project, role, activeSessions, autoFix: !dryRun, provider }); const fixes = await checkWorkerHealth({ workspaceDir, groupId, project, role, activeSessions, autoFix: !dryRun, provider });
healthFixes.push(...fixes.map((f) => ({ ...f, project: project.name, role }))); healthFixes.push(...fixes.map((f) => ({ ...f, project: project.name, role })));
@@ -105,8 +103,7 @@ export function createAutoPickupTool(api: OpenClawPluginApi) {
}); });
// Notify // Notify
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? []; const context = await resolveContext(ctx, api);
const context = await detectContext(ctx, devClawAgentIds);
const notifyConfig = getNotificationConfig(pluginConfig); const notifyConfig = getNotificationConfig(pluginConfig);
await notify( await notify(
{ type: "heartbeat", projectsScanned: projectEntries.length, healthFixes: healthFixes.length, pickups: pickups.length, skipped: skipped.length, dryRun, pickupDetails: pickups.map((p) => ({ project: p.project, issueId: p.issueId, role: p.role })) }, { type: "heartbeat", projectsScanned: projectEntries.length, healthFixes: healthFixes.length, pickups: pickups.length, skipped: skipped.length, dryRun, pickupDetails: pickups.map((p) => ({ project: p.project, issueId: p.issueId, role: p.role })) },

View File

@@ -2,12 +2,12 @@
* setup — Agent-driven DevClaw setup. * setup — Agent-driven DevClaw setup.
* *
* Creates agent, configures model tiers, writes workspace files. * Creates agent, configures model tiers, writes workspace files.
* Thin wrapper around lib/setup.ts. * Thin wrapper around lib/setup/.
*/ */
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { runSetup } from "../setup.js"; import { runSetup } from "../setup/index.js";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js"; import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
export function createSetupTool(api: OpenClawPluginApi) { export function createSetupTool(api: OpenClawPluginApi) {

View File

@@ -8,14 +8,14 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { readProjects, getProject, type Project } from "../projects.js"; import { readProjects, getProject, type Project } from "../projects.js";
import { detectContext, generateGuardrails } from "../context-guard.js"; import { generateGuardrails } from "../context-guard.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { checkWorkerHealth } from "../services/health.js"; import { checkWorkerHealth } from "../services/health.js";
import { import {
fetchProjectQueues, buildParallelProjectSequences, buildGlobalTaskSequence, fetchProjectQueues, buildParallelProjectSequences, buildGlobalTaskSequence,
formatProjectQueues, type ProjectQueues, type ProjectExecutionConfig, formatProjectQueues, type ProjectQueues, type ProjectExecutionConfig,
} from "../services/queue.js"; } from "../services/queue.js";
import { createProvider } from "../providers/index.js"; import { requireWorkspaceDir, resolveContext, resolveProvider, getPluginConfig } from "../tool-helpers.js";
export function createStatusTool(api: OpenClawPluginApi) { export function createStatusTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -32,16 +32,11 @@ export function createStatusTool(api: OpenClawPluginApi) {
}, },
async execute(_id: string, params: Record<string, unknown>) { async execute(_id: string, params: Record<string, unknown>) {
const workspaceDir = ctx.workspaceDir; const workspaceDir = requireWorkspaceDir(ctx);
if (!workspaceDir) throw new Error("No workspace directory available");
const includeHealth = (params.includeHealth as boolean) ?? true; const includeHealth = (params.includeHealth as boolean) ?? true;
const activeSessions = (params.activeSessions as string[]) ?? []; const activeSessions = (params.activeSessions as string[]) ?? [];
// Context detection const context = await resolveContext(ctx, api);
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? [];
const context = await detectContext(ctx, devClawAgentIds);
if (context.type === "via-agent") { if (context.type === "via-agent") {
return jsonResult({ return jsonResult({
success: false, success: false,
@@ -55,7 +50,7 @@ export function createStatusTool(api: OpenClawPluginApi) {
let groupId = params.projectGroupId as string | undefined; let groupId = params.projectGroupId as string | undefined;
if (context.type === "group" && !groupId) groupId = context.groupId; if (context.type === "group" && !groupId) groupId = context.groupId;
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined; const pluginConfig = getPluginConfig(api);
const projectExecution = (pluginConfig?.projectExecution as "parallel" | "sequential") ?? "parallel"; const projectExecution = (pluginConfig?.projectExecution as "parallel" | "sequential") ?? "parallel";
const data = await readProjects(workspaceDir); const data = await readProjects(workspaceDir);
@@ -81,7 +76,7 @@ export function createStatusTool(api: OpenClawPluginApi) {
const healthIssues: Array<Record<string, unknown>> = []; const healthIssues: Array<Record<string, unknown>> = [];
if (includeHealth) { if (includeHealth) {
for (const { id, project } of projectList) { for (const { id, project } of projectList) {
const { provider } = createProvider({ repo: project.repo }); const { provider } = resolveProvider(project);
for (const role of ["dev", "qa"] as const) { for (const role of ["dev", "qa"] as const) {
const fixes = await checkWorkerHealth({ const fixes = await checkWorkerHealth({
workspaceDir, groupId: id, project, role, activeSessions, workspaceDir, groupId: id, project, role, activeSessions,
@@ -108,8 +103,8 @@ export function createStatusTool(api: OpenClawPluginApi) {
// Build project details // Build project details
const projects = projectQueues.map(({ projectId, project, queues }) => ({ const projects = projectQueues.map(({ projectId, project, queues }) => ({
name: project.name, groupId: projectId, name: project.name, groupId: projectId,
dev: { active: project.dev.active, issueId: project.dev.issueId, model: project.dev.model, sessions: project.dev.sessions }, dev: { active: project.dev.active, issueId: project.dev.issueId, tier: project.dev.tier, sessions: project.dev.sessions },
qa: { active: project.qa.active, issueId: project.qa.issueId, model: project.qa.model, sessions: project.qa.sessions }, qa: { active: project.qa.active, issueId: project.qa.issueId, tier: project.qa.tier, sessions: project.qa.sessions },
queue: formatProjectQueues(queues), queue: formatProjectQueues(queues),
})); }));

View File

@@ -9,9 +9,8 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { readProjects } from "../projects.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
/** Valid author roles for attribution */ /** Valid author roles for attribution */
const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const; const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const;
@@ -62,73 +61,46 @@ Examples:
const issueId = params.issueId as number; const issueId = params.issueId as number;
const body = params.body as string; const body = params.body as string;
const authorRole = (params.authorRole as AuthorRole) ?? undefined; const authorRole = (params.authorRole as AuthorRole) ?? undefined;
const workspaceDir = ctx.workspaceDir; const workspaceDir = requireWorkspaceDir(ctx);
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
// Validate body is not empty
if (!body || body.trim().length === 0) { if (!body || body.trim().length === 0) {
throw new Error("Comment body cannot be empty."); throw new Error("Comment body cannot be empty.");
} }
// 1. Resolve project const { project } = await resolveProject(workspaceDir, groupId);
const data = await readProjects(workspaceDir); const { provider, type: providerType } = resolveProvider(project);
const project = data.projects[groupId];
if (!project) {
throw new Error(
`Project not found for groupId ${groupId}. Run project_register first.`,
);
}
// 2. Create provider
const { provider, type: providerType } = createProvider({
repo: project.repo,
});
// 3. Fetch issue to verify it exists and get title
const issue = await provider.getIssue(issueId); const issue = await provider.getIssue(issueId);
// 4. Prepare comment body with optional attribution header const commentBody = authorRole
let commentBody = body; ? `${ROLE_EMOJI[authorRole]} **${authorRole.toUpperCase()}**: ${body}`
if (authorRole) { : body;
const roleEmoji: Record<AuthorRole, string> = {
dev: "👨‍💻",
qa: "🔍",
orchestrator: "🎛️",
};
commentBody = `${roleEmoji[authorRole]} **${authorRole.toUpperCase()}**: ${body}`;
}
// 5. Add the comment
await provider.addComment(issueId, commentBody); await provider.addComment(issueId, commentBody);
// 6. Audit log
await auditLog(workspaceDir, "task_comment", { await auditLog(workspaceDir, "task_comment", {
project: project.name, project: project.name, groupId, issueId,
groupId,
issueId,
authorRole: authorRole ?? null, authorRole: authorRole ?? null,
bodyPreview: body.slice(0, 100) + (body.length > 100 ? "..." : ""), bodyPreview: body.slice(0, 100) + (body.length > 100 ? "..." : ""),
provider: providerType, provider: providerType,
}); });
// 7. Build response return jsonResult({
const result = { success: true, issueId, issueTitle: issue.title, issueUrl: issue.web_url,
success: true, commentAdded: true, authorRole: authorRole ?? null, bodyLength: body.length,
issueId, project: project.name, provider: providerType,
issueTitle: issue.title,
issueUrl: issue.web_url,
commentAdded: true,
authorRole: authorRole ?? null,
bodyLength: body.length,
project: project.name,
provider: providerType,
announcement: `💬 Comment added to #${issueId}${authorRole ? ` by ${authorRole.toUpperCase()}` : ""}`, announcement: `💬 Comment added to #${issueId}${authorRole ? ` by ${authorRole.toUpperCase()}` : ""}`,
}; });
return jsonResult(result);
}, },
}); });
} }
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
const ROLE_EMOJI: Record<AuthorRole, string> = {
dev: "👨‍💻",
qa: "🔍",
orchestrator: "🎛️",
};

View File

@@ -12,21 +12,9 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { readProjects } from "../projects.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import type { StateLabel } from "../providers/provider.js"; import { STATE_LABELS, type StateLabel } from "../providers/provider.js";
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
const STATE_LABELS: StateLabel[] = [
"Planning",
"To Do",
"Doing",
"To Test",
"Testing",
"Done",
"To Improve",
"Refining",
];
export function createTaskCreateTool(api: OpenClawPluginApi) { export function createTaskCreateTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -80,69 +68,29 @@ The issue is created with a state label (defaults to "Planning"). Returns the cr
const label = (params.label as StateLabel) ?? "Planning"; const label = (params.label as StateLabel) ?? "Planning";
const assignees = (params.assignees as string[] | undefined) ?? []; const assignees = (params.assignees as string[] | undefined) ?? [];
const pickup = (params.pickup as boolean) ?? false; const pickup = (params.pickup as boolean) ?? false;
const workspaceDir = ctx.workspaceDir; const workspaceDir = requireWorkspaceDir(ctx);
if (!workspaceDir) { const { project } = await resolveProject(workspaceDir, groupId);
throw new Error("No workspace directory available in tool context"); const { provider, type: providerType } = resolveProvider(project);
}
// 1. Resolve project
const data = await readProjects(workspaceDir);
const project = data.projects[groupId];
if (!project) {
throw new Error(`Project not found for groupId ${groupId}. Run project_register first.`);
}
// 2. Create provider
const { provider, type: providerType } = createProvider({
repo: project.repo,
});
// 3. Create the issue
const issue = await provider.createIssue(title, description, label, assignees); const issue = await provider.createIssue(title, description, label, assignees);
// 4. Audit log
await auditLog(workspaceDir, "task_create", { await auditLog(workspaceDir, "task_create", {
project: project.name, project: project.name, groupId, issueId: issue.iid,
groupId, title, label, provider: providerType, pickup,
issueId: issue.iid,
title,
label,
provider: providerType,
pickup,
}); });
// 5. Build response
const hasBody = description && description.trim().length > 0; const hasBody = description && description.trim().length > 0;
// Build announcement with URL
let announcement = `📋 Created #${issue.iid}: "${title}" (${label})`; let announcement = `📋 Created #${issue.iid}: "${title}" (${label})`;
if (hasBody) { if (hasBody) announcement += "\nWith detailed description.";
announcement += "\nWith detailed description.";
}
announcement += `\n🔗 ${issue.web_url}`; announcement += `\n🔗 ${issue.web_url}`;
if (pickup) { announcement += pickup ? "\nPicking up for DEV..." : "\nReady for pickup when needed.";
announcement += "\nPicking up for DEV...";
} else {
announcement += "\nReady for pickup when needed.";
}
const result = { return jsonResult({
success: true, success: true,
issue: { issue: { id: issue.iid, title: issue.title, body: hasBody ? description : null, url: issue.web_url, label },
id: issue.iid, project: project.name, provider: providerType, pickup, announcement,
title: issue.title, });
body: hasBody ? description : null,
url: issue.web_url,
label,
},
project: project.name,
provider: providerType,
pickup,
announcement,
};
return jsonResult(result);
}, },
}); });
} }

View File

@@ -9,21 +9,9 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { readProjects } from "../projects.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import type { StateLabel } from "../providers/provider.js"; import { STATE_LABELS, type StateLabel } from "../providers/provider.js";
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
const STATE_LABELS: StateLabel[] = [
"Planning",
"To Do",
"Doing",
"To Test",
"Testing",
"Done",
"To Improve",
"Refining",
];
export function createTaskUpdateTool(api: OpenClawPluginApi) { export function createTaskUpdateTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -69,76 +57,40 @@ Examples:
const issueId = params.issueId as number; const issueId = params.issueId as number;
const newState = params.state as StateLabel; const newState = params.state as StateLabel;
const reason = (params.reason as string) ?? undefined; const reason = (params.reason as string) ?? undefined;
const workspaceDir = ctx.workspaceDir; const workspaceDir = requireWorkspaceDir(ctx);
if (!workspaceDir) { const { project } = await resolveProject(workspaceDir, groupId);
throw new Error("No workspace directory available in tool context"); const { provider, type: providerType } = resolveProvider(project);
}
// 1. Resolve project
const data = await readProjects(workspaceDir);
const project = data.projects[groupId];
if (!project) {
throw new Error(
`Project not found for groupId ${groupId}. Run project_register first.`,
);
}
// 2. Create provider
const { provider, type: providerType } = createProvider({
repo: project.repo,
});
// 3. Fetch current issue to get current state
const issue = await provider.getIssue(issueId); const issue = await provider.getIssue(issueId);
const currentState = provider.getCurrentStateLabel(issue); const currentState = provider.getCurrentStateLabel(issue);
if (!currentState) { if (!currentState) {
throw new Error( throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform transition.`);
`Issue #${issueId} has no recognized state label. Cannot perform transition.`,
);
} }
if (currentState === newState) { if (currentState === newState) {
return jsonResult({ return jsonResult({
success: true, success: true, issueId, state: newState, changed: false,
issueId,
state: newState,
changed: false,
message: `Issue #${issueId} is already in state "${newState}".`, message: `Issue #${issueId} is already in state "${newState}".`,
project: project.name, project: project.name, provider: providerType,
provider: providerType,
}); });
} }
// 4. Perform the transition
await provider.transitionLabel(issueId, currentState, newState); await provider.transitionLabel(issueId, currentState, newState);
// 5. Audit log
await auditLog(workspaceDir, "task_update", { await auditLog(workspaceDir, "task_update", {
project: project.name, project: project.name, groupId, issueId,
groupId, fromState: currentState, toState: newState,
issueId, reason: reason ?? null, provider: providerType,
fromState: currentState,
toState: newState,
reason: reason ?? null,
provider: providerType,
}); });
// 6. Build response return jsonResult({
const result = { success: true, issueId, issueTitle: issue.title,
success: true, state: newState, changed: true,
issueId,
issueTitle: issue.title,
state: newState,
changed: true,
labelTransition: `${currentState}${newState}`, labelTransition: `${currentState}${newState}`,
project: project.name, project: project.name, provider: providerType,
provider: providerType,
announcement: `🔄 Updated #${issueId}: "${currentState}" → "${newState}"${reason ? ` (${reason})` : ""}`, announcement: `🔄 Updated #${issueId}: "${currentState}" → "${newState}"${reason ? ` (${reason})` : ""}`,
}; });
return jsonResult(result);
}, },
}); });
} }

View File

@@ -7,12 +7,12 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import { readProjects, getProject, getWorker, resolveRepoPath } from "../projects.js"; import { getWorker, resolveRepoPath } from "../projects.js";
import { createProvider } from "../providers/index.js";
import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js"; import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js";
import { projectTick, type TickResult } from "../services/tick.js"; import { projectTick, type TickResult } from "../services/tick.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { notify, getNotificationConfig } from "../notify.js"; import { notify, getNotificationConfig } from "../notify.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
export function createWorkFinishTool(api: OpenClawPluginApi) { export function createWorkFinishTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -37,9 +37,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
const groupId = params.projectGroupId as string; const groupId = params.projectGroupId as string;
const summary = params.summary as string | undefined; const summary = params.summary as string | undefined;
const prUrl = params.prUrl as string | undefined; const prUrl = params.prUrl as string | undefined;
const workspaceDir = ctx.workspaceDir; const workspaceDir = requireWorkspaceDir(ctx);
if (!workspaceDir) throw new Error("No workspace directory available");
// Validate role:result // Validate role:result
if (role === "dev" && result !== "done" && result !== "blocked") if (role === "dev" && result !== "done" && result !== "blocked")
@@ -50,17 +48,14 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
throw new Error(`Invalid completion: ${role}:${result}`); throw new Error(`Invalid completion: ${role}:${result}`);
// Resolve project + worker // Resolve project + worker
const data = await readProjects(workspaceDir); const { project } = await resolveProject(workspaceDir, groupId);
const project = getProject(data, groupId);
if (!project) throw new Error(`Project not found for groupId: ${groupId}`);
const worker = getWorker(project, role); const worker = getWorker(project, role);
if (!worker.active) throw new Error(`${role.toUpperCase()} worker not active on ${project.name}`); if (!worker.active) throw new Error(`${role.toUpperCase()} worker not active on ${project.name}`);
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 for active ${role.toUpperCase()} on ${project.name}`); if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`);
const { provider } = createProvider({ repo: project.repo }); const { provider } = resolveProvider(project);
const repoPath = resolveRepoPath(project.repo); const repoPath = resolveRepoPath(project.repo);
// Execute completion (pipeline service) // Execute completion (pipeline service)
@@ -74,7 +69,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
}; };
// Tick: fill free slots after completion // Tick: fill free slots after completion
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined; const pluginConfig = getPluginConfig(api);
let tickResult: TickResult | null = null; let tickResult: TickResult | null = null;
try { try {
tickResult = await projectTick({ tickResult = await projectTick({

View File

@@ -2,20 +2,19 @@
* work_start — Pick up a task from the issue queue. * work_start — Pick up a task from the issue queue.
* *
* Context-aware: ONLY works in project group chats. * Context-aware: ONLY works in project group chats.
* Auto-detects: projectGroupId, role, model, issueId. * Auto-detects: projectGroupId, role, tier, issueId.
* After dispatch, ticks the project queue to fill parallel slots. * After dispatch, ticks the project queue to fill parallel slots.
*/ */
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js"; import type { ToolContext } from "../types.js";
import type { StateLabel } from "../providers/provider.js"; import type { StateLabel } from "../providers/provider.js";
import { createProvider } from "../providers/index.js"; import { selectTier } from "../model-selector.js";
import { selectModel } from "../model-selector.js"; import { getWorker } from "../projects.js";
import { activateWorker, getProject, getWorker, readProjects } from "../projects.js";
import { dispatchTask } from "../dispatch.js"; import { dispatchTask } from "../dispatch.js";
import { detectContext, generateGuardrails } from "../context-guard.js";
import { notify, getNotificationConfig } from "../notify.js"; import { notify, getNotificationConfig } from "../notify.js";
import { findNextIssue, detectRoleFromLabel, detectTierFromLabels, projectTick, type TickResult } from "../services/tick.js"; import { findNextIssue, detectRoleFromLabel, detectTierFromLabels, projectTick, type TickResult } from "../services/tick.js";
import { requireWorkspaceDir, resolveContext, resolveProject, resolveProvider, groupOnlyError, getPluginConfig } from "../tool-helpers.js";
export function createWorkStartTool(api: OpenClawPluginApi) { export function createWorkStartTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({ return (ctx: ToolContext) => ({
@@ -28,7 +27,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." }, issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
role: { type: "string", enum: ["dev", "qa"], description: "Worker role. Auto-detected from label if omitted." }, role: { type: "string", enum: ["dev", "qa"], description: "Worker role. Auto-detected from label if omitted." },
projectGroupId: { type: "string", description: "Project group ID. Auto-detected from group context." }, projectGroupId: { type: "string", description: "Project group ID. Auto-detected from group context." },
model: { type: "string", description: "Developer tier (junior/medior/senior/qa). Auto-detected if omitted." }, tier: { type: "string", description: "Developer tier (junior/medior/senior/qa). Auto-detected if omitted." },
}, },
}, },
@@ -36,28 +35,16 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
const issueIdParam = params.issueId as number | undefined; const issueIdParam = params.issueId as number | undefined;
const roleParam = params.role as "dev" | "qa" | undefined; const roleParam = params.role as "dev" | "qa" | undefined;
const groupIdParam = params.projectGroupId as string | undefined; const groupIdParam = params.projectGroupId as string | undefined;
const modelParam = params.model as string | undefined; const tierParam = params.tier as string | undefined;
const workspaceDir = ctx.workspaceDir; const workspaceDir = requireWorkspaceDir(ctx);
if (!workspaceDir) throw new Error("No workspace directory available");
// Context guard: group only // Context guard: group only
const devClawAgentIds = ((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as string[] | undefined) ?? []; const context = await resolveContext(ctx, api);
const context = await detectContext(ctx, devClawAgentIds); if (context.type !== "group") return groupOnlyError("work_start", context);
if (context.type !== "group") {
return jsonResult({
success: false,
error: "work_start can only be used in project group chats.",
recommendation: context.type === "via-agent" ? "Use onboard instead for setup." : "Use the relevant project group.",
contextGuidance: generateGuardrails(context),
});
}
const groupId = groupIdParam ?? context.groupId; const groupId = groupIdParam ?? context.groupId;
const data = await readProjects(workspaceDir); const { project } = await resolveProject(workspaceDir, groupId);
const project = getProject(data, groupId); const { provider } = resolveProvider(project);
if (!project) throw new Error(`Project not found for groupId: ${groupId}`);
const { provider } = createProvider({ repo: project.repo });
// Find issue // Find issue
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string }; let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string };
@@ -88,47 +75,37 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`); if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`);
} }
// Select model // Select tier
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
let modelAlias: string, modelReason: string, modelSource: string; let selectedTier: string, tierReason: string, tierSource: string;
if (modelParam) { if (tierParam) {
modelAlias = modelParam; modelReason = "LLM-selected"; modelSource = "llm"; selectedTier = tierParam; tierReason = "LLM-selected"; tierSource = "llm";
} else { } else {
const tier = detectTierFromLabels(issue.labels); const labelTier = detectTierFromLabels(issue.labels);
if (tier) { if (labelTier) {
if (role === "qa" && tier !== "qa") { modelAlias = "qa"; modelReason = `QA overrides "${tier}"`; modelSource = "role-override"; } if (role === "qa" && labelTier !== "qa") { selectedTier = "qa"; tierReason = `QA overrides "${labelTier}"`; tierSource = "role-override"; }
else if (role === "dev" && tier === "qa") { const s = selectModel(issue.title, issue.description ?? "", role); modelAlias = s.tier; modelReason = s.reason; modelSource = "heuristic"; } else if (role === "dev" && labelTier === "qa") { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; }
else { modelAlias = tier; modelReason = `Label: "${tier}"`; modelSource = "label"; } else { selectedTier = labelTier; tierReason = `Label: "${labelTier}"`; tierSource = "label"; }
} else { } else {
const s = selectModel(issue.title, issue.description ?? "", role); const s = selectTier(issue.title, issue.description ?? "", role);
modelAlias = s.tier; modelReason = s.reason; modelSource = "heuristic"; selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic";
} }
} }
// Dispatch // Dispatch
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined; const pluginConfig = getPluginConfig(api);
const dr = await dispatchTask({ const dr = await dispatchTask({
workspaceDir, agentId: ctx.agentId, groupId, project, issueId: issue.iid, workspaceDir, agentId: ctx.agentId, groupId, project, issueId: issue.iid,
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url, issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
role, modelAlias, fromLabel: currentLabel, toLabel: targetLabel, role, tier: selectedTier, fromLabel: currentLabel, toLabel: targetLabel,
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel), transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
pluginConfig, sessionKey: ctx.sessionKey, pluginConfig, sessionKey: ctx.sessionKey,
}); });
// Ensure worker state
const stateUpdate: { issueId: string; model: string; sessionKey?: string; startTime?: string } = {
issueId: String(issue.iid), model: modelAlias,
};
if (dr.sessionAction === "spawn") {
stateUpdate.sessionKey = dr.sessionKey;
stateUpdate.startTime = new Date().toISOString();
}
await activateWorker(workspaceDir, groupId, role, stateUpdate);
// Notify // Notify
const notifyConfig = getNotificationConfig(pluginConfig); const notifyConfig = getNotificationConfig(pluginConfig);
await notify( await notify(
{ type: "workerStart", project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, model: dr.modelAlias, sessionAction: dr.sessionAction }, { type: "workerStart", project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, tier: dr.tier, sessionAction: dr.sessionAction },
{ workspaceDir, config: notifyConfig, groupId, channel: context.channel }, { workspaceDir, config: notifyConfig, groupId, channel: context.channel },
); );
@@ -143,10 +120,10 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
const output: Record<string, unknown> = { const output: Record<string, unknown> = {
success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,
role, model: dr.modelAlias, fullModel: dr.fullModel, sessionAction: dr.sessionAction, role, tier: dr.tier, model: dr.model, sessionAction: dr.sessionAction,
announcement: dr.announcement, labelTransition: `${currentLabel}${targetLabel}`, announcement: dr.announcement, labelTransition: `${currentLabel}${targetLabel}`,
modelReason, modelSource, tierReason, tierSource,
autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, model: !modelParam }, autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, tier: !tierParam },
}; };
if (tickResult?.pickups.length) output.tickPickups = tickResult.pickups; if (tickResult?.pickups.length) output.tickPickups = tickResult.pickups;