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:
113
README.md
113
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
12
index.ts
12
index.ts
@@ -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
240
lib/dispatch.ts
Normal 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
80
lib/issue-provider.ts
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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
211
lib/providers/github.ts
Normal 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
174
lib/providers/gitlab.ts
Normal 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
54
lib/providers/index.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
230
lib/tools/project-register.ts
Normal file
230
lib/tools/project-register.ts
Normal 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),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
143
lib/tools/task-create.ts
Normal 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),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user