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:
Lauren ten Hoor
2026-02-09 18:34:45 +08:00
parent 32eb079521
commit a9a3fc3f1f
18 changed files with 1532 additions and 44 deletions

View 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
View 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,
});
},
});
}

View 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),
});
},
});
}

View File

@@ -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`,
);

View File

@@ -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),
});
},
});

View File

@@ -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),
});
},
});
}

View File

@@ -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",
},
},
},

View File

@@ -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",

View File

@@ -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);