From aa8e8dbd1bc2ab3ac60f2e6510c9913c3a8694e3 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 9 Feb 2026 13:41:22 +0800 Subject: [PATCH] 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. --- README.md | 114 +++++++++------ docs/ARCHITECTURE.md | 159 ++++++++++---------- docs/ONBOARDING.md | 102 ++++++++----- index.ts | 41 +++++- lib/cli.ts | 208 +++++++++++++++++++++++++++ lib/dispatch.ts | 24 ++-- lib/model-selector.ts | 34 ++--- lib/projects.ts | 80 +++++++---- lib/setup.ts | 263 ++++++++++++++++++++++++++++++++++ lib/templates.ts | 179 +++++++++++++++++++++++ lib/tiers.ts | 66 +++++++++ lib/tools/devclaw-setup.ts | 84 +++++++++++ lib/tools/project-register.ts | 31 +--- lib/tools/task-complete.ts | 10 +- lib/tools/task-pickup.ts | 8 +- openclaw.plugin.json | 16 ++- 16 files changed, 1162 insertions(+), 257 deletions(-) create mode 100644 lib/cli.ts create mode 100644 lib/setup.ts create mode 100644 lib/templates.ts create mode 100644 lib/tiers.ts create mode 100644 lib/tools/devclaw-setup.ts diff --git a/README.md b/README.md index 963ef61..5157f48 100644 --- a/README.md +++ b/README.md @@ -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//.md` (fallback: `roles/default/.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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fd5a65c..682d0c4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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::subagent:`. -### 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: , sonnet: , opus: null } - │ └─ QA sessions: { grok: } + │ ├─ DEV sessions: { junior: , medior: , senior: null } + │ └─ QA sessions: { qa: } │ └─ Project B - ├─ DEV sessions: { haiku: null, sonnet: , opus: null } - └─ QA sessions: { grok: } + ├─ DEV sessions: { junior: null, medior: , senior: null } + └─ QA sessions: { qa: } ``` -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 --message "task..." 3. Existing: openclaw agent --session-id --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
orchestrator agent] GW[Gateway RPC
sessions.patch / sessions.list] CLI[openclaw agent CLI] - DEV_H[DEV session
haiku] - DEV_S[DEV session
sonnet] - DEV_O[DEV session
opus] - QA_G[QA session
grok] + DEV_J[DEV session
junior] + DEV_M[DEV session
medior] + DEV_S[DEV session
senior] + QA_E[QA session
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
(sonnet) - participant GL as GitLab + participant DEV as DEV Session
(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 --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
(sonnet, existing) + participant DEV as DEV Session
(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 --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 --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:
model select → session lookup →
label transition → dispatch task →
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:
tier resolve → session lookup →
label transition → dispatch task →
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: │ │ sessions.delete → cleanup │ -│ sonnet: │ │ │ -│ opus: │ │ openclaw agent │ +│ junior: │ │ sessions.delete → cleanup │ +│ medior: │ │ │ +│ senior: │ │ openclaw agent │ │ qa: │ │ --session-id │ │ active, issueId, model │ │ --message "task..." │ │ sessions: │ │ → dispatches to session │ -│ grok: │ │ │ +│ qa: │ │ │ └────────────────────────────────┘ └──────────────────────────────┘ ↕ 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
create + send via CLI] AC[Auto-chaining
DEV→QA, QA fail→DEV] RI[Role instructions
loaded per project] @@ -523,7 +526,7 @@ graph LR MSG[Telegram announcements] HB[Heartbeat scheduling] DEC[Task prioritization] - M[Model selection] + M[Developer assignment
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-/memory/projects.json` | Per-project DEV/QA state | | Audit log | `~/.openclaw/workspace-/memory/audit.log` | NDJSON event log | | Session transcripts | `~/.openclaw/agents//sessions/.jsonl` | Conversation history per session | diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index c73df24..4e9e8ac 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -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//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. | diff --git a/index.ts b/index.ts index 49764f0..b215f8d 100644 --- a/index.ts +++ b/index.ts @@ -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)"); }, }; diff --git a/lib/cli.ts b/lib/cli.ts new file mode 100644 index 0000000..2093af2 --- /dev/null +++ b/lib/cli.ts @@ -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 { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const ask = (question: string): Promise => + 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> = {}; + 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 { + const args = parseArgs(argv); + + let opts: SetupOpts; + + if (args.nonInteractive || args.newAgent || args.agent || args.workspace) { + // Non-interactive mode + const models: Partial> = {}; + 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 at for group "', + ); + console.log(" 3. Create your first issue and pick it up"); + console.log(""); +} diff --git a/lib/dispatch.ts b/lib/dispatch.ts index 85f4890..12cab9f 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -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 = { - 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; + /** Plugin config for model resolution */ + pluginConfig?: Record; }; export type DispatchResult = { @@ -118,9 +115,10 @@ export async function dispatchTask(opts: DispatchOpts): Promise 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 issue: issueId, issueTitle, role, - model: modelAlias, + tier: modelAlias, sessionAction, sessionKey, labelTransition: `${fromLabel} → ${toLabel}`, @@ -219,14 +217,14 @@ export async function dispatchTask(opts: DispatchOpts): Promise 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}`; diff --git a/lib/model-selector.ts b/lib/model-selector.ts index 0470ec8..876a6a6 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -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", }; } diff --git a/lib/projects.ts b/lib/projects.ts index fef94a6..e1284c6 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -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): WorkerState { - // Already migrated — has sessions map - if (worker.sessions && typeof worker.sessions === "object") { - return worker as unknown as WorkerState; + // 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 = {}; + + if (sessionId && model) { + // 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 ? (TIER_MIGRATION[model] ?? model) : null, + sessions, + }; } - // Old schema: { sessionId, model, ... } - const sessionId = worker.sessionId as string | null; - const model = worker.model as string | null; - const sessions: Record = {}; + // Migration 2: model-alias session keys → tier-name keys + const oldSessions = worker.sessions as Record; + const needsMigration = Object.keys(oldSessions).some((key) => key in TIER_MIGRATION); - if (sessionId && model) { - sessions[model] = sessionId; + if (needsMigration) { + const newSessions: Record = {}; + 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 { - active: worker.active as boolean, - issueId: worker.issueId as string | null, - startTime: worker.startTime as string | null, - model, - sessions, - }; + 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 = {}; - 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 }; } diff --git a/lib/setup.ts b/lib/setup.ts new file mode 100644 index 0000000..b4c515a --- /dev/null +++ b/lib/setup.ts @@ -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>; +}; + +export type SetupResult = { + agentId: string; + agentCreated: boolean; + workspacePath: string; + models: Record; + 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 { + 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 { + 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, +): Promise { + 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 { + 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 { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} diff --git a/lib/templates.ts b/lib/templates.ts new file mode 100644 index 0000000..cdfe59a --- /dev/null +++ b/lib/templates.ts @@ -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/-\` or \`fix/-\` +- **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: "", summary: "" })\` +- **QA pass:** \`task_complete({ role: "qa", result: "pass", projectGroupId: "", summary: "" })\` +- **QA fail:** \`task_complete({ role: "qa", result: "fail", projectGroupId: "", summary: "" })\` +- **QA refine:** \`task_complete({ role: "qa", result: "refine", projectGroupId: "", summary: "" })\` + +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: "", 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//.md\` in the workspace (with fallback to \`roles/default/.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\`. +`; diff --git a/lib/tiers.ts b/lib/tiers.ts new file mode 100644 index 0000000..c05803c --- /dev/null +++ b/lib/tiers.ts @@ -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 = { + 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 = { + 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 { + const models = (pluginConfig as { models?: Record })?.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 = { + haiku: "junior", + sonnet: "medior", + opus: "senior", + grok: "qa", +}; diff --git a/lib/tools/devclaw-setup.ts b/lib/tools/devclaw-setup.ts new file mode 100644 index 0000000..9388e8d --- /dev/null +++ b/lib/tools/devclaw-setup.ts @@ -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) { + const newAgentName = params.newAgentName as string | undefined; + const modelsParam = params.models as Partial> | 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 at for group "`, + ` 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), + }], + }; + }, + }); +} diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index 7a4e608..c2479f4 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -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); diff --git a/lib/tools/task-complete.ts b/lib/tools/task-complete.ts index 360f4a3..6079f66 100644 --- a/lib/tools/task-complete.ts +++ b/lib/tools/task-complete.ts @@ -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 | 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 | 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, diff --git a/lib/tools/task-pickup.ts b/lib/tools/task-pickup.ts index 46a6f04..8d4e2d9 100644 --- a/lib/tools/task-pickup.ts +++ b/lib/tools/task-pickup.ts @@ -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 | 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 diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 48cf3bd..9a0f007 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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." } } }