From 70af40e98602501d6e15d99ef34fd9e0e0ede3d1 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Tue, 10 Feb 2026 22:51:35 +0800 Subject: [PATCH] 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. --- lib/cli.ts | 2 +- lib/dispatch.ts | 330 ++++++++++++++------------------- lib/model-selector.ts | 6 +- lib/notify.ts | 4 +- lib/projects.ts | 118 ++++-------- lib/services/health.ts | 16 +- lib/services/tick.ts | 47 +++-- lib/setup.ts | 375 -------------------------------------- lib/setup/agent.ts | 87 +++++++++ lib/setup/config.ts | 88 +++++++++ lib/setup/index.ts | 120 ++++++++++++ lib/setup/workspace.ts | 79 ++++++++ lib/tiers.ts | 2 +- lib/tool-helpers.ts | 79 ++++++++ lib/tools/auto-pickup.ts | 13 +- lib/tools/setup.ts | 4 +- lib/tools/status.ts | 21 +-- lib/tools/task-comment.ts | 74 +++----- lib/tools/task-create.ts | 80 ++------ lib/tools/task-update.ts | 80 ++------ lib/tools/work-finish.ts | 17 +- lib/tools/work-start.ts | 79 +++----- 22 files changed, 768 insertions(+), 953 deletions(-) delete mode 100644 lib/setup.ts create mode 100644 lib/setup/agent.ts create mode 100644 lib/setup/config.ts create mode 100644 lib/setup/index.ts create mode 100644 lib/setup/workspace.ts create mode 100644 lib/tool-helpers.ts diff --git a/lib/cli.ts b/lib/cli.ts index 2709d2a..f7f0c5b 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -4,7 +4,7 @@ * Uses Commander.js (provided by OpenClaw plugin SDK context). */ 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"; /** diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 124f544..5df90a8 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -12,10 +12,10 @@ import { log as auditLog } from "./audit.js"; import { type Project, activateWorker, - getSessionForModel, + getSessionForTier, getWorker, } from "./projects.js"; -import { TIER_EMOJI, isTier, resolveModel } from "./tiers.js"; +import { TIER_EMOJI, isTier, resolveTierToModel } from "./tiers.js"; const execFileAsync = promisify(execFile); @@ -30,12 +30,12 @@ export type DispatchOpts = { issueUrl: string; role: "dev" | "qa"; /** 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") */ fromLabel: string; /** Label to transition TO (e.g. "Doing", "Testing") */ 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; /** Plugin config for model resolution */ pluginConfig?: Record; @@ -46,8 +46,8 @@ export type DispatchOpts = { export type DispatchResult = { sessionAction: "spawn" | "send"; sessionKey: string; - modelAlias: string; - fullModel: string; + tier: string; + model: string; announcement: string; }; @@ -69,43 +69,12 @@ export async function buildTaskMessage(opts: { groupId: string; }): Promise { const { - workspaceDir, - projectName, - role, - issueId, - issueTitle, - issueDescription, - issueUrl, - repo, - baseBranch, - groupId, + workspaceDir, projectName, role, issueId, issueTitle, + issueDescription, issueUrl, repo, baseBranch, groupId, } = opts; - // Read role-specific instructions - let roleInstructions = ""; - const projectRoleFile = path.join( - workspaceDir, - "roles", - projectName, - `${role}.md`, - ); - const defaultRoleFile = path.join( - workspaceDir, - "roles", - "default", - `${role}.md`, - ); - try { - roleInstructions = await fs.readFile(projectRoleFile, "utf-8"); - } catch { - try { - roleInstructions = await fs.readFile(defaultRoleFile, "utf-8"); - } catch { - // No role instructions — that's fine - } - } + const roleInstructions = await loadRoleInstructions(workspaceDir, projectName, role); - // Build available results based on role const availableResults = role === "dev" ? '"done" (completed successfully) or "blocked" (cannot complete, need help)' @@ -125,11 +94,8 @@ export async function buildTaskMessage(opts: { parts.push(``, `---`, ``, roleInstructions.trim()); } - // Mandatory completion contract parts.push( - ``, - `---`, - ``, + ``, `---`, ``, `## MANDATORY: Task Completion`, ``, `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, - * CLI dispatch, state update, and audit logging. + * Dispatch a task to a worker session. * - * Returns dispatch result on success. Throws on dispatch failure - * (with label rollback). Logs warning on state update failure - * (dispatch succeeded, session IS running). + * Flow: resolve model → build message → transition label → spawn/send session + * → update worker state → audit → build announcement. + * + * On dispatch failure: rolls back label transition. + * On state update failure after dispatch: logs warning (session IS running). */ export async function dispatchTask( opts: DispatchOpts, ): Promise { const { - workspaceDir, - agentId, - groupId, - project, - issueId, - issueTitle, - issueDescription, - issueUrl, - role, - modelAlias, - fromLabel, - toLabel, - transitionLabel, - pluginConfig, + workspaceDir, agentId, groupId, project, issueId, issueTitle, + issueDescription, issueUrl, role, tier, fromLabel, toLabel, + transitionLabel, pluginConfig, } = opts; - const fullModel = resolveModel(modelAlias, pluginConfig); + const model = resolveTierToModel(tier, pluginConfig); const worker = getWorker(project, role); - const existingSessionKey = getSessionForModel(worker, modelAlias); + const existingSessionKey = getSessionForTier(worker, tier); const sessionAction = existingSessionKey ? "send" : "spawn"; - // Build task message with role instructions const taskMessage = await buildTaskMessage({ - workspaceDir, - projectName: project.name, - role, - issueId, - issueTitle, - issueDescription, - issueUrl, - repo: project.repo, - baseBranch: project.baseBranch, - groupId, + workspaceDir, projectName: project.name, role, issueId, + issueTitle, issueDescription, issueUrl, + repo: project.repo, baseBranch: project.baseBranch, groupId, }); - // Transition label await transitionLabel(issueId, fromLabel, toLabel); - // Dispatch let sessionKey = existingSessionKey; let dispatched = false; try { - if (sessionAction === "spawn") { - sessionKey = `agent:${agentId ?? "unknown"}:subagent:${project.name}-${role}-${modelAlias}`; - await execFileAsync( - "openclaw", - [ - "gateway", - "call", - "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 } - : {}), + sessionKey = await ensureSession(sessionAction, sessionKey, { + agentId, projectName: project.name, role, tier, model, + }); + + await sendToAgent(sessionKey!, taskMessage, { + agentId, projectName: project.name, issueId, role, + orchestratorSessionKey: opts.sessionKey, }); - const child = spawn( - "openclaw", - [ - "gateway", - "call", - "agent", - "--params", - gatewayParams, - "--expect-final", - "--json", - ], - { detached: true, stdio: "ignore" }, - ); - child.unref(); dispatched = true; - // Update state - const now = new Date().toISOString(); - 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, - }); - } + await recordWorkerState(workspaceDir, groupId, role, { + issueId, tier, sessionKey: sessionKey!, sessionAction, + }); } catch (err) { if (dispatched) { - // State update failed but session IS running — log warning, don't rollback await auditLog(workspaceDir, "work_start", { - project: project.name, - groupId, - issue: issueId, - role, + project: project.name, groupId, issue: issueId, role, warning: "State update failed after successful dispatch", - error: (err as Error).message, - sessionKey, + error: (err as Error).message, sessionKey, }); - // Re-throw so caller knows state update failed throw new Error( `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 - } - throw new Error( - `Session dispatch failed: ${(err as Error).message}. Label reverted to "${fromLabel}".`, - ); } + try { await transitionLabel(issueId, toLabel, fromLabel); } catch { /* best-effort rollback */ } + throw new Error( + `Session dispatch failed: ${(err as Error).message}. Label reverted to "${fromLabel}".`, + ); } - // Audit - await auditLog(workspaceDir, "work_start", { - project: project.name, - groupId, - issue: issueId, - issueTitle, - role, - tier: modelAlias, - sessionAction, - sessionKey, - labelTransition: `${fromLabel} → ${toLabel}`, + await auditDispatch(workspaceDir, { + project: project.name, groupId, issueId, issueTitle, + role, tier, model, sessionAction, sessionKey: sessionKey!, + fromLabel, toLabel, }); - await auditLog(workspaceDir, "model_selection", { - issue: issueId, - role, - tier: modelAlias, - fullModel, - }); + const announcement = buildAnnouncement(tier, role, sessionAction, issueId, issueTitle, issueUrl); - // Build announcement - const emoji = isTier(modelAlias) - ? TIER_EMOJI[modelAlias] - : role === "qa" - ? "🔍" - : "🔧"; - const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending"; - const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${modelAlias}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`; - - return { - sessionAction, - sessionKey: sessionKey!, - modelAlias, - fullModel, - announcement, - }; + return { sessionAction, sessionKey: sessionKey!, tier, model, announcement }; +} + +// --------------------------------------------------------------------------- +// Private helpers — exist so dispatchTask reads as a sequence of steps +// --------------------------------------------------------------------------- + +async function loadRoleInstructions( + workspaceDir: string, projectName: string, role: "dev" | "qa", +): Promise { + const projectFile = path.join(workspaceDir, "roles", projectName, `${role}.md`); + 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 { + 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, + message: taskMessage, + deliver: false, + lane: "subagent", + ...(opts.orchestratorSessionKey ? { spawnedBy: opts.orchestratorSessionKey } : {}), + }); + const child = spawn( + "openclaw", + ["gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"], + { detached: true, stdio: "ignore" }, + ); + child.unref(); +} + +async function recordWorkerState( + workspaceDir: string, groupId: string, role: "dev" | "qa", + opts: { issueId: number; tier: string; sessionKey: string; sessionAction: "spawn" | "send" }, +): Promise { + const params: { issueId: string; tier: string; sessionKey?: string; startTime?: string } = { + issueId: String(opts.issueId), tier: opts.tier, + }; + if (opts.sessionAction === "spawn") { + params.sessionKey = opts.sessionKey; + params.startTime = new Date().toISOString(); + } + await activateWorker(workspaceDir, groupId, role, params); +} + +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 { + 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}`; } diff --git a/lib/model-selector.ts b/lib/model-selector.ts index 876a6a6..012f1e2 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -4,7 +4,7 @@ * Returns tier names (junior, medior, senior, qa) instead of model aliases. */ -export type ModelRecommendation = { +export type TierRecommendation = { tier: string; reason: string; }; @@ -47,11 +47,11 @@ const COMPLEX_KEYWORDS = [ * - senior: deep/architectural (system-wide refactoring, novel design) * - qa: all QA tasks (code inspection, validation, test runs) */ -export function selectModel( +export function selectTier( issueTitle: string, issueDescription: string, role: "dev" | "qa", -): ModelRecommendation { +): TierRecommendation { if (role === "qa") { return { tier: "qa", diff --git a/lib/notify.ts b/lib/notify.ts index 9e4f482..a01e628 100644 --- a/lib/notify.ts +++ b/lib/notify.ts @@ -33,7 +33,7 @@ export type NotifyEvent = issueTitle: string; issueUrl: string; role: "dev" | "qa"; - model: string; + tier: string; sessionAction: "spawn" | "send"; } | { @@ -67,7 +67,7 @@ function buildMessage(event: NotifyEvent): string { switch (event.type) { case "workerStart": { 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": { diff --git a/lib/projects.ts b/lib/projects.ts index 566a866..cb9c1ca 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -11,7 +11,7 @@ export type WorkerState = { active: boolean; issueId: string | null; startTime: string | null; - model: string | null; + tier: string | null; sessions: Record; }; @@ -39,20 +39,22 @@ export type ProjectsData = { /** * Migrate old WorkerState schema to current format. * - * Handles two migrations: + * Handles three migrations: * 1. Old sessionId field → sessions map (pre-sessions era) * 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): 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 if (!worker.sessions || typeof worker.sessions !== "object") { const sessionId = worker.sessionId as string | null; - const model = worker.model as string | null; const sessions: Record = {}; - if (sessionId && model) { - // Apply tier migration to the model key too - const tierKey = TIER_MIGRATION[model] ?? model; + if (sessionId && rawTier) { + const tierKey = TIER_MIGRATION[rawTier] ?? rawTier; sessions[tierKey] = sessionId; } @@ -60,7 +62,7 @@ function migrateWorkerState(worker: Record): WorkerState { active: worker.active as boolean, issueId: worker.issueId as string | null, startTime: worker.startTime as string | null, - model: model ? (TIER_MIGRATION[model] ?? model) : null, + tier: rawTier ? (TIER_MIGRATION[rawTier] ?? rawTier) : null, sessions, }; } @@ -75,17 +77,23 @@ function migrateWorkerState(worker: Record): WorkerState { const newKey = TIER_MIGRATION[key] ?? key; newSessions[newKey] = value; } - const model = worker.model as string | null; return { active: worker.active as boolean, issueId: worker.issueId 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, }; } - 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): WorkerState { */ export function emptyWorkerState(tiers: string[]): WorkerState { const sessions: Record = {}; - for (const tier of tiers) { - sessions[tier] = null; + for (const t of tiers) { + sessions[t] = null; } return { active: false, issueId: null, startTime: null, - model: null, + tier: null, sessions, }; } @@ -108,7 +116,7 @@ export function emptyWorkerState(tiers: string[]): WorkerState { /** * Get session key for a specific tier from a worker's sessions map. */ -export function getSessionForModel( +export function getSessionForTier( worker: WorkerState, tier: string, ): string | null { @@ -123,7 +131,6 @@ export async function readProjects(workspaceDir: string): Promise const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8"); const data = JSON.parse(raw) as ProjectsData; - // Migrate any old-schema or missing fields transparently for (const project of Object.values(data.projects)) { project.dev = project.dev ? migrateWorkerState(project.dev as unknown as Record) @@ -144,7 +151,6 @@ export async function writeProjects( data: ProjectsData, ): Promise { const filePath = projectsPath(workspaceDir); - // Write to temp file first, then rename for atomicity const tmpPath = filePath + ".tmp"; await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8"); await fs.rename(tmpPath, filePath); @@ -166,12 +172,7 @@ export function getWorker( /** * Update worker state for a project. Only provided fields are updated. - * This prevents accidentally nulling out fields that should be preserved. - * - * 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 + * Sessions are merged (not replaced) when both existing and new sessions are present. */ export async function updateWorker( workspaceDir: string, @@ -186,14 +187,11 @@ export async function updateWorker( } 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) { updates.sessions = { ...worker.sessions, ...updates.sessions }; } - - // Spread worker first, then updates - this preserves any fields not in updates + project[role] = { ...worker, ...updates }; await writeProjects(workspaceDir, data); @@ -202,22 +200,7 @@ export async function updateWorker( /** * Mark a worker as active with a new task. - * Sets active=true, issueId, model (tier). Stores session key in sessions[tier]. - * - * 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!) + * Stores session key in sessions[tier] when a new session is spawned. */ export async function activateWorker( workspaceDir: string, @@ -225,7 +208,7 @@ export async function activateWorker( role: "dev" | "qa", params: { issueId: string; - model: string; + tier: string; sessionKey?: string; startTime?: string; }, @@ -233,12 +216,10 @@ export async function activateWorker( const updates: Partial = { active: true, 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) { - updates.sessions = { [params.model]: params.sessionKey }; + updates.sessions = { [params.tier]: params.sessionKey }; } if (params.startTime !== undefined) { updates.startTime = params.startTime; @@ -248,58 +229,21 @@ export async function activateWorker( /** * Mark a worker as inactive after task completion. - * Clears issueId and active, PRESERVES sessions map, model, startTime for reuse. - * - * 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. + * Preserves sessions map and tier for reuse via updateWorker's spread. */ export async function deactivateWorker( workspaceDir: string, groupId: string, role: "dev" | "qa", ): Promise { - // Read current state to verify sessions will be preserved - 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, { + return updateWorker(workspaceDir, groupId, role, { active: false, 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). - * Uses os.homedir() for cross-platform home directory resolution. */ export function resolveRepoPath(repoField: string): string { if (repoField.startsWith("~/")) { diff --git a/lib/services/health.ts b/lib/services/health.ts index c5529c0..053bd82 100644 --- a/lib/services/health.ts +++ b/lib/services/health.ts @@ -6,7 +6,7 @@ */ import type { StateLabel } from "../providers/provider.js"; import { - getSessionForModel, + getSessionForTier, getWorker, updateWorker, type Project, @@ -19,7 +19,7 @@ export type HealthIssue = { groupId: string; role: "dev" | "qa"; message: string; - model?: string | null; + tier?: string | null; sessionKey?: string | null; hoursActive?: number; issueId?: string | null; @@ -46,7 +46,7 @@ export async function checkWorkerHealth(opts: { const { workspaceDir, groupId, project, role, activeSessions, autoFix, provider } = opts; const fixes: HealthFix[] = []; 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 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) { const fix: HealthFix = { issue: { type: "active_no_session", severity: "critical", project: project.name, groupId, role, - model: worker.model, - message: `${role.toUpperCase()} active but no session for model "${worker.model}"`, + tier: worker.tier, + message: `${role.toUpperCase()} active but no session for tier "${worker.tier}"`, }, fixed: false, }; @@ -86,7 +86,7 @@ export async function checkWorkerHealth(opts: { issue: { type: "zombie_session", severity: "critical", project: project.name, groupId, role, - sessionKey, model: worker.model, + sessionKey, tier: worker.tier, message: `${role.toUpperCase()} session not in active sessions list`, }, fixed: false, @@ -94,7 +94,7 @@ export async function checkWorkerHealth(opts: { if (autoFix) { await revertIssueLabel(fix); 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 }); fix.fixed = true; } diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 8d7facb..b4cf6bb 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -7,10 +7,10 @@ import type { Issue, StateLabel } from "../providers/provider.js"; import type { IssueProvider } from "../providers/provider.js"; import { createProvider } from "../providers/index.js"; -import { selectModel } from "../model-selector.js"; -import { getWorker, getSessionForModel, readProjects } from "../projects.js"; +import { selectTier } from "../model-selector.js"; +import { getWorker, getSessionForTier, readProjects } from "../projects.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) @@ -20,11 +20,9 @@ export const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"]; export const QA_LABELS: StateLabel[] = ["To Test"]; 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 { 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 { @@ -78,7 +76,7 @@ export type TickAction = { issueId: number; issueTitle: string; role: "dev" | "qa"; - model: string; + tier: string; sessionAction: "spawn" | "send"; announcement: string; }; @@ -145,21 +143,13 @@ export async function projectTick(opts: { const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; // Tier selection: label → heuristic - let modelAlias: string; - 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; - } + const selectedTier = resolveTierForIssue(issue, role); if (dryRun) { pickups.push({ project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, - role, model: modelAlias, - sessionAction: getSessionForModel(worker, modelAlias) ? "send" : "spawn", + role, tier: selectedTier, + sessionAction: getSessionForTier(worker, selectedTier) ? "send" : "spawn", announcement: `[DRY RUN] Would pick up #${issue.iid}`, }); } else { @@ -167,13 +157,13 @@ export async function projectTick(opts: { const dr = await dispatchTask({ workspaceDir, agentId, groupId, project: fresh, issueId: issue.iid, 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), pluginConfig, sessionKey, }); pickups.push({ 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) { skipped.push({ role, reason: `Dispatch failed: ${(err as Error).message}` }); @@ -185,3 +175,20 @@ export async function projectTick(opts: { 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; +} diff --git a/lib/setup.ts b/lib/setup.ts deleted file mode 100644 index 9f2c034..0000000 --- a/lib/setup.ts +++ /dev/null @@ -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>; - /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ - projectExecution?: "parallel" | "sequential"; -}; - -export type SetupResult = { - agentId: string; - agentCreated: boolean; - workspacePath: string; - models: Record; - 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 { - 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 { - 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, - agentId?: string, - projectExecution?: "parallel" | "sequential", -): Promise { - 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 { - 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 { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} diff --git a/lib/setup/agent.ts b/lib/setup/agent.ts new file mode 100644 index 0000000..1cfff58 --- /dev/null +++ b/lib/setup/agent.ts @@ -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 { + 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 { + // 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 { + 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}`); + } +} diff --git a/lib/setup/config.ts b/lib/setup/config.ts new file mode 100644 index 0000000..b6e9695 --- /dev/null +++ b/lib/setup/config.ts @@ -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, + agentId?: string, + projectExecution?: "parallel" | "sequential", +): Promise { + 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): void { + if (!config.plugins) config.plugins = {}; + const plugins = config.plugins as Record; + if (!plugins.entries) plugins.entries = {}; + const entries = plugins.entries as Record; + if (!entries.devclaw) entries.devclaw = {}; + const devclaw = entries.devclaw as Record; + if (!devclaw.config) devclaw.config = {}; +} + +function configureSubagentCleanup(config: Record): void { + if (!config.agents) config.agents = {}; + const agents = config.agents as Record; + if (!agents.defaults) agents.defaults = {}; + const defaults = agents.defaults as Record; + if (!defaults.subagents) defaults.subagents = {}; + (defaults.subagents as Record).archiveAfterMinutes = 43200; +} + +function addDevClawAgentId(config: Record, 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, 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; + } +} diff --git a/lib/setup/index.ts b/lib/setup/index.ts new file mode 100644 index 0000000..a174ede --- /dev/null +++ b/lib/setup/index.ts @@ -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>; + /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ + projectExecution?: "parallel" | "sequential"; +}; + +export type SetupResult = { + agentId: string; + agentCreated: boolean; + workspacePath: string; + models: Record; + 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 { + 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 { + 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 { + 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; +} diff --git a/lib/setup/workspace.ts b/lib/setup/workspace.ts new file mode 100644 index 0000000..36582c4 --- /dev/null +++ b/lib/setup/workspace.ts @@ -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 { + 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 { + 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 { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} diff --git a/lib/tiers.ts b/lib/tiers.ts index c05803c..aed6da6 100644 --- a/lib/tiers.ts +++ b/lib/tiers.ts @@ -46,7 +46,7 @@ export function isDevTier(value: string): value is DevTier { * 2. DEFAULT_MODELS (hardcoded defaults) * 3. Treat input as raw model ID (passthrough for non-tier values) */ -export function resolveModel( +export function resolveTierToModel( tier: string, pluginConfig?: Record, ): string { diff --git a/lib/tool-helpers.ts b/lib/tool-helpers.ts new file mode 100644 index 0000000..7bd29fb --- /dev/null +++ b/lib/tool-helpers.ts @@ -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 { + const devClawAgentIds = + ((api.pluginConfig as Record)?.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 | undefined { + return api.pluginConfig as Record | undefined; +} diff --git a/lib/tools/auto-pickup.ts b/lib/tools/auto-pickup.ts index 97ef7b4..e59435d 100644 --- a/lib/tools/auto-pickup.ts +++ b/lib/tools/auto-pickup.ts @@ -7,13 +7,12 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; -import { createProvider } from "../providers/index.js"; import { readProjects } from "../projects.js"; -import { detectContext } from "../context-guard.js"; import { log as auditLog } from "../audit.js"; import { notify, getNotificationConfig } from "../notify.js"; import { checkWorkerHealth, type HealthFix } from "../services/health.js"; import { projectTick, type TickAction } from "../services/tick.js"; +import { requireWorkspaceDir, resolveContext, resolveProvider, getPluginConfig } from "../tool-helpers.js"; type ExecutionMode = "parallel" | "sequential"; @@ -37,10 +36,9 @@ export function createAutoPickupTool(api: OpenClawPluginApi) { const dryRun = (params.dryRun as boolean) ?? false; const maxPickups = params.maxPickups as number | undefined; const activeSessions = (params.activeSessions as string[]) ?? []; - const workspaceDir = ctx.workspaceDir; - if (!workspaceDir) throw new Error("No workspace directory available"); + const workspaceDir = requireWorkspaceDir(ctx); - const pluginConfig = api.pluginConfig as Record | undefined; + const pluginConfig = getPluginConfig(api); const projectExecution: ExecutionMode = (pluginConfig?.projectExecution as ExecutionMode) ?? "parallel"; const data = await readProjects(workspaceDir); @@ -59,7 +57,7 @@ export function createAutoPickupTool(api: OpenClawPluginApi) { // Pass 1: health checks for (const [groupId, project] of projectEntries) { - const { provider } = createProvider({ repo: project.repo }); + const { provider } = resolveProvider(project); for (const role of ["dev", "qa"] as const) { const fixes = await checkWorkerHealth({ workspaceDir, groupId, project, role, activeSessions, autoFix: !dryRun, provider }); healthFixes.push(...fixes.map((f) => ({ ...f, project: project.name, role }))); @@ -105,8 +103,7 @@ export function createAutoPickupTool(api: OpenClawPluginApi) { }); // Notify - const devClawAgentIds = ((api.pluginConfig as Record)?.devClawAgentIds as string[] | undefined) ?? []; - const context = await detectContext(ctx, devClawAgentIds); + const context = await resolveContext(ctx, api); const notifyConfig = getNotificationConfig(pluginConfig); 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 })) }, diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index 4aa359d..ee9e3af 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -2,12 +2,12 @@ * setup — Agent-driven DevClaw setup. * * 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 { jsonResult } from "openclaw/plugin-sdk"; 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"; export function createSetupTool(api: OpenClawPluginApi) { diff --git a/lib/tools/status.ts b/lib/tools/status.ts index 489f044..e9d98b1 100644 --- a/lib/tools/status.ts +++ b/lib/tools/status.ts @@ -8,14 +8,14 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.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 { checkWorkerHealth } from "../services/health.js"; import { fetchProjectQueues, buildParallelProjectSequences, buildGlobalTaskSequence, formatProjectQueues, type ProjectQueues, type ProjectExecutionConfig, } from "../services/queue.js"; -import { createProvider } from "../providers/index.js"; +import { requireWorkspaceDir, resolveContext, resolveProvider, getPluginConfig } from "../tool-helpers.js"; export function createStatusTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -32,16 +32,11 @@ export function createStatusTool(api: OpenClawPluginApi) { }, async execute(_id: string, params: Record) { - const workspaceDir = ctx.workspaceDir; - if (!workspaceDir) throw new Error("No workspace directory available"); - + const workspaceDir = requireWorkspaceDir(ctx); const includeHealth = (params.includeHealth as boolean) ?? true; const activeSessions = (params.activeSessions as string[]) ?? []; - // Context detection - const devClawAgentIds = ((api.pluginConfig as Record)?.devClawAgentIds as string[] | undefined) ?? []; - const context = await detectContext(ctx, devClawAgentIds); - + const context = await resolveContext(ctx, api); if (context.type === "via-agent") { return jsonResult({ success: false, @@ -55,7 +50,7 @@ export function createStatusTool(api: OpenClawPluginApi) { let groupId = params.projectGroupId as string | undefined; if (context.type === "group" && !groupId) groupId = context.groupId; - const pluginConfig = api.pluginConfig as Record | undefined; + const pluginConfig = getPluginConfig(api); const projectExecution = (pluginConfig?.projectExecution as "parallel" | "sequential") ?? "parallel"; const data = await readProjects(workspaceDir); @@ -81,7 +76,7 @@ export function createStatusTool(api: OpenClawPluginApi) { const healthIssues: Array> = []; if (includeHealth) { for (const { id, project } of projectList) { - const { provider } = createProvider({ repo: project.repo }); + const { provider } = resolveProvider(project); for (const role of ["dev", "qa"] as const) { const fixes = await checkWorkerHealth({ workspaceDir, groupId: id, project, role, activeSessions, @@ -108,8 +103,8 @@ export function createStatusTool(api: OpenClawPluginApi) { // Build project details const projects = projectQueues.map(({ projectId, project, queues }) => ({ name: project.name, groupId: projectId, - dev: { active: project.dev.active, issueId: project.dev.issueId, model: project.dev.model, sessions: project.dev.sessions }, - qa: { active: project.qa.active, issueId: project.qa.issueId, model: project.qa.model, sessions: project.qa.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, tier: project.qa.tier, sessions: project.qa.sessions }, queue: formatProjectQueues(queues), })); diff --git a/lib/tools/task-comment.ts b/lib/tools/task-comment.ts index 391321f..715e6c1 100644 --- a/lib/tools/task-comment.ts +++ b/lib/tools/task-comment.ts @@ -9,9 +9,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; -import { readProjects } from "../projects.js"; -import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; +import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; /** Valid author roles for attribution */ const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const; @@ -62,73 +61,46 @@ Examples: const issueId = params.issueId as number; const body = params.body as string; 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) { throw new Error("Comment body cannot be empty."); } - // 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.`, - ); - } + const { project } = await resolveProject(workspaceDir, groupId); + const { provider, type: providerType } = resolveProvider(project); - // 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); - // 4. Prepare comment body with optional attribution header - let commentBody = body; - if (authorRole) { - const roleEmoji: Record = { - dev: "👨‍💻", - qa: "🔍", - orchestrator: "🎛️", - }; - commentBody = `${roleEmoji[authorRole]} **${authorRole.toUpperCase()}**: ${body}`; - } + const commentBody = authorRole + ? `${ROLE_EMOJI[authorRole]} **${authorRole.toUpperCase()}**: ${body}` + : body; - // 5. Add the comment await provider.addComment(issueId, commentBody); - // 6. Audit log await auditLog(workspaceDir, "task_comment", { - project: project.name, - groupId, - issueId, + project: project.name, groupId, issueId, authorRole: authorRole ?? null, bodyPreview: body.slice(0, 100) + (body.length > 100 ? "..." : ""), provider: providerType, }); - // 7. Build response - const result = { - success: true, - issueId, - issueTitle: issue.title, - issueUrl: issue.web_url, - commentAdded: true, - authorRole: authorRole ?? null, - bodyLength: body.length, - project: project.name, - provider: providerType, + return jsonResult({ + success: true, issueId, 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()}` : ""}`, - }; - - return jsonResult(result); + }); }, }); } + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +const ROLE_EMOJI: Record = { + dev: "👨‍💻", + qa: "🔍", + orchestrator: "🎛️", +}; diff --git a/lib/tools/task-create.ts b/lib/tools/task-create.ts index 30579ae..b7fdba8 100644 --- a/lib/tools/task-create.ts +++ b/lib/tools/task-create.ts @@ -12,21 +12,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; -import { readProjects } from "../projects.js"; -import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; -import type { StateLabel } from "../providers/provider.js"; - -const STATE_LABELS: StateLabel[] = [ - "Planning", - "To Do", - "Doing", - "To Test", - "Testing", - "Done", - "To Improve", - "Refining", -]; +import { STATE_LABELS, type StateLabel } from "../providers/provider.js"; +import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; export function createTaskCreateTool(api: OpenClawPluginApi) { 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 assignees = (params.assignees as string[] | undefined) ?? []; const pickup = (params.pickup as boolean) ?? false; - const workspaceDir = ctx.workspaceDir; + const workspaceDir = requireWorkspaceDir(ctx); - if (!workspaceDir) { - throw new Error("No workspace directory available in tool context"); - } + const { project } = await resolveProject(workspaceDir, groupId); + 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); - // 4. Audit log await auditLog(workspaceDir, "task_create", { - project: project.name, - groupId, - issueId: issue.iid, - title, - label, - provider: providerType, - pickup, + project: project.name, groupId, issueId: issue.iid, + title, label, provider: providerType, pickup, }); - // 5. Build response const hasBody = description && description.trim().length > 0; - - // Build announcement with URL let announcement = `📋 Created #${issue.iid}: "${title}" (${label})`; - if (hasBody) { - announcement += "\nWith detailed description."; - } + if (hasBody) announcement += "\nWith detailed description."; announcement += `\n🔗 ${issue.web_url}`; - if (pickup) { - announcement += "\nPicking up for DEV..."; - } else { - announcement += "\nReady for pickup when needed."; - } - - const result = { - success: true, - issue: { - id: issue.iid, - title: issue.title, - body: hasBody ? description : null, - url: issue.web_url, - label, - }, - project: project.name, - provider: providerType, - pickup, - announcement, - }; + announcement += pickup ? "\nPicking up for DEV..." : "\nReady for pickup when needed."; - return jsonResult(result); + return jsonResult({ + success: true, + issue: { id: issue.iid, title: issue.title, body: hasBody ? description : null, url: issue.web_url, label }, + project: project.name, provider: providerType, pickup, announcement, + }); }, }); } diff --git a/lib/tools/task-update.ts b/lib/tools/task-update.ts index d1870d7..235e45f 100644 --- a/lib/tools/task-update.ts +++ b/lib/tools/task-update.ts @@ -9,21 +9,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; -import { readProjects } from "../projects.js"; -import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; -import type { StateLabel } from "../providers/provider.js"; - -const STATE_LABELS: StateLabel[] = [ - "Planning", - "To Do", - "Doing", - "To Test", - "Testing", - "Done", - "To Improve", - "Refining", -]; +import { STATE_LABELS, type StateLabel } from "../providers/provider.js"; +import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js"; export function createTaskUpdateTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -69,76 +57,40 @@ Examples: const issueId = params.issueId as number; const newState = params.state as StateLabel; const reason = (params.reason as string) ?? undefined; - const workspaceDir = ctx.workspaceDir; + const workspaceDir = requireWorkspaceDir(ctx); - if (!workspaceDir) { - throw new Error("No workspace directory available in tool context"); - } + const { project } = await resolveProject(workspaceDir, groupId); + 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 currentState = provider.getCurrentStateLabel(issue); - if (!currentState) { - throw new Error( - `Issue #${issueId} has no recognized state label. Cannot perform transition.`, - ); + throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform transition.`); } if (currentState === newState) { return jsonResult({ - success: true, - issueId, - state: newState, - changed: false, + success: true, issueId, state: newState, changed: false, message: `Issue #${issueId} is already in state "${newState}".`, - project: project.name, - provider: providerType, + project: project.name, provider: providerType, }); } - // 4. Perform the transition await provider.transitionLabel(issueId, currentState, newState); - // 5. Audit log await auditLog(workspaceDir, "task_update", { - project: project.name, - groupId, - issueId, - fromState: currentState, - toState: newState, - reason: reason ?? null, - provider: providerType, + project: project.name, groupId, issueId, + fromState: currentState, toState: newState, + reason: reason ?? null, provider: providerType, }); - // 6. Build response - const result = { - success: true, - issueId, - issueTitle: issue.title, - state: newState, - changed: true, + return jsonResult({ + success: true, issueId, issueTitle: issue.title, + state: newState, changed: true, labelTransition: `${currentState} → ${newState}`, - project: project.name, - provider: providerType, + project: project.name, provider: providerType, announcement: `🔄 Updated #${issueId}: "${currentState}" → "${newState}"${reason ? ` (${reason})` : ""}`, - }; - - return jsonResult(result); + }); }, }); } diff --git a/lib/tools/work-finish.ts b/lib/tools/work-finish.ts index 5c768f8..b3858b9 100644 --- a/lib/tools/work-finish.ts +++ b/lib/tools/work-finish.ts @@ -7,12 +7,12 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; -import { readProjects, getProject, getWorker, resolveRepoPath } from "../projects.js"; -import { createProvider } from "../providers/index.js"; +import { getWorker, resolveRepoPath } from "../projects.js"; import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js"; import { projectTick, type TickResult } from "../services/tick.js"; import { log as auditLog } from "../audit.js"; import { notify, getNotificationConfig } from "../notify.js"; +import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; export function createWorkFinishTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -37,9 +37,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { const groupId = params.projectGroupId as string; const summary = params.summary as string | undefined; const prUrl = params.prUrl as string | undefined; - const workspaceDir = ctx.workspaceDir; - - if (!workspaceDir) throw new Error("No workspace directory available"); + const workspaceDir = requireWorkspaceDir(ctx); // Validate role:result if (role === "dev" && result !== "done" && result !== "blocked") @@ -50,17 +48,14 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { throw new Error(`Invalid completion: ${role}:${result}`); // Resolve project + worker - const data = await readProjects(workspaceDir); - const project = getProject(data, groupId); - if (!project) throw new Error(`Project not found for groupId: ${groupId}`); - + const { project } = await resolveProject(workspaceDir, groupId); const worker = getWorker(project, role); if (!worker.active) throw new Error(`${role.toUpperCase()} worker not active on ${project.name}`); const issueId = worker.issueId ? Number(worker.issueId.split(",")[0]) : null; 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); // Execute completion (pipeline service) @@ -74,7 +69,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) { }; // Tick: fill free slots after completion - const pluginConfig = api.pluginConfig as Record | undefined; + const pluginConfig = getPluginConfig(api); let tickResult: TickResult | null = null; try { tickResult = await projectTick({ diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index 0e90729..f8c26c1 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -2,20 +2,19 @@ * work_start — Pick up a task from the issue queue. * * 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. */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import type { StateLabel } from "../providers/provider.js"; -import { createProvider } from "../providers/index.js"; -import { selectModel } from "../model-selector.js"; -import { activateWorker, getProject, getWorker, readProjects } from "../projects.js"; +import { selectTier } from "../model-selector.js"; +import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; -import { detectContext, generateGuardrails } from "../context-guard.js"; import { notify, getNotificationConfig } from "../notify.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) { return (ctx: ToolContext) => ({ @@ -28,7 +27,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { 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." }, 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 roleParam = params.role as "dev" | "qa" | undefined; const groupIdParam = params.projectGroupId as string | undefined; - const modelParam = params.model as string | undefined; - const workspaceDir = ctx.workspaceDir; - if (!workspaceDir) throw new Error("No workspace directory available"); + const tierParam = params.tier as string | undefined; + const workspaceDir = requireWorkspaceDir(ctx); // Context guard: group only - const devClawAgentIds = ((api.pluginConfig as Record)?.devClawAgentIds as string[] | undefined) ?? []; - const context = await detectContext(ctx, devClawAgentIds); - 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 context = await resolveContext(ctx, api); + if (context.type !== "group") return groupOnlyError("work_start", context); const groupId = groupIdParam ?? context.groupId; - const data = await readProjects(workspaceDir); - const project = getProject(data, groupId); - if (!project) throw new Error(`Project not found for groupId: ${groupId}`); - - const { provider } = createProvider({ repo: project.repo }); + const { project } = await resolveProject(workspaceDir, groupId); + const { provider } = resolveProvider(project); // Find issue 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`); } - // Select model + // Select tier const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; - let modelAlias: string, modelReason: string, modelSource: string; - if (modelParam) { - modelAlias = modelParam; modelReason = "LLM-selected"; modelSource = "llm"; + let selectedTier: string, tierReason: string, tierSource: string; + if (tierParam) { + selectedTier = tierParam; tierReason = "LLM-selected"; tierSource = "llm"; } else { - const tier = detectTierFromLabels(issue.labels); - if (tier) { - if (role === "qa" && tier !== "qa") { modelAlias = "qa"; modelReason = `QA overrides "${tier}"`; modelSource = "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 { modelAlias = tier; modelReason = `Label: "${tier}"`; modelSource = "label"; } + const labelTier = detectTierFromLabels(issue.labels); + if (labelTier) { + if (role === "qa" && labelTier !== "qa") { selectedTier = "qa"; tierReason = `QA overrides "${labelTier}"`; tierSource = "role-override"; } + else if (role === "dev" && labelTier === "qa") { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; } + else { selectedTier = labelTier; tierReason = `Label: "${labelTier}"`; tierSource = "label"; } } else { - const s = selectModel(issue.title, issue.description ?? "", role); - modelAlias = s.tier; modelReason = s.reason; modelSource = "heuristic"; + const s = selectTier(issue.title, issue.description ?? "", role); + selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; } } // Dispatch - const pluginConfig = api.pluginConfig as Record | undefined; + const pluginConfig = getPluginConfig(api); const dr = await dispatchTask({ workspaceDir, agentId: ctx.agentId, groupId, project, issueId: issue.iid, 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), 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 const notifyConfig = getNotificationConfig(pluginConfig); 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 }, ); @@ -143,10 +120,10 @@ export function createWorkStartTool(api: OpenClawPluginApi) { const output: Record = { 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}`, - modelReason, modelSource, - autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, model: !modelParam }, + tierReason, tierSource, + autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, tier: !tierParam }, }; if (tickResult?.pickups.length) output.tickPickups = tickResult.pickups;