feat: Implement context detection and onboarding tools for DevClaw
- Add context-guard.ts to detect interaction context (via-agent, direct, group) and generate guardrails. - Introduce onboarding.ts for conversational onboarding context templates and workspace file checks. - Enhance setup.ts to support new agent creation with channel binding and migration of existing bindings. - Create analyze-channel-bindings.ts to analyze channel availability and detect binding conflicts. - Implement context-test.ts for debugging context detection. - Develop devclaw_onboard.ts for explicit onboarding tool that guides users through setup. - Update devclaw_setup.ts to include channel binding and migration support in setup process. - Modify project-register.ts to enforce project registration from group context and auto-populate group ID. - Enhance queue-status.ts to provide context-aware status checks and recommendations. - Update task tools (task-complete, task-create, task-pickup) to clarify group ID usage for Telegram/WhatsApp.
This commit is contained in:
174
lib/binding-manager.ts
Normal file
174
lib/binding-manager.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* binding-manager.ts — Channel binding analysis and migration.
|
||||
*
|
||||
* Handles detection of existing channel bindings, channel availability,
|
||||
* and safe migration of bindings between agents.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type ChannelType = "telegram" | "whatsapp";
|
||||
|
||||
export interface BindingAnalysis {
|
||||
channelEnabled: boolean;
|
||||
channelConfigured: boolean;
|
||||
existingChannelWideBinding?: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
};
|
||||
groupSpecificBindings: Array<{
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
groupId: string;
|
||||
}>;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze the current state of channel bindings for a given channel.
|
||||
*/
|
||||
export async function analyzeChannelBindings(
|
||||
channel: ChannelType,
|
||||
): Promise<BindingAnalysis> {
|
||||
const configPath = path.join(
|
||||
process.env.HOME ?? "/home/lauren",
|
||||
".openclaw",
|
||||
"openclaw.json",
|
||||
);
|
||||
|
||||
const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
|
||||
|
||||
// Check if channel is configured and enabled
|
||||
const channelConfig = config.channels?.[channel];
|
||||
const channelConfigured = !!channelConfig;
|
||||
const channelEnabled = channelConfig?.enabled === true;
|
||||
|
||||
// Find existing bindings
|
||||
const bindings = config.bindings ?? [];
|
||||
let existingChannelWideBinding:
|
||||
| BindingAnalysis["existingChannelWideBinding"]
|
||||
| undefined;
|
||||
const groupSpecificBindings: BindingAnalysis["groupSpecificBindings"] = [];
|
||||
|
||||
for (const binding of bindings) {
|
||||
if (binding.match?.channel === channel) {
|
||||
const agent = config.agents?.list?.find(
|
||||
(a: { id: string }) => a.id === binding.agentId,
|
||||
);
|
||||
const agentName = agent?.name ?? binding.agentId;
|
||||
|
||||
if (!binding.match.peer) {
|
||||
// Channel-wide binding (no peer filter) - potential conflict
|
||||
existingChannelWideBinding = {
|
||||
agentId: binding.agentId,
|
||||
agentName,
|
||||
};
|
||||
} else if (binding.match.peer.kind === "group") {
|
||||
// Group-specific binding - no conflict
|
||||
groupSpecificBindings.push({
|
||||
agentId: binding.agentId,
|
||||
agentName,
|
||||
groupId: binding.match.peer.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate recommendation
|
||||
let recommendation: string;
|
||||
if (!channelConfigured) {
|
||||
recommendation = `⚠️ ${channel} is not configured in OpenClaw. Configure it first via the wizard or openclaw.json, then restart OpenClaw.`;
|
||||
} else if (!channelEnabled) {
|
||||
recommendation = `⚠️ ${channel} is configured but disabled. Enable it in openclaw.json (channels.${channel}.enabled: true) and restart OpenClaw.`;
|
||||
} else if (existingChannelWideBinding) {
|
||||
recommendation = `⚠️ Agent "${existingChannelWideBinding.agentName}" is already bound to all ${channel} messages. Options:\n 1. Migrate binding to the new agent (recommended if replacing)\n 2. Use group-specific binding instead (if you want both agents active)\n 3. Skip binding for now`;
|
||||
} else if (groupSpecificBindings.length > 0) {
|
||||
recommendation = `✅ ${groupSpecificBindings.length} group-specific binding(s) exist. No conflicts - safe to add channel-wide binding.`;
|
||||
} else {
|
||||
recommendation = `✅ No existing ${channel} bindings. Safe to bind the new agent.`;
|
||||
}
|
||||
|
||||
return {
|
||||
channelEnabled,
|
||||
channelConfigured,
|
||||
existingChannelWideBinding,
|
||||
groupSpecificBindings,
|
||||
recommendation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a channel-wide binding from one agent to another.
|
||||
*/
|
||||
export async function migrateChannelBinding(
|
||||
channel: ChannelType,
|
||||
fromAgentId: string,
|
||||
toAgentId: string,
|
||||
): Promise<void> {
|
||||
const configPath = path.join(
|
||||
process.env.HOME ?? "/home/lauren",
|
||||
".openclaw",
|
||||
"openclaw.json",
|
||||
);
|
||||
|
||||
const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
|
||||
const bindings = config.bindings ?? [];
|
||||
|
||||
// Find the channel-wide binding for this channel and agent
|
||||
const bindingIndex = bindings.findIndex(
|
||||
(b: {
|
||||
agentId: string;
|
||||
match?: { channel: string; peer?: unknown };
|
||||
}) =>
|
||||
b.match?.channel === channel &&
|
||||
!b.match.peer &&
|
||||
b.agentId === fromAgentId,
|
||||
);
|
||||
|
||||
if (bindingIndex === -1) {
|
||||
throw new Error(
|
||||
`No channel-wide ${channel} binding found for agent "${fromAgentId}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Update the binding to point to the new agent
|
||||
bindings[bindingIndex].agentId = toAgentId;
|
||||
config.bindings = bindings;
|
||||
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(config, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a channel-wide binding for a specific agent.
|
||||
*/
|
||||
export async function removeChannelBinding(
|
||||
channel: ChannelType,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const configPath = path.join(
|
||||
process.env.HOME ?? "/home/lauren",
|
||||
".openclaw",
|
||||
"openclaw.json",
|
||||
);
|
||||
|
||||
const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
|
||||
const bindings = config.bindings ?? [];
|
||||
|
||||
// Filter out the channel-wide binding for this channel and agent
|
||||
config.bindings = bindings.filter(
|
||||
(b: {
|
||||
agentId: string;
|
||||
match?: { channel: string; peer?: unknown };
|
||||
}) => !(b.match?.channel === channel && !b.match.peer && b.agentId === agentId),
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(config, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
166
lib/context-guard.ts
Normal file
166
lib/context-guard.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* context-guard.ts — Detect interaction context and provide guardrails.
|
||||
*
|
||||
* DevClaw should respond differently based on how it's being contacted:
|
||||
* 1. Via another agent (setup/onboarding) - guide to devclaw_onboard/devclaw_setup
|
||||
* 2. Direct to DevClaw agent (status queries) - use queue_status, session_health
|
||||
* 3. Via Telegram group (project work) - use task_pickup, task_complete, task_create
|
||||
*/
|
||||
import type { ToolContext } from "./types.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type InteractionContext =
|
||||
| { type: "via-agent"; agentId: string; agentName?: string }
|
||||
| { type: "direct"; channel?: "telegram" | "whatsapp" | "cli" }
|
||||
| {
|
||||
type: "group";
|
||||
channel: "telegram" | "whatsapp";
|
||||
groupId: string;
|
||||
projectName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect the interaction context from ToolContext.
|
||||
*
|
||||
* Logic:
|
||||
* - If agentId doesn't match a known DevClaw agent → via-agent
|
||||
* - If messageChannel + sessionKey contains group ID → group
|
||||
* - Otherwise → direct
|
||||
*/
|
||||
export async function detectContext(
|
||||
ctx: ToolContext,
|
||||
devClawAgentIds: string[],
|
||||
): Promise<InteractionContext> {
|
||||
const { agentId, messageChannel, sessionKey } = ctx;
|
||||
|
||||
// --- Via another agent (not DevClaw) ---
|
||||
if (agentId && !devClawAgentIds.includes(agentId)) {
|
||||
return {
|
||||
type: "via-agent",
|
||||
agentId,
|
||||
// agentName could be resolved from openclaw.json if needed
|
||||
};
|
||||
}
|
||||
|
||||
// --- Group chat (has messageChannel + group-like sessionKey) ---
|
||||
if (messageChannel && sessionKey) {
|
||||
// sessionKey format: "agent:{agentId}:{channel}:{type}:{groupId}"
|
||||
// Examples:
|
||||
// - Telegram: "agent:devclaw:telegram:group:-5266044536"
|
||||
// - WhatsApp: "agent:devclaw:whatsapp:group:120363123@g.us"
|
||||
const isGroupLike = sessionKey.includes(":group:");
|
||||
|
||||
if (isGroupLike) {
|
||||
// Extract the actual group ID (last component after splitting)
|
||||
const parts = sessionKey.split(":");
|
||||
const actualGroupId = parts[parts.length - 1];
|
||||
|
||||
// Try to match with a registered project
|
||||
const projectName = await findProjectByGroupId(
|
||||
actualGroupId,
|
||||
ctx.workspaceDir,
|
||||
);
|
||||
|
||||
return {
|
||||
type: "group",
|
||||
channel: messageChannel as "telegram" | "whatsapp",
|
||||
groupId: actualGroupId,
|
||||
projectName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- Direct (DM or CLI) ---
|
||||
return {
|
||||
type: "direct",
|
||||
channel: messageChannel
|
||||
? (messageChannel as "telegram" | "whatsapp")
|
||||
: "cli",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate guardrail guidance based on context.
|
||||
*
|
||||
* Returns a message to prepend to tool results or inject into system context.
|
||||
*/
|
||||
export function generateGuardrails(context: InteractionContext): string {
|
||||
switch (context.type) {
|
||||
case "via-agent":
|
||||
return `## 🛡️ Context: Setup Mode (via ${context.agentId})
|
||||
|
||||
You're being called by another agent. This is likely a **setup or onboarding** scenario.
|
||||
|
||||
**What you should do:**
|
||||
- If the user mentions "setup", "install", "configure", or "onboard" → call \`devclaw_onboard\` first
|
||||
- Then follow the guidance to call \`devclaw_setup\` with collected answers
|
||||
- After setup, offer to register a project via \`project_register\`
|
||||
|
||||
**What to avoid:**
|
||||
- Don't discuss ongoing development tasks (those happen in group chats)
|
||||
- Don't use task_pickup/task_complete/queue_status (not relevant during setup)
|
||||
`;
|
||||
|
||||
case "direct":
|
||||
return `## 🛡️ Context: Direct Communication (${context.channel})
|
||||
|
||||
You're in a **direct message** with the DevClaw agent (not a project group).
|
||||
|
||||
**What you should do:**
|
||||
- Provide **general status** via \`queue_status\` (across all projects)
|
||||
- Check system health via \`session_health\`
|
||||
- Answer questions about DevClaw configuration
|
||||
- Guide to project-specific work: "For project tasks, please message the relevant Telegram/WhatsApp group"
|
||||
|
||||
**What to avoid:**
|
||||
- Don't start development tasks here (use \`task_pickup\` only in project groups)
|
||||
- Don't discuss project-specific issues (redirect to the group)
|
||||
`;
|
||||
|
||||
case "group":
|
||||
return `## 🛡️ Context: Project Group Chat (${context.channel})
|
||||
|
||||
You're in a **Telegram/WhatsApp group** bound to ${context.projectName ? `project **${context.projectName}**` : "a project"}.
|
||||
|
||||
**What you should do:**
|
||||
- Handle task lifecycle: \`task_pickup\` (start work), \`task_complete\` (finish)
|
||||
- Create new issues via \`task_create\`
|
||||
- Check this project's queue via \`queue_status\` (with projectName filter)
|
||||
- Discuss implementation details, code reviews, bugs
|
||||
|
||||
**What to avoid:**
|
||||
- Don't discuss DevClaw setup (that's for direct DMs or via another agent)
|
||||
- Don't show status for unrelated projects (focus on this group's project)
|
||||
`;
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find project name by matching groupId in memory/projects.json.
|
||||
* The groupId (Telegram or WhatsApp) is the KEY in the projects Record.
|
||||
*/
|
||||
async function findProjectByGroupId(
|
||||
groupId: string,
|
||||
workspaceDir?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!workspaceDir) return undefined;
|
||||
|
||||
try {
|
||||
const projectsPath = path.join(workspaceDir, "memory", "projects.json");
|
||||
const raw = await fs.readFile(projectsPath, "utf-8");
|
||||
const data = JSON.parse(raw) as {
|
||||
projects: Record<string, { name: string }>;
|
||||
};
|
||||
|
||||
// groupId IS the key in the Record
|
||||
return data.projects[groupId]?.name;
|
||||
} catch {
|
||||
// File doesn't exist or parse error
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
122
lib/onboarding.ts
Normal file
122
lib/onboarding.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* onboarding.ts — Conversational onboarding context templates.
|
||||
*
|
||||
* Provides context templates for the devclaw_onboard tool.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isPluginConfigured(
|
||||
pluginConfig?: Record<string, unknown>,
|
||||
): boolean {
|
||||
const models = (pluginConfig as { models?: Record<string, string> })?.models;
|
||||
return !!models && Object.keys(models).length > 0;
|
||||
}
|
||||
|
||||
export async function hasWorkspaceFiles(
|
||||
workspaceDir?: string,
|
||||
): Promise<boolean> {
|
||||
if (!workspaceDir) return false;
|
||||
try {
|
||||
const content = await fs.readFile(
|
||||
path.join(workspaceDir, "AGENTS.md"),
|
||||
"utf-8",
|
||||
);
|
||||
return content.includes("DevClaw") && content.includes("task_pickup");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildModelTable(pluginConfig?: Record<string, unknown>): string {
|
||||
const models =
|
||||
(pluginConfig as { models?: Record<string, string> })?.models ?? {};
|
||||
return ALL_TIERS.map(
|
||||
(t) =>
|
||||
` - **${t}**: ${models[t] || DEFAULT_MODELS[t as Tier]} (default: ${DEFAULT_MODELS[t as Tier]})`,
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
export function buildReconfigContext(
|
||||
pluginConfig?: Record<string, unknown>,
|
||||
): string {
|
||||
const modelTable = buildModelTable(pluginConfig);
|
||||
return `# DevClaw Reconfiguration
|
||||
|
||||
The user wants to reconfigure DevClaw. Current model configuration:
|
||||
|
||||
${modelTable}
|
||||
|
||||
## What can be changed
|
||||
1. **Model tiers** — call \`devclaw_setup\` with a \`models\` object containing only the tiers to change
|
||||
2. **Workspace files** — \`devclaw_setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files)
|
||||
3. **Register new projects** — use \`project_register\`
|
||||
|
||||
Ask what they want to change, then call the appropriate tool.
|
||||
\`devclaw_setup\` is safe to re-run — it backs up existing files before overwriting.
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildOnboardToolContext(): string {
|
||||
return `# DevClaw Onboarding
|
||||
|
||||
## What is DevClaw?
|
||||
DevClaw turns each Telegram group into an autonomous development team:
|
||||
- An **orchestrator** that manages backlogs and delegates work
|
||||
- **DEV workers** (junior/medior/senior tiers) that write code in isolated sessions
|
||||
- **QA workers** that review code and run tests
|
||||
- Atomic tools for label transitions, session dispatch, state management, and audit logging
|
||||
|
||||
## Setup Steps
|
||||
|
||||
**Step 1: Agent Selection**
|
||||
Ask: "Do you want to configure DevClaw for the current agent, or create a new dedicated agent?"
|
||||
- Current agent → no \`newAgentName\` needed
|
||||
- New agent → ask for:
|
||||
1. Agent name
|
||||
2. **Channel binding**: "Which channel should this agent listen to? (telegram/whatsapp/none)"
|
||||
- If telegram/whatsapp selected:
|
||||
a) Call \`analyze_channel_bindings\` to check for conflicts
|
||||
b) If channel not configured/enabled → warn and recommend skipping binding for now
|
||||
c) If channel-wide binding exists on another agent → ask: "Migrate binding from {agentName}?"
|
||||
d) Collect migration decision
|
||||
- If none selected, user can add bindings manually later via openclaw.json
|
||||
|
||||
**Step 2: Model Configuration**
|
||||
Show the default tier-to-model mapping and ask if they want to customize:
|
||||
|
||||
| Tier | Default Model | Purpose |
|
||||
|------|---------------|---------|
|
||||
| junior | anthropic/claude-haiku-4-5 | Typos, single-file fixes |
|
||||
| medior | anthropic/claude-sonnet-4-5 | Features, bug fixes |
|
||||
| senior | anthropic/claude-opus-4-5 | Architecture, refactoring |
|
||||
| qa | anthropic/claude-sonnet-4-5 | Code review, testing |
|
||||
|
||||
If the defaults are fine, proceed. If customizing, ask which tiers to change.
|
||||
|
||||
**Step 3: Run Setup**
|
||||
Call \`devclaw_setup\` with the collected answers:
|
||||
- Current agent: \`devclaw_setup({})\` or \`devclaw_setup({ models: { ... } })\`
|
||||
- New agent: \`devclaw_setup({ newAgentName: "<name>", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: "<agentId>"|null, models: { ... } })\`
|
||||
- \`migrateFrom\`: Include if user wants to migrate an existing channel-wide binding
|
||||
|
||||
**Step 4: Optional Project Registration**
|
||||
After setup, ask: "Would you like to register a project now?"
|
||||
If yes, collect: project name, repo path, Telegram group ID, group name, base branch.
|
||||
Then call \`project_register\`.
|
||||
|
||||
## Guidelines
|
||||
- Be conversational and friendly. Ask one question at a time.
|
||||
- Show defaults so the user can accept them quickly.
|
||||
- After setup, summarize what was configured (including channel binding if applicable).
|
||||
`;
|
||||
}
|
||||
113
lib/setup.ts
113
lib/setup.ts
@@ -15,12 +15,17 @@ import {
|
||||
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). */
|
||||
@@ -36,6 +41,10 @@ export type SetupResult = {
|
||||
models: Record<Tier, string>;
|
||||
filesWritten: string[];
|
||||
warnings: string[];
|
||||
bindingMigrated?: {
|
||||
from: string;
|
||||
channel: "telegram" | "whatsapp";
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -51,13 +60,33 @@ export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
||||
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);
|
||||
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);
|
||||
@@ -80,8 +109,8 @@ export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Write plugin config to openclaw.json
|
||||
await writePluginConfig(models);
|
||||
// Write plugin config to openclaw.json (includes agentId in devClawAgentIds)
|
||||
await writePluginConfig(models, agentId);
|
||||
|
||||
// --- Step 3: Workspace files ---
|
||||
|
||||
@@ -131,6 +160,7 @@ export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
||||
models,
|
||||
filesWritten,
|
||||
warnings,
|
||||
bindingMigrated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +169,7 @@ export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
||||
*/
|
||||
async function createAgent(
|
||||
name: string,
|
||||
channelBinding?: "telegram" | "whatsapp" | null,
|
||||
): Promise<{ agentId: string; workspacePath: string }> {
|
||||
// Generate ID from name (lowercase, hyphenated)
|
||||
const agentId = name
|
||||
@@ -152,31 +183,71 @@ async function createAgent(
|
||||
`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", [
|
||||
"agents",
|
||||
"add",
|
||||
agentId,
|
||||
"--name",
|
||||
name,
|
||||
"--workspace",
|
||||
workspacePath,
|
||||
"--non-interactive",
|
||||
], { timeout: 30_000 });
|
||||
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 in the workspace — remove it
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -205,11 +276,12 @@ async function resolveWorkspacePath(agentId: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write DevClaw model tier config to openclaw.json plugins section.
|
||||
* Write DevClaw model tier config and devClawAgentIds to openclaw.json plugins section.
|
||||
* Read-modify-write to preserve existing config.
|
||||
*/
|
||||
async function writePluginConfig(
|
||||
models: Record<Tier, string>,
|
||||
agentId?: string,
|
||||
): Promise<void> {
|
||||
const configPath = path.join(
|
||||
process.env.HOME ?? "/home/lauren",
|
||||
@@ -219,15 +291,24 @@ async function writePluginConfig(
|
||||
const raw = await fs.readFile(configPath, "utf-8");
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
// Ensure plugins.entries.devclaw.config.models exists
|
||||
// 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/update devClawAgentIds
|
||||
if (agentId) {
|
||||
const existing = config.plugins.entries.devclaw.config.devClawAgentIds ?? [];
|
||||
if (!existing.includes(agentId)) {
|
||||
config.plugins.entries.devclaw.config.devClawAgentIds = [...existing, agentId];
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic write
|
||||
const tmpPath = configPath + ".tmp";
|
||||
await fs.writeFile(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||
|
||||
82
lib/tools/analyze-channel-bindings.ts
Normal file
82
lib/tools/analyze-channel-bindings.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* analyze_channel_bindings — Check channel availability and detect binding conflicts.
|
||||
*
|
||||
* Returns analysis of the current channel binding state, including:
|
||||
* - Whether the channel is configured and enabled
|
||||
* - Existing channel-wide bindings (potential conflicts)
|
||||
* - Existing group-specific bindings (no conflicts)
|
||||
* - Recommendations for what to do
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import {
|
||||
analyzeChannelBindings,
|
||||
type ChannelType,
|
||||
} from "../binding-manager.js";
|
||||
|
||||
export function createAnalyzeChannelBindingsTool(api: OpenClawPluginApi) {
|
||||
return (_ctx: ToolContext) => ({
|
||||
name: "analyze_channel_bindings",
|
||||
label: "Analyze Channel Bindings",
|
||||
description:
|
||||
"Check if a channel (telegram/whatsapp) is configured and analyze existing bindings. Use this during onboarding when the user selects a channel binding (telegram/whatsapp) to: detect if the channel is configured and enabled, identify existing channel-wide bindings that would conflict, and provide smart recommendations (migrate binding, skip binding, or proceed). Call this BEFORE devclaw_setup when creating a new agent with channel binding.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
channel: {
|
||||
type: "string",
|
||||
enum: ["telegram", "whatsapp"],
|
||||
description: "The channel to analyze (telegram or whatsapp)",
|
||||
},
|
||||
},
|
||||
required: ["channel"],
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const channel = params.channel as ChannelType;
|
||||
|
||||
const analysis = await analyzeChannelBindings(channel);
|
||||
|
||||
const lines = [`**${channel.charAt(0).toUpperCase() + channel.slice(1)} Binding Analysis**`, ``];
|
||||
|
||||
if (!analysis.channelConfigured) {
|
||||
lines.push(`❌ Channel not configured`);
|
||||
} else if (!analysis.channelEnabled) {
|
||||
lines.push(`⚠️ Channel configured but disabled`);
|
||||
} else {
|
||||
lines.push(`✅ Channel configured and enabled`);
|
||||
}
|
||||
|
||||
lines.push(``);
|
||||
|
||||
if (analysis.existingChannelWideBinding) {
|
||||
lines.push(
|
||||
`**Existing Channel-Wide Binding:**`,
|
||||
` Agent: ${analysis.existingChannelWideBinding.agentName} (${analysis.existingChannelWideBinding.agentId})`,
|
||||
` ⚠️ This agent receives ALL ${channel} messages`,
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
if (analysis.groupSpecificBindings.length > 0) {
|
||||
lines.push(
|
||||
`**Group-Specific Bindings:**`,
|
||||
...analysis.groupSpecificBindings.map(
|
||||
(b) => ` • ${b.agentName} (${b.agentId}) → group ${b.groupId}`,
|
||||
),
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`**Recommendation:**`, analysis.recommendation);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
channel,
|
||||
...analysis,
|
||||
summary: lines.join("\n"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
50
lib/tools/context-test.ts
Normal file
50
lib/tools/context-test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* context_test — Debug tool to test context detection.
|
||||
*
|
||||
* Call this from different contexts (DM, group, via another agent) to see
|
||||
* what context is detected and what guardrails are generated.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
export function createContextTestTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "context_test",
|
||||
label: "Context Test (Debug)",
|
||||
description:
|
||||
"Debug tool: Shows detected context and guardrails. Use this to verify context detection works correctly in different scenarios (DM, group, via another agent).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
|
||||
async execute(_id: string, _params: Record<string, unknown>) {
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
const guardrails = generateGuardrails(context);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
debug: {
|
||||
toolContext: {
|
||||
agentId: ctx.agentId,
|
||||
messageChannel: ctx.messageChannel,
|
||||
sessionKey: ctx.sessionKey,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
agentAccountId: ctx.agentAccountId,
|
||||
sandboxed: ctx.sandboxed,
|
||||
},
|
||||
devClawAgentIds,
|
||||
},
|
||||
detectedContext: context,
|
||||
guardrails,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
85
lib/tools/devclaw-onboard.ts
Normal file
85
lib/tools/devclaw-onboard.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* devclaw_onboard — Explicit tool for triggering DevClaw onboarding.
|
||||
*
|
||||
* Provides discoverable, tool-based onboarding that doesn't rely on
|
||||
* keyword detection. Returns conversational context as a tool result.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import {
|
||||
isPluginConfigured,
|
||||
hasWorkspaceFiles,
|
||||
buildOnboardToolContext,
|
||||
buildReconfigContext,
|
||||
} from "../onboarding.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
export function createOnboardTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "devclaw_onboard",
|
||||
label: "DevClaw Onboarding",
|
||||
description:
|
||||
"Start DevClaw onboarding workflow. Use this tool when the user wants to: set up DevClaw, install DevClaw, onboard DevClaw, configure DevClaw, get started with DevClaw, or asks questions like 'can we install devclaw?', 'how do I set up devclaw?', 'let's onboard devclaw'. Returns step-by-step QA-style guidance. Call this FIRST before calling devclaw_setup to provide conversational setup experience.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["first-run", "reconfigure"],
|
||||
description:
|
||||
"Whether this is first-time setup (first-run) or reconfiguration (reconfigure). Auto-detected if omitted.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// Warn if called in wrong context (group chat)
|
||||
if (context.type === "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "DevClaw onboarding should not be done in project group chats.",
|
||||
recommendation:
|
||||
"Please discuss DevClaw setup in a direct message with the DevClaw agent or via another agent (like your main assistant).",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
const configured = isPluginConfigured(
|
||||
api.pluginConfig as Record<string, unknown>,
|
||||
);
|
||||
const hasWorkspace = await hasWorkspaceFiles(ctx.workspaceDir);
|
||||
|
||||
const mode = params.mode
|
||||
? (params.mode as "first-run" | "reconfigure")
|
||||
: configured && hasWorkspace
|
||||
? "reconfigure"
|
||||
: "first-run";
|
||||
|
||||
const instructions =
|
||||
mode === "first-run"
|
||||
? buildOnboardToolContext()
|
||||
: buildReconfigContext(api.pluginConfig as Record<string, unknown>);
|
||||
|
||||
return jsonResult({
|
||||
success: true,
|
||||
mode,
|
||||
configured,
|
||||
instructions,
|
||||
contextGuidance: generateGuardrails(context),
|
||||
nextSteps: [
|
||||
"Follow the instructions above",
|
||||
"Call devclaw_setup with your collected answers",
|
||||
mode === "first-run" ? "Optional: register a project afterward" : null,
|
||||
].filter(Boolean),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "devclaw_setup",
|
||||
label: "DevClaw Setup",
|
||||
description: `Set up DevClaw in an agent's workspace. Creates AGENTS.md, HEARTBEAT.md, role templates, memory/projects.json, and writes model tier config to openclaw.json. Optionally creates a new agent. Backs up existing files before overwriting.`,
|
||||
description: `Execute DevClaw setup with collected configuration. Creates AGENTS.md, HEARTBEAT.md, role templates, memory/projects.json, and writes model tier config to openclaw.json. Optionally creates a new agent with channel binding and migration support. Backs up existing files before overwriting. This tool is typically called AFTER devclaw_onboard guides the conversation, but can be called directly if the user provides explicit configuration parameters.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -22,6 +22,15 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
type: "string",
|
||||
description: "Create a new agent with this name. If omitted, configures the current agent's workspace.",
|
||||
},
|
||||
channelBinding: {
|
||||
type: "string",
|
||||
enum: ["telegram", "whatsapp"],
|
||||
description: "Channel to bind the new agent to (optional). Only used when newAgentName is specified. If omitted, no binding is created.",
|
||||
},
|
||||
migrateFrom: {
|
||||
type: "string",
|
||||
description: "Agent ID to migrate channel binding from (optional). Use when replacing an existing agent's channel-wide binding. Call analyze_channel_bindings first to detect conflicts.",
|
||||
},
|
||||
models: {
|
||||
type: "object",
|
||||
description: `Model overrides per tier. Missing tiers use defaults. Example: { "junior": "anthropic/claude-haiku-4-5", "senior": "anthropic/claude-opus-4-5" }`,
|
||||
@@ -37,11 +46,15 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const newAgentName = params.newAgentName as string | undefined;
|
||||
const channelBinding = params.channelBinding as "telegram" | "whatsapp" | undefined;
|
||||
const migrateFrom = params.migrateFrom as string | undefined;
|
||||
const modelsParam = params.models as Partial<Record<Tier, string>> | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
const result = await runSetup({
|
||||
newAgentName,
|
||||
channelBinding: channelBinding ?? null,
|
||||
migrateFrom,
|
||||
// If no new agent name, use the current agent's workspace
|
||||
agentId: newAgentName ? undefined : ctx.agentId,
|
||||
workspacePath: newAgentName ? undefined : workspaceDir,
|
||||
@@ -53,12 +66,23 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
? `Agent "${result.agentId}" created`
|
||||
: `Configured workspace for agent "${result.agentId}"`,
|
||||
``,
|
||||
];
|
||||
|
||||
if (result.bindingMigrated) {
|
||||
lines.push(
|
||||
`✅ Channel binding migrated:`,
|
||||
` ${result.bindingMigrated.channel} (from "${result.bindingMigrated.from}" → "${result.agentId}")`,
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`Models:`,
|
||||
...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`),
|
||||
``,
|
||||
`Files written:`,
|
||||
...result.filesWritten.map((f) => ` ${f}`),
|
||||
];
|
||||
);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
lines.push(``, `Warnings:`, ...result.warnings.map((w) => ` ${w}`));
|
||||
@@ -67,7 +91,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
lines.push(
|
||||
``,
|
||||
`Next steps:`,
|
||||
` 1. Add bot to a Telegram group`,
|
||||
` 1. Add bot to a Telegram/WhatsApp group`,
|
||||
` 2. Register a project: "Register project <name> at <repo> for group <id>"`,
|
||||
` 3. Create your first issue and pick it up`,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { DEV_TIERS, QA_TIERS } from "../tiers.js";
|
||||
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS } from "../templates.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
/**
|
||||
* Ensure default role files exist, then copy them into the project's role directory.
|
||||
@@ -72,14 +73,14 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "project_register",
|
||||
label: "Project Register",
|
||||
description: `Register a new project with DevClaw. Creates all required state labels (idempotent) and adds the project to projects.json. One-time setup per project. Auto-detects GitHub/GitLab from git remote.`,
|
||||
description: `Register a new project with DevClaw. ONLY works in the Telegram/WhatsApp group you're registering. Creates state labels, adds to projects.json, auto-populates group ID. One-time setup per project.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["projectGroupId", "name", "repo", "groupName", "baseBranch"],
|
||||
required: ["name", "repo", "baseBranch"],
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Telegram group ID (will be the key in projects.json)",
|
||||
description: "Telegram/WhatsApp group ID (optional - auto-detected from current group if omitted)",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
@@ -91,7 +92,7 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
},
|
||||
groupName: {
|
||||
type: "string",
|
||||
description: "Telegram group display name (e.g. 'Dev - My Project')",
|
||||
description: "Group display name (optional - defaults to 'Project: {name}')",
|
||||
},
|
||||
baseBranch: {
|
||||
type: "string",
|
||||
@@ -112,7 +113,7 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
const groupId = params.projectGroupId as string;
|
||||
const name = params.name as string;
|
||||
const repo = params.repo as string;
|
||||
const groupName = params.groupName as string;
|
||||
const groupName = (params.groupName as string) ?? `Project: ${name}`;
|
||||
const baseBranch = params.baseBranch as string;
|
||||
const deployBranch = (params.deployBranch as string) ?? baseBranch;
|
||||
const deployUrl = (params.deployUrl as string) ?? "";
|
||||
@@ -122,12 +123,46 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// ONLY allow registration from group context
|
||||
// Design principle: One Group = One Project = One Team
|
||||
// This enforces project isolation and prevents accidental cross-registration.
|
||||
// You must be IN the group to register it, making the binding explicit and intentional.
|
||||
if (context.type !== "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "Project registration can only be done from the Telegram/WhatsApp group you're registering.",
|
||||
recommendation:
|
||||
context.type === "via-agent"
|
||||
? "If you're setting up DevClaw for the first time, use devclaw_onboard. Then go to the project's Telegram/WhatsApp group to register it."
|
||||
: "Please go to the Telegram/WhatsApp group you want to register and call project_register from there.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-populate projectGroupId if not provided (use current group)
|
||||
const actualGroupId = groupId || ctx.sessionKey;
|
||||
if (!actualGroupId) {
|
||||
throw new Error("Could not determine group ID from context. Please provide projectGroupId explicitly.");
|
||||
}
|
||||
|
||||
// Provide helpful note if project is already registered
|
||||
const contextInfo = context.projectName
|
||||
? `Note: This group is already registered as "${context.projectName}". You may be re-registering it.`
|
||||
: `Registering project for this ${context.channel === "whatsapp" ? "WhatsApp" : "Telegram"} group (ID: ${actualGroupId.substring(0, 20)}...).`;
|
||||
|
||||
// 1. Check project not already registered (allow re-register if incomplete)
|
||||
const data = await readProjects(workspaceDir);
|
||||
const existing = data.projects[groupId];
|
||||
const existing = data.projects[actualGroupId];
|
||||
if (existing && existing.dev?.sessions && Object.keys(existing.dev.sessions).length > 0) {
|
||||
throw new Error(
|
||||
`Project already registered for groupId ${groupId}: "${existing.name}". Use a different group ID or remove the existing entry first.`,
|
||||
`Project already registered for this group: "${existing.name}". Remove the existing entry first or use a different group.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,7 +193,7 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
await provider.ensureAllStateLabels();
|
||||
|
||||
// 5. Add project to projects.json
|
||||
data.projects[groupId] = {
|
||||
data.projects[actualGroupId] = {
|
||||
name,
|
||||
repo,
|
||||
groupName,
|
||||
@@ -178,7 +213,7 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
// 7. Audit log
|
||||
await auditLog(workspaceDir, "project_register", {
|
||||
project: name,
|
||||
groupId,
|
||||
groupId: actualGroupId,
|
||||
repo,
|
||||
baseBranch,
|
||||
deployBranch,
|
||||
@@ -192,13 +227,15 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
return jsonResult({
|
||||
success: true,
|
||||
project: name,
|
||||
groupId,
|
||||
groupId: actualGroupId,
|
||||
repo,
|
||||
baseBranch,
|
||||
deployBranch,
|
||||
labelsCreated: 8,
|
||||
rolesScaffolded: rolesCreated,
|
||||
announcement,
|
||||
...(contextInfo && { contextInfo }),
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,12 +9,13 @@ import type { ToolContext } from "../types.js";
|
||||
import { readProjects, getProject } from "../projects.js";
|
||||
import { listIssuesByLabel, resolveRepoPath, type StateLabel } from "../gitlab.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
export function createQueueStatusTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "queue_status",
|
||||
label: "Queue Status",
|
||||
description: `Show task queue counts and worker status for all projects (or a specific project). Returns To Improve, To Test, To Do issue counts and active DEV/QA session state.`,
|
||||
description: `Show task queue and worker status. Context-aware: In group chats, auto-filters to that project. In direct messages, shows all projects. Best for status checks, not during setup.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -26,13 +27,35 @@ export function createQueueStatusTool(api: OpenClawPluginApi) {
|
||||
},
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const groupId = params.projectGroupId as string | undefined;
|
||||
const workspaceDir = ctx.workspaceDir;
|
||||
|
||||
if (!workspaceDir) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// If via another agent (setup mode), suggest devclaw_onboard instead
|
||||
if (context.type === "via-agent") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
warning: "queue_status is for operational use, not setup.",
|
||||
recommendation: "If you're setting up DevClaw, use devclaw_onboard instead.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-filter to current project in group context
|
||||
let groupId = params.projectGroupId as string | undefined;
|
||||
if (context.type === "group" && !groupId) {
|
||||
groupId = context.groupId; // Use the actual group ID for lookup
|
||||
}
|
||||
|
||||
const data = await readProjects(workspaceDir);
|
||||
const projectIds = groupId
|
||||
? [groupId]
|
||||
@@ -101,7 +124,17 @@ export function createQueueStatusTool(api: OpenClawPluginApi) {
|
||||
),
|
||||
});
|
||||
|
||||
return jsonResult({ projects });
|
||||
return jsonResult({
|
||||
projects,
|
||||
context: {
|
||||
type: context.type,
|
||||
...(context.type === "group" && {
|
||||
projectName: context.projectName,
|
||||
autoFiltered: !params.projectGroupId,
|
||||
}),
|
||||
},
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,11 +55,11 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
},
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Telegram group ID (key in projects.json)",
|
||||
description: "Telegram/WhatsApp group ID (key in projects.json)",
|
||||
},
|
||||
summary: {
|
||||
type: "string",
|
||||
description: "Brief summary for Telegram announcement",
|
||||
description: "Brief summary for group announcement",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -47,7 +47,7 @@ The issue is created with a state label (defaults to "Planning"). Returns the cr
|
||||
properties: {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description: "Telegram group ID for the project",
|
||||
description: "Telegram/WhatsApp group ID for the project",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
|
||||
@@ -21,12 +21,13 @@ import {
|
||||
import { selectModel } from "../model-selector.js";
|
||||
import { getProject, getWorker, readProjects } from "../projects.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "task_pickup",
|
||||
label: "Task Pickup",
|
||||
description: `Pick up a task from the issue queue for a DEV or QA worker. Handles everything end-to-end: label transition, tier assignment, session creation/reuse, task dispatch, state update, and audit logging. The orchestrator should analyze the issue and pass the appropriate developer tier. Returns an announcement for the agent to post — no further session actions needed.`,
|
||||
description: `Pick up a task from the issue queue. Context-aware: ONLY works in project group chats, not in DMs or during setup. Handles label transition, tier assignment, session creation, task dispatch, and audit logging. Returns an announcement for posting in the group.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["issueId", "role", "projectGroupId"],
|
||||
@@ -40,7 +41,7 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
projectGroupId: {
|
||||
type: "string",
|
||||
description:
|
||||
"Telegram group ID (key in projects.json). Required — pass the group ID from the current conversation.",
|
||||
"Telegram/WhatsApp group ID (key in projects.json). Required — pass the group ID from the current conversation.",
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
@@ -61,6 +62,26 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
throw new Error("No workspace directory available in tool context");
|
||||
}
|
||||
|
||||
// --- Context detection ---
|
||||
const devClawAgentIds =
|
||||
((api.pluginConfig as Record<string, unknown>)?.devClawAgentIds as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
const context = await detectContext(ctx, devClawAgentIds);
|
||||
|
||||
// ONLY allow in group context
|
||||
if (context.type !== "group") {
|
||||
return jsonResult({
|
||||
success: false,
|
||||
error: "task_pickup can only be used in project group chats.",
|
||||
recommendation:
|
||||
context.type === "via-agent"
|
||||
? "If you're setting up DevClaw, use devclaw_onboard instead."
|
||||
: "To pick up tasks, please use the relevant project's Telegram/WhatsApp group.",
|
||||
contextGuidance: generateGuardrails(context),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Resolve project
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = getProject(data, groupId);
|
||||
|
||||
Reference in New Issue
Block a user