feat: Implement GitLabProvider for issue management using glab CLI

- Add GitLabProvider class for handling issue operations, label management, and MR checks.
- Implement methods for ensuring labels, creating issues, listing issues by label, and transitioning labels.
- Introduce a provider factory to auto-detect GitLab or GitHub based on the repository URL.
- Create project registration tool to validate repositories, create state labels, and log project entries.
- Enhance queue status and session health tools to support new session management features.
- Update task completion and task creation tools to support auto-chaining and improved session handling.
- Refactor task pickup tool to streamline model selection and session management.
This commit is contained in:
Lauren ten Hoor
2026-02-09 12:54:50 +08:00
parent d921b5c7bb
commit 8a79755e4c
16 changed files with 1578 additions and 242 deletions

113
README.md
View File

@@ -16,7 +16,7 @@ DevClaw fills that gap with guardrails. It gives the orchestrator atomic tools t
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 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.
DevClaw gives the orchestrator four 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. 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`.
## How it works ## How it works
@@ -75,17 +75,30 @@ stateDiagram-v2
ToDo --> Doing: task_pickup (DEV) ToDo --> Doing: task_pickup (DEV)
Doing --> ToTest: task_complete (DEV done) Doing --> ToTest: task_complete (DEV done)
ToTest --> Testing: task_pickup (QA) ToTest --> Testing: task_pickup (QA) or auto-chain
Testing --> Done: task_complete (QA pass) Testing --> Done: task_complete (QA pass)
Testing --> ToImprove: task_complete (QA fail) Testing --> ToImprove: task_complete (QA fail)
Testing --> Refining: task_complete (QA refine) Testing --> Refining: task_complete (QA refine)
ToImprove --> Doing: task_pickup (DEV fix) ToImprove --> Doing: task_pickup (DEV fix) or auto-chain
Refining --> ToDo: Human decision Refining --> ToDo: Human decision
Done --> [*] Done --> [*]
``` ```
### Worker self-reporting
Workers (DEV/QA sub-agent sessions) call `task_complete` directly when they finish — no orchestrator involvement needed for the state transition. Workers can also call `task_create` to file follow-up issues they discover during work.
### 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)
- **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 ## 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 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.
@@ -111,7 +124,9 @@ sequenceDiagram
## Model selection ## Model selection
The plugin selects the cheapest model that can handle each task: 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 keyword heuristic in `model-selector.ts` serves as a **fallback only**, used when the orchestrator omits the `model` parameter.
| Complexity | Model | When | | Complexity | Model | When |
|------------|-------|------| |------------|-------|------|
@@ -120,8 +135,6 @@ The plugin selects the cheapest model that can handle each task:
| Complex | Opus | Architecture, migrations, security, system-wide refactoring | | Complex | Opus | Architecture, migrations, security, system-wide refactoring |
| QA | Grok | All QA tasks (code review, test validation) | | QA | Grok | All QA tasks (code review, test validation) |
Selection is based on issue title/description keywords. The orchestrator can override with `modelOverride` on any `task_pickup` call.
## State management ## State management
All project state lives in a single `memory/projects.json` file in the orchestrator's workspace, keyed by Telegram group ID: All project state lives in a single `memory/projects.json` file in the orchestrator's workspace, keyed by Telegram group ID:
@@ -134,6 +147,7 @@ All project state lives in a single `memory/projects.json` file in the orchestra
"repo": "~/git/my-webapp", "repo": "~/git/my-webapp",
"groupName": "Dev - My Webapp", "groupName": "Dev - My Webapp",
"baseBranch": "development", "baseBranch": "development",
"autoChain": true,
"dev": { "dev": {
"active": false, "active": false,
"issueId": null, "issueId": null,
@@ -175,24 +189,25 @@ Pick up a task from the GitLab queue for a DEV or QA worker.
- `issueId` (number, required) — GitLab issue ID - `issueId` (number, required) — GitLab issue ID
- `role` ("dev" | "qa", required) — Worker role - `role` ("dev" | "qa", required) — Worker role
- `projectGroupId` (string, required) — Telegram group ID - `projectGroupId` (string, required) — Telegram group ID
- `modelOverride` (string, optional) — Force a specific model - `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.
**What it does atomically:** **What it does atomically:**
1. Resolves project from `projects.json` 1. Resolves project from `projects.json`
2. Validates no active worker for this role 2. Validates no active worker for this role
3. Fetches issue from GitLab, verifies correct label state 3. Fetches issue from issue tracker, verifies correct label state
4. Selects model based on task complexity 4. Selects model (LLM-chosen via `model` param, keyword heuristic fallback)
5. Looks up existing session for selected model (session-per-model) 5. Loads role instructions from `roles/<project>/<role>.md` (fallback: `roles/default/<role>.md`)
6. Creates session via Gateway RPC if new (`sessions.patch`) 6. Looks up existing session for selected model (session-per-model)
7. Dispatches task to worker session via CLI (`openclaw agent`) 7. Transitions label (e.g. `To Do``Doing`)
8. Transitions GitLab label (e.g. `To Do``Doing`) 8. Creates session via Gateway RPC if new (`sessions.patch`)
9. Updates `projects.json` state (active, issueId, model, session key) 9. Dispatches task to worker session via CLI (`openclaw agent`) with role instructions appended
10. Writes audit log entry 10. Updates `projects.json` state (active, issueId, model, session key)
11. Returns announcement text for the orchestrator to post 11. Writes audit log entry
12. Returns announcement text for the orchestrator to post
### `task_complete` ### `task_complete`
Complete a task with one of four results. Complete a task with one of four results. Called by workers (DEV/QA sub-agent sessions) directly, or by the orchestrator.
**Parameters:** **Parameters:**
- `role` ("dev" | "qa", required) - `role` ("dev" | "qa", required)
@@ -201,11 +216,23 @@ Complete a task with one of four results.
- `summary` (string, optional) — For the Telegram announcement - `summary` (string, optional) — For the Telegram announcement
**Results:** **Results:**
- **DEV "done"** — Pulls latest code, moves label `Doing``To Test`, deactivates worker - **DEV "done"** — Pulls latest code, moves label `Doing``To Test`, deactivates worker. If `autoChain` enabled, automatically dispatches QA (grok).
- **QA "pass"** — Moves label `Testing``Done`, closes issue, deactivates worker - **QA "pass"** — Moves label `Testing``Done`, closes issue, deactivates worker
- **QA "fail"** — Moves label `Testing``To Improve`, reopens issue, prepares DEV fix cycle with model selection - **QA "fail"** — Moves label `Testing``To Improve`, reopens issue. If `autoChain` enabled, automatically dispatches DEV fix (reuses previous model).
- **QA "refine"** — Moves label `Testing``Refining`, awaits human decision - **QA "refine"** — Moves label `Testing``Refining`, awaits human decision
### `task_create`
Create a new issue in the project's issue tracker. Used by workers to file follow-up bugs, or by the orchestrator to create tasks from chat.
**Parameters:**
- `projectGroupId` (string, required) — Telegram group ID
- `title` (string, required) — Issue title
- `description` (string, optional) — Full issue body in markdown
- `label` (string, optional) — State label (defaults to "Planning")
- `assignees` (string[], optional) — Usernames to assign
- `pickup` (boolean, optional) — If true, immediately pick up for DEV after creation
### `queue_status` ### `queue_status`
Returns task queue counts and worker status across all projects (or a specific one). Returns task queue counts and worker status across all projects (or a specific one).
@@ -230,6 +257,28 @@ Detects and optionally fixes state inconsistencies.
- Worker active for >2 hours (warning) - Worker active for >2 hours (warning)
- Inactive worker with lingering issue ID (warning) - Inactive worker with lingering issue ID (warning)
### `project_register`
Register a new project with DevClaw. Creates all required issue tracker labels (idempotent), scaffolds role instruction files, and adds the project to `projects.json`. One-time setup per project. Auto-detects GitHub/GitLab from git remote.
**Parameters:**
- `projectGroupId` (string, required) — Telegram group ID (key in projects.json)
- `name` (string, required) — Short project name
- `repo` (string, required) — Path to git repo (e.g. `~/git/my-project`)
- `groupName` (string, required) — Telegram group display name
- `baseBranch` (string, required) — Base branch for development
- `deployBranch` (string, optional) — Defaults to baseBranch
- `deployUrl` (string, optional) — Deployment URL
**What it does atomically:**
1. Validates project not already registered
2. Resolves repo path, auto-detects GitHub/GitLab, and verifies access
3. Creates all 8 state labels (idempotent — safe to run on existing projects)
4. Adds project entry to `projects.json` with empty worker state and `autoChain: false`
5. Scaffolds role instruction files: `roles/<project>/dev.md` and `roles/<project>/qa.md` (copied from `roles/default/`)
6. Writes audit log entry
7. Returns announcement text
## Audit logging ## Audit logging
Every tool call automatically appends an NDJSON entry to `memory/audit.log`. No manual logging required from the orchestrator agent. Every tool call automatically appends an NDJSON entry to `memory/audit.log`. No manual logging required from the orchestrator agent.
@@ -276,18 +325,40 @@ Restrict tools to your orchestrator agent only:
"list": [{ "list": [{
"id": "my-orchestrator", "id": "my-orchestrator",
"tools": { "tools": {
"allow": ["task_pickup", "task_complete", "queue_status", "session_health"] "allow": ["task_pickup", "task_complete", "task_create", "queue_status", "session_health", "project_register"]
} }
}] }]
} }
} }
``` ```
> DevClaw uses an `IssueProvider` interface to abstract issue tracker operations. GitLab (via `glab` CLI) and GitHub (via `gh` CLI) are supported — the provider is auto-detected from the git remote URL. Jira is planned.
## Role instructions
Workers receive role-specific instructions appended to their task message. `project_register` scaffolds editable files:
```
workspace/
├── roles/
│ ├── default/ ← sensible defaults (created once)
│ │ ├── dev.md
│ │ └── qa.md
│ ├── my-webapp/ ← per-project overrides (edit to customize)
│ │ ├── dev.md
│ │ └── qa.md
│ └── another-project/
│ ├── dev.md
│ └── qa.md
```
`task_pickup` loads `roles/<project>/<role>.md` with fallback to `roles/default/<role>.md`. Edit the per-project files to customize worker behavior — for example, adding project-specific deployment steps or test commands.
## Requirements ## Requirements
- [OpenClaw](https://openclaw.ai) - [OpenClaw](https://openclaw.ai)
- Node.js >= 20 - Node.js >= 20
- [`glab`](https://gitlab.com/gitlab-org/cli) CLI installed and authenticated - [`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 - A `memory/projects.json` in the orchestrator agent's workspace
## License ## License

View File

@@ -84,8 +84,10 @@ graph TB
subgraph "DevClaw Plugin" subgraph "DevClaw Plugin"
TP[task_pickup] TP[task_pickup]
TC[task_complete] TC[task_complete]
TCR[task_create]
QS[queue_status] QS[queue_status]
SH[session_health] SH[session_health]
PR[project_register]
MS_SEL[Model Selector] MS_SEL[Model Selector]
PJ[projects.json] PJ[projects.json]
AL[audit.log] AL[audit.log]
@@ -102,8 +104,10 @@ graph TB
MS -->|calls| TP MS -->|calls| TP
MS -->|calls| TC MS -->|calls| TC
MS -->|calls| TCR
MS -->|calls| QS MS -->|calls| QS
MS -->|calls| SH MS -->|calls| SH
MS -->|calls| PR
TP -->|selects model| MS_SEL TP -->|selects model| MS_SEL
TP -->|transitions labels| GL TP -->|transitions labels| GL
@@ -116,8 +120,12 @@ graph TB
TC -->|closes/reopens| GL TC -->|closes/reopens| GL
TC -->|reads/writes| PJ TC -->|reads/writes| PJ
TC -->|git pull| REPO TC -->|git pull| REPO
TC -->|auto-chain dispatch| CLI
TC -->|appends| AL TC -->|appends| AL
TCR -->|creates issue| GL
TCR -->|appends| AL
QS -->|lists issues by label| GL QS -->|lists issues by label| GL
QS -->|reads| PJ QS -->|reads| PJ
QS -->|appends| AL QS -->|appends| AL
@@ -127,6 +135,10 @@ graph TB
SH -->|reverts labels| GL SH -->|reverts labels| GL
SH -->|appends| AL SH -->|appends| AL
PR -->|creates labels| GL
PR -->|writes entry| PJ
PR -->|appends| AL
CLI -->|sends task| DEV_H CLI -->|sends task| DEV_H
CLI -->|sends task| DEV_S CLI -->|sends task| DEV_S
CLI -->|sends task| DEV_O CLI -->|sends task| DEV_O
@@ -271,8 +283,7 @@ sequenceDiagram
TP->>GL: glab issue view 42 --output json TP->>GL: glab issue view 42 --output json
GL-->>TP: { title: "Add login page", labels: ["To Do"] } GL-->>TP: { title: "Add login page", labels: ["To Do"] }
TP->>TP: Verify label is "To Do" ✓ TP->>TP: Verify label is "To Do" ✓
TP->>MS: selectModel("Add login page", description, "dev") TP->>TP: model from agent param (LLM-selected) or fallback heuristic
MS-->>TP: { alias: "sonnet" }
TP->>PJ: lookup dev.sessions.sonnet TP->>PJ: lookup dev.sessions.sonnet
TP->>GL: glab issue update 42 --unlabel "To Do" --label "Doing" TP->>GL: glab issue update 42 --unlabel "To Do" --label "Doing"
alt New session alt New session
@@ -294,38 +305,47 @@ sequenceDiagram
``` ```
DEV sub-agent session → reads codebase, writes code, creates MR DEV sub-agent session → reads codebase, writes code, creates MR
DEV sub-agent session → reports back to orchestrator: "done, MR merged" DEV sub-agent session → calls task_complete({ role: "dev", result: "done", ... })
``` ```
This happens inside the OpenClaw session. DevClaw is not involved — the DEV sub-agent session works autonomously with the codebase. This happens inside the OpenClaw session. The worker calls `task_complete` directly for atomic state updates. If the worker discovers unrelated bugs, it calls `task_create` to file them.
### Phase 5: DEV complete ### Phase 5: DEV complete (worker self-reports)
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant A as Orchestrator participant DEV as DEV Session
participant TC as task_complete participant TC as task_complete
participant GL as GitLab participant GL as GitLab
participant PJ as projects.json participant PJ as projects.json
participant AL as audit.log participant AL as audit.log
participant REPO as Git Repo participant REPO as Git Repo
participant QA as QA Session (auto-chain)
A->>TC: task_complete({ role: "dev", result: "done", projectGroupId: "-123", summary: "Login page with OAuth" }) DEV->>TC: task_complete({ role: "dev", result: "done", projectGroupId: "-123", summary: "Login page with OAuth" })
TC->>PJ: readProjects() TC->>PJ: readProjects()
PJ-->>TC: { dev: { active: true, issueId: "42" } } PJ-->>TC: { dev: { active: true, issueId: "42" } }
TC->>REPO: git pull TC->>REPO: git pull
TC->>PJ: deactivateWorker(-123, dev) TC->>PJ: deactivateWorker(-123, dev)
Note over PJ: active→false, issueId→null<br/>sessions map PRESERVED Note over PJ: active→false, issueId→null<br/>sessions map PRESERVED
TC->>GL: glab issue update 42 --unlabel "Doing" --label "To Test" TC->>GL: transition label "Doing" "To Test"
TC->>AL: append { event: "task_complete", role: "dev", result: "done" } TC->>AL: append { event: "task_complete", role: "dev", result: "done" }
TC-->>A: { announcement: "✅ DEV done #42 — Login page with OAuth. Moved to QA queue." }
alt autoChain enabled
TC->>GL: transition label "To Test" → "Testing"
TC->>QA: dispatchTask(role: "qa", model: "grok")
TC->>PJ: activateWorker(-123, qa)
TC-->>DEV: { announcement: "✅ DEV done #42", autoChain: { dispatched: true, role: "qa" } }
else autoChain disabled
TC-->>DEV: { announcement: "✅ DEV done #42", nextAction: "qa_pickup" }
end
``` ```
**Writes:** **Writes:**
- `Git repo`: pulled latest (has DEV's merged code) - `Git repo`: pulled latest (has DEV's merged code)
- `projects.json`: dev.active=false, dev.issueId=null (sessions map preserved for reuse) - `projects.json`: dev.active=false, dev.issueId=null (sessions map preserved for reuse)
- `GitLab`: label "Doing" → "To Test" - `GitLab`: label "Doing" → "To Test" (+ "To Test" → "Testing" if auto-chain)
- `audit.log`: 1 entry (task_complete) - `audit.log`: 1 entry (task_complete) + optional auto-chain entries
### Phase 6: QA pickup ### Phase 6: QA pickup
@@ -415,22 +435,24 @@ Every piece of data and where it lives:
``` ```
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
GitLab (source of truth for tasks) Issue Tracker (source of truth for tasks) │
│ │ │ │
│ Issue #42: "Add login page" │ │ Issue #42: "Add login page" │
│ Labels: [To Do | Doing | To Test | Testing | Done | ...] │ │ Labels: [To Do | Doing | To Test | Testing | Done | ...] │
│ State: open / closed │ │ State: open / closed │
│ MRs: linked merge requests │ MRs/PRs: linked merge/pull requests │
│ Created by: orchestrator agent, DEV/QA sub-agents, or humans │ │ Created by: orchestrator (task_create), workers, or humans
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
↕ glab CLI (read/write) ↕ glab/gh CLI (read/write, auto-detected)
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
│ DevClaw Plugin (orchestration logic) │ │ DevClaw Plugin (orchestration logic) │
│ │ │ │
│ task_pickup → model + label + dispatch + state (end-to-end) │ task_pickup → model + label + dispatch + role instr (e2e)
│ task_complete → label transition + state update + git pull │ task_complete → label + state + git pull + auto-chain
│ task_create → create issue in tracker │
│ queue_status → read labels + read state │ │ queue_status → read labels + read state │
│ session_health → check sessions + fix zombies │ │ session_health → check sessions + fix zombies │
│ project_register → labels + roles + state init (one-time) │
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
↕ atomic file I/O ↕ OpenClaw CLI (plugin shells out) ↕ atomic file I/O ↕ OpenClaw CLI (plugin shells out)
┌────────────────────────────────┐ ┌──────────────────────────────┐ ┌────────────────────────────────┐ ┌──────────────────────────────┐
@@ -454,7 +476,8 @@ Every piece of data and where it lives:
│ │ │ │
│ NDJSON, one line per event: │ │ NDJSON, one line per event: │
│ task_pickup, task_complete, model_selection, │ │ task_pickup, task_complete, model_selection, │
│ queue_status, health_check, session_spawn, session_reuse │ queue_status, health_check, session_spawn, session_reuse,
│ project_register │
│ │ │ │
│ Query with: cat audit.log | jq 'select(.event=="task_pickup")' │ │ Query with: cat audit.log | jq 'select(.event=="task_pickup")' │
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
@@ -488,8 +511,10 @@ graph LR
subgraph "DevClaw controls (deterministic)" subgraph "DevClaw controls (deterministic)"
L[Label transitions] L[Label transitions]
S[Worker state] S[Worker state]
M[Model selection] PR[Project registration]
SD[Session dispatch<br/>create + send via CLI] SD[Session dispatch<br/>create + send via CLI]
AC[Auto-chaining<br/>DEV→QA, QA fail→DEV]
RI[Role instructions<br/>loaded per project]
A[Audit logging] A[Audit logging]
Z[Zombie cleanup] Z[Zombie cleanup]
end end
@@ -497,14 +522,15 @@ graph LR
subgraph "Orchestrator handles" subgraph "Orchestrator handles"
MSG[Telegram announcements] MSG[Telegram announcements]
HB[Heartbeat scheduling] HB[Heartbeat scheduling]
IC[Issue creation via glab]
DEC[Task prioritization] DEC[Task prioritization]
M[Model selection]
end end
subgraph "Sub-agent sessions handle" subgraph "Sub-agent sessions handle"
CR[Code writing] CR[Code writing]
MR[MR creation/review] MR[MR creation/review]
BUG[Bug issue creation] TC_W[Task completion<br/>via task_complete]
BUG[Bug filing<br/>via task_create]
end end
subgraph "External" subgraph "External"
@@ -513,6 +539,28 @@ graph LR
end end
``` ```
## IssueProvider abstraction
All issue tracker operations go through the `IssueProvider` interface, defined in `lib/issue-provider.ts`. This abstraction allows DevClaw to support multiple issue trackers without changing tool logic.
**Interface methods:**
- `ensureLabel` / `ensureAllStateLabels` — idempotent label creation
- `listIssuesByLabel` / `getIssue` — issue queries
- `transitionLabel` — atomic label state transition (unlabel + label)
- `closeIssue` / `reopenIssue` — issue lifecycle
- `hasStateLabel` / `getCurrentStateLabel` — label inspection
- `hasMergedMR` — MR/PR verification
- `healthCheck` — verify provider connectivity
**Current providers:**
- **GitLab** (`lib/providers/gitlab.ts`) — wraps `glab` CLI
- **GitHub** (`lib/providers/github.ts`) — wraps `gh` CLI
**Planned providers:**
- **Jira** — via REST API
Provider selection is handled by `createProvider()` in `lib/providers/index.ts`. Auto-detects GitHub vs GitLab from the git remote URL.
## Error recovery ## Error recovery
| Failure | Detection | Recovery | | Failure | Detection | Recovery |
@@ -525,6 +573,7 @@ graph LR
| Label out of sync | `task_pickup` verifies label before transitioning | Throws error if label doesn't match expected state. Agent reports mismatch. | | Label out of sync | `task_pickup` verifies label before transitioning | Throws error if label doesn't match expected state. Agent reports mismatch. |
| Worker already active | `task_pickup` checks `active` flag | Throws error: "DEV worker already active on project". Must complete current task first. | | Worker already active | `task_pickup` checks `active` flag | Throws error: "DEV worker already active on project". Must complete current task first. |
| Stale worker (>2h) | `session_health` flags as warning | Agent can investigate or `autoFix` can clear. | | Stale worker (>2h) | `session_health` flags as warning | Agent can investigate or `autoFix` can clear. |
| `project_register` fails | Plugin catches error during label creation or state write | Clean error returned. No partial state — labels are idempotent, projects.json not written until all labels succeed. |
## File locations ## File locations

View File

@@ -6,9 +6,9 @@
|---|---|---| |---|---|---|
| [OpenClaw](https://openclaw.ai) installed | DevClaw is an OpenClaw plugin | `openclaw --version` | | [OpenClaw](https://openclaw.ai) installed | DevClaw is an OpenClaw plugin | `openclaw --version` |
| Node.js >= 20 | Runtime for plugin | `node --version` | | Node.js >= 20 | Runtime for plugin | `node --version` |
| [`glab`](https://gitlab.com/gitlab-org/cli) CLI | GitLab issue/label management | `glab --version` | | [`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` |
| glab authenticated | Plugin calls glab for every label transition | `glab auth status` | | CLI authenticated | Plugin calls glab/gh for every label transition | `glab auth status` or `gh auth status` |
| A GitLab repo with issues | The task backlog lives in GitLab | `glab issue list` from your repo | | 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` | | An OpenClaw agent with Telegram | The orchestrator agent that will manage projects | Agent defined in `openclaw.json` |
## Setup steps ## Setup steps
@@ -41,8 +41,10 @@ In `openclaw.json`, your orchestrator agent needs access to the DevClaw tools:
"allow": [ "allow": [
"task_pickup", "task_pickup",
"task_complete", "task_complete",
"task_create",
"queue_status", "queue_status",
"session_health" "session_health",
"project_register"
] ]
} }
}] }]
@@ -50,79 +52,68 @@ In `openclaw.json`, your orchestrator agent needs access to the DevClaw tools:
} }
``` ```
The agent only needs the four DevClaw tools. Session management (`sessions_spawn`, `sessions_send`) is **not needed** — the plugin handles session creation and task dispatch internally via OpenClaw CLI. This eliminates the fragile handoff where agents had to correctly call session tools with the right parameters. 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.
### 3. Create GitLab labels ### 3. Register your project
DevClaw uses these labels as a state machine. Create them once per GitLab project: Tell the orchestrator agent to register a new project:
```bash > "Register project my-project at ~/git/my-project for group -1234567890 with base branch development"
cd ~/git/your-project
glab label create "Planning" --color "#6699cc"
glab label create "To Do" --color "#428bca"
glab label create "Doing" --color "#f0ad4e"
glab label create "To Test" --color "#5bc0de"
glab label create "Testing" --color "#9b59b6"
glab label create "Done" --color "#5cb85c"
glab label create "To Improve" --color "#d9534f"
glab label create "Refining" --color "#f39c12"
```
### 4. Register a project The agent calls `project_register`, which atomically:
- Validates the repo and auto-detects GitHub/GitLab from remote
Add your project to `memory/projects.json` in the orchestrator's workspace: - Creates all 8 state labels (idempotent)
- Scaffolds role instruction files (`roles/<project>/dev.md` and `qa.md`)
- Adds the project entry to `projects.json` with `autoChain: false`
- Logs the registration event
```json ```json
{ {
"projects": { "projects": {
"<telegram-group-id>": { "-1234567890": {
"name": "my-project", "name": "my-project",
"repo": "~/git/my-project", "repo": "~/git/my-project",
"groupName": "Dev - My Project", "groupName": "Dev - My Project",
"deployUrl": "https://my-project.example.com", "deployUrl": "",
"baseBranch": "development", "baseBranch": "development",
"deployBranch": "development", "deployBranch": "development",
"autoChain": false,
"dev": { "dev": {
"active": false, "active": false,
"issueId": null, "issueId": null,
"startTime": null, "startTime": null,
"model": null, "model": null,
"sessions": { "sessions": { "haiku": null, "sonnet": null, "opus": null }
"haiku": null,
"sonnet": null,
"opus": null
}
}, },
"qa": { "qa": {
"active": false, "active": false,
"issueId": null, "issueId": null,
"startTime": null, "startTime": null,
"model": null, "model": null,
"sessions": { "sessions": { "grok": null }
"grok": null
}
} }
} }
} }
} }
``` ```
**Manual fallback:** If you prefer CLI control, you can still create labels manually with `glab label create` and edit `projects.json` directly. See the [Architecture docs](ARCHITECTURE.md) for label names and colors.
**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. **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.
### 5. Add the agent to the Telegram group ### 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. 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.
### 6. Create your first issue ### 5. Create your first issue
Issues can be created in multiple ways: Issues can be created in multiple ways:
- **Via the agent** — Ask the orchestrator in the Telegram group: "Create an issue for adding a login page" - **Via the agent** — Ask the orchestrator in the Telegram group: "Create an issue for adding a login page" (uses `task_create`)
- **Via glab CLI** — `cd ~/git/my-project && glab issue create --title "My first task" --label "To Do"` - **Via workers** — DEV/QA workers can call `task_create` to file follow-up bugs they discover
- **Via GitLab UI** — Create an issue and add the "To Do" label - **Via CLI** — `cd ~/git/my-project && glab issue create --title "My first task" --label "To Do"` (or `gh issue create`)
- **Via web UI** — Create an issue and add the "To Do" label
The orchestrator agent and worker sessions can all create and update issues via `glab` tool usage. ### 6. Test the pipeline
### 7. Test the pipeline
Ask the agent in the Telegram group: Ask the agent in the Telegram group:
@@ -136,10 +127,7 @@ The agent calls `task_pickup`, which selects a model, transitions the label to "
## Adding more projects ## Adding more projects
Repeat steps 3-5 for each new project: Tell the agent to register a new project (step 3) and add the bot to the new Telegram group (step 4). That's it — `project_register` handles labels and state setup.
1. Create labels in the GitLab repo
2. Add an entry to `projects.json` with the new Telegram group ID
3. Add the bot to the new Telegram group
Each project is fully isolated — separate queue, separate workers, separate state. Each project is fully isolated — separate queue, separate workers, separate state.
@@ -147,15 +135,18 @@ Each project is fully isolated — separate queue, separate workers, separate st
| Responsibility | Who | Details | | Responsibility | Who | Details |
|---|---|---| |---|---|---|
| GitLab label setup | You (once per project) | 8 labels, created via `glab label create` | | Label setup | Plugin (`project_register`) | 8 labels, created idempotently via `IssueProvider` |
| Project registration | You (once per project) | Entry in `projects.json` | | 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 | | Agent definition | You (once) | Agent in `openclaw.json` with tool permissions |
| Telegram group setup | You (once per project) | Add bot to group | | Telegram group setup | You (once per project) | Add bot to group |
| Issue creation | Agent or worker sessions | Created via `glab` tool usage (or manually via GitLab UI) | | Issue creation | Plugin (`task_create`) | Orchestrator or workers create issues from chat |
| Label transitions | Plugin | Atomic `--unlabel` + `--label` via glab | | Label transitions | Plugin | Atomic label transitions via issue tracker CLI |
| Model selection | Plugin | Keyword-based heuristic per task | | Model selection | Plugin | LLM-selected by orchestrator, keyword heuristic fallback |
| State management | Plugin | Atomic read/write to `projects.json` | | 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. | | 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. |
| Role instructions | Plugin (`task_pickup`) | Loaded from `roles/<project>/<role>.md`, appended to task message |
| Audit logging | Plugin | Automatic NDJSON append per tool call | | Audit logging | Plugin | Automatic NDJSON append per tool call |
| Zombie detection | Plugin | `session_health` checks active vs alive | | Zombie detection | Plugin | `session_health` checks active vs alive |
| Queue scanning | Plugin | `queue_status` queries GitLab per project | | Queue scanning | Plugin | `queue_status` queries issue tracker per project |

View File

@@ -3,12 +3,14 @@ import { createTaskPickupTool } from "./lib/tools/task-pickup.js";
import { createTaskCompleteTool } from "./lib/tools/task-complete.js"; import { createTaskCompleteTool } from "./lib/tools/task-complete.js";
import { createQueueStatusTool } from "./lib/tools/queue-status.js"; import { createQueueStatusTool } from "./lib/tools/queue-status.js";
import { createSessionHealthTool } from "./lib/tools/session-health.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";
const plugin = { const plugin = {
id: "devclaw", id: "devclaw",
name: "DevClaw", name: "DevClaw",
description: description:
"Multi-project dev/qa pipeline orchestration with GitLab integration, model selection, and audit logging.", "Multi-project dev/qa pipeline orchestration with GitHub/GitLab integration, model selection, and audit logging.",
configSchema: {}, configSchema: {},
register(api: OpenClawPluginApi) { register(api: OpenClawPluginApi) {
@@ -25,8 +27,14 @@ const plugin = {
api.registerTool(createSessionHealthTool(api), { api.registerTool(createSessionHealthTool(api), {
names: ["session_health"], names: ["session_health"],
}); });
api.registerTool(createProjectRegisterTool(api), {
names: ["project_register"],
});
api.registerTool(createTaskCreateTool(api), {
names: ["task_create"],
});
api.logger.info("DevClaw plugin registered (4 tools)"); api.logger.info("DevClaw plugin registered (6 tools)");
}, },
}; };

240
lib/dispatch.ts Normal file
View File

@@ -0,0 +1,240 @@
/**
* dispatch.ts — Core dispatch logic shared by task_pickup and task_complete (auto-chain).
*
* Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI,
* state update (activateWorker), and audit logging.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
type Project,
type WorkerState,
getWorker,
getSessionForModel,
activateWorker,
} from "./projects.js";
import { selectModel } from "./model-selector.js";
import { log as auditLog } from "./audit.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;
groupId: string;
project: Project;
issueId: number;
issueTitle: string;
issueDescription: string;
issueUrl: string;
role: "dev" | "qa";
modelAlias: string;
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
fromLabel: string;
/** Label to transition TO (e.g. "Doing", "Testing") */
toLabel: string;
/** Function to transition labels (injected to avoid gitlab.ts dependency) */
transitionLabel: (issueId: number, from: string, to: string) => Promise<void>;
};
export type DispatchResult = {
sessionAction: "spawn" | "send";
sessionKey: string;
modelAlias: string;
fullModel: string;
announcement: string;
};
/**
* Build the task message sent to a worker session.
* Reads role-specific instructions from workspace/roles/<project>/<role>.md
* with fallback to workspace/roles/default/<role>.md.
*/
async function buildTaskMessage(opts: {
workspaceDir: string;
projectName: string;
role: "dev" | "qa";
issueId: number;
issueTitle: string;
issueDescription: string;
issueUrl: string;
repo: string;
baseBranch: string;
groupId: string;
}): Promise<string> {
const { workspaceDir, projectName, role, issueId, issueTitle, issueDescription, issueUrl, repo, baseBranch, groupId } = opts;
// Read role-specific instructions
let roleInstructions = "";
const projectRoleFile = path.join(workspaceDir, "roles", projectName, `${role}.md`);
const defaultRoleFile = path.join(workspaceDir, "roles", "default", `${role}.md`);
try {
roleInstructions = await fs.readFile(projectRoleFile, "utf-8");
} catch {
try {
roleInstructions = await fs.readFile(defaultRoleFile, "utf-8");
} catch {
// No role instructions — that's fine
}
}
const parts = [
`${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`,
``,
issueTitle,
issueDescription ? `\n${issueDescription}` : "",
``,
`Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`,
`Project group ID: ${groupId}`,
];
if (roleInstructions) {
parts.push(``, `---`, ``, roleInstructions.trim());
}
return parts.join("\n");
}
/**
* Dispatch a task to a worker session. Handles session spawn/reuse,
* CLI dispatch, state update, and audit logging.
*
* Returns dispatch result on success. Throws on dispatch failure
* (with label rollback). Logs warning on state update failure
* (dispatch succeeded, session IS running).
*/
export async function dispatchTask(opts: DispatchOpts): Promise<DispatchResult> {
const {
workspaceDir, agentId, groupId, project, issueId,
issueTitle, issueDescription, issueUrl,
role, modelAlias, fromLabel, toLabel, transitionLabel,
} = opts;
const fullModel = MODEL_MAP[modelAlias] ?? modelAlias;
const worker = getWorker(project, role);
const existingSessionKey = getSessionForModel(worker, modelAlias);
const sessionAction = existingSessionKey ? "send" : "spawn";
// Build task message with role instructions
const taskMessage = await buildTaskMessage({
workspaceDir,
projectName: project.name,
role,
issueId,
issueTitle,
issueDescription,
issueUrl,
repo: project.repo,
baseBranch: project.baseBranch,
groupId,
});
// Transition label
await transitionLabel(issueId, fromLabel, toLabel);
// Dispatch
let sessionKey = existingSessionKey;
let dispatched = false;
try {
if (sessionAction === "spawn") {
sessionKey = `agent:${agentId}:subagent:${randomUUID()}`;
await execFileAsync("openclaw", [
"gateway", "call", "sessions.patch",
"--data", JSON.stringify({ key: sessionKey, model: fullModel }),
], { timeout: 30_000 });
}
await execFileAsync("openclaw", [
"agent",
"--session-id", sessionKey!,
"--message", taskMessage,
], { timeout: 60_000 });
dispatched = true;
// Update state
const now = new Date().toISOString();
if (sessionAction === "spawn") {
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: modelAlias,
sessionKey: sessionKey!,
startTime: now,
});
} else {
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: modelAlias,
});
}
} catch (err) {
if (dispatched) {
// State update failed but session IS running — log warning, don't rollback
await auditLog(workspaceDir, "task_pickup", {
project: project.name,
groupId,
issue: issueId,
role,
warning: "State update failed after successful dispatch",
error: (err as Error).message,
sessionKey,
});
} else {
// Dispatch failed — rollback label
try {
await transitionLabel(issueId, toLabel, fromLabel);
} catch {
// Best-effort rollback
}
throw new Error(
`Session dispatch failed: ${(err as Error).message}. Label reverted to "${fromLabel}".`,
);
}
}
// Audit
await auditLog(workspaceDir, "task_pickup", {
project: project.name,
groupId,
issue: issueId,
issueTitle,
role,
model: modelAlias,
sessionAction,
sessionKey,
labelTransition: `${fromLabel}${toLabel}`,
});
await auditLog(workspaceDir, "model_selection", {
issue: issueId,
role,
selected: modelAlias,
fullModel,
});
// Build announcement
const emoji = role === "dev"
? (modelAlias === "haiku" ? "⚡" : modelAlias === "opus" ? "🧠" : "🔧")
: "🔍";
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${modelAlias}) for #${issueId}: ${issueTitle}`;
return {
sessionAction,
sessionKey: sessionKey!,
modelAlias,
fullModel,
announcement,
};
}

80
lib/issue-provider.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* IssueProvider — Abstract interface for issue tracker operations.
*
* GitLab is the first implementation (via glab CLI).
* Future providers: GitHub (via gh CLI), Jira (via API).
*
* All DevClaw tools operate through this interface, making it possible
* to swap issue trackers without changing tool logic.
*/
export const STATE_LABELS = [
"Planning",
"To Do",
"Doing",
"To Test",
"Testing",
"Done",
"To Improve",
"Refining",
] as const;
export type StateLabel = (typeof STATE_LABELS)[number];
export const LABEL_COLORS: Record<StateLabel, string> = {
Planning: "#6699cc",
"To Do": "#428bca",
Doing: "#f0ad4e",
"To Test": "#5bc0de",
Testing: "#9b59b6",
Done: "#5cb85c",
"To Improve": "#d9534f",
Refining: "#f39c12",
};
export type Issue = {
iid: number;
title: string;
description: string;
labels: string[];
state: string;
web_url: string;
};
export interface IssueProvider {
/** Create a label if it doesn't exist (idempotent). */
ensureLabel(name: string, color: string): Promise<void>;
/** Create all 8 state labels (idempotent). */
ensureAllStateLabels(): Promise<void>;
/** Create a new issue. */
createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise<Issue>;
/** List issues with a specific state label. */
listIssuesByLabel(label: StateLabel): Promise<Issue[]>;
/** Fetch a single issue by ID. */
getIssue(issueId: number): Promise<Issue>;
/** Transition an issue from one state label to another (atomic unlabel + label). */
transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void>;
/** Close an issue. */
closeIssue(issueId: number): Promise<void>;
/** Reopen an issue. */
reopenIssue(issueId: number): Promise<void>;
/** Check if an issue has a specific state label. */
hasStateLabel(issue: Issue, expected: StateLabel): boolean;
/** Get the current state label of an issue. */
getCurrentStateLabel(issue: Issue): StateLabel | null;
/** Check if any merged MR/PR exists for a specific issue. */
hasMergedMR(issueId: number): Promise<boolean>;
/** Verify the provider is working (CLI available, auth valid, repo accessible). */
healthCheck(): Promise<boolean>;
}

View File

@@ -7,10 +7,10 @@ import path from "node:path";
export type WorkerState = { export type WorkerState = {
active: boolean; active: boolean;
sessionId: string | null;
issueId: string | null; issueId: string | null;
startTime: string | null; startTime: string | null;
model: string | null; model: string | null;
sessions: Record<string, string | null>;
}; };
export type Project = { export type Project = {
@@ -20,6 +20,7 @@ export type Project = {
deployUrl: string; deployUrl: string;
baseBranch: string; baseBranch: string;
deployBranch: string; deployBranch: string;
autoChain: boolean;
dev: WorkerState; dev: WorkerState;
qa: WorkerState; qa: WorkerState;
}; };
@@ -28,13 +29,84 @@ export type ProjectsData = {
projects: Record<string, Project>; projects: Record<string, Project>;
}; };
/**
* Migrate old WorkerState schema (sessionId field) to new sessions map.
* Called transparently on read — old data is converted in memory,
* persisted on next write.
*/
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, ... }
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;
}
return {
active: worker.active as boolean,
issueId: worker.issueId as string | null,
startTime: worker.startTime as string | null,
model,
sessions,
};
}
/**
* Create a blank WorkerState with null sessions for given model aliases.
*/
export function emptyWorkerState(aliases: string[]): WorkerState {
const sessions: Record<string, string | null> = {};
for (const alias of aliases) {
sessions[alias] = null;
}
return {
active: false,
issueId: null,
startTime: null,
model: null,
sessions,
};
}
/**
* Get session key for a specific model alias from a worker's sessions map.
*/
export function getSessionForModel(
worker: WorkerState,
modelAlias: string,
): string | null {
return worker.sessions[modelAlias] ?? null;
}
function projectsPath(workspaceDir: string): string { function projectsPath(workspaceDir: string): string {
return path.join(workspaceDir, "memory", "projects.json"); return path.join(workspaceDir, "memory", "projects.json");
} }
export async function readProjects(workspaceDir: string): Promise<ProjectsData> { export async function readProjects(workspaceDir: string): Promise<ProjectsData> {
const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8"); const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8");
return JSON.parse(raw) as ProjectsData; const data = JSON.parse(raw) as ProjectsData;
// Migrate any old-schema or missing fields transparently
for (const project of Object.values(data.projects)) {
project.dev = project.dev
? migrateWorkerState(project.dev as unknown as Record<string, unknown>)
: emptyWorkerState([]);
project.qa = project.qa
? migrateWorkerState(project.qa as unknown as Record<string, unknown>)
: emptyWorkerState([]);
if (project.autoChain === undefined) {
project.autoChain = false;
}
}
return data;
} }
export async function writeProjects( export async function writeProjects(
@@ -79,6 +151,10 @@ export async function updateWorker(
} }
const worker = project[role]; const worker = project[role];
// Merge sessions maps if both exist
if (updates.sessions && worker.sessions) {
updates.sessions = { ...worker.sessions, ...updates.sessions };
}
project[role] = { ...worker, ...updates }; project[role] = { ...worker, ...updates };
await writeProjects(workspaceDir, data); await writeProjects(workspaceDir, data);
@@ -87,7 +163,7 @@ export async function updateWorker(
/** /**
* Mark a worker as active with a new task. * Mark a worker as active with a new task.
* Sets active=true, issueId, model. Preserves sessionId and startTime if reusing. * Sets active=true, issueId, model. Stores session key in sessions[model].
*/ */
export async function activateWorker( export async function activateWorker(
workspaceDir: string, workspaceDir: string,
@@ -96,7 +172,7 @@ export async function activateWorker(
params: { params: {
issueId: string; issueId: string;
model: string; model: string;
sessionId?: string; sessionKey?: string;
startTime?: string; startTime?: string;
}, },
): Promise<ProjectsData> { ): Promise<ProjectsData> {
@@ -105,9 +181,9 @@ export async function activateWorker(
issueId: params.issueId, issueId: params.issueId,
model: params.model, model: params.model,
}; };
// Only set sessionId and startTime if provided (new spawn) // Store session key in the sessions map for this model
if (params.sessionId !== undefined) { if (params.sessionKey !== undefined) {
updates.sessionId = params.sessionId; updates.sessions = { [params.model]: params.sessionKey };
} }
if (params.startTime !== undefined) { if (params.startTime !== undefined) {
updates.startTime = params.startTime; updates.startTime = params.startTime;
@@ -117,7 +193,7 @@ export async function activateWorker(
/** /**
* Mark a worker as inactive after task completion. * Mark a worker as inactive after task completion.
* Clears issueId and active, PRESERVES sessionId, model, startTime for reuse. * Clears issueId and active, PRESERVES sessions map, model, startTime for reuse.
*/ */
export async function deactivateWorker( export async function deactivateWorker(
workspaceDir: string, workspaceDir: string,

211
lib/providers/github.ts Normal file
View File

@@ -0,0 +1,211 @@
/**
* GitHubProvider — IssueProvider implementation using gh CLI.
*
* Wraps gh commands for label management, issue operations, and PR checks.
* ensureLabel is idempotent — catches "already exists" errors gracefully.
*
* Note: gh CLI JSON output uses different field names than GitLab:
* number (not iid), body (not description), url (not web_url),
* labels are objects with { name } (not plain strings).
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { writeFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
type IssueProvider,
type Issue,
type StateLabel,
STATE_LABELS,
LABEL_COLORS,
} from "../issue-provider.js";
const execFileAsync = promisify(execFile);
export type GitHubProviderOptions = {
ghPath?: string;
repoPath: string;
};
type GhIssue = {
number: number;
title: string;
body: string;
labels: Array<{ name: string }>;
state: string;
url: string;
};
/** Convert gh JSON issue to the common Issue type. */
function toIssue(gh: GhIssue): Issue {
return {
iid: gh.number,
title: gh.title,
description: gh.body ?? "",
labels: gh.labels.map((l) => l.name),
state: gh.state,
web_url: gh.url,
};
}
export class GitHubProvider implements IssueProvider {
private ghPath: string;
private repoPath: string;
constructor(opts: GitHubProviderOptions) {
this.ghPath = opts.ghPath ?? "gh";
this.repoPath = opts.repoPath;
}
private async gh(args: string[]): Promise<string> {
const { stdout } = await execFileAsync(this.ghPath, args, {
cwd: this.repoPath,
timeout: 30_000,
});
return stdout.trim();
}
async ensureLabel(name: string, color: string): Promise<void> {
// gh expects color without # prefix
const hex = color.replace(/^#/, "");
try {
await this.gh(["label", "create", name, "--color", hex]);
} catch (err) {
const msg = (err as Error).message ?? "";
if (msg.includes("already exists")) {
return;
}
throw err;
}
}
async ensureAllStateLabels(): Promise<void> {
for (const label of STATE_LABELS) {
await this.ensureLabel(label, LABEL_COLORS[label]);
}
}
async createIssue(
title: string,
description: string,
label: StateLabel,
assignees?: string[],
): Promise<Issue> {
// Write description to temp file to preserve newlines
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`);
await writeFile(tempFile, description, "utf-8");
try {
const args = [
"issue", "create",
"--title", title,
"--body-file", tempFile,
"--label", label,
];
if (assignees && assignees.length > 0) {
args.push("--assignee", assignees.join(","));
}
// gh issue create returns the URL of the created issue
const url = await this.gh(args);
// Extract issue number from URL (e.g., https://github.com/owner/repo/issues/42)
const match = url.match(/\/issues\/(\d+)$/);
if (!match) {
throw new Error(`Failed to parse issue number from created issue URL: ${url}`);
}
const issueId = parseInt(match[1], 10);
// Fetch the full issue details
return this.getIssue(issueId);
} finally {
// Clean up temp file
try {
await unlink(tempFile);
} catch {
// Ignore cleanup errors
}
}
}
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
try {
const raw = await this.gh([
"issue", "list",
"--label", label,
"--state", "open",
"--json", "number,title,body,labels,state,url",
]);
const issues = JSON.parse(raw) as GhIssue[];
return issues.map(toIssue);
} catch {
return [];
}
}
async getIssue(issueId: number): Promise<Issue> {
const raw = await this.gh([
"issue", "view", String(issueId),
"--json", "number,title,body,labels,state,url",
]);
return toIssue(JSON.parse(raw) as GhIssue);
}
async transitionLabel(
issueId: number,
from: StateLabel,
to: StateLabel,
): Promise<void> {
await this.gh([
"issue", "edit", String(issueId),
"--remove-label", from,
"--add-label", to,
]);
}
async closeIssue(issueId: number): Promise<void> {
await this.gh(["issue", "close", String(issueId)]);
}
async reopenIssue(issueId: number): Promise<void> {
await this.gh(["issue", "reopen", String(issueId)]);
}
hasStateLabel(issue: Issue, expected: StateLabel): boolean {
return issue.labels.includes(expected);
}
getCurrentStateLabel(issue: Issue): StateLabel | null {
for (const label of STATE_LABELS) {
if (issue.labels.includes(label)) {
return label;
}
}
return null;
}
async hasMergedMR(issueId: number): Promise<boolean> {
try {
const raw = await this.gh([
"pr", "list",
"--state", "merged",
"--json", "title,body",
]);
const prs = JSON.parse(raw) as Array<{ title: string; body: string }>;
const pattern = `#${issueId}`;
return prs.some(
(pr) =>
pr.title.includes(pattern) || (pr.body ?? "").includes(pattern),
);
} catch {
return false;
}
}
async healthCheck(): Promise<boolean> {
try {
await this.gh(["auth", "status"]);
return true;
} catch {
return false;
}
}
}

174
lib/providers/gitlab.ts Normal file
View File

@@ -0,0 +1,174 @@
/**
* GitLabProvider — IssueProvider implementation using glab CLI.
*
* Wraps glab commands for label management, issue operations, and MR checks.
* ensureLabel is idempotent — catches "already exists" errors gracefully.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { writeFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
type IssueProvider,
type Issue,
type StateLabel,
STATE_LABELS,
LABEL_COLORS,
} from "../issue-provider.js";
const execFileAsync = promisify(execFile);
export type GitLabProviderOptions = {
glabPath?: string;
repoPath: string;
};
export class GitLabProvider implements IssueProvider {
private glabPath: string;
private repoPath: string;
constructor(opts: GitLabProviderOptions) {
this.glabPath = opts.glabPath ?? "glab";
this.repoPath = opts.repoPath;
}
private async glab(args: string[]): Promise<string> {
const { stdout } = await execFileAsync(this.glabPath, args, {
cwd: this.repoPath,
timeout: 30_000,
});
return stdout.trim();
}
async ensureLabel(name: string, color: string): Promise<void> {
try {
await this.glab(["label", "create", "--name", name, "--color", color]);
} catch (err) {
const msg = (err as Error).message ?? "";
// Idempotent: ignore "already exists" errors
if (msg.includes("already exists") || msg.includes("409")) {
return;
}
throw err;
}
}
async ensureAllStateLabels(): Promise<void> {
for (const label of STATE_LABELS) {
await this.ensureLabel(label, LABEL_COLORS[label]);
}
}
async createIssue(
title: string,
description: string,
label: StateLabel,
assignees?: string[],
): Promise<Issue> {
// Write description to temp file to preserve newlines
const tempFile = join(tmpdir(), `devclaw-issue-${Date.now()}.md`);
await writeFile(tempFile, description, "utf-8");
try {
// Use shell to read file content into description
const { exec } = await import("node:child_process");
const { promisify } = await import("node:util");
const execAsync = promisify(exec);
let cmd = `${this.glabPath} issue create --title "${title.replace(/"/g, '\\"')}" --description "$(cat ${tempFile})" --label "${label}" --output json`;
if (assignees && assignees.length > 0) {
cmd += ` --assignee "${assignees.join(",")}"`;
}
const { stdout } = await execAsync(cmd, {
cwd: this.repoPath,
timeout: 30_000,
});
return JSON.parse(stdout.trim()) as Issue;
} finally {
// Clean up temp file
try {
await unlink(tempFile);
} catch {
// Ignore cleanup errors
}
}
}
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
try {
const raw = await this.glab([
"issue", "list", "--label", label, "--output", "json",
]);
return JSON.parse(raw) as Issue[];
} catch {
return [];
}
}
async getIssue(issueId: number): Promise<Issue> {
const raw = await this.glab([
"issue", "view", String(issueId), "--output", "json",
]);
return JSON.parse(raw) as Issue;
}
async transitionLabel(
issueId: number,
from: StateLabel,
to: StateLabel,
): Promise<void> {
await this.glab([
"issue", "update", String(issueId),
"--unlabel", from,
"--label", to,
]);
}
async closeIssue(issueId: number): Promise<void> {
await this.glab(["issue", "close", String(issueId)]);
}
async reopenIssue(issueId: number): Promise<void> {
await this.glab(["issue", "reopen", String(issueId)]);
}
hasStateLabel(issue: Issue, expected: StateLabel): boolean {
return issue.labels.includes(expected);
}
getCurrentStateLabel(issue: Issue): StateLabel | null {
for (const label of STATE_LABELS) {
if (issue.labels.includes(label)) {
return label;
}
}
return null;
}
async hasMergedMR(issueId: number): Promise<boolean> {
try {
const raw = await this.glab([
"mr", "list", "--output", "json", "--state", "merged",
]);
const mrs = JSON.parse(raw) as Array<{ title: string; description: string }>;
const pattern = `#${issueId}`;
return mrs.some(
(mr) =>
mr.title.includes(pattern) || (mr.description ?? "").includes(pattern),
);
} catch {
return false;
}
}
async healthCheck(): Promise<boolean> {
try {
await this.glab(["auth", "status"]);
return true;
} catch {
return false;
}
}
}

54
lib/providers/index.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* Provider factory — creates the appropriate IssueProvider for a repository.
*
* Auto-detects provider from git remote URL:
* - github.com → GitHubProvider (gh CLI)
* - Everything else → GitLabProvider (glab CLI)
*
* Can be overridden with explicit `provider` option.
*/
import { execFileSync } from "node:child_process";
import type { IssueProvider } from "../issue-provider.js";
import { GitLabProvider } from "./gitlab.js";
import { GitHubProvider } from "./github.js";
export type ProviderOptions = {
provider?: "gitlab" | "github";
glabPath?: string;
ghPath?: string;
repoPath: string;
};
function detectProvider(repoPath: string): "gitlab" | "github" {
try {
const url = execFileSync("git", ["remote", "get-url", "origin"], {
cwd: repoPath,
timeout: 5_000,
}).toString().trim();
if (url.includes("github.com")) return "github";
return "gitlab";
} catch {
return "gitlab";
}
}
export type ProviderWithType = {
provider: IssueProvider;
type: "github" | "gitlab";
};
export function createProvider(opts: ProviderOptions): ProviderWithType {
const type = opts.provider ?? detectProvider(opts.repoPath);
if (type === "github") {
return {
provider: new GitHubProvider({ ghPath: opts.ghPath, repoPath: opts.repoPath }),
type: "github",
};
}
return {
provider: new GitLabProvider({ glabPath: opts.glabPath, repoPath: opts.repoPath }),
type: "gitlab",
};
}

View File

@@ -0,0 +1,230 @@
/**
* project_register — Register a new project with DevClaw.
*
* Atomically: validates repo, detects GitHub/GitLab provider, creates all 8 state labels (idempotent),
* adds project entry to projects.json, and logs the event.
*
* Replaces the manual steps of running glab/gh label create + editing projects.json.
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import fs from "node:fs/promises";
import path from "node:path";
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";
/**
* Ensure default role files exist, then copy them into the project's role directory.
* Returns true if files were created, false if they already existed.
*/
async function scaffoldRoleFiles(workspaceDir: string, projectName: string): Promise<boolean> {
const defaultDir = path.join(workspaceDir, "roles", "default");
const projectDir = path.join(workspaceDir, "roles", projectName);
// Ensure default role files exist
await fs.mkdir(defaultDir, { recursive: true });
const defaultDev = path.join(defaultDir, "dev.md");
const defaultQa = path.join(defaultDir, "qa.md");
try {
await fs.access(defaultDev);
} catch {
await fs.writeFile(defaultDev, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
}
try {
await fs.access(defaultQa);
} catch {
await fs.writeFile(defaultQa, DEFAULT_QA_INSTRUCTIONS, "utf-8");
}
// Create project-specific role files (copy from default if not exist)
await fs.mkdir(projectDir, { recursive: true });
const projectDev = path.join(projectDir, "dev.md");
const projectQa = path.join(projectDir, "qa.md");
let created = false;
try {
await fs.access(projectDev);
} catch {
await fs.copyFile(defaultDev, projectDev);
created = true;
}
try {
await fs.access(projectQa);
} catch {
await fs.copyFile(defaultQa, projectQa);
created = true;
}
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",
description: `Register a new project with DevClaw. Creates all required state labels (idempotent) and adds the project to projects.json. One-time setup per project. Auto-detects GitHub/GitLab from git remote.`,
parameters: {
type: "object",
required: ["projectGroupId", "name", "repo", "groupName", "baseBranch"],
properties: {
projectGroupId: {
type: "string",
description: "Telegram group ID (will be the key in projects.json)",
},
name: {
type: "string",
description: "Short project name (e.g. 'my-webapp')",
},
repo: {
type: "string",
description: "Path to git repo (e.g. '~/git/my-project')",
},
groupName: {
type: "string",
description: "Telegram group display name (e.g. 'Dev - My Project')",
},
baseBranch: {
type: "string",
description: "Base branch for development (e.g. 'development', 'main')",
},
deployBranch: {
type: "string",
description: "Branch that triggers deployment. Defaults to baseBranch.",
},
deployUrl: {
type: "string",
description: "Deployment URL for the project",
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const groupId = params.projectGroupId as string;
const name = params.name as string;
const repo = params.repo as string;
const groupName = params.groupName as string;
const baseBranch = params.baseBranch as string;
const deployBranch = (params.deployBranch as string) ?? baseBranch;
const deployUrl = (params.deployUrl as string) ?? "";
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
// 1. Check project not already registered (allow re-register if incomplete)
const data = await readProjects(workspaceDir);
const existing = data.projects[groupId];
if (existing && existing.dev?.sessions && Object.keys(existing.dev.sessions).length > 0) {
throw new Error(
`Project already registered for groupId ${groupId}: "${existing.name}". Use a different group ID or remove the existing entry first.`,
);
}
// 2. Resolve repo path
const repoPath = resolveRepoPath(repo);
// 3. Create provider and verify it works
const glabPath = (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined;
const ghPath = (api.pluginConfig as Record<string, unknown>)?.ghPath as string | undefined;
const { provider, type: providerType } = createProvider({ glabPath, ghPath, repoPath });
const healthy = await provider.healthCheck();
if (!healthy) {
const cliName = providerType === "github" ? "gh" : "glab";
const cliInstallUrl = providerType === "github"
? "https://cli.github.com"
: "https://gitlab.com/gitlab-org/cli";
throw new Error(
`${providerType.toUpperCase()} health check failed for ${repoPath}. ` +
`Detected provider: ${providerType}. ` +
`Ensure '${cliName}' CLI is installed, authenticated (${cliName} auth status), ` +
`and the repo has a ${providerType.toUpperCase()} remote. ` +
`Install ${cliName} from: ${cliInstallUrl}`
);
}
// 4. Create all 8 state labels (idempotent)
await provider.ensureAllStateLabels();
// 5. Add project to projects.json
data.projects[groupId] = {
name,
repo,
groupName,
deployUrl,
baseBranch,
deployBranch,
autoChain: false,
dev: emptyWorkerState(["haiku", "sonnet", "opus"]),
qa: emptyWorkerState(["grok"]),
};
await writeProjects(workspaceDir, data);
// 6. Scaffold role files
const rolesCreated = await scaffoldRoleFiles(workspaceDir, name);
// 7. Audit log
await auditLog(workspaceDir, "project_register", {
project: name,
groupId,
repo,
baseBranch,
deployBranch,
deployUrl: deployUrl || null,
});
// 8. Return announcement
const rolesNote = rolesCreated ? " Role files scaffolded." : "";
const announcement = `📋 Project "${name}" registered for group ${groupName}. Labels created.${rolesNote} Ready for tasks.`;
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
project: name,
groupId,
repo,
baseBranch,
deployBranch,
labelsCreated: 8,
rolesScaffolded: rolesCreated,
announcement,
}, null, 2),
}],
};
},
});
}

View File

@@ -63,15 +63,15 @@ export function createQueueStatusTool(api: OpenClawPluginApi) {
groupId: pid, groupId: pid,
dev: { dev: {
active: project.dev.active, active: project.dev.active,
sessionId: project.dev.sessionId,
issueId: project.dev.issueId, issueId: project.dev.issueId,
model: project.dev.model, model: project.dev.model,
sessions: project.dev.sessions,
}, },
qa: { qa: {
active: project.qa.active, active: project.qa.active,
sessionId: project.qa.sessionId,
issueId: project.qa.issueId, issueId: project.qa.issueId,
model: project.qa.model, model: project.qa.model,
sessions: project.qa.sessions,
}, },
queue: { queue: {
toImprove: queue["To Improve"], toImprove: queue["To Improve"],

View File

@@ -2,21 +2,17 @@
* session_health — Check and fix session state consistency. * session_health — Check and fix session state consistency.
* *
* Detects zombie sessions (active=true but session dead) and stale workers. * Detects zombie sessions (active=true but session dead) and stale workers.
* Replaces manual HEARTBEAT.md step 1. * Checks the sessions map for each worker's current model.
*
* NOTE: This tool checks projects.json state only. The agent should verify
* session liveness via sessions_list and pass the results. The tool cannot
* call sessions_list directly (it's an agent-level tool).
*/ */
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { readProjects, updateWorker } from "../projects.js"; import { readProjects, updateWorker, getSessionForModel } from "../projects.js";
import { transitionLabel, resolveRepoPath, type StateLabel } from "../gitlab.js"; import { transitionLabel, resolveRepoPath, type StateLabel } from "../gitlab.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
export function createSessionHealthTool(api: OpenClawPluginApi) { export function createSessionHealthTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({ return (ctx: OpenClawPluginToolContext) => ({
name: "session_health", name: "session_health",
description: `Check session state consistency across all projects. Detects: active workers with dead sessions, stale workers (>2 hours), and state mismatches. With autoFix=true, clears zombie states and reverts GitLab labels. Pass activeSessions (from sessions_list) so the tool can verify liveness.`, description: `Check session state consistency across all projects. Detects: active workers with no session in their sessions map, stale workers (>2 hours), and state mismatches. With autoFix=true, clears zombie states and reverts GitLab labels. Pass activeSessions (from sessions_list) so the tool can verify liveness.`,
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
@@ -53,16 +49,20 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
for (const role of ["dev", "qa"] as const) { for (const role of ["dev", "qa"] as const) {
const worker = project[role]; const worker = project[role];
const currentSessionKey = worker.model
? getSessionForModel(worker, worker.model)
: null;
// Check 1: Active but no sessionId // Check 1: Active but no session key for current model
if (worker.active && !worker.sessionId) { if (worker.active && !currentSessionKey) {
const issue: Record<string, unknown> = { const issue: Record<string, unknown> = {
type: "active_no_session", type: "active_no_session",
severity: "critical", severity: "critical",
project: project.name, project: project.name,
groupId, groupId,
role, role,
message: `${role.toUpperCase()} marked active but has no sessionId`, model: worker.model,
message: `${role.toUpperCase()} marked active but has no session for model "${worker.model}"`,
}; };
if (autoFix) { if (autoFix) {
@@ -76,12 +76,12 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
issues.push(issue); issues.push(issue);
} }
// Check 2: Active with sessionId but session is dead (zombie) // Check 2: Active with session but session is dead (zombie)
if ( if (
worker.active && worker.active &&
worker.sessionId && currentSessionKey &&
activeSessions.length > 0 && activeSessions.length > 0 &&
!activeSessions.includes(worker.sessionId) !activeSessions.includes(currentSessionKey)
) { ) {
const issue: Record<string, unknown> = { const issue: Record<string, unknown> = {
type: "zombie_session", type: "zombie_session",
@@ -89,8 +89,9 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
project: project.name, project: project.name,
groupId, groupId,
role, role,
sessionId: worker.sessionId, sessionKey: currentSessionKey,
message: `${role.toUpperCase()} session ${worker.sessionId} not found in active sessions`, model: worker.model,
message: `${role.toUpperCase()} session ${currentSessionKey} not found in active sessions`,
}; };
if (autoFix) { if (autoFix) {
@@ -107,9 +108,16 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
issue.labelRevertFailed = true; issue.labelRevertFailed = true;
} }
// Clear the dead session from the sessions map
const updatedSessions = { ...worker.sessions };
if (worker.model) {
updatedSessions[worker.model] = null;
}
await updateWorker(workspaceDir, groupId, role, { await updateWorker(workspaceDir, groupId, role, {
active: false, active: false,
issueId: null, issueId: null,
sessions: updatedSessions,
}); });
issue.fixed = true; issue.fixed = true;
fixesApplied++; fixesApplied++;
@@ -131,7 +139,7 @@ export function createSessionHealthTool(api: OpenClawPluginApi) {
groupId, groupId,
role, role,
hoursActive: Math.round(hoursActive * 10) / 10, hoursActive: Math.round(hoursActive * 10) / 10,
sessionId: worker.sessionId, sessionKey: currentSessionKey,
issueId: worker.issueId, issueId: worker.issueId,
message: `${role.toUpperCase()} has been active for ${Math.round(hoursActive * 10) / 10}h — may need attention`, message: `${role.toUpperCase()} has been active for ${Math.round(hoursActive * 10) / 10}h — may need attention`,
}); });

View File

@@ -1,16 +1,20 @@
/** /**
* task_complete — Atomically complete a task (DEV done, QA pass/fail/refine). * task_complete — Atomically complete a task (DEV done, QA pass/fail/refine).
* *
* Handles: validation, GitLab label transition, projects.json state update, * Handles: validation, label transition, projects.json state update,
* issue close/reopen, and audit logging. * 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)
*/ */
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { import {
readProjects, readProjects,
getProject, getProject,
getWorker, getWorker,
getSessionForModel,
deactivateWorker, deactivateWorker,
activateWorker,
} from "../projects.js"; } from "../projects.js";
import { import {
getIssue, getIssue,
@@ -20,8 +24,8 @@ import {
resolveRepoPath, resolveRepoPath,
type StateLabel, type StateLabel,
} from "../gitlab.js"; } from "../gitlab.js";
import { selectModel } from "../model-selector.js";
import { log as auditLog } from "../audit.js"; import { log as auditLog } from "../audit.js";
import { dispatchTask } from "../dispatch.js";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
@@ -30,7 +34,7 @@ const execFileAsync = promisify(execFile);
export function createTaskCompleteTool(api: OpenClawPluginApi) { export function createTaskCompleteTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({ return (ctx: OpenClawPluginToolContext) => ({
name: "task_complete", name: "task_complete",
description: `Complete a task: DEV done, QA pass, QA fail, or QA refine. Atomically handles: label transition, projects.json update, issue close/reopen, and audit logging. For QA fail, also prepares DEV session instructions for the fix cycle.`, description: `Complete a task: DEV done, QA pass, QA fail, or QA refine. Atomically handles: label transition, projects.json update, issue close/reopen, and audit logging. If the project has autoChain enabled, automatically dispatches the next step (DEV done → QA, QA fail → DEV fix).`,
parameters: { parameters: {
type: "object", type: "object",
required: ["role", "result", "projectGroupId"], required: ["role", "result", "projectGroupId"],
@@ -101,7 +105,6 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
// === DEV DONE === // === DEV DONE ===
if (role === "dev" && result === "done") { if (role === "dev" && result === "done") {
// Pull latest on the project repo
try { try {
await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 }); await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 });
output.gitPull = "success"; output.gitPull = "success";
@@ -109,22 +112,49 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
output.gitPull = `warning: ${(err as Error).message}`; output.gitPull = `warning: ${(err as Error).message}`;
} }
// Deactivate DEV (preserves sessionId, model, startTime)
await deactivateWorker(workspaceDir, groupId, "dev"); await deactivateWorker(workspaceDir, groupId, "dev");
// Transition label: Doing → To Test
await transitionLabel(issueId, "Doing", "To Test", glabOpts); await transitionLabel(issueId, "Doing", "To Test", glabOpts);
output.labelTransition = "Doing → To Test"; output.labelTransition = "Doing → To Test";
output.announcement = `✅ DEV done #${issueId}${summary ? `${summary}` : ""}. Moved to QA queue.`; output.announcement = `✅ DEV done #${issueId}${summary ? `${summary}` : ""}. Moved to QA queue.`;
if (project.autoChain) {
try {
const issue = await getIssue(issueId, glabOpts);
const chainResult = await dispatchTask({
workspaceDir,
agentId: ctx.agentId,
groupId,
project,
issueId,
issueTitle: issue.title,
issueDescription: issue.description ?? "",
issueUrl: issue.web_url,
role: "qa",
modelAlias: "grok",
fromLabel: "To Test",
toLabel: "Testing",
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
});
output.autoChain = {
dispatched: true,
role: "qa",
model: chainResult.modelAlias,
sessionAction: chainResult.sessionAction,
announcement: chainResult.announcement,
};
} catch (err) {
output.autoChain = { dispatched: false, error: (err as Error).message };
}
} else {
output.nextAction = "qa_pickup";
}
} }
// === QA PASS === // === QA PASS ===
if (role === "qa" && result === "pass") { if (role === "qa" && result === "pass") {
// Deactivate QA
await deactivateWorker(workspaceDir, groupId, "qa"); await deactivateWorker(workspaceDir, groupId, "qa");
// Transition label: Testing → Done, close issue
await transitionLabel(issueId, "Testing", "Done", glabOpts); await transitionLabel(issueId, "Testing", "Done", glabOpts);
await closeIssue(issueId, glabOpts); await closeIssue(issueId, glabOpts);
@@ -135,44 +165,57 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
// === QA FAIL === // === QA FAIL ===
if (role === "qa" && result === "fail") { if (role === "qa" && result === "fail") {
// Deactivate QA
await deactivateWorker(workspaceDir, groupId, "qa"); await deactivateWorker(workspaceDir, groupId, "qa");
// Transition label: Testing → To Improve, reopen issue
await transitionLabel(issueId, "Testing", "To Improve", glabOpts); await transitionLabel(issueId, "Testing", "To Improve", glabOpts);
await reopenIssue(issueId, glabOpts); await reopenIssue(issueId, glabOpts);
// Prepare DEV fix cycle
const issue = await getIssue(issueId, glabOpts);
const devModel = selectModel(issue.title, issue.description ?? "", "dev");
const devWorker = getWorker(project, "dev"); const devWorker = getWorker(project, "dev");
const devModel = devWorker.model;
const devSessionKey = devModel ? getSessionForModel(devWorker, devModel) : null;
output.labelTransition = "Testing → To Improve"; output.labelTransition = "Testing → To Improve";
output.issueReopened = true; output.issueReopened = true;
output.announcement = `❌ QA FAIL #${issueId}${summary ? `${summary}` : ""}. Sent back to DEV.`; output.announcement = `❌ QA FAIL #${issueId}${summary ? `${summary}` : ""}. Sent back to DEV.`;
output.devSessionAvailable = !!devSessionKey;
if (devModel) output.devModel = devModel;
// If DEV session exists, prepare reuse instructions if (project.autoChain && devModel) {
if (devWorker.sessionId) { try {
output.devFixInstructions = const issue = await getIssue(issueId, glabOpts);
`Send QA feedback to existing DEV session ${devWorker.sessionId}. ` + const chainResult = await dispatchTask({
`If model "${devModel.alias}" differs from "${devWorker.model}", call sessions.patch first. ` + workspaceDir,
`Then sessions_send with QA failure details. ` + agentId: ctx.agentId,
`DEV will pick up from To Improve → Doing automatically.`; groupId,
output.devSessionId = devWorker.sessionId; project,
output.devModel = devModel.alias; issueId,
issueTitle: issue.title,
issueDescription: issue.description ?? "",
issueUrl: issue.web_url,
role: "dev",
modelAlias: devModel,
fromLabel: "To Improve",
toLabel: "Doing",
transitionLabel: (id, from, to) =>
transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
});
output.autoChain = {
dispatched: true,
role: "dev",
model: chainResult.modelAlias,
sessionAction: chainResult.sessionAction,
announcement: chainResult.announcement,
};
} catch (err) {
output.autoChain = { dispatched: false, error: (err as Error).message };
}
} else { } else {
output.devFixInstructions = output.nextAction = "dev_fix";
`No existing DEV session. Spawn new DEV worker with model "${devModel.alias}" to fix #${issueId}.`;
output.devModel = devModel.alias;
} }
} }
// === QA REFINE === // === QA REFINE ===
if (role === "qa" && result === "refine") { if (role === "qa" && result === "refine") {
// Deactivate QA
await deactivateWorker(workspaceDir, groupId, "qa"); await deactivateWorker(workspaceDir, groupId, "qa");
// Transition label: Testing → Refining
await transitionLabel(issueId, "Testing", "Refining", glabOpts); await transitionLabel(issueId, "Testing", "Refining", glabOpts);
output.labelTransition = "Testing → Refining"; output.labelTransition = "Testing → Refining";
@@ -188,6 +231,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
result, result,
summary: summary ?? null, summary: summary ?? null,
labelTransition: output.labelTransition, labelTransition: output.labelTransition,
autoChain: output.autoChain ?? null,
}); });
return { return {

143
lib/tools/task-create.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* task_create — Create a new task (issue) in the project's issue tracker.
*
* Atomically: creates an issue with the specified title, description, and label.
* Returns the created issue for immediate pickup if desired.
*
* Use this when:
* - You want to create work items from chat
* - A sub-agent finds a bug and needs to file a follow-up issue
* - Breaking down an epic into smaller tasks
*/
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { readProjects, resolveRepoPath } from "../projects.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js";
import type { StateLabel } from "../issue-provider.js";
const STATE_LABELS: StateLabel[] = [
"Planning",
"To Do",
"Doing",
"To Test",
"Testing",
"Done",
"To Improve",
"Refining",
];
export function createTaskCreateTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({
name: "task_create",
description: `Create a new task (issue) in the project's issue tracker. Use this to file bugs, features, or tasks from chat.
Examples:
- Simple: { title: "Fix login bug" }
- With body: { title: "Add dark mode", description: "## Why\nUsers want dark mode...\n\n## Acceptance Criteria\n- [ ] Toggle in settings" }
- Ready for dev: { title: "Implement auth", description: "...", label: "To Do", pickup: true }
The issue is created with a state label (defaults to "Planning"). Returns the created issue for immediate pickup.`,
parameters: {
type: "object",
required: ["projectGroupId", "title"],
properties: {
projectGroupId: {
type: "string",
description: "Telegram group ID for the project",
},
title: {
type: "string",
description: "Short, descriptive issue title (e.g., 'Fix login timeout bug')",
},
description: {
type: "string",
description: "Full issue body in markdown. Use for detailed context, acceptance criteria, reproduction steps, links. Supports GitHub-flavored markdown.",
},
label: {
type: "string",
description: `State label for the issue. One of: ${STATE_LABELS.join(", ")}. Defaults to "Planning".`,
enum: STATE_LABELS,
},
assignees: {
type: "array",
items: { type: "string" },
description: "GitHub/GitLab usernames to assign (optional)",
},
pickup: {
type: "boolean",
description: "If true, immediately pick up this issue for DEV after creation. Defaults to false.",
},
},
},
async execute(_id: string, params: Record<string, unknown>) {
const groupId = params.projectGroupId as string;
const title = params.title as string;
const description = (params.description as string) ?? "";
const label = (params.label as StateLabel) ?? "Planning";
const assignees = (params.assignees as string[] | undefined) ?? [];
const pickup = (params.pickup as boolean) ?? false;
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
throw new Error("No workspace directory available in tool context");
}
// 1. Resolve project
const data = await readProjects(workspaceDir);
const project = data.projects[groupId];
if (!project) {
throw new Error(`Project not found for groupId ${groupId}. Run project_register first.`);
}
// 2. Create provider
const repoPath = resolveRepoPath(project.repo);
const config = api.pluginConfig as Record<string, unknown> | undefined;
const { provider, type: providerType } = createProvider({
glabPath: config?.glabPath as string | undefined,
ghPath: config?.ghPath as string | undefined,
repoPath,
});
// 3. Create the issue
const issue = await provider.createIssue(title, description, label, assignees);
// 4. Audit log
await auditLog(workspaceDir, "task_create", {
project: project.name,
groupId,
issueId: issue.iid,
title,
label,
provider: providerType,
pickup,
});
// 5. Build response
const hasBody = description && description.trim().length > 0;
const result = {
success: true,
issue: {
id: issue.iid,
title: issue.title,
body: hasBody ? description : null,
url: issue.web_url,
label,
},
project: project.name,
provider: providerType,
pickup,
announcement: pickup
? `📋 Created #${issue.iid}: "${title}" (${label}).${hasBody ? " With detailed description." : ""} Picking up for DEV...`
: `📋 Created #${issue.iid}: "${title}" (${label}).${hasBody ? " With detailed description." : ""} Ready for pickup when needed.`,
};
return {
content: [{
type: "text" as const,
text: JSON.stringify(result, null, 2),
}],
};
},
});
}

View File

@@ -1,18 +1,15 @@
/** /**
* task_pickup — Atomically pick up a task from the GitLab queue. * task_pickup — Atomically pick up a task from the issue queue.
* *
* Handles: validation, model selection, GitLab label transition, * Handles: validation, model selection, then delegates to dispatchTask()
* projects.json state update, and audit logging. * for label transition, session creation/reuse, task dispatch, state update,
* and audit logging.
* *
* Returns structured instructions for the agent to spawn/send a session. * Model selection is LLM-based: the orchestrator passes a `model` param.
* A keyword heuristic is used as fallback if no model is specified.
*/ */
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
import { import { readProjects, getProject, getWorker } from "../projects.js";
readProjects,
getProject,
getWorker,
activateWorker,
} from "../projects.js";
import { import {
getIssue, getIssue,
getCurrentStateLabel, getCurrentStateLabel,
@@ -21,25 +18,25 @@ import {
type StateLabel, type StateLabel,
} from "../gitlab.js"; } from "../gitlab.js";
import { selectModel } from "../model-selector.js"; import { selectModel } from "../model-selector.js";
import { log as auditLog } from "../audit.js"; import { dispatchTask } from "../dispatch.js";
export function createTaskPickupTool(api: OpenClawPluginApi) { export function createTaskPickupTool(api: OpenClawPluginApi) {
return (ctx: OpenClawPluginToolContext) => ({ return (ctx: OpenClawPluginToolContext) => ({
name: "task_pickup", name: "task_pickup",
description: `Pick up a task from the GitLab queue for a DEV or QA worker. Atomically handles: label transition, model selection, projects.json update, and audit logging. Returns session action instructions (spawn or send) for the agent to execute.`, 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.`,
parameters: { parameters: {
type: "object", type: "object",
required: ["issueId", "role", "projectGroupId"], required: ["issueId", "role", "projectGroupId"],
properties: { properties: {
issueId: { type: "number", description: "GitLab issue ID to pick up" }, issueId: { type: "number", description: "Issue ID to pick up" },
role: { type: "string", enum: ["dev", "qa"], description: "Worker role: dev or qa" }, role: { type: "string", enum: ["dev", "qa"], description: "Worker role: dev or qa" },
projectGroupId: { projectGroupId: {
type: "string", type: "string",
description: "Telegram group ID (key in projects.json). Required — pass the group ID from the current conversation.", description: "Telegram group ID (key in projects.json). Required — pass the group ID from the current conversation.",
}, },
modelOverride: { model: {
type: "string", type: "string",
description: "Force a specific model alias (e.g. haiku, sonnet, opus, grok). Overrides automatic selection.", 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.",
}, },
}, },
}, },
@@ -48,7 +45,7 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
const issueId = params.issueId as number; const issueId = params.issueId as number;
const role = params.role as "dev" | "qa"; const role = params.role as "dev" | "qa";
const groupId = params.projectGroupId as string; const groupId = params.projectGroupId as string;
const modelOverride = params.modelOverride as string | undefined; const modelParam = params.model as string | undefined;
const workspaceDir = ctx.workspaceDir; const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) { if (!workspaceDir) {
@@ -68,11 +65,11 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
const worker = getWorker(project, role); const worker = getWorker(project, role);
if (worker.active) { if (worker.active) {
throw new Error( throw new Error(
`${role.toUpperCase()} worker already active on ${project.name} (issue: ${worker.issueId}, session: ${worker.sessionId}). Complete current task first.`, `${role.toUpperCase()} worker already active on ${project.name} (issue: ${worker.issueId}). Complete current task first.`,
); );
} }
// 3. Fetch issue from GitLab and verify state // 3. Fetch issue and verify state
const repoPath = resolveRepoPath(project.repo); const repoPath = resolveRepoPath(project.repo);
const glabOpts = { const glabOpts = {
glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined, glabPath: (api.pluginConfig as Record<string, unknown>)?.glabPath as string | undefined,
@@ -82,7 +79,6 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
const issue = await getIssue(issueId, glabOpts); const issue = await getIssue(issueId, glabOpts);
const currentLabel = getCurrentStateLabel(issue); const currentLabel = getCurrentStateLabel(issue);
// Validate label matches expected state for the role
const validLabelsForDev: StateLabel[] = ["To Do", "To Improve"]; const validLabelsForDev: StateLabel[] = ["To Do", "To Improve"];
const validLabelsForQa: StateLabel[] = ["To Test"]; const validLabelsForQa: StateLabel[] = ["To Test"];
const validLabels = role === "dev" ? validLabelsForDev : validLabelsForQa; const validLabels = role === "dev" ? validLabelsForDev : validLabelsForQa;
@@ -95,70 +91,40 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
// 4. Select model // 4. Select model
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
let selectedModel = selectModel(issue.title, issue.description ?? "", role); let modelAlias: string;
if (modelOverride) { let modelReason: string;
selectedModel = { let modelSource: string;
model: modelOverride,
alias: modelOverride,
reason: `User override: ${modelOverride}`,
};
}
// 5. Determine session action (spawn vs reuse) if (modelParam) {
const existingSessionId = worker.sessionId; modelAlias = modelParam;
const sessionAction = existingSessionId ? "send" : "spawn"; modelReason = "LLM-selected by orchestrator";
modelSource = "llm";
// 6. Transition GitLab label
await transitionLabel(issueId, currentLabel, targetLabel, glabOpts);
// 7. Update projects.json
const now = new Date().toISOString();
if (sessionAction === "spawn") {
// New spawn — agent will provide sessionId after spawning
await activateWorker(workspaceDir, groupId, role, {
issueId: String(issueId),
model: selectedModel.alias,
startTime: now,
});
} else { } else {
// Reuse existing session — preserve sessionId and startTime const selected = selectModel(issue.title, issue.description ?? "", role);
await activateWorker(workspaceDir, groupId, role, { modelAlias = selected.alias;
issueId: String(issueId), modelReason = selected.reason;
model: selectedModel.alias, modelSource = "heuristic";
});
} }
// 8. Audit log // 5. Dispatch via shared logic
await auditLog(workspaceDir, "task_pickup", { const dispatchResult = await dispatchTask({
project: project.name, workspaceDir,
agentId: ctx.agentId,
groupId, groupId,
issue: issueId, project,
issueId,
issueTitle: issue.title, issueTitle: issue.title,
issueDescription: issue.description ?? "",
issueUrl: issue.web_url,
role, role,
model: selectedModel.alias, modelAlias,
modelReason: selectedModel.reason, fromLabel: currentLabel,
sessionAction, toLabel: targetLabel,
sessionId: existingSessionId, transitionLabel: (id, from, to) =>
labelTransition: `${currentLabel}${targetLabel}`, transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts),
}); });
await auditLog(workspaceDir, "model_selection", { // 6. Build result
issue: issueId,
role,
selected: selectedModel.alias,
fullModel: selectedModel.model,
reason: selectedModel.reason,
override: modelOverride ?? null,
});
// 9. Build announcement and session instructions
const emoji = role === "dev"
? (selectedModel.alias === "haiku" ? "⚡" : selectedModel.alias === "opus" ? "🧠" : "🔧")
: "🔍";
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
const announcement = `${emoji} ${actionVerb} ${role.toUpperCase()} (${selectedModel.alias}) for #${issueId}: ${issue.title}`;
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
success: true, success: true,
project: project.name, project: project.name,
@@ -166,26 +132,17 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
issueId, issueId,
issueTitle: issue.title, issueTitle: issue.title,
role, role,
model: selectedModel.alias, model: dispatchResult.modelAlias,
fullModel: selectedModel.model, fullModel: dispatchResult.fullModel,
modelReason: selectedModel.reason, sessionAction: dispatchResult.sessionAction,
sessionAction, announcement: dispatchResult.announcement,
announcement,
labelTransition: `${currentLabel}${targetLabel}`, labelTransition: `${currentLabel}${targetLabel}`,
modelReason,
modelSource,
}; };
if (sessionAction === "send") { if (dispatchResult.sessionAction === "send") {
result.sessionId = existingSessionId;
result.instructions =
`Session reuse: send new task to existing session ${existingSessionId}. ` +
`If model "${selectedModel.alias}" differs from current session model, call sessions.patch first to update the model. ` +
`Then call sessions_send with the task description. ` +
`After spawning/sending, update projects.json sessionId if it changed.`;
result.tokensSavedEstimate = "~50K (session reuse)"; result.tokensSavedEstimate = "~50K (session reuse)";
} else {
result.instructions =
`New session: call sessions_spawn with model "${selectedModel.model}" for this ${role.toUpperCase()} task. ` +
`After spawn completes, call task_pickup_confirm with the returned sessionId to update projects.json.`;
} }
return { return {