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:
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
322
lib/dispatch.ts
322
lib/dispatch.ts
@@ -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}".`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Audit
|
await auditDispatch(workspaceDir, {
|
||||||
await auditLog(workspaceDir, "work_start", {
|
project: project.name, groupId, issueId, issueTitle,
|
||||||
project: project.name,
|
role, tier, model, sessionAction, sessionKey: sessionKey!,
|
||||||
groupId,
|
fromLabel, toLabel,
|
||||||
issue: issueId,
|
|
||||||
issueTitle,
|
|
||||||
role,
|
|
||||||
tier: modelAlias,
|
|
||||||
sessionAction,
|
|
||||||
sessionKey,
|
|
||||||
labelTransition: `${fromLabel} → ${toLabel}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await auditLog(workspaceDir, "model_selection", {
|
const announcement = buildAnnouncement(tier, role, sessionAction, issueId, issueTitle, issueUrl);
|
||||||
issue: issueId,
|
|
||||||
role,
|
|
||||||
tier: modelAlias,
|
|
||||||
fullModel,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build announcement
|
return { sessionAction, sessionKey: sessionKey!, tier, model, announcement };
|
||||||
const emoji = isTier(modelAlias)
|
}
|
||||||
? TIER_EMOJI[modelAlias]
|
|
||||||
: role === "qa"
|
// ---------------------------------------------------------------------------
|
||||||
? "🔍"
|
// Private helpers — exist so dispatchTask reads as a sequence of steps
|
||||||
: "🔧";
|
// ---------------------------------------------------------------------------
|
||||||
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
|
|
||||||
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${modelAlias}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
|
async function loadRoleInstructions(
|
||||||
|
workspaceDir: string, projectName: string, role: "dev" | "qa",
|
||||||
return {
|
): Promise<string> {
|
||||||
sessionAction,
|
const projectFile = path.join(workspaceDir, "roles", projectName, `${role}.md`);
|
||||||
sessionKey: sessionKey!,
|
const defaultFile = path.join(workspaceDir, "roles", "default", `${role}.md`);
|
||||||
modelAlias,
|
try { return await fs.readFile(projectFile, "utf-8"); } catch { /* fallback */ }
|
||||||
fullModel,
|
try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* none */ }
|
||||||
announcement,
|
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,
|
||||||
|
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<void> {
|
||||||
|
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<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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
114
lib/projects.ts
114
lib/projects.ts
@@ -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("~/")) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
375
lib/setup.ts
375
lib/setup.ts
@@ -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
87
lib/setup/agent.ts
Normal 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
88
lib/setup/config.ts
Normal 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
120
lib/setup/index.ts
Normal 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
79
lib/setup/workspace.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
79
lib/tool-helpers.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 })) },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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: "🎛️",
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user