feat: refactor model selection to use developer tiers

- Replaced raw model aliases with developer tiers (junior, medior, senior, qa) in dispatch and model selection logic.
- Updated `dispatchTask` to resolve models based on tiers and plugin configuration.
- Modified `selectModel` to return tier names instead of model aliases based on task description.
- Implemented migration logic for transitioning from old model aliases to new tier names in worker state.
- Added setup logic for agent creation and model configuration in `setup.ts`.
- Created shared templates for workspace files and instructions for DEV/QA workers.
- Enhanced project registration to scaffold role files based on developer tiers.
- Updated task management tools to reflect changes in model selection and tier assignment.
- Introduced a new `devclaw_setup` tool for agent-driven setup and configuration.
- Updated plugin configuration schema to support model mapping per developer tier.
This commit is contained in:
Lauren ten Hoor
2026-02-09 13:41:22 +08:00
parent 8a79755e4c
commit aa8e8dbd1b
16 changed files with 1162 additions and 257 deletions

114
README.md
View File

@@ -14,9 +14,22 @@ 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 creates (or reuses) a **DEV** worker session to write code or a **QA** worker session to review 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 group is a separate project — the orchestrator keeps them completely isolated while managing them all from a single process.
DevClaw gives the orchestrator six 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`.
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`.
## Developer tiers
DevClaw uses a developer seniority model. Each tier maps to a configurable LLM model:
| Tier | Role | Default model | Assigns to |
|------|------|---------------|------------|
| **junior** | Junior developer | `anthropic/claude-haiku-4-5` | Typos, single-file fixes, simple changes |
| **medior** | Mid-level developer | `anthropic/claude-sonnet-4-5` | Features, bug fixes, multi-file changes |
| **senior** | Senior developer | `anthropic/claude-opus-4-5` | Architecture, migrations, system-wide refactoring |
| **qa** | QA engineer | `anthropic/claude-sonnet-4-5` | Code review, test validation |
Configure which model each tier uses during setup or in `openclaw.json` plugin config.
## How it works
@@ -93,15 +106,15 @@ Workers (DEV/QA sub-agent sessions) call `task_complete` directly when they fini
### Auto-chaining
When a project has `autoChain: true`, `task_complete` automatically dispatches the next step:
- **DEV "done"** → QA is dispatched immediately (default model: grok)
- **QA "fail"** → DEV fix is dispatched immediately (reuses previous DEV model)
- **DEV "done"** → QA is dispatched immediately (using the qa tier)
- **QA "fail"** → DEV fix is dispatched immediately (reuses previous DEV tier)
- **QA "pass" / "refine"** → no chaining (pipeline done or needs human input)
When `autoChain` is false, `task_complete` returns a `nextAction` hint for the orchestrator to act on.
## Session reuse
Worker sessions are expensive to start — each new spawn requires the session to read the full codebase (~50K tokens). DevClaw maintains **separate sessions per model per role** (session-per-model design). When a DEV finishes task A and picks up task B on the same project with the same model, the plugin detects the existing session and sends the task directly — no new session needed.
Worker sessions are expensive to start — each new spawn requires the session to read the full codebase (~50K tokens). DevClaw maintains **separate sessions per tier per role** (session-per-tier design). When a medior dev finishes task A and picks up task B on the same project, the plugin detects the existing session and sends the task directly — no new session needed.
The plugin handles session dispatch internally via OpenClaw CLI. The orchestrator agent never calls `sessions_spawn` or `sessions_send` — it just calls `task_pickup` and the plugin does the rest.
@@ -114,26 +127,26 @@ sequenceDiagram
O->>DC: task_pickup({ issueId: 42, role: "dev" })
DC->>GL: Fetch issue, verify label
DC->>DC: Select model (haiku/sonnet/opus)
DC->>DC: Check existing session for selected model
DC->>DC: Assign tier (junior/medior/senior)
DC->>DC: Check existing session for assigned tier
DC->>GL: Transition label (To Do → Doing)
DC->>S: Dispatch task via CLI (create or reuse session)
DC->>DC: Update projects.json, write audit log
DC-->>O: { success: true, announcement: "🔧 DEV (sonnet) picking up #42" }
DC-->>O: { success: true, announcement: "🔧 DEV (medior) picking up #42" }
```
## Model selection
## Developer assignment
The orchestrator LLM analyzes each issue's title, description, and labels to choose the appropriate model tier, then passes it to `task_pickup` via the `model` parameter. This gives the LLM full context for the decision — it can weigh factors like codebase familiarity, task dependencies, and recent failure history that keyword matching would miss.
The orchestrator LLM evaluates each issue's title, description, and labels to assign the appropriate developer tier, then passes it to `task_pickup` via the `model` parameter. This gives the LLM full context for the decision — it can weigh factors like codebase familiarity, task dependencies, and recent failure history that keyword matching would miss.
The keyword heuristic in `model-selector.ts` serves as a **fallback only**, used when the orchestrator omits the `model` parameter.
| Complexity | Model | When |
|------------|-------|------|
| Simple | Haiku | Typos, CSS, renames, copy changes |
| Standard | Sonnet | Features, bug fixes, multi-file changes |
| Complex | Opus | Architecture, migrations, security, system-wide refactoring |
| QA | Grok | All QA tasks (code review, test validation) |
| Tier | Role | When |
|------|------|------|
| junior | Junior developer | Typos, CSS, renames, copy changes |
| medior | Mid-level developer | Features, bug fixes, multi-file changes |
| senior | Senior developer | Architecture, migrations, security, system-wide refactoring |
| qa | QA engineer | All QA tasks (code review, test validation) |
## State management
@@ -151,19 +164,19 @@ All project state lives in a single `memory/projects.json` file in the orchestra
"dev": {
"active": false,
"issueId": null,
"model": "haiku",
"model": "medior",
"sessions": {
"haiku": "agent:orchestrator:subagent:a9e4d078-...",
"sonnet": "agent:orchestrator:subagent:b3f5c912-...",
"opus": null
"junior": "agent:orchestrator:subagent:a9e4d078-...",
"medior": "agent:orchestrator:subagent:b3f5c912-...",
"senior": null
}
},
"qa": {
"active": false,
"issueId": null,
"model": "grok",
"model": "qa",
"sessions": {
"grok": "agent:orchestrator:subagent:18707821-..."
"qa": "agent:orchestrator:subagent:18707821-..."
}
}
}
@@ -172,7 +185,7 @@ All project state lives in a single `memory/projects.json` file in the orchestra
```
Key design decisions:
- **Session-per-model** — each model gets its own worker session, accumulating context independently. Model selection maps directly to a session key.
- **Session-per-tier** — each tier gets its own worker session, accumulating context independently. Tier selection maps directly to a session key.
- **Sessions preserved on completion** — when a worker completes a task, `sessions` map is **preserved** (only `active` and `issueId` are cleared). This enables session reuse on the next pickup.
- **Plugin-controlled dispatch** — the plugin creates and dispatches to sessions via OpenClaw CLI (`sessions.patch` + `openclaw agent`). The orchestrator agent never calls `sessions_spawn` or `sessions_send`.
- **Sessions persist indefinitely** — no auto-cleanup. `session_health` handles manual cleanup when needed.
@@ -181,27 +194,35 @@ All writes go through atomic temp-file-then-rename to prevent corruption.
## Tools
### `task_pickup`
### `devclaw_setup`
Pick up a task from the GitLab queue for a DEV or QA worker.
Set up DevClaw in an agent's workspace. Creates AGENTS.md, HEARTBEAT.md, role templates, and configures models. Can optionally create a new agent.
**Parameters:**
- `issueId` (number, required) — GitLab issue ID
- `newAgentName` (string, optional) — Create a new agent with this name
- `models` (object, optional) — Model overrides per tier: `{ junior, medior, senior, qa }`
### `task_pickup`
Pick up a task from the issue queue for a DEV or QA worker.
**Parameters:**
- `issueId` (number, required) — Issue ID
- `role` ("dev" | "qa", required) — Worker role
- `projectGroupId` (string, required) — Telegram group ID
- `model` (string, optional) — Model alias to use (e.g. haiku, sonnet, opus, grok). The orchestrator should analyze the issue complexity and choose. Falls back to keyword heuristic if omitted.
- `model` (string, optional) — Developer tier (junior, medior, senior, qa). The orchestrator should evaluate the task complexity and choose. Falls back to keyword heuristic if omitted.
**What it does atomically:**
1. Resolves project from `projects.json`
2. Validates no active worker for this role
3. Fetches issue from issue tracker, verifies correct label state
4. Selects model (LLM-chosen via `model` param, keyword heuristic fallback)
4. Assigns tier (LLM-chosen via `model` param, keyword heuristic fallback)
5. Loads role instructions from `roles/<project>/<role>.md` (fallback: `roles/default/<role>.md`)
6. Looks up existing session for selected model (session-per-model)
6. Looks up existing session for assigned tier (session-per-tier)
7. Transitions label (e.g. `To Do``Doing`)
8. Creates session via Gateway RPC if new (`sessions.patch`)
9. Dispatches task to worker session via CLI (`openclaw agent`) with role instructions appended
10. Updates `projects.json` state (active, issueId, model, session key)
10. Updates `projects.json` state (active, issueId, tier, session key)
11. Writes audit log entry
12. Returns announcement text for the orchestrator to post
@@ -216,9 +237,9 @@ Complete a task with one of four results. Called by workers (DEV/QA sub-agent se
- `summary` (string, optional) — For the Telegram announcement
**Results:**
- **DEV "done"** — Pulls latest code, moves label `Doing``To Test`, deactivates worker. If `autoChain` enabled, automatically dispatches QA (grok).
- **DEV "done"** — Pulls latest code, moves label `Doing``To Test`, deactivates worker. If `autoChain` enabled, automatically dispatches QA.
- **QA "pass"** — Moves label `Testing``Done`, closes issue, deactivates worker
- **QA "fail"** — Moves label `Testing``To Improve`, reopens issue. If `autoChain` enabled, automatically dispatches DEV fix (reuses previous model).
- **QA "fail"** — Moves label `Testing``To Improve`, reopens issue. If `autoChain` enabled, automatically dispatches DEV fix (reuses previous DEV tier).
- **QA "refine"** — Moves label `Testing``Refining`, awaits human decision
### `task_create`
@@ -284,24 +305,29 @@ Register a new project with DevClaw. Creates all required issue tracker labels (
Every tool call automatically appends an NDJSON entry to `memory/audit.log`. No manual logging required from the orchestrator agent.
```jsonl
{"ts":"2026-02-08T10:30:00Z","event":"task_pickup","project":"my-webapp","issue":42,"role":"dev","model":"sonnet","sessionAction":"send"}
{"ts":"2026-02-08T10:30:01Z","event":"model_selection","issue":42,"role":"dev","selected":"sonnet","reason":"Standard dev task"}
{"ts":"2026-02-08T10:30:00Z","event":"task_pickup","project":"my-webapp","issue":42,"role":"dev","tier":"medior","sessionAction":"send"}
{"ts":"2026-02-08T10:30:01Z","event":"model_selection","issue":42,"role":"dev","tier":"medior","reason":"Standard dev task"}
{"ts":"2026-02-08T10:45:00Z","event":"task_complete","project":"my-webapp","issue":42,"role":"dev","result":"done"}
```
## Installation
## Quick start
```bash
# Local (place in extensions directory — auto-discovered)
# 1. Install the plugin
cp -r devclaw ~/.openclaw/extensions/
# From npm (future)
openclaw plugins install @openclaw/devclaw
# 2. Run setup (interactive — creates agent, configures models, writes workspace files)
openclaw devclaw setup
# 3. Add bot to Telegram group, then register a project
# (via the agent in Telegram)
```
See the [Onboarding Guide](docs/ONBOARDING.md) for detailed instructions.
## Configuration
Optional config in `openclaw.json`:
Model tier configuration in `openclaw.json`:
```json
{
@@ -309,7 +335,12 @@ Optional config in `openclaw.json`:
"entries": {
"devclaw": {
"config": {
"glabPath": "/usr/local/bin/glab"
"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"
}
}
}
}
@@ -325,7 +356,7 @@ Restrict tools to your orchestrator agent only:
"list": [{
"id": "my-orchestrator",
"tools": {
"allow": ["task_pickup", "task_complete", "task_create", "queue_status", "session_health", "project_register"]
"allow": ["devclaw_setup", "task_pickup", "task_complete", "task_create", "queue_status", "session_health", "project_register"]
}
}]
}
@@ -359,7 +390,6 @@ workspace/
- [OpenClaw](https://openclaw.ai)
- Node.js >= 20
- [`glab`](https://gitlab.com/gitlab-org/cli) CLI installed and authenticated (GitLab provider), or [`gh`](https://cli.github.com) CLI (GitHub provider)
- A `memory/projects.json` in the orchestrator agent's workspace
## License

View File

@@ -8,28 +8,28 @@ Understanding the OpenClaw model is key to understanding how DevClaw works:
- **Session** — A runtime conversation instance. Each session has its own context window and conversation history, stored as a `.jsonl` transcript file.
- **Sub-agent session** — A session created under the orchestrator agent for a specific worker role. NOT a separate agent — it's a child session running under the same agent, with its own isolated context. Format: `agent:<parent>:subagent:<uuid>`.
### Session-per-model design
### Session-per-tier design
Each project maintains **separate sessions per model per role**. A project's DEV might have a Haiku session, a Sonnet session, and an Opus session — each accumulating its own codebase context over time.
Each project maintains **separate sessions per developer tier per role**. A project's DEV might have a junior session, a medior session, and a senior session — each accumulating its own codebase context over time.
```
Orchestrator Agent (configured in openclaw.json)
└─ Main session (long-lived, handles all projects)
├─ Project A
│ ├─ DEV sessions: { haiku: <uuid>, sonnet: <uuid>, opus: null }
│ └─ QA sessions: { grok: <uuid> }
│ ├─ DEV sessions: { junior: <uuid>, medior: <uuid>, senior: null }
│ └─ QA sessions: { qa: <uuid> }
└─ Project B
├─ DEV sessions: { haiku: null, sonnet: <uuid>, opus: null }
└─ QA sessions: { grok: <uuid> }
├─ DEV sessions: { junior: null, medior: <uuid>, senior: null }
└─ QA sessions: { qa: <uuid> }
```
Why per-model instead of switching models on one session:
Why per-tier instead of switching models on one session:
- **No model switching overhead** — each session always uses the same model
- **Accumulated context** — a Haiku session that's done 20 typo fixes knows the project well; a Sonnet session that's done 5 features knows it differently
- **Accumulated context** — a junior session that's done 20 typo fixes knows the project well; a medior session that's done 5 features knows it differently
- **No cross-model confusion** — conversation history stays with the model that generated it
- **Deterministic reuse** — model selection directly maps to a session key, no patching needed
- **Deterministic reuse** — tier selection directly maps to a session key, no patching needed
### Plugin-controlled session lifecycle
@@ -37,14 +37,14 @@ DevClaw controls the **full** session lifecycle end-to-end. The orchestrator age
```
Plugin dispatch (inside task_pickup):
1. Select model, look up session, decide spawn vs send
1. Assign tier, look up session, decide spawn vs send
2. New session: openclaw gateway call sessions.patch → create entry + set model
openclaw agent --session-id <key> --message "task..."
3. Existing: openclaw agent --session-id <key> --message "task..."
4. Return result to orchestrator (announcement text, no session instructions)
```
The agent's only job after `task_pickup` returns is to post the announcement to Telegram. Everything else — model selection, session creation, task dispatch, state update, audit logging — is deterministic plugin code.
The agent's only job after `task_pickup` returns is to post the announcement to Telegram. Everything else — tier assignment, session creation, task dispatch, state update, audit logging — is deterministic plugin code.
**Why this matters:** Previously the plugin returned instructions like `{ sessionAction: "spawn", model: "sonnet" }` and the agent had to correctly call `sessions_spawn` with the right params. This was the fragile handoff point where agents would forget `cleanup: "keep"`, use wrong models, or corrupt session state. Moving dispatch into the plugin eliminates that entire class of errors.
@@ -75,10 +75,10 @@ graph TB
MS[Main Session<br/>orchestrator agent]
GW[Gateway RPC<br/>sessions.patch / sessions.list]
CLI[openclaw agent CLI]
DEV_H[DEV session<br/>haiku]
DEV_S[DEV session<br/>sonnet]
DEV_O[DEV session<br/>opus]
QA_G[QA session<br/>grok]
DEV_J[DEV session<br/>junior]
DEV_M[DEV session<br/>medior]
DEV_S[DEV session<br/>senior]
QA_E[QA session<br/>qa]
end
subgraph "DevClaw Plugin"
@@ -88,13 +88,14 @@ graph TB
QS[queue_status]
SH[session_health]
PR[project_register]
MS_SEL[Model Selector]
DS[devclaw_setup]
TIER[Tier Resolver]
PJ[projects.json]
AL[audit.log]
end
subgraph "External"
GL[GitLab]
GL[Issue Tracker]
REPO[Git Repository]
end
@@ -108,8 +109,9 @@ graph TB
MS -->|calls| QS
MS -->|calls| SH
MS -->|calls| PR
MS -->|calls| DS
TP -->|selects model| MS_SEL
TP -->|resolves tier| TIER
TP -->|transitions labels| GL
TP -->|reads/writes| PJ
TP -->|appends| AL
@@ -139,15 +141,15 @@ graph TB
PR -->|writes entry| PJ
PR -->|appends| AL
CLI -->|sends task| DEV_H
CLI -->|sends task| DEV_J
CLI -->|sends task| DEV_M
CLI -->|sends task| DEV_S
CLI -->|sends task| DEV_O
CLI -->|sends task| QA_G
CLI -->|sends task| QA_E
DEV_H -->|writes code, creates MRs| REPO
DEV_J -->|writes code, creates MRs| REPO
DEV_M -->|writes code, creates MRs| REPO
DEV_S -->|writes code, creates MRs| REPO
DEV_O -->|writes code, creates MRs| REPO
QA_G -->|reviews code, tests| REPO
QA_E -->|reviews code, tests| REPO
```
## End-to-end flow: human to sub-agent
@@ -162,8 +164,8 @@ sequenceDiagram
participant DC as DevClaw Plugin
participant GW as Gateway RPC
participant CLI as openclaw agent CLI
participant DEV as DEV Session<br/>(sonnet)
participant GL as GitLab
participant DEV as DEV Session<br/>(medior)
participant GL as Issue Tracker
Note over H,GL: Issue exists in queue (To Do)
@@ -173,49 +175,49 @@ sequenceDiagram
DC->>GL: glab issue list --label "To Do"
DC-->>MS: { toDo: [#42], dev: idle }
Note over MS: Decides to pick up #42 for DEV
Note over MS: Decides to pick up #42 for DEV as medior
MS->>DC: task_pickup({ issueId: 42, role: "dev", ... })
DC->>DC: selectModel → "sonnet"
DC->>DC: lookup dev.sessions.sonnet → null (first time)
MS->>DC: task_pickup({ issueId: 42, role: "dev", model: "medior", ... })
DC->>DC: resolve tier "medior" → model ID
DC->>DC: lookup dev.sessions.medior → null (first time)
DC->>GL: glab issue update 42 --unlabel "To Do" --label "Doing"
DC->>GW: sessions.patch({ key: new-session-key, model: "sonnet" })
DC->>GW: sessions.patch({ key: new-session-key, model: "anthropic/claude-sonnet-4-5" })
DC->>CLI: openclaw agent --session-id <key> --message "Build login page for #42..."
CLI->>DEV: creates session, delivers task
DC->>DC: store session key in projects.json + append audit.log
DC-->>MS: { success: true, announcement: "🔧 DEV (sonnet) picking up #42" }
DC-->>MS: { success: true, announcement: "🔧 DEV (medior) picking up #42" }
MS->>TG: "🔧 DEV (sonnet) picking up #42: Add login page"
MS->>TG: "🔧 DEV (medior) picking up #42: Add login page"
TG->>H: sees announcement
Note over DEV: Works autonomously — reads code, writes code, creates MR
Note over MS: Heartbeat detects DEV session idle → triggers task_complete
Note over DEV: Calls task_complete when done
MS->>DC: task_complete({ role: "dev", result: "done", ... })
DEV->>DC: task_complete({ role: "dev", result: "done", ... })
DC->>GL: glab issue update 42 --unlabel "Doing" --label "To Test"
DC->>DC: deactivate worker (sessions preserved)
DC-->>MS: { announcement: "✅ DEV done #42" }
DC-->>DEV: { announcement: "✅ DEV done #42" }
MS->>TG: "✅ DEV done #42 — moved to QA queue"
TG->>H: sees announcement
```
On the **next DEV task** for this project that also selects Sonnet:
On the **next DEV task** for this project that also assigns medior:
```mermaid
sequenceDiagram
participant MS as Main Session
participant DC as DevClaw Plugin
participant CLI as openclaw agent CLI
participant DEV as DEV Session<br/>(sonnet, existing)
participant DEV as DEV Session<br/>(medior, existing)
MS->>DC: task_pickup({ issueId: 57, role: "dev", ... })
DC->>DC: selectModel → "sonnet"
DC->>DC: lookup dev.sessions.sonnet → existing key!
MS->>DC: task_pickup({ issueId: 57, role: "dev", model: "medior", ... })
DC->>DC: resolve tier "medior" → model ID
DC->>DC: lookup dev.sessions.medior → existing key!
Note over DC: No sessions.patch needed — session already exists
DC->>CLI: openclaw agent --session-id <key> --message "Fix validation for #57..."
CLI->>DEV: delivers task to existing session (has full codebase context)
DC-->>MS: { success: true, announcement: "⚡ DEV (sonnet) picking up #57" }
DC-->>MS: { success: true, announcement: "⚡ DEV (medior) picking up #57" }
```
Session reuse saves ~50K tokens per task by not re-reading the codebase.
@@ -229,10 +231,10 @@ This traces a single issue from creation to completion, showing every component
Issues are created by the orchestrator agent or by sub-agent sessions via `glab`. The orchestrator can create issues based on user requests in Telegram, backlog planning, or QA feedback. Sub-agents can also create issues when they discover bugs or related work during development.
```
Orchestrator Agent → GitLab: creates issue #42 with label "To Do"
Orchestrator Agent → Issue Tracker: creates issue #42 with label "To Do"
```
**State:** GitLab has issue #42 labeled "To Do". Nothing in DevClaw yet.
**State:** Issue tracker has issue #42 labeled "To Do". Nothing in DevClaw yet.
### Phase 2: Heartbeat detects work
@@ -244,7 +246,7 @@ Heartbeat triggers → Orchestrator calls queue_status()
sequenceDiagram
participant A as Orchestrator
participant QS as queue_status
participant GL as GitLab
participant GL as Issue Tracker
participant PJ as projects.json
participant AL as audit.log
@@ -261,33 +263,33 @@ sequenceDiagram
QS-->>A: { dev: idle, queue: { toDo: [#42] } }
```
**Orchestrator decides:** DEV is idle, issue #42 is in To Do → pick it up.
**Orchestrator decides:** DEV is idle, issue #42 is in To Do → pick it up. Evaluates complexity → assigns medior tier.
### Phase 3: DEV pickup
The plugin handles everything end-to-end — model selection, session lookup, label transition, state update, **and** task dispatch to the worker session. The agent's only job after is to post the announcement.
The plugin handles everything end-to-end — tier resolution, session lookup, label transition, state update, **and** task dispatch to the worker session. The agent's only job after is to post the announcement.
```mermaid
sequenceDiagram
participant A as Orchestrator
participant TP as task_pickup
participant GL as GitLab
participant MS as Model Selector
participant GL as Issue Tracker
participant TIER as Tier Resolver
participant GW as Gateway RPC
participant CLI as openclaw agent CLI
participant PJ as projects.json
participant AL as audit.log
A->>TP: task_pickup({ issueId: 42, role: "dev", projectGroupId: "-123" })
A->>TP: task_pickup({ issueId: 42, role: "dev", projectGroupId: "-123", model: "medior" })
TP->>PJ: readProjects()
TP->>GL: glab issue view 42 --output json
GL-->>TP: { title: "Add login page", labels: ["To Do"] }
TP->>TP: Verify label is "To Do" ✓
TP->>TP: model from agent param (LLM-selected) or fallback heuristic
TP->>PJ: lookup dev.sessions.sonnet
TP->>TIER: resolve "medior" → "anthropic/claude-sonnet-4-5"
TP->>PJ: lookup dev.sessions.medior
TP->>GL: glab issue update 42 --unlabel "To Do" --label "Doing"
alt New session
TP->>GW: sessions.patch({ key: new-key, model: "sonnet" })
TP->>GW: sessions.patch({ key: new-key, model: "anthropic/claude-sonnet-4-5" })
end
TP->>CLI: openclaw agent --session-id <key> --message "task..."
TP->>PJ: activateWorker + store session key
@@ -296,8 +298,8 @@ sequenceDiagram
```
**Writes:**
- `GitLab`: label "To Do" → "Doing"
- `projects.json`: dev.active=true, dev.issueId="42", dev.model="sonnet", dev.sessions.sonnet=key
- `Issue Tracker`: label "To Do" → "Doing"
- `projects.json`: dev.active=true, dev.issueId="42", dev.model="medior", dev.sessions.medior=key
- `audit.log`: 2 entries (task_pickup, model_selection)
- `Session`: task message delivered to worker session via CLI
@@ -316,7 +318,7 @@ This happens inside the OpenClaw session. The worker calls `task_complete` direc
sequenceDiagram
participant DEV as DEV Session
participant TC as task_complete
participant GL as GitLab
participant GL as Issue Tracker
participant PJ as projects.json
participant AL as audit.log
participant REPO as Git Repo
@@ -333,7 +335,7 @@ sequenceDiagram
alt autoChain enabled
TC->>GL: transition label "To Test" → "Testing"
TC->>QA: dispatchTask(role: "qa", model: "grok")
TC->>QA: dispatchTask(role: "qa", tier: "qa")
TC->>PJ: activateWorker(-123, qa)
TC-->>DEV: { announcement: "✅ DEV done #42", autoChain: { dispatched: true, role: "qa" } }
else autoChain disabled
@@ -344,12 +346,12 @@ sequenceDiagram
**Writes:**
- `Git repo`: pulled latest (has DEV's merged code)
- `projects.json`: dev.active=false, dev.issueId=null (sessions map preserved for reuse)
- `GitLab`: label "Doing" → "To Test" (+ "To Test" → "Testing" if auto-chain)
- `Issue Tracker`: label "Doing" → "To Test" (+ "To Test" → "Testing" if auto-chain)
- `audit.log`: 1 entry (task_complete) + optional auto-chain entries
### Phase 6: QA pickup
Same as Phase 3, but with `role: "qa"`. Label transitions "To Test" → "Testing". Model defaults to Grok for QA.
Same as Phase 3, but with `role: "qa"`. Label transitions "To Test" → "Testing". Uses the qa tier.
### Phase 7: QA result (3 possible outcomes)
@@ -359,7 +361,7 @@ Same as Phase 3, but with `role: "qa"`. Label transitions "To Test" → "Testing
sequenceDiagram
participant A as Orchestrator
participant TC as task_complete
participant GL as GitLab
participant GL as Issue Tracker
participant PJ as projects.json
participant AL as audit.log
@@ -379,8 +381,7 @@ sequenceDiagram
sequenceDiagram
participant A as Orchestrator
participant TC as task_complete
participant GL as GitLab
participant MS as Model Selector
participant GL as Issue Tracker
participant PJ as projects.json
participant AL as audit.log
@@ -421,12 +422,12 @@ sequenceDiagram
A->>QS: queue_status()
QS-->>A: { projects: [{ dev: idle, queue: { toDo: [#43], toTest: [#44] } }] }
Note over A: DEV idle + To Do #43 → pick up
A->>TP: task_pickup({ issueId: 43, role: "dev", ... })
Note over TP: Plugin handles everything:<br/>model select → session lookup →<br/>label transition → dispatch task →<br/>state update → audit log
Note over A: DEV idle + To Do #43 → assign medior
A->>TP: task_pickup({ issueId: 43, role: "dev", model: "medior", ... })
Note over TP: Plugin handles everything:<br/>tier resolve → session lookup →<br/>label transition → dispatch task →<br/>state update → audit log
Note over A: QA idle + To Test #44 → pick up
A->>TP: task_pickup({ issueId: 44, role: "qa", ... })
Note over A: QA idle + To Test #44 → assign qa
A->>TP: task_pickup({ issueId: 44, role: "qa", model: "qa", ... })
```
## Data flow map
@@ -447,7 +448,8 @@ Every piece of data and where it lives:
┌─────────────────────────────────────────────────────────────────┐
│ DevClaw Plugin (orchestration logic) │
│ │
task_pickup model + label + dispatch + role instr (e2e)
devclaw_setup → agent creation + workspace + model config
│ task_pickup → tier + label + dispatch + role instr (e2e) │
│ task_complete → label + state + git pull + auto-chain │
│ task_create → create issue in tracker │
│ queue_status → read labels + read state │
@@ -462,13 +464,13 @@ Every piece of data and where it lives:
│ dev: │ │ openclaw gateway call │
│ active, issueId, model │ │ sessions.patch → create │
│ sessions: │ │ sessions.list → health │
haiku: <key> │ │ sessions.delete → cleanup │
sonnet: <key> │ │ │
opus: <key> │ │ openclaw agent │
junior: <key> │ │ sessions.delete → cleanup │
medior: <key> │ │ │
senior: <key> │ │ openclaw agent │
│ qa: │ │ --session-id <key> │
│ active, issueId, model │ │ --message "task..." │
│ sessions: │ │ → dispatches to session │
grok: <key> │ │ │
qa: <key> │ │ │
└────────────────────────────────┘ └──────────────────────────────┘
↕ append-only
┌─────────────────────────────────────────────────────────────────┐
@@ -477,7 +479,7 @@ Every piece of data and where it lives:
│ NDJSON, one line per event: │
│ task_pickup, task_complete, model_selection, │
│ queue_status, health_check, session_spawn, session_reuse, │
│ project_register
│ project_register, devclaw_setup
│ │
│ Query with: cat audit.log | jq 'select(.event=="task_pickup")' │
└─────────────────────────────────────────────────────────────────┘
@@ -486,8 +488,8 @@ Every piece of data and where it lives:
│ Telegram (user-facing messages) │
│ │
│ Per group chat: │
│ "🔧 Spawning DEV (sonnet) for #42: Add login page" │
│ "⚡ Sending DEV (sonnet) for #57: Fix validation" │
│ "🔧 Spawning DEV (medior) for #42: Add login page" │
│ "⚡ Sending DEV (medior) for #57: Fix validation" │
│ "✅ DEV done #42 — Login page with OAuth. Moved to QA queue."│
│ "🎉 QA PASS #42. Issue closed." │
│ "❌ QA FAIL #42 — OAuth redirect broken. Sent back to DEV." │
@@ -512,6 +514,7 @@ graph LR
L[Label transitions]
S[Worker state]
PR[Project registration]
SETUP[Agent + workspace setup]
SD[Session dispatch<br/>create + send via CLI]
AC[Auto-chaining<br/>DEV→QA, QA fail→DEV]
RI[Role instructions<br/>loaded per project]
@@ -523,7 +526,7 @@ graph LR
MSG[Telegram announcements]
HB[Heartbeat scheduling]
DEC[Task prioritization]
M[Model selection]
M[Developer assignment<br/>junior/medior/senior]
end
subgraph "Sub-agent sessions handle"
@@ -565,7 +568,7 @@ Provider selection is handled by `createProvider()` in `lib/providers/index.ts`.
| Failure | Detection | Recovery |
|---|---|---|
| Session dies mid-task | `session_health` checks via `sessions.list` Gateway RPC | `autoFix`: reverts label, clears active state, removes dead session from sessions map. Next heartbeat picks up task again (creates fresh session for that model). |
| Session dies mid-task | `session_health` checks via `sessions.list` Gateway RPC | `autoFix`: reverts label, clears active state, removes dead session from sessions map. Next heartbeat picks up task again (creates fresh session for that tier). |
| glab command fails | Plugin tool throws error, returns to agent | Agent retries or reports to Telegram group |
| `openclaw agent` CLI fails | Plugin catches error during dispatch | Plugin rolls back: reverts label, clears active state. Returns error to agent for reporting. |
| `sessions.patch` fails | Plugin catches error during session creation | Plugin rolls back label transition. Returns error. No orphaned state. |
@@ -581,7 +584,7 @@ Provider selection is handled by `createProvider()` in `lib/providers/index.ts`.
|---|---|---|
| Plugin source | `~/.openclaw/extensions/devclaw/` | Plugin code |
| Plugin manifest | `~/.openclaw/extensions/devclaw/openclaw.plugin.json` | Plugin registration |
| Agent config | `~/.openclaw/openclaw.json` | Agent definition + tool permissions |
| Agent config | `~/.openclaw/openclaw.json` | Agent definition + tool permissions + tier config |
| Worker state | `~/.openclaw/workspace-<agent>/memory/projects.json` | Per-project DEV/QA state |
| Audit log | `~/.openclaw/workspace-<agent>/memory/audit.log` | NDJSON event log |
| Session transcripts | `~/.openclaw/agents/<agent>/sessions/<uuid>.jsonl` | Conversation history per session |

View File

@@ -9,9 +9,8 @@
| [`glab`](https://gitlab.com/gitlab-org/cli) or [`gh`](https://cli.github.com) CLI | Issue tracker provider (auto-detected from remote) | `glab --version` or `gh --version` |
| CLI authenticated | Plugin calls glab/gh for every label transition | `glab auth status` or `gh auth status` |
| A GitLab/GitHub repo with issues | The task backlog lives in the issue tracker | `glab issue list` or `gh issue list` from your repo |
| An OpenClaw agent with Telegram | The orchestrator agent that will manage projects | Agent defined in `openclaw.json` |
## Setup steps
## Setup
### 1. Install the plugin
@@ -26,35 +25,38 @@ openclaw plugins list
# Should show: DevClaw | devclaw | loaded
```
### 2. Configure your orchestrator agent
### 2. Run setup
In `openclaw.json`, your orchestrator agent needs access to the DevClaw tools:
```json
{
"agents": {
"list": [{
"id": "my-orchestrator",
"name": "Dev Orchestrator",
"model": "anthropic/claude-sonnet-4-5",
"tools": {
"allow": [
"task_pickup",
"task_complete",
"task_create",
"queue_status",
"session_health",
"project_register"
]
}
}]
}
}
```bash
openclaw devclaw setup
```
The agent needs the six DevClaw tools. Session management (`sessions_spawn`, `sessions_send`) is **not needed** — the plugin handles session creation and task dispatch internally via OpenClaw CLI. Workers (DEV/QA sub-agent sessions) also use `task_complete` and `task_create` directly for atomic self-reporting.
The setup wizard walks you through:
### 3. Register your project
1. **Agent** — Create a new orchestrator agent or configure an existing one
2. **Developer team** — Choose which LLM model powers each developer tier:
- **Junior** (fast, cheap tasks) — default: `anthropic/claude-haiku-4-5`
- **Medior** (standard tasks) — default: `anthropic/claude-sonnet-4-5`
- **Senior** (complex tasks) — default: `anthropic/claude-opus-4-5`
- **QA** (code review) — default: `anthropic/claude-sonnet-4-5`
3. **Workspace** — Writes AGENTS.md, HEARTBEAT.md, role templates, and initializes memory
Non-interactive mode:
```bash
# Create new agent with default models
openclaw devclaw setup --new-agent "My Dev Orchestrator" --non-interactive
# Configure existing agent with custom models
openclaw devclaw setup --agent my-orchestrator \
--junior "anthropic/claude-haiku-4-5" \
--senior "anthropic/claude-opus-4-5"
```
### 3. Add the agent to the Telegram group
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.
### 4. Register your project
Tell the orchestrator agent to register a new project:
@@ -83,14 +85,14 @@ The agent calls `project_register`, which atomically:
"issueId": null,
"startTime": null,
"model": null,
"sessions": { "haiku": null, "sonnet": null, "opus": null }
"sessions": { "junior": null, "medior": null, "senior": null }
},
"qa": {
"active": false,
"issueId": null,
"startTime": null,
"model": null,
"sessions": { "grok": null }
"sessions": { "qa": null }
}
}
}
@@ -101,10 +103,6 @@ The agent calls `project_register`, which atomically:
**Finding the Telegram group ID:** The group ID is the numeric ID of your Telegram supergroup (a negative number like `-1234567890`). You can find it via the Telegram bot API or from message metadata in OpenClaw logs.
### 4. Add the agent to the Telegram group
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.
### 5. Create your first issue
Issues can be created in multiple ways:
@@ -123,7 +121,7 @@ The agent should call `queue_status` and report the "To Do" issue. Then:
> "Pick up issue #1 for DEV"
The agent calls `task_pickup`, which selects a model, transitions the label to "Doing", creates or reuses a worker session, and dispatches the task — all in one call. The agent just posts the announcement.
The agent calls `task_pickup`, which assigns a developer tier, transitions the label to "Doing", creates or reuses a worker session, and dispatches the task — all in one call. The agent just posts the announcement.
## Adding more projects
@@ -131,18 +129,50 @@ Tell the agent to register a new project (step 3) and add the bot to the new Tel
Each project is fully isolated — separate queue, separate workers, separate state.
## Developer tiers
DevClaw assigns tasks to developer tiers instead of raw model names. This makes the system intuitive — you're assigning a "junior dev" to fix a typo, not configuring model parameters.
| Tier | Role | Default model | When to assign |
|------|------|---------------|----------------|
| **junior** | Junior developer | `anthropic/claude-haiku-4-5` | Typos, single-file fixes, CSS changes |
| **medior** | Mid-level developer | `anthropic/claude-sonnet-4-5` | Features, bug fixes, multi-file changes |
| **senior** | Senior developer | `anthropic/claude-opus-4-5` | Architecture, migrations, system-wide refactoring |
| **qa** | QA engineer | `anthropic/claude-sonnet-4-5` | Code review, test validation |
Change which model powers each tier in `openclaw.json`:
```json
{
"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"
}
}
}
}
}
}
```
## What the plugin handles vs. what you handle
| 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 |
| 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 |
| Agent definition | You (once) | Agent in `openclaw.json` with tool permissions |
| Telegram group setup | You (once per project) | Add bot to group |
| Issue creation | Plugin (`task_create`) | Orchestrator or workers create issues from chat |
| Label transitions | Plugin | Atomic label transitions via issue tracker CLI |
| Model selection | Plugin | LLM-selected by orchestrator, keyword heuristic fallback |
| Developer assignment | Plugin | LLM-selected tier by orchestrator, keyword heuristic fallback |
| State management | Plugin | Atomic read/write to `projects.json` |
| Session management | Plugin | Creates, reuses, and dispatches to sessions via CLI. Agent never touches session tools. |
| Task completion | Plugin (`task_complete`) | Workers self-report. Auto-chains if enabled. |

View File

@@ -5,13 +5,37 @@ import { createQueueStatusTool } from "./lib/tools/queue-status.js";
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 { runCli } from "./lib/cli.js";
const plugin = {
id: "devclaw",
name: "DevClaw",
description:
"Multi-project dev/qa pipeline orchestration with GitHub/GitLab integration, model selection, and audit logging.",
configSchema: {},
"Multi-project dev/qa pipeline orchestration with GitHub/GitLab integration, developer tiers, and audit logging.",
configSchema: {
type: "object",
properties: {
models: {
type: "object",
description: "Model mapping per developer tier",
properties: {
junior: { type: "string", description: "Junior dev model" },
medior: { type: "string", description: "Medior dev model" },
senior: { type: "string", description: "Senior dev model" },
qa: { type: "string", description: "QA engineer model" },
},
},
glabPath: {
type: "string",
description: "Path to glab CLI binary. Defaults to 'glab' on PATH.",
},
ghPath: {
type: "string",
description: "Path to gh CLI binary. Defaults to 'gh' on PATH.",
},
},
},
register(api: OpenClawPluginApi) {
// Agent tools (primary interface — agent calls these directly)
@@ -33,8 +57,19 @@ const plugin = {
api.registerTool(createTaskCreateTool(api), {
names: ["task_create"],
});
api.registerTool(createSetupTool(api), {
names: ["devclaw_setup"],
});
api.logger.info("DevClaw plugin registered (6 tools)");
// CLI commands
api.registerCli("setup", {
description: "Set up DevClaw: create agent, configure models, write workspace files",
run: async (argv: string[]) => {
await runCli(argv);
},
});
api.logger.info("DevClaw plugin registered (7 tools, 1 CLI command)");
},
};

208
lib/cli.ts Normal file
View File

@@ -0,0 +1,208 @@
/**
* cli.ts — CLI command for `openclaw devclaw setup`.
*
* Interactive and non-interactive modes for onboarding.
*/
import { createInterface } from "node:readline";
import { runSetup, type SetupOpts } from "./setup.js";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js";
type CliArgs = {
/** Create a new agent */
newAgent?: string;
/** Use existing agent */
agent?: string;
/** Direct workspace path */
workspace?: string;
/** Model overrides */
junior?: string;
medior?: string;
senior?: string;
qa?: string;
/** Skip prompts */
nonInteractive?: boolean;
};
/**
* Parse CLI arguments from argv-style array.
* Expects: ["setup", "--new-agent", "name", "--junior", "model", ...]
*/
export function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = argv[i + 1];
switch (arg) {
case "--new-agent":
args.newAgent = next;
i++;
break;
case "--agent":
args.agent = next;
i++;
break;
case "--workspace":
args.workspace = next;
i++;
break;
case "--junior":
args.junior = next;
i++;
break;
case "--medior":
args.medior = next;
i++;
break;
case "--senior":
args.senior = next;
i++;
break;
case "--qa":
args.qa = next;
i++;
break;
case "--non-interactive":
args.nonInteractive = true;
break;
}
}
return args;
}
/**
* Run the interactive setup wizard.
*/
async function interactiveSetup(): Promise<SetupOpts> {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
const ask = (question: string): Promise<string> =>
new Promise((resolve) => rl.question(question, resolve));
console.log("");
console.log("DevClaw Setup");
console.log("=============");
console.log("");
// Step 1: Agent
console.log("Step 1: Agent");
console.log("─────────────");
const agentChoice = await ask(
"Create a new agent or use an existing one? [new/existing]: ",
);
let newAgentName: string | undefined;
let agentId: string | undefined;
if (agentChoice.toLowerCase().startsWith("n")) {
newAgentName = await ask("Agent name: ");
if (!newAgentName.trim()) {
rl.close();
throw new Error("Agent name cannot be empty");
}
newAgentName = newAgentName.trim();
} else {
agentId = await ask("Agent ID: ");
if (!agentId.trim()) {
rl.close();
throw new Error("Agent ID cannot be empty");
}
agentId = agentId.trim();
}
// Step 2: Models
console.log("");
console.log("Step 2: Developer Team (models)");
console.log("───────────────────────────────");
console.log("Press Enter to accept defaults.");
console.log("");
const models: Partial<Record<Tier, string>> = {};
for (const tier of ALL_TIERS) {
const label =
tier === "junior"
? "Junior dev (fast, cheap tasks)"
: tier === "medior"
? "Medior dev (standard tasks)"
: tier === "senior"
? "Senior dev (complex tasks)"
: "QA engineer (code review)";
const answer = await ask(` ${label} [${DEFAULT_MODELS[tier]}]: `);
if (answer.trim()) {
models[tier] = answer.trim();
}
}
rl.close();
console.log("");
console.log("Step 3: Workspace");
console.log("─────────────────");
return { newAgentName, agentId, models };
}
/**
* Main CLI entry point.
*/
export async function runCli(argv: string[]): Promise<void> {
const args = parseArgs(argv);
let opts: SetupOpts;
if (args.nonInteractive || args.newAgent || args.agent || args.workspace) {
// Non-interactive mode
const models: Partial<Record<Tier, string>> = {};
if (args.junior) models.junior = args.junior;
if (args.medior) models.medior = args.medior;
if (args.senior) models.senior = args.senior;
if (args.qa) models.qa = args.qa;
opts = {
newAgentName: args.newAgent,
agentId: args.agent,
workspacePath: args.workspace,
models: Object.keys(models).length > 0 ? models : undefined,
};
} else {
// Interactive mode
opts = await interactiveSetup();
}
console.log("");
const result = await runSetup(opts);
// Print results
if (result.agentCreated) {
console.log(` Agent "${result.agentId}" created`);
}
console.log(` Models configured:`);
for (const tier of ALL_TIERS) {
console.log(` ${tier}: ${result.models[tier]}`);
}
console.log(` Files written:`);
for (const file of result.filesWritten) {
console.log(` ${file}`);
}
if (result.warnings.length > 0) {
console.log("");
console.log(" Warnings:");
for (const w of result.warnings) {
console.log(` ${w}`);
}
}
console.log("");
console.log("Done! Next steps:");
console.log(" 1. Add bot to a Telegram group");
console.log(
' 2. Register a project: "Register project <name> at <repo> for group <id>"',
);
console.log(" 3. Create your first issue and pick it up");
console.log("");
}

View File

@@ -18,16 +18,10 @@ import {
} from "./projects.js";
import { selectModel } from "./model-selector.js";
import { log as auditLog } from "./audit.js";
import { resolveModel, TIER_EMOJI, isTier } from "./tiers.js";
const execFileAsync = promisify(execFile);
export const MODEL_MAP: Record<string, string> = {
haiku: "anthropic/claude-haiku-4-5",
sonnet: "anthropic/claude-sonnet-4-5",
opus: "anthropic/claude-opus-4-5",
grok: "github-copilot/grok-code-fast-1",
};
export type DispatchOpts = {
workspaceDir: string;
agentId: string;
@@ -38,6 +32,7 @@ export type DispatchOpts = {
issueDescription: string;
issueUrl: string;
role: "dev" | "qa";
/** Developer tier (junior, medior, senior, qa) or raw model ID */
modelAlias: string;
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
fromLabel: string;
@@ -45,6 +40,8 @@ export type DispatchOpts = {
toLabel: string;
/** Function to transition labels (injected to avoid gitlab.ts dependency) */
transitionLabel: (issueId: number, from: string, to: string) => Promise<void>;
/** Plugin config for model resolution */
pluginConfig?: Record<string, unknown>;
};
export type DispatchResult = {
@@ -118,9 +115,10 @@ export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult>
workspaceDir, agentId, groupId, project, issueId,
issueTitle, issueDescription, issueUrl,
role, modelAlias, fromLabel, toLabel, transitionLabel,
pluginConfig,
} = opts;
const fullModel = MODEL_MAP[modelAlias] ?? modelAlias;
const fullModel = resolveModel(modelAlias, pluginConfig);
const worker = getWorker(project, role);
const existingSessionKey = getSessionForModel(worker, modelAlias);
const sessionAction = existingSessionKey ? "send" : "spawn";
@@ -210,7 +208,7 @@ export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult>
issue: issueId,
issueTitle,
role,
model: modelAlias,
tier: modelAlias,
sessionAction,
sessionKey,
labelTransition: `${fromLabel}${toLabel}`,
@@ -219,14 +217,14 @@ export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult>
await auditLog(workspaceDir, "model_selection", {
issue: issueId,
role,
selected: modelAlias,
tier: modelAlias,
fullModel,
});
// Build announcement
const emoji = role === "dev"
? (modelAlias === "haiku" ? "⚡" : modelAlias === "opus" ? "🧠" : "🔧")
: "🔍";
const emoji = isTier(modelAlias)
? TIER_EMOJI[modelAlias]
: (role === "qa" ? "🔍" : "🔧");
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${modelAlias}) for #${issueId}: ${issueTitle}`;

View File

@@ -1,11 +1,11 @@
/**
* Model selection for dev/qa tasks.
* MVP: Simple heuristic-based selection. LLM-based analysis can be added later.
* Keyword heuristic fallback — used when the orchestrator doesn't specify a tier.
* Returns tier names (junior, medior, senior, qa) instead of model aliases.
*/
export type ModelRecommendation = {
model: string;
alias: string;
tier: string;
reason: string;
};
@@ -39,13 +39,13 @@ const COMPLEX_KEYWORDS = [
];
/**
* Select appropriate model based on task description.
* Select appropriate developer tier based on task description.
*
* Model tiers:
* - haiku: very simple (typos, single-file fixes, CSS tweaks)
* - grok: default QA (code inspection, validation, test runs)
* - sonnet: default DEV (features, bug fixes, multi-file changes)
* - opus: deep/architectural (system-wide refactoring, novel design)
* Developer tiers:
* - junior: very simple (typos, single-file fixes, CSS tweaks)
* - medior: standard DEV (features, bug fixes, multi-file changes)
* - senior: deep/architectural (system-wide refactoring, novel design)
* - qa: all QA tasks (code inspection, validation, test runs)
*/
export function selectModel(
issueTitle: string,
@@ -54,9 +54,8 @@ export function selectModel(
): ModelRecommendation {
if (role === "qa") {
return {
model: "github-copilot/grok-code-fast-1",
alias: "grok",
reason: "Default QA model for code inspection and validation",
tier: "qa",
reason: "Default QA tier for code inspection and validation",
};
}
@@ -67,8 +66,7 @@ export function selectModel(
const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw));
if (isSimple && wordCount < 100) {
return {
model: "anthropic/claude-haiku-4-5",
alias: "haiku",
tier: "junior",
reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`,
};
}
@@ -77,16 +75,14 @@ export function selectModel(
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
if (isComplex || wordCount > 500) {
return {
model: "anthropic/claude-opus-4-5",
alias: "opus",
tier: "senior",
reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`,
};
}
// Default: sonnet for standard dev work
// Default: medior for standard dev work
return {
model: "anthropic/claude-sonnet-4-5",
alias: "sonnet",
tier: "medior",
reason: "Standard dev task — multi-file changes, features, bug fixes",
};
}

View File

@@ -4,6 +4,7 @@
*/
import fs from "node:fs/promises";
import path from "node:path";
import { TIER_MIGRATION } from "./tiers.js";
export type WorkerState = {
active: boolean;
@@ -30,41 +31,64 @@ export type ProjectsData = {
};
/**
* Migrate old WorkerState schema (sessionId field) to new sessions map.
* Called transparently on read — old data is converted in memory,
* persisted on next write.
* Migrate old WorkerState schema to current format.
*
* Handles two migrations:
* 1. Old sessionId field → sessions map (pre-sessions era)
* 2. Model-alias session keys → tier-name keys (haiku→junior, sonnet→medior, etc.)
*/
function migrateWorkerState(worker: Record<string, unknown>): WorkerState {
// Already migrated — has sessions map
if (worker.sessions && typeof worker.sessions === "object") {
return worker as unknown as WorkerState;
}
// Old schema: { sessionId, model, ... }
// Migration 1: old sessionId field → sessions map
if (!worker.sessions || typeof worker.sessions !== "object") {
const sessionId = worker.sessionId as string | null;
const model = worker.model as string | null;
const sessions: Record<string, string | null> = {};
if (sessionId && model) {
sessions[model] = sessionId;
// Apply tier migration to the model key too
const tierKey = TIER_MIGRATION[model] ?? model;
sessions[tierKey] = sessionId;
}
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
model,
model: model ? (TIER_MIGRATION[model] ?? model) : null,
sessions,
};
}
// Migration 2: model-alias session keys → tier-name keys
const oldSessions = worker.sessions as Record<string, string | null>;
const needsMigration = Object.keys(oldSessions).some((key) => key in TIER_MIGRATION);
if (needsMigration) {
const newSessions: Record<string, string | null> = {};
for (const [key, value] of Object.entries(oldSessions)) {
const newKey = TIER_MIGRATION[key] ?? key;
newSessions[newKey] = value;
}
const model = worker.model as string | null;
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
model: model ? (TIER_MIGRATION[model] ?? model) : null,
sessions: newSessions,
};
}
return worker as unknown as WorkerState;
}
/**
* Create a blank WorkerState with null sessions for given model aliases.
* Create a blank WorkerState with null sessions for given tier names.
*/
export function emptyWorkerState(aliases: string[]): WorkerState {
export function emptyWorkerState(tiers: string[]): WorkerState {
const sessions: Record<string, string | null> = {};
for (const alias of aliases) {
sessions[alias] = null;
for (const tier of tiers) {
sessions[tier] = null;
}
return {
active: false,
@@ -76,13 +100,13 @@ export function emptyWorkerState(aliases: string[]): WorkerState {
}
/**
* Get session key for a specific model alias from a worker's sessions map.
* Get session key for a specific tier from a worker's sessions map.
*/
export function getSessionForModel(
worker: WorkerState,
modelAlias: string,
tier: string,
): string | null {
return worker.sessions[modelAlias] ?? null;
return worker.sessions[tier] ?? null;
}
function projectsPath(workspaceDir: string): string {
@@ -163,7 +187,7 @@ export async function updateWorker(
/**
* Mark a worker as active with a new task.
* Sets active=true, issueId, model. Stores session key in sessions[model].
* Sets active=true, issueId, model (tier). Stores session key in sessions[tier].
*/
export async function activateWorker(
workspaceDir: string,
@@ -181,7 +205,7 @@ export async function activateWorker(
issueId: params.issueId,
model: params.model,
};
// Store session key in the sessions map for this model
// Store session key in the sessions map for this tier
if (params.sessionKey !== undefined) {
updates.sessions = { [params.model]: params.sessionKey };
}

263
lib/setup.ts Normal file
View File

@@ -0,0 +1,263 @@
/**
* setup.ts — Shared setup logic for DevClaw onboarding.
*
* Used by both the `devclaw_setup` tool and the `openclaw devclaw setup` CLI command.
* Handles: agent creation, model configuration, workspace file writes.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import path from "node:path";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js";
import {
AGENTS_MD_TEMPLATE,
HEARTBEAT_MD_TEMPLATE,
DEFAULT_DEV_INSTRUCTIONS,
DEFAULT_QA_INSTRUCTIONS,
} from "./templates.js";
const execFileAsync = promisify(execFile);
export type SetupOpts = {
/** Create a new agent with this name. Mutually exclusive with agentId. */
newAgentName?: string;
/** Use an existing agent by ID. Mutually exclusive with newAgentName. */
agentId?: string;
/** Override workspace path (auto-detected from agent if not given). */
workspacePath?: string;
/** Model overrides per tier. Missing tiers use defaults. */
models?: Partial<Record<Tier, string>>;
};
export type SetupResult = {
agentId: string;
agentCreated: boolean;
workspacePath: string;
models: Record<Tier, string>;
filesWritten: string[];
warnings: string[];
};
/**
* Run the full DevClaw setup.
*
* 1. Create agent (optional) or resolve existing workspace
* 2. Merge model config and write to openclaw.json
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, roles, memory)
*/
export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
const warnings: string[] = [];
const filesWritten: string[] = [];
let agentId: string;
let agentCreated = false;
let workspacePath: string;
// --- Step 1: Agent ---
if (opts.newAgentName) {
const result = await createAgent(opts.newAgentName);
agentId = result.agentId;
workspacePath = result.workspacePath;
agentCreated = true;
} else if (opts.agentId) {
agentId = opts.agentId;
workspacePath = opts.workspacePath ?? await resolveWorkspacePath(agentId);
} else if (opts.workspacePath) {
agentId = "unknown";
workspacePath = opts.workspacePath;
} else {
throw new Error(
"Setup requires either newAgentName, agentId, or workspacePath",
);
}
// --- Step 2: Models ---
const models = { ...DEFAULT_MODELS };
if (opts.models) {
for (const [tier, model] of Object.entries(opts.models)) {
if (model && (ALL_TIERS as readonly string[]).includes(tier)) {
models[tier as Tier] = model;
}
}
}
// Write plugin config to openclaw.json
await writePluginConfig(models);
// --- Step 3: Workspace files ---
// AGENTS.md (backup existing)
const agentsMdPath = path.join(workspacePath, "AGENTS.md");
await backupAndWrite(agentsMdPath, AGENTS_MD_TEMPLATE);
filesWritten.push("AGENTS.md");
// HEARTBEAT.md
const heartbeatPath = path.join(workspacePath, "HEARTBEAT.md");
await backupAndWrite(heartbeatPath, HEARTBEAT_MD_TEMPLATE);
filesWritten.push("HEARTBEAT.md");
// roles/default/dev.md and qa.md
const rolesDefaultDir = path.join(workspacePath, "roles", "default");
await fs.mkdir(rolesDefaultDir, { recursive: true });
const devRolePath = path.join(rolesDefaultDir, "dev.md");
const qaRolePath = path.join(rolesDefaultDir, "qa.md");
if (!await fileExists(devRolePath)) {
await fs.writeFile(devRolePath, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
filesWritten.push("roles/default/dev.md");
}
if (!await fileExists(qaRolePath)) {
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
filesWritten.push("roles/default/qa.md");
}
// memory/projects.json
const memoryDir = path.join(workspacePath, "memory");
await fs.mkdir(memoryDir, { recursive: true });
const projectsJsonPath = path.join(memoryDir, "projects.json");
if (!await fileExists(projectsJsonPath)) {
await fs.writeFile(
projectsJsonPath,
JSON.stringify({ projects: {} }, null, 2) + "\n",
"utf-8",
);
filesWritten.push("memory/projects.json");
}
return {
agentId,
agentCreated,
workspacePath,
models,
filesWritten,
warnings,
};
}
/**
* Create a new agent via `openclaw agents add`.
*/
async function createAgent(
name: string,
): Promise<{ agentId: string; workspacePath: string }> {
// Generate ID from name (lowercase, hyphenated)
const agentId = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const workspacePath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
`workspace-${agentId}`,
);
try {
await execFileAsync("openclaw", [
"agents",
"add",
agentId,
"--name",
name,
"--workspace",
workspacePath,
"--non-interactive",
], { 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
const gitDir = path.join(workspacePath, ".git");
try {
await fs.rm(gitDir, { recursive: true });
} catch {
// May not exist — that's fine
}
return { agentId, workspacePath };
}
/**
* Resolve workspace path from an agent ID by reading openclaw.json.
*/
async function resolveWorkspacePath(agentId: string): Promise<string> {
const configPath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
"openclaw.json",
);
const raw = await fs.readFile(configPath, "utf-8");
const config = JSON.parse(raw);
const agent = config.agents?.list?.find(
(a: { id: string }) => a.id === agentId,
);
if (!agent?.workspace) {
throw new Error(
`Agent "${agentId}" not found in openclaw.json or has no workspace configured.`,
);
}
return agent.workspace;
}
/**
* Write DevClaw model tier config to openclaw.json plugins section.
* Read-modify-write to preserve existing config.
*/
async function writePluginConfig(
models: Record<Tier, string>,
): Promise<void> {
const configPath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
"openclaw.json",
);
const raw = await fs.readFile(configPath, "utf-8");
const config = JSON.parse(raw);
// Ensure plugins.entries.devclaw.config.models 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 = {};
config.plugins.entries.devclaw.config.models = { ...models };
// Atomic write
const tmpPath = configPath + ".tmp";
await fs.writeFile(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
await fs.rename(tmpPath, configPath);
}
/**
* Backup existing file (if any) and write new content.
*/
async function backupAndWrite(
filePath: string,
content: string,
): Promise<void> {
try {
await fs.access(filePath);
// File exists — backup
const bakPath = filePath + ".bak";
await fs.copyFile(filePath, bakPath);
} catch {
// File doesn't exist — ensure directory
await fs.mkdir(path.dirname(filePath), { recursive: true });
}
await fs.writeFile(filePath, content, "utf-8");
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

179
lib/templates.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* Shared templates for workspace files.
* Used by setup and project_register.
*/
export const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions
- Work in a git worktree (never switch branches in the main repo)
- Run tests before completing
- Create an MR/PR to the base branch and merge it
- Clean up the worktree after merging
- When done, call task_complete with role "dev", result "done", and a brief summary
- If you discover unrelated bugs, call task_create to file them
- Do NOT call task_pickup, queue_status, session_health, or project_register
`;
export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
- Pull latest from the base branch
- Run tests and linting
- Verify the changes address the issue requirements
- Check for regressions in related functionality
- When done, call task_complete with role "qa" and one of:
- result "pass" if everything looks good
- result "fail" with specific issues if problems found
- result "refine" if you need human input to decide
- If you discover unrelated bugs, call task_create to file them
- Do NOT call task_pickup, queue_status, session_health, or project_register
`;
export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw)
## If You Are a Sub-Agent (DEV/QA Worker)
Skip the orchestrator section. Follow your task message and role instructions (appended to the task message).
### Conventions
- Conventional commits: \`feat:\`, \`fix:\`, \`chore:\`, \`refactor:\`, \`test:\`, \`docs:\`
- Include issue number: \`feat: add user authentication (#12)\`
- Branch naming: \`feature/<id>-<slug>\` or \`fix/<id>-<slug>\`
- **DEV always works in a git worktree** (never switch branches in the main repo)
- **DEV must merge to base branch** before announcing completion
- **QA tests on the deployed version** and inspects code on the base branch
- Always run tests before completing
### Completing Your Task
When you are done, **call \`task_complete\` yourself** — do not just announce in text.
- **DEV done:** \`task_complete({ role: "dev", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **QA pass:** \`task_complete({ role: "qa", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **QA fail:** \`task_complete({ role: "qa", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
- **QA refine:** \`task_complete({ role: "qa", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
The \`projectGroupId\` is included in your task message.
### Filing Follow-Up Issues
If you discover unrelated bugs or needed improvements during your work, call \`task_create\` to file them:
\`task_create({ projectGroupId: "<from task message>", title: "Bug: ...", description: "..." })\`
### Tools You Should NOT Use
These are orchestrator-only tools. Do not call them:
- \`task_pickup\`, \`queue_status\`, \`session_health\`, \`project_register\`
---
## Orchestrator
You are a **development orchestrator**. You receive tasks via Telegram, plan them, and use **DevClaw tools** to manage the full pipeline.
### DevClaw Tools
All orchestration goes through these tools. You do NOT manually manage sessions, labels, or projects.json.
| Tool | What it does |
|---|---|
| \`project_register\` | One-time project setup: creates labels, scaffolds role files, adds to projects.json |
| \`task_create\` | Create issues from chat (bugs, features, tasks) |
| \`queue_status\` | Scans issue queue (To Do, To Test, To Improve) + shows worker state |
| \`task_pickup\` | End-to-end: label transition, tier assignment, session create/reuse, dispatch with role instructions, state update |
| \`task_complete\` | End-to-end: label transition, state update, issue close/reopen. Auto-chains if enabled. |
| \`session_health\` | Detects zombie workers, stale sessions. Can auto-fix. |
### Pipeline Flow
\`\`\`
Planning → To Do → Doing → To Test → Testing → Done
To Improve → Doing (fix cycle)
Refining (human decision)
\`\`\`
Issue labels are the single source of truth for task state.
### Developer Assignment
Evaluate each task and pass the appropriate developer tier to \`task_pickup\`:
- **junior** — trivial: typos, single-file fix, quick change
- **medior** — standard: features, bug fixes, multi-file changes
- **senior** — complex: architecture, system-wide refactoring, 5+ services
- **qa** — review: code inspection, validation, test runs
### Picking Up Work
1. Use \`queue_status\` to see what's available
2. Priority: \`To Improve\` (fix failures) > \`To Test\` (QA) > \`To Do\` (new work)
3. Evaluate complexity, choose developer tier
4. Call \`task_pickup\` with \`issueId\`, \`role\`, \`projectGroupId\`, \`model\` (tier name)
5. Post the \`announcement\` from the tool response to Telegram
### When Work Completes
Workers call \`task_complete\` themselves — the label transition, state update, and audit log happen atomically.
**If \`autoChain\` is enabled on the project:**
- DEV "done" → QA is dispatched automatically (qa tier)
- QA "fail" → DEV fix is dispatched automatically (reuses previous DEV tier)
- QA "pass" / "refine" → pipeline done or needs human input, no chaining
**If \`autoChain\` is disabled:**
- The \`task_complete\` response includes a \`nextAction\` hint
- \`"qa_pickup"\` → pick up QA for this issue
- \`"dev_fix"\` → pick up DEV to fix
- absent → pipeline done or needs human input
Post the \`announcement\` from the tool response to Telegram.
### Role Instructions
Workers receive role-specific instructions appended to their task message. These are loaded from \`roles/<project-name>/<role>.md\` in the workspace (with fallback to \`roles/default/<role>.md\`). \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project.
### Heartbeats
On heartbeat, follow \`HEARTBEAT.md\`.
### Safety
- Don't push to main directly
- Don't force-push
- Don't close issues without QA pass
- Ask before architectural decisions affecting multiple projects
`;
export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md
On each heartbeat, run these checks using DevClaw tools:
## 1. Health Check
Call \`session_health\` with \`projectGroupId\` and \`autoFix: true\`.
- Detects zombie workers (active but session dead)
- Auto-fixes stale state in projects.json
## 2. Queue Scan
Call \`queue_status\` with \`projectGroupId\`.
- Shows issues in To Do, To Test, To Improve
- Shows current worker state (active/idle)
## 3. Pick Up Work (if slots free)
If a worker slot is free (DEV or QA not active), pick up work by priority:
1. \`To Improve\` issues → \`task_pickup\` with role \`dev\`
2. \`To Test\` issues → \`task_pickup\` with role \`qa\`
3. \`To Do\` issues → \`task_pickup\` with role \`dev\`
Choose the developer tier based on task complexity (see AGENTS.md developer assignment guide).
## 4. Nothing to do?
If no issues in queue and no active workers → reply \`HEARTBEAT_OK\`.
`;

66
lib/tiers.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* Developer tier definitions and model resolution.
*
* Tasks are assigned to developer tiers (junior, medior, senior, qa)
* instead of raw model names. Each tier maps to a configurable LLM model.
*/
export const DEV_TIERS = ["junior", "medior", "senior"] as const;
export const QA_TIERS = ["qa"] as const;
export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const;
export type DevTier = (typeof DEV_TIERS)[number];
export type QaTier = (typeof QA_TIERS)[number];
export type Tier = (typeof ALL_TIERS)[number];
export const DEFAULT_MODELS: Record<Tier, string> = {
junior: "anthropic/claude-haiku-4-5",
medior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
qa: "anthropic/claude-sonnet-4-5",
};
/** Emoji used in announcements per tier. */
export const TIER_EMOJI: Record<Tier, string> = {
junior: "⚡",
medior: "🔧",
senior: "🧠",
qa: "🔍",
};
/** Check if a string is a valid tier name. */
export function isTier(value: string): value is Tier {
return (ALL_TIERS as readonly string[]).includes(value);
}
/** Check if a string is a valid dev tier name. */
export function isDevTier(value: string): value is DevTier {
return (DEV_TIERS as readonly string[]).includes(value);
}
/**
* Resolve a tier name to a full model ID.
*
* Resolution order:
* 1. Plugin config `models` map (user overrides)
* 2. DEFAULT_MODELS (hardcoded defaults)
* 3. Treat input as raw model ID (passthrough for non-tier values)
*/
export function resolveModel(
tier: string,
pluginConfig?: Record<string, unknown>,
): string {
const models = (pluginConfig as { models?: Record<string, string> })?.models;
return models?.[tier] ?? DEFAULT_MODELS[tier as Tier] ?? tier;
}
/**
* Migration map from old model-alias session keys to tier names.
* Used by migrateWorkerState() in projects.ts.
*/
export const TIER_MIGRATION: Record<string, string> = {
haiku: "junior",
sonnet: "medior",
opus: "senior",
grok: "qa",
};

View File

@@ -0,0 +1,84 @@
/**
* devclaw_setup — Agent-driven setup tool.
*
* Creates a new agent (optional), configures model tiers,
* and writes workspace files (AGENTS.md, HEARTBEAT.md, roles, memory).
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { runSetup } from "../setup.js";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
export function createSetupTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "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.`,
parameters: {
type: "object",
properties: {
newAgentName: {
type: "string",
description: "Create a new agent with this name. If omitted, configures the current agent's workspace.",
},
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" }`,
properties: {
junior: { type: "string", description: `Junior dev model (default: ${DEFAULT_MODELS.junior})` },
medior: { type: "string", description: `Medior dev model (default: ${DEFAULT_MODELS.medior})` },
senior: { type: "string", description: `Senior dev model (default: ${DEFAULT_MODELS.senior})` },
qa: { type: "string", description: `QA engineer model (default: ${DEFAULT_MODELS.qa})` },
},
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const newAgentName = params.newAgentName as string | undefined;
const modelsParam = params.models as Partial<Record<Tier, string>> | undefined;
const workspaceDir = ctx.workspaceDir;
const result = await runSetup({
newAgentName,
// If no new agent name, use the current agent's workspace
agentId: newAgentName ? undefined : ctx.agentId,
workspacePath: newAgentName ? undefined : workspaceDir,
models: modelsParam,
});
const lines = [
result.agentCreated
? `Agent "${result.agentId}" created`
: `Configured workspace for agent "${result.agentId}"`,
``,
`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}`));
}
lines.push(
``,
`Next steps:`,
` 1. Add bot to a Telegram group`,
` 2. Register a project: "Register project <name> at <repo> for group <id>"`,
` 3. Create your first issue and pick it up`,
);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
...result,
summary: lines.join("\n"),
}, null, 2),
}],
};
},
});
}

View File

@@ -13,6 +13,8 @@ import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
import { resolveRepoPath } from "../gitlab.js";
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";
/**
* Ensure default role files exist, then copy them into the project's role directory.
@@ -64,31 +66,6 @@ async function scaffoldRoleFiles(workspaceDir: string, projectName: string): Pro
return created;
}
const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions
- Work in a git worktree (never switch branches in the main repo)
- Run tests before completing
- Create an MR/PR to the base branch and merge it
- Clean up the worktree after merging
- When done, call task_complete with role "dev", result "done", and a brief summary
- If you discover unrelated bugs, call task_create to file them
- Do NOT call task_pickup, queue_status, session_health, or project_register
`;
const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
- Pull latest from the base branch
- Run tests and linting
- Verify the changes address the issue requirements
- Check for regressions in related functionality
- When done, call task_complete with role "qa" and one of:
- result "pass" if everything looks good
- result "fail" with specific issues if problems found
- result "refine" if you need human input to decide
- If you discover unrelated bugs, call task_create to file them
- Do NOT call task_pickup, queue_status, session_health, or project_register
`;
export function createProjectRegisterTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "project_register",
@@ -186,8 +163,8 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
baseBranch,
deployBranch,
autoChain: false,
dev: emptyWorkerState(["haiku", "sonnet", "opus"]),
qa: emptyWorkerState(["grok"]),
dev: emptyWorkerState([...DEV_TIERS]),
qa: emptyWorkerState([...QA_TIERS]),
};
await writeProjects(workspaceDir, data);

View File

@@ -5,8 +5,8 @@
* issue close/reopen, audit logging, and optional auto-chaining.
*
* When project.autoChain is true:
* - DEV "done" → automatically dispatches QA (default model: grok)
* - QA "fail" → automatically dispatches DEV fix (reuses previous DEV model)
* - DEV "done" → automatically dispatches QA (qa tier)
* - QA "fail" → automatically dispatches DEV fix (reuses previous DEV tier)
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import {
@@ -120,6 +120,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
if (project.autoChain) {
try {
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
const issue = await getIssue(issueId, glabOpts);
const chainResult = await dispatchTask({
workspaceDir,
@@ -131,11 +132,12 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
issueDescription: issue.description ?? "",
issueUrl: issue.web_url,
role: "qa",
modelAlias: "grok",
modelAlias: "qa",
fromLabel: "To Test",
toLabel: "Testing",
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
pluginConfig,
});
output.autoChain = {
dispatched: true,
@@ -181,6 +183,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
if (project.autoChain && devModel) {
try {
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
const issue = await getIssue(issueId, glabOpts);
const chainResult = await dispatchTask({
workspaceDir,
@@ -197,6 +200,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
toLabel: "Doing",
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
pluginConfig,
});
output.autoChain = {
dispatched: true,

View File

@@ -23,7 +23,7 @@ import { dispatchTask } from "../dispatch.js";
export function createTaskPickupTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "task_pickup",
description: `Pick up a task from the issue queue for a DEV or QA worker. Handles everything end-to-end: label transition, model selection, session creation/reuse, task dispatch, state update, and audit logging. The orchestrator should analyze the issue and pass the appropriate model. Returns an announcement for the agent to post — no further session actions needed.`,
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.`,
parameters: {
type: "object",
required: ["issueId", "role", "projectGroupId"],
@@ -36,7 +36,7 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
},
model: {
type: "string",
description: "Model alias to use (e.g. haiku, sonnet, opus, grok). The orchestrator should analyze the issue complexity and choose. Falls back to keyword heuristic if omitted.",
description: "Developer tier (junior, medior, senior, qa). The orchestrator should evaluate the task complexity and choose the right tier. Falls back to keyword heuristic if omitted.",
},
},
},
@@ -101,12 +101,13 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
modelSource = "llm";
} else {
const selected = selectModel(issue.title, issue.description ?? "", role);
modelAlias = selected.alias;
modelAlias = selected.tier;
modelReason = selected.reason;
modelSource = "heuristic";
}
// 5. Dispatch via shared logic
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
const dispatchResult = await dispatchTask({
workspaceDir,
agentId: ctx.agentId,
@@ -122,6 +123,7 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
toLabel: targetLabel,
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
pluginConfig,
});
// 6. Build result

View File

@@ -1,21 +1,27 @@
{
"id": "devclaw",
"name": "DevClaw",
"description": "Multi-project dev/qa pipeline orchestration for OpenClaw. Atomic task pickup, completion, queue status, and session health tools.",
"description": "Multi-project dev/qa pipeline orchestration for OpenClaw. Developer tiers, atomic task management, session health, and audit logging.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"modelSelection": {
"models": {
"type": "object",
"description": "Model mapping per developer tier (junior, medior, senior, qa)",
"properties": {
"enabled": { "type": "boolean" },
"analyzerModel": { "type": "string" }
"junior": { "type": "string", "description": "Junior dev model (default: anthropic/claude-haiku-4-5)" },
"medior": { "type": "string", "description": "Medior dev model (default: anthropic/claude-sonnet-4-5)" },
"senior": { "type": "string", "description": "Senior dev model (default: anthropic/claude-opus-4-5)" },
"qa": { "type": "string", "description": "QA engineer model (default: anthropic/claude-sonnet-4-5)" }
}
},
"glabPath": {
"type": "string",
"description": "Path to glab CLI binary. Defaults to 'glab' on PATH."
},
"ghPath": {
"type": "string",
"description": "Path to gh CLI binary. Defaults to 'gh' on PATH."
}
}
}