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:
@@ -2,7 +2,7 @@
|
||||
|
||||
**Every group chat becomes an autonomous development team.**
|
||||
|
||||
Add the agent to a Telegram group, point it at a GitLab repo — that group now has an **orchestrator** managing the backlog, a **DEV** worker session writing code, and a **QA** worker session reviewing it. All autonomous. Add another group, get another team. Each project runs in complete isolation with its own task queue, workers, and session state.
|
||||
Add the agent to a Telegram/WhatsApp group, point it at a GitLab/GitHub repo — that group now has an **orchestrator** managing the backlog, a **DEV** worker session writing code, and a **QA** worker session reviewing it. All autonomous. Add another group, get another team. Each project runs in complete isolation with its own task queue, workers, and session state.
|
||||
|
||||
DevClaw is the [OpenClaw](https://openclaw.ai) plugin that makes this work.
|
||||
|
||||
@@ -14,7 +14,7 @@ DevClaw fills that gap with guardrails. It gives the orchestrator atomic tools t
|
||||
|
||||
## The idea
|
||||
|
||||
One orchestrator agent manages all your projects. It reads task backlogs, creates issues, decides priorities, and delegates work. For each task, DevClaw assigns a developer from your **team** — a junior, medior, or senior dev writes the code, then a QA engineer reviews it. Every Telegram group is a separate project — the orchestrator keeps them completely isolated while managing them all from a single process.
|
||||
One orchestrator agent manages all your projects. It reads task backlogs, creates issues, decides priorities, and delegates work. For each task, DevClaw assigns a developer from your **team** — a junior, medior, or senior dev writes the code, then a QA engineer reviews it. Every Telegram/WhatsApp group is a separate project — the orchestrator keeps them completely isolated while managing them all from a single process.
|
||||
|
||||
DevClaw gives the orchestrator seven tools that replace hundreds of lines of manual orchestration logic. Instead of following a 10-step checklist per task (fetch issue, check labels, pick model, check for existing session, transition label, dispatch task, update state, log audit event...), it calls `task_pickup` and the plugin handles everything atomically — including session dispatch. Workers call `task_complete` themselves for atomic state updates, and can file follow-up issues via `task_create`.
|
||||
|
||||
|
||||
181
docs/CONTEXT-AWARENESS.md
Normal file
181
docs/CONTEXT-AWARENESS.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Context-Aware DevClaw
|
||||
|
||||
DevClaw now adapts its behavior based on how you interact with it.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
**One Group = One Project = One Team**
|
||||
|
||||
DevClaw enforces strict boundaries between projects:
|
||||
- Each Telegram/WhatsApp group represents a **single project**
|
||||
- Each project has its **own dedicated dev/qa workers**
|
||||
- Project work happens **inside that project's group**
|
||||
- Setup and configuration happen **outside project groups**
|
||||
|
||||
This design prevents:
|
||||
- ❌ Cross-project contamination (workers picking up wrong project's tasks)
|
||||
- ❌ Confusion about which project you're working on
|
||||
- ❌ Accidental registration of wrong groups
|
||||
- ❌ Setup discussions cluttering project work channels
|
||||
|
||||
This enables:
|
||||
- ✅ Clear mental model: "This group = this project"
|
||||
- ✅ Isolated work streams: Each project progresses independently
|
||||
- ✅ Dedicated teams: Workers focus on one project at a time
|
||||
- ✅ Clean separation: Setup vs. operational work
|
||||
|
||||
## Three Interaction Contexts
|
||||
|
||||
### 1. **Via Another Agent** (Setup Mode)
|
||||
When you talk to your main agent (like Henk) about DevClaw:
|
||||
- ✅ Use: `devclaw_onboard`, `devclaw_setup`
|
||||
- ❌ Avoid: `task_pickup`, `queue_status` (operational tools)
|
||||
|
||||
**Example:**
|
||||
```
|
||||
User → Henk: "Can you help me set up DevClaw?"
|
||||
Henk → Calls devclaw_onboard
|
||||
```
|
||||
|
||||
### 2. **Direct Message to DevClaw Agent**
|
||||
When you DM the DevClaw agent directly on Telegram/WhatsApp:
|
||||
- ✅ Use: `queue_status` (all projects), `session_health` (system overview)
|
||||
- ❌ Avoid: `task_pickup` (project-specific work), setup tools
|
||||
|
||||
**Example:**
|
||||
```
|
||||
User → DevClaw DM: "Show me the status of all projects"
|
||||
DevClaw → Calls queue_status (shows all projects)
|
||||
```
|
||||
|
||||
### 3. **Project Group Chat**
|
||||
When you message in a Telegram/WhatsApp group bound to a project:
|
||||
- ✅ Use: `task_pickup`, `task_complete`, `task_create`, `queue_status` (auto-filtered)
|
||||
- ❌ Avoid: Setup tools, system-wide queries
|
||||
|
||||
**Example:**
|
||||
```
|
||||
User → OpenClaw Dev Group: "@henk pick up issue #42"
|
||||
DevClaw → Calls task_pickup (only works in groups)
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Context Detection
|
||||
Each tool automatically detects:
|
||||
- **Agent ID** - Is this the DevClaw agent or another agent?
|
||||
- **Message Channel** - Telegram, WhatsApp, or CLI?
|
||||
- **Session Key** - Is this a group chat or direct message?
|
||||
- Format: `agent:{agentId}:{channel}:{type}:{id}`
|
||||
- Telegram group: `agent:devclaw:telegram:group:-5266044536`
|
||||
- WhatsApp group: `agent:devclaw:whatsapp:group:120363123@g.us`
|
||||
- DM: `agent:devclaw:telegram:user:657120585`
|
||||
- **Project Binding** - Which project is this group bound to?
|
||||
|
||||
### Guardrails
|
||||
Tools include context-aware guidance in their responses:
|
||||
```json
|
||||
{
|
||||
"contextGuidance": "🛡️ Context: Project Group Chat (telegram)\n
|
||||
You're in a Telegram group for project 'openclaw-core'.\n
|
||||
Use task_pickup, task_complete for project work.",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Integrated Tools
|
||||
|
||||
### ✅ `devclaw_onboard`
|
||||
- **Works best:** Via another agent or direct DM
|
||||
- **Blocks:** Group chats (setup shouldn't happen in project groups)
|
||||
|
||||
### ✅ `queue_status`
|
||||
- **Group context:** Auto-filters to that project
|
||||
- **Direct context:** Shows all projects
|
||||
- **Via-agent context:** Suggests using devclaw_onboard instead
|
||||
|
||||
### ✅ `task_pickup`
|
||||
- **ONLY works:** In project group chats
|
||||
- **Blocks:** Direct DMs and setup conversations
|
||||
|
||||
### ✅ `project_register`
|
||||
- **ONLY works:** In the Telegram/WhatsApp group you're registering
|
||||
- **Blocks:** Direct DMs and via-agent conversations
|
||||
- **Auto-detects:** Group ID from current chat (projectGroupId parameter now optional)
|
||||
|
||||
**Why this matters:**
|
||||
- **Project Isolation**: Each group = one project = one dedicated team
|
||||
- **Clear Boundaries**: Forces deliberate project registration from within the project's space
|
||||
- **Team Clarity**: You're physically in the group when binding it, making the connection explicit
|
||||
- **No Mistakes**: Impossible to accidentally register the wrong group when you're in it
|
||||
- **Natural Workflow**: "This group is for Project X" → register Project X here
|
||||
|
||||
## Testing
|
||||
|
||||
### Debug Tool
|
||||
Use `context_test` to see what context is detected:
|
||||
```
|
||||
# In any context:
|
||||
context_test
|
||||
|
||||
# Returns:
|
||||
{
|
||||
"detectedContext": { "type": "group", "projectName": "openclaw-core" },
|
||||
"guardrails": "🛡️ Context: Project Group Chat..."
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
1. **Setup Mode:** Message your main agent → "Help me configure DevClaw"
|
||||
2. **Status Check:** DM DevClaw agent (Telegram/WhatsApp) → "Show me the queue"
|
||||
3. **Project Work:** Post in project group (Telegram/WhatsApp) → "@henk pick up #42"
|
||||
|
||||
Each context should trigger different guardrails.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to `~/.openclaw/openclaw.json`:
|
||||
```json
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"devclaw": {
|
||||
"config": {
|
||||
"devClawAgentIds": ["henk-development", "devclaw-test"],
|
||||
"models": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `devClawAgentIds` array lists which agents are DevClaw orchestrators.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- **Module:** [lib/context-guard.ts](../lib/context-guard.ts)
|
||||
- **Tests:** [tests/unit/context-guard.test.ts](../tests/unit/context-guard.test.ts) (15 passing)
|
||||
- **Integrated tools:** 4 key tools (`devclaw_onboard`, `queue_status`, `task_pickup`, `project_register`)
|
||||
- **Detection logic:** Checks agentId, messageChannel, sessionKey pattern matching
|
||||
|
||||
## WhatsApp Support
|
||||
|
||||
DevClaw **fully supports WhatsApp** groups with the same architecture as Telegram:
|
||||
|
||||
- ✅ WhatsApp group detection via `sessionKey.includes("@g.us")`
|
||||
- ✅ Projects keyed by WhatsApp group ID (e.g., `"120363123@g.us"`)
|
||||
- ✅ Context-aware tools work identically for both channels
|
||||
- ✅ One project = one group (Telegram OR WhatsApp)
|
||||
|
||||
**To register a WhatsApp project:**
|
||||
1. Go to the WhatsApp group chat
|
||||
2. Call `project_register` from within the group
|
||||
3. Group ID auto-detected from context
|
||||
|
||||
The architecture treats Telegram and WhatsApp identically - the only difference is the group ID format.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Integrate into remaining tools (`task_complete`, `session_health`, `task_create`, `devclaw_setup`)
|
||||
- [ ] System prompt injection (requires OpenClaw core support)
|
||||
- [ ] Context-based tool filtering (hide irrelevant tools)
|
||||
- [ ] Per-project context overrides
|
||||
@@ -27,6 +27,20 @@ openclaw plugins list
|
||||
|
||||
### 2. Run setup
|
||||
|
||||
There are three ways to set up DevClaw:
|
||||
|
||||
#### Option A: Conversational onboarding (recommended)
|
||||
|
||||
Call the `devclaw_onboard` tool from any agent that has the DevClaw plugin loaded. The agent will walk you through configuration step by step — asking about:
|
||||
- Agent selection (current or create new)
|
||||
- Channel binding (telegram/whatsapp/none) — for new agents only
|
||||
- Model tiers (accept defaults or customize)
|
||||
- Optional project registration
|
||||
|
||||
The tool returns instructions that guide the agent through the QA-style setup conversation.
|
||||
|
||||
#### Option B: CLI wizard
|
||||
|
||||
```bash
|
||||
openclaw devclaw setup
|
||||
```
|
||||
@@ -44,7 +58,7 @@ The setup wizard walks you through:
|
||||
Non-interactive mode:
|
||||
```bash
|
||||
# Create new agent with default models
|
||||
openclaw devclaw setup --new-agent "My Dev Orchestrator" --non-interactive
|
||||
openclaw devclaw setup --new-agent "My Dev Orchestrator"
|
||||
|
||||
# Configure existing agent with custom models
|
||||
openclaw devclaw setup --agent my-orchestrator \
|
||||
@@ -52,9 +66,77 @@ openclaw devclaw setup --agent my-orchestrator \
|
||||
--senior "anthropic/claude-opus-4-5"
|
||||
```
|
||||
|
||||
### 3. Add the agent to the Telegram group
|
||||
#### Option C: Tool call (agent-driven)
|
||||
|
||||
Add your orchestrator bot to the Telegram group for the project. The agent will now receive messages from this group and can operate on the linked project.
|
||||
**Conversational onboarding via tool:**
|
||||
```json
|
||||
devclaw_onboard({ mode: "first-run" })
|
||||
```
|
||||
|
||||
The tool returns step-by-step instructions that guide the agent through the QA-style setup conversation.
|
||||
|
||||
**Direct setup (skip conversation):**
|
||||
```json
|
||||
{
|
||||
"newAgentName": "My Dev Orchestrator",
|
||||
"channelBinding": "telegram",
|
||||
"models": {
|
||||
"junior": "anthropic/claude-haiku-4-5",
|
||||
"senior": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This calls `devclaw_setup` directly without conversational prompts.
|
||||
|
||||
### 3. Channel binding (optional, for new agents)
|
||||
|
||||
If you created a new agent during conversational onboarding and selected a channel binding (telegram/whatsapp), the agent is automatically bound and will receive messages from that channel. **Skip to step 4.**
|
||||
|
||||
**Smart Migration**: If an existing agent already has a channel-wide binding (e.g., the old orchestrator receives all telegram messages), the onboarding agent will:
|
||||
1. Call `analyze_channel_bindings` to detect the conflict
|
||||
2. Ask if you want to migrate the binding from the old agent to the new one
|
||||
3. If you confirm, the binding is automatically moved — no manual config edit needed
|
||||
|
||||
This is useful when you're replacing an old orchestrator with a new one.
|
||||
|
||||
If you didn't bind a channel during setup, you have two options:
|
||||
|
||||
**Option A: Manually edit `openclaw.json`** (for existing agents or post-creation binding)
|
||||
|
||||
Add an entry to the `bindings` array:
|
||||
```json
|
||||
{
|
||||
"bindings": [
|
||||
{
|
||||
"agentId": "my-orchestrator",
|
||||
"match": {
|
||||
"channel": "telegram"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For group-specific bindings:
|
||||
```json
|
||||
{
|
||||
"agentId": "my-orchestrator",
|
||||
"match": {
|
||||
"channel": "telegram",
|
||||
"peer": {
|
||||
"kind": "group",
|
||||
"id": "-1234567890"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart OpenClaw after editing.
|
||||
|
||||
**Option B: Add bot to Telegram/WhatsApp group**
|
||||
|
||||
If using a channel-wide binding (no peer filter), the agent will receive all messages from that channel. Add your orchestrator bot to the relevant Telegram group for the project.
|
||||
|
||||
### 4. Register your project
|
||||
|
||||
@@ -165,7 +247,9 @@ Change which model powers each tier in `openclaw.json`:
|
||||
| Responsibility | Who | Details |
|
||||
|---|---|---|
|
||||
| Plugin installation | You (once) | `cp -r devclaw ~/.openclaw/extensions/` |
|
||||
| Agent + workspace setup | Plugin (`devclaw setup`) | Creates agent, configures models, writes workspace files |
|
||||
| Agent + workspace setup | Plugin (`devclaw_setup`) | Creates agent, configures models, writes workspace files |
|
||||
| Channel binding analysis | Plugin (`analyze_channel_bindings`) | Detects channel conflicts, validates channel configuration |
|
||||
| Channel binding migration | Plugin (`devclaw_setup` with `migrateFrom`) | Automatically moves channel-wide bindings between agents |
|
||||
| Label setup | Plugin (`project_register`) | 8 labels, created idempotently via `IssueProvider` |
|
||||
| Role file scaffolding | Plugin (`project_register`) | Creates `roles/<project>/dev.md` and `qa.md` from defaults |
|
||||
| Project registration | Plugin (`project_register`) | Entry in `projects.json` with empty worker state |
|
||||
|
||||
334
docs/TESTING.md
Normal file
334
docs/TESTING.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# DevClaw Testing Guide
|
||||
|
||||
Comprehensive automated testing for DevClaw onboarding and setup.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run with coverage report
|
||||
npm run test:coverage
|
||||
|
||||
# Run in watch mode (auto-rerun on changes)
|
||||
npm run test:watch
|
||||
|
||||
# Run with UI (browser-based test explorer)
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Scenario 1: New User (No Prior DevClaw Setup)
|
||||
**File:** `tests/setup/new-user.test.ts`
|
||||
|
||||
**What's tested:**
|
||||
- First-time agent creation with default models
|
||||
- Channel binding creation (telegram/whatsapp)
|
||||
- Workspace file generation (AGENTS.md, HEARTBEAT.md, roles/, memory/)
|
||||
- Plugin configuration initialization
|
||||
- Error handling: channel not configured
|
||||
- Error handling: channel disabled
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Before: openclaw.json has no DevClaw agents
|
||||
{
|
||||
"agents": { "list": [{ "id": "main", ... }] },
|
||||
"bindings": [],
|
||||
"plugins": { "entries": {} }
|
||||
}
|
||||
|
||||
// After: New orchestrator created
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{ "id": "main", ... },
|
||||
{ "id": "my-first-orchestrator", ... }
|
||||
]
|
||||
},
|
||||
"bindings": [
|
||||
{ "agentId": "my-first-orchestrator", "match": { "channel": "telegram" } }
|
||||
],
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"devclaw": {
|
||||
"config": {
|
||||
"models": {
|
||||
"junior": "anthropic/claude-haiku-4-5",
|
||||
"medior": "anthropic/claude-sonnet-4-5",
|
||||
"senior": "anthropic/claude-opus-4-5",
|
||||
"qa": "anthropic/claude-sonnet-4-5"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scenario 2: Existing User (Migration)
|
||||
**File:** `tests/setup/existing-user.test.ts`
|
||||
|
||||
**What's tested:**
|
||||
- Channel conflict detection (existing channel-wide binding)
|
||||
- Binding migration from old agent to new agent
|
||||
- Custom model preservation during migration
|
||||
- Old agent preservation (not deleted)
|
||||
- Error handling: migration source doesn't exist
|
||||
- Error handling: migration source has no binding
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Before: Old orchestrator has telegram binding
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{ "id": "main", ... },
|
||||
{ "id": "old-orchestrator", ... }
|
||||
]
|
||||
},
|
||||
"bindings": [
|
||||
{ "agentId": "old-orchestrator", "match": { "channel": "telegram" } }
|
||||
]
|
||||
}
|
||||
|
||||
// After: Binding migrated to new orchestrator
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{ "id": "main", ... },
|
||||
{ "id": "old-orchestrator", ... },
|
||||
{ "id": "new-orchestrator", ... }
|
||||
]
|
||||
},
|
||||
"bindings": [
|
||||
{ "agentId": "new-orchestrator", "match": { "channel": "telegram" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Scenario 3: Power User (Multiple Agents)
|
||||
**File:** `tests/setup/power-user.test.ts`
|
||||
|
||||
**What's tested:**
|
||||
- No conflicts with group-specific bindings
|
||||
- Channel-wide binding creation alongside group bindings
|
||||
- Multiple orchestrators coexisting
|
||||
- Routing logic (specific bindings win over channel-wide)
|
||||
- WhatsApp support
|
||||
- Scale testing (12+ orchestrators)
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Before: Two project orchestrators with group-specific bindings
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{ "id": "project-a-orchestrator", ... },
|
||||
{ "id": "project-b-orchestrator", ... }
|
||||
]
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"agentId": "project-a-orchestrator",
|
||||
"match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1001234567890" } }
|
||||
},
|
||||
{
|
||||
"agentId": "project-b-orchestrator",
|
||||
"match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1009876543210" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// After: Channel-wide orchestrator added (no conflicts)
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{ "id": "project-a-orchestrator", ... },
|
||||
{ "id": "project-b-orchestrator", ... },
|
||||
{ "id": "global-orchestrator", ... }
|
||||
]
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"agentId": "project-a-orchestrator",
|
||||
"match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1001234567890" } }
|
||||
},
|
||||
{
|
||||
"agentId": "project-b-orchestrator",
|
||||
"match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1009876543210" } }
|
||||
},
|
||||
{
|
||||
"agentId": "global-orchestrator",
|
||||
"match": { "channel": "telegram" } // Channel-wide (no peer)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Routing: Group messages go to specific agents, everything else goes to global
|
||||
```
|
||||
|
||||
## Test Architecture
|
||||
|
||||
### Mock File System
|
||||
The tests use an in-memory mock file system (`MockFileSystem`) that simulates:
|
||||
- Reading/writing openclaw.json
|
||||
- Creating/reading workspace files
|
||||
- Tracking command executions (openclaw agents add)
|
||||
|
||||
**Why?** Tests run in isolation without touching the real file system, making them:
|
||||
- Fast (no I/O)
|
||||
- Reliable (no file conflicts)
|
||||
- Repeatable (clean state every test)
|
||||
|
||||
### Fixtures
|
||||
Pre-built configurations for different user types:
|
||||
- `createNewUserConfig()` - Empty slate
|
||||
- `createCommonUserConfig()` - One orchestrator with binding
|
||||
- `createPowerUserConfig()` - Multiple orchestrators with group bindings
|
||||
- `createNoChannelConfig()` - Channel not configured
|
||||
- `createDisabledChannelConfig()` - Channel disabled
|
||||
|
||||
### Assertions
|
||||
Reusable assertion helpers that make tests readable:
|
||||
```typescript
|
||||
assertAgentExists(mockFs, "my-agent", "My Agent");
|
||||
assertChannelBinding(mockFs, "my-agent", "telegram");
|
||||
assertWorkspaceFilesExist(mockFs, "my-agent");
|
||||
assertDevClawConfig(mockFs, { junior: "anthropic/claude-haiku-4-5" });
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
```yaml
|
||||
name: Test
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: npm run test:coverage
|
||||
- uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./coverage/coverage-final.json
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
```yaml
|
||||
test:
|
||||
image: node:20
|
||||
script:
|
||||
- npm ci
|
||||
- npm test
|
||||
- npm run test:coverage
|
||||
coverage: '/Lines\s*:\s*(\d+\.\d+)%/'
|
||||
artifacts:
|
||||
reports:
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage/cobertura-coverage.xml
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Run specific test
|
||||
```bash
|
||||
npm test -- new-user # Run all new-user tests
|
||||
npm test -- "should create agent" # Run tests matching pattern
|
||||
```
|
||||
|
||||
### Debug with Node inspector
|
||||
```bash
|
||||
node --inspect-brk node_modules/.bin/vitest run
|
||||
```
|
||||
|
||||
Then open Chrome DevTools at `chrome://inspect`
|
||||
|
||||
### View coverage report
|
||||
```bash
|
||||
npm run test:coverage
|
||||
open coverage/index.html
|
||||
```
|
||||
|
||||
## Adding Tests
|
||||
|
||||
### 1. Choose the right test file
|
||||
- New feature → `tests/setup/new-user.test.ts`
|
||||
- Migration feature → `tests/setup/existing-user.test.ts`
|
||||
- Multi-agent feature → `tests/setup/power-user.test.ts`
|
||||
|
||||
### 2. Write the test
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { MockFileSystem } from "../helpers/mock-fs.js";
|
||||
import { createNewUserConfig } from "../helpers/fixtures.js";
|
||||
import { assertAgentExists } from "../helpers/assertions.js";
|
||||
|
||||
describe("My new feature", () => {
|
||||
let mockFs: MockFileSystem;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs = new MockFileSystem(createNewUserConfig());
|
||||
});
|
||||
|
||||
it("should do something useful", async () => {
|
||||
// GIVEN: initial state (via fixture)
|
||||
const beforeCount = countAgents(mockFs);
|
||||
|
||||
// WHEN: execute the operation
|
||||
const config = mockFs.getConfig();
|
||||
config.agents.list.push({
|
||||
id: "test-agent",
|
||||
name: "Test Agent",
|
||||
workspace: "/home/test/.openclaw/workspace-test-agent",
|
||||
agentDir: "/home/test/.openclaw/agents/test-agent/agent",
|
||||
});
|
||||
mockFs.setConfig(config);
|
||||
|
||||
// THEN: verify the outcome
|
||||
assertAgentExists(mockFs, "test-agent", "Test Agent");
|
||||
expect(countAgents(mockFs)).toBe(beforeCount + 1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Run your test
|
||||
```bash
|
||||
npm test -- "should do something useful"
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
- Test one thing per test
|
||||
- Use descriptive test names ("should create agent with telegram binding")
|
||||
- Use fixtures for initial state
|
||||
- Use assertion helpers for readability
|
||||
- Test error cases
|
||||
|
||||
### ❌ DON'T
|
||||
- Test implementation details (test behavior, not internals)
|
||||
- Share state between tests (use beforeEach)
|
||||
- Mock everything (only mock file system and commands)
|
||||
- Write brittle tests (avoid hard-coded UUIDs, timestamps)
|
||||
|
||||
## Test Metrics
|
||||
|
||||
Current coverage:
|
||||
- **Lines:** Target 80%+
|
||||
- **Functions:** Target 90%+
|
||||
- **Branches:** Target 75%+
|
||||
|
||||
Run `npm run test:coverage` to see detailed metrics.
|
||||
16
index.ts
16
index.ts
@@ -6,6 +6,9 @@ import { createSessionHealthTool } from "./lib/tools/session-health.js";
|
||||
import { createProjectRegisterTool } from "./lib/tools/project-register.js";
|
||||
import { createTaskCreateTool } from "./lib/tools/task-create.js";
|
||||
import { createSetupTool } from "./lib/tools/devclaw-setup.js";
|
||||
import { createOnboardTool } from "./lib/tools/devclaw-onboard.js";
|
||||
import { createAnalyzeChannelBindingsTool } from "./lib/tools/analyze-channel-bindings.js";
|
||||
import { createContextTestTool } from "./lib/tools/context-test.js";
|
||||
import { registerCli } from "./lib/cli.js";
|
||||
|
||||
const plugin = {
|
||||
@@ -60,13 +63,24 @@ const plugin = {
|
||||
api.registerTool(createSetupTool(api), {
|
||||
names: ["devclaw_setup"],
|
||||
});
|
||||
api.registerTool(createOnboardTool(api), {
|
||||
names: ["devclaw_onboard"],
|
||||
});
|
||||
api.registerTool(createAnalyzeChannelBindingsTool(api), {
|
||||
names: ["analyze_channel_bindings"],
|
||||
});
|
||||
api.registerTool(createContextTestTool(api), {
|
||||
names: ["context_test"],
|
||||
});
|
||||
|
||||
// CLI: `openclaw devclaw setup`
|
||||
api.registerCli(({ program }: { program: any }) => registerCli(program), {
|
||||
commands: ["devclaw"],
|
||||
});
|
||||
|
||||
api.logger.info("DevClaw plugin registered (7 tools, 1 CLI command)");
|
||||
api.logger.info(
|
||||
"DevClaw plugin registered (10 tools, 1 CLI command)",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
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