From 8a79755e4c9f7c9e1fea448fe34991cb0d58af96 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 9 Feb 2026 12:54:50 +0800 Subject: [PATCH] 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. --- README.md | 113 +++++++++++++--- docs/ARCHITECTURE.md | 91 ++++++++++--- docs/ONBOARDING.md | 89 ++++++------- index.ts | 12 +- lib/dispatch.ts | 240 ++++++++++++++++++++++++++++++++++ lib/issue-provider.ts | 80 ++++++++++++ lib/projects.ts | 92 +++++++++++-- lib/providers/github.ts | 211 ++++++++++++++++++++++++++++++ lib/providers/gitlab.ts | 174 ++++++++++++++++++++++++ lib/providers/index.ts | 54 ++++++++ lib/tools/project-register.ts | 230 ++++++++++++++++++++++++++++++++ lib/tools/queue-status.ts | 4 +- lib/tools/session-health.ts | 40 +++--- lib/tools/task-complete.ts | 110 +++++++++++----- lib/tools/task-create.ts | 143 ++++++++++++++++++++ lib/tools/task-pickup.ts | 137 +++++++------------ 16 files changed, 1578 insertions(+), 242 deletions(-) create mode 100644 lib/dispatch.ts create mode 100644 lib/issue-provider.ts create mode 100644 lib/providers/github.ts create mode 100644 lib/providers/gitlab.ts create mode 100644 lib/providers/index.ts create mode 100644 lib/tools/project-register.ts create mode 100644 lib/tools/task-create.ts diff --git a/README.md b/README.md index e434208..963ef61 100644 --- a/README.md +++ b/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. -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 @@ -75,17 +75,30 @@ stateDiagram-v2 ToDo --> Doing: task_pickup (DEV) 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 --> ToImprove: task_complete (QA fail) 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 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 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 -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 | |------------|-------|------| @@ -120,8 +135,6 @@ The plugin selects the cheapest model that can handle each task: | Complex | Opus | Architecture, migrations, security, system-wide refactoring | | 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 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", "groupName": "Dev - My Webapp", "baseBranch": "development", + "autoChain": true, "dev": { "active": false, "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 - `role` ("dev" | "qa", required) — Worker role - `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:** 1. Resolves project from `projects.json` 2. Validates no active worker for this role -3. Fetches issue from GitLab, verifies correct label state -4. Selects model based on task complexity -5. Looks up existing session for selected model (session-per-model) -6. Creates session via Gateway RPC if new (`sessions.patch`) -7. Dispatches task to worker session via CLI (`openclaw agent`) -8. Transitions GitLab label (e.g. `To Do` → `Doing`) -9. Updates `projects.json` state (active, issueId, model, session key) -10. Writes audit log entry -11. Returns announcement text for the orchestrator to post +3. Fetches issue from issue tracker, verifies correct label state +4. Selects model (LLM-chosen via `model` param, keyword heuristic fallback) +5. Loads role instructions from `roles//.md` (fallback: `roles/default/.md`) +6. Looks up existing session for selected model (session-per-model) +7. Transitions label (e.g. `To Do` → `Doing`) +8. Creates session via Gateway RPC if new (`sessions.patch`) +9. Dispatches task to worker session via CLI (`openclaw agent`) with role instructions appended +10. Updates `projects.json` state (active, issueId, model, session key) +11. Writes audit log entry +12. Returns announcement text for the orchestrator to post ### `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:** - `role` ("dev" | "qa", required) @@ -201,11 +216,23 @@ Complete a task with one of four results. - `summary` (string, optional) — For the Telegram announcement **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 "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 +### `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` 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) - 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//dev.md` and `roles//qa.md` (copied from `roles/default/`) +6. Writes audit log entry +7. Returns announcement text + ## Audit logging 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": [{ "id": "my-orchestrator", "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//.md` with fallback to `roles/default/.md`. Edit the per-project files to customize worker behavior — for example, adding project-specific deployment steps or test commands. + ## Requirements - [OpenClaw](https://openclaw.ai) - 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 ## License diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f8ec09..fd5a65c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -84,8 +84,10 @@ graph TB subgraph "DevClaw Plugin" TP[task_pickup] TC[task_complete] + TCR[task_create] QS[queue_status] SH[session_health] + PR[project_register] MS_SEL[Model Selector] PJ[projects.json] AL[audit.log] @@ -102,8 +104,10 @@ graph TB MS -->|calls| TP MS -->|calls| TC + MS -->|calls| TCR MS -->|calls| QS MS -->|calls| SH + MS -->|calls| PR TP -->|selects model| MS_SEL TP -->|transitions labels| GL @@ -116,8 +120,12 @@ graph TB TC -->|closes/reopens| GL TC -->|reads/writes| PJ TC -->|git pull| REPO + TC -->|auto-chain dispatch| CLI TC -->|appends| AL + TCR -->|creates issue| GL + TCR -->|appends| AL + QS -->|lists issues by label| GL QS -->|reads| PJ QS -->|appends| AL @@ -127,6 +135,10 @@ graph TB SH -->|reverts labels| GL SH -->|appends| AL + PR -->|creates labels| GL + PR -->|writes entry| PJ + PR -->|appends| AL + CLI -->|sends task| DEV_H CLI -->|sends task| DEV_S CLI -->|sends task| DEV_O @@ -271,8 +283,7 @@ sequenceDiagram TP->>GL: glab issue view 42 --output json GL-->>TP: { title: "Add login page", labels: ["To Do"] } TP->>TP: Verify label is "To Do" ✓ - TP->>MS: selectModel("Add login page", description, "dev") - MS-->>TP: { alias: "sonnet" } + TP->>TP: model from agent param (LLM-selected) or fallback heuristic TP->>PJ: lookup dev.sessions.sonnet TP->>GL: glab issue update 42 --unlabel "To Do" --label "Doing" alt New session @@ -294,38 +305,47 @@ sequenceDiagram ``` 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 sequenceDiagram - participant A as Orchestrator + participant DEV as DEV Session participant TC as task_complete participant GL as GitLab participant PJ as projects.json participant AL as audit.log 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() PJ-->>TC: { dev: { active: true, issueId: "42" } } TC->>REPO: git pull TC->>PJ: deactivateWorker(-123, dev) Note over PJ: active→false, issueId→null
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-->>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:** - `Git repo`: pulled latest (has DEV's merged code) - `projects.json`: dev.active=false, dev.issueId=null (sessions map preserved for reuse) -- `GitLab`: label "Doing" → "To Test" -- `audit.log`: 1 entry (task_complete) +- `GitLab`: label "Doing" → "To Test" (+ "To Test" → "Testing" if auto-chain) +- `audit.log`: 1 entry (task_complete) + optional auto-chain entries ### 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" │ │ Labels: [To Do | Doing | To Test | Testing | Done | ...] │ │ State: open / closed │ -│ MRs: linked merge requests │ -│ Created by: orchestrator agent, DEV/QA sub-agents, or humans │ +│ MRs/PRs: linked merge/pull requests │ +│ Created by: orchestrator (task_create), workers, or humans │ └─────────────────────────────────────────────────────────────────┘ - ↕ glab CLI (read/write) + ↕ glab/gh CLI (read/write, auto-detected) ┌─────────────────────────────────────────────────────────────────┐ │ DevClaw Plugin (orchestration logic) │ │ │ -│ task_pickup → model + label + dispatch + state (end-to-end) │ -│ task_complete → label transition + state update + git pull │ +│ task_pickup → model + label + dispatch + role instr (e2e) │ +│ task_complete → label + state + git pull + auto-chain │ +│ task_create → create issue in tracker │ │ queue_status → read labels + read state │ │ session_health → check sessions + fix zombies │ +│ project_register → labels + roles + state init (one-time) │ └─────────────────────────────────────────────────────────────────┘ ↕ 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: │ │ 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")' │ └─────────────────────────────────────────────────────────────────┘ @@ -488,8 +511,10 @@ graph LR subgraph "DevClaw controls (deterministic)" L[Label transitions] S[Worker state] - M[Model selection] + PR[Project registration] SD[Session dispatch
create + send via CLI] + AC[Auto-chaining
DEV→QA, QA fail→DEV] + RI[Role instructions
loaded per project] A[Audit logging] Z[Zombie cleanup] end @@ -497,14 +522,15 @@ graph LR subgraph "Orchestrator handles" MSG[Telegram announcements] HB[Heartbeat scheduling] - IC[Issue creation via glab] DEC[Task prioritization] + M[Model selection] end subgraph "Sub-agent sessions handle" CR[Code writing] MR[MR creation/review] - BUG[Bug issue creation] + TC_W[Task completion
via task_complete] + BUG[Bug filing
via task_create] end subgraph "External" @@ -513,6 +539,28 @@ graph LR 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 | 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. | | 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. | +| `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 diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index fa281f1..c73df24 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -6,9 +6,9 @@ |---|---|---| | [OpenClaw](https://openclaw.ai) installed | DevClaw is an OpenClaw plugin | `openclaw --version` | | Node.js >= 20 | Runtime for plugin | `node --version` | -| [`glab`](https://gitlab.com/gitlab-org/cli) CLI | GitLab issue/label management | `glab --version` | -| glab authenticated | Plugin calls glab for every label transition | `glab auth status` | -| A GitLab repo with issues | The task backlog lives in GitLab | `glab issue list` from your repo | +| [`glab`](https://gitlab.com/gitlab-org/cli) or [`gh`](https://cli.github.com) CLI | Issue tracker provider (auto-detected from remote) | `glab --version` or `gh --version` | +| CLI authenticated | Plugin calls glab/gh for every label transition | `glab auth status` or `gh auth status` | +| A GitLab/GitHub repo with issues | The task backlog lives in the issue tracker | `glab issue list` or `gh issue list` from your repo | | An OpenClaw agent with Telegram | The orchestrator agent that will manage projects | Agent defined in `openclaw.json` | ## Setup steps @@ -41,8 +41,10 @@ In `openclaw.json`, your orchestrator agent needs access to the DevClaw tools: "allow": [ "task_pickup", "task_complete", + "task_create", "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 -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" -``` +> "Register project my-project at ~/git/my-project for group -1234567890 with base branch development" -### 4. Register a project - -Add your project to `memory/projects.json` in the orchestrator's workspace: +The agent calls `project_register`, which atomically: +- Validates the repo and auto-detects GitHub/GitLab from remote +- Creates all 8 state labels (idempotent) +- Scaffolds role instruction files (`roles//dev.md` and `qa.md`) +- Adds the project entry to `projects.json` with `autoChain: false` +- Logs the registration event ```json { "projects": { - "": { + "-1234567890": { "name": "my-project", "repo": "~/git/my-project", "groupName": "Dev - My Project", - "deployUrl": "https://my-project.example.com", + "deployUrl": "", "baseBranch": "development", "deployBranch": "development", + "autoChain": false, "dev": { "active": false, "issueId": null, "startTime": null, "model": null, - "sessions": { - "haiku": null, - "sonnet": null, - "opus": null - } + "sessions": { "haiku": null, "sonnet": null, "opus": null } }, "qa": { "active": false, "issueId": null, "startTime": null, "model": null, - "sessions": { - "grok": null - } + "sessions": { "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. -### 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. -### 6. Create your first issue +### 5. Create your first issue 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 glab CLI** — `cd ~/git/my-project && glab issue create --title "My first task" --label "To Do"` -- **Via GitLab UI** — Create an issue and add the "To Do" label +- **Via the agent** — Ask the orchestrator in the Telegram group: "Create an issue for adding a login page" (uses `task_create`) +- **Via workers** — DEV/QA workers can call `task_create` to file follow-up bugs they discover +- **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. - -### 7. Test the pipeline +### 6. Test the pipeline 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 -Repeat steps 3-5 for each new project: -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 +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. 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 | |---|---|---| -| GitLab label setup | You (once per project) | 8 labels, created via `glab label create` | -| Project registration | You (once per project) | Entry in `projects.json` | +| Label setup | Plugin (`project_register`) | 8 labels, created idempotently via `IssueProvider` | +| Role file scaffolding | Plugin (`project_register`) | Creates `roles//dev.md` and `qa.md` from defaults | +| Project registration | Plugin (`project_register`) | Entry in `projects.json` with empty worker state | | Agent definition | You (once) | Agent in `openclaw.json` with tool permissions | | Telegram group setup | You (once per project) | Add bot to group | -| Issue creation | Agent or worker sessions | Created via `glab` tool usage (or manually via GitLab UI) | -| Label transitions | Plugin | Atomic `--unlabel` + `--label` via glab | -| Model selection | Plugin | Keyword-based heuristic per task | +| Issue creation | Plugin (`task_create`) | Orchestrator or workers create issues from chat | +| Label transitions | Plugin | Atomic label transitions via issue tracker CLI | +| Model selection | Plugin | LLM-selected by orchestrator, keyword heuristic fallback | | State management | Plugin | Atomic read/write to `projects.json` | | Session management | Plugin | Creates, reuses, and dispatches to sessions via CLI. Agent never touches session tools. | +| Task completion | Plugin (`task_complete`) | Workers self-report. Auto-chains if enabled. | +| Role instructions | Plugin (`task_pickup`) | Loaded from `roles//.md`, appended to task message | | Audit logging | Plugin | Automatic NDJSON append per tool call | | 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 | diff --git a/index.ts b/index.ts index ef7c586..49764f0 100644 --- a/index.ts +++ b/index.ts @@ -3,12 +3,14 @@ import { createTaskPickupTool } from "./lib/tools/task-pickup.js"; import { createTaskCompleteTool } from "./lib/tools/task-complete.js"; import { createQueueStatusTool } from "./lib/tools/queue-status.js"; import { createSessionHealthTool } from "./lib/tools/session-health.js"; +import { createProjectRegisterTool } from "./lib/tools/project-register.js"; +import { createTaskCreateTool } from "./lib/tools/task-create.js"; const plugin = { id: "devclaw", name: "DevClaw", 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: {}, register(api: OpenClawPluginApi) { @@ -25,8 +27,14 @@ const plugin = { api.registerTool(createSessionHealthTool(api), { 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)"); }, }; diff --git a/lib/dispatch.ts b/lib/dispatch.ts new file mode 100644 index 0000000..85f4890 --- /dev/null +++ b/lib/dispatch.ts @@ -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 = { + 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; +}; + +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//.md + * with fallback to workspace/roles/default/.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 { + 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 { + 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, + }; +} diff --git a/lib/issue-provider.ts b/lib/issue-provider.ts new file mode 100644 index 0000000..2f233c3 --- /dev/null +++ b/lib/issue-provider.ts @@ -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 = { + 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; + + /** Create all 8 state labels (idempotent). */ + ensureAllStateLabels(): Promise; + + /** Create a new issue. */ + createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise; + + /** List issues with a specific state label. */ + listIssuesByLabel(label: StateLabel): Promise; + + /** Fetch a single issue by ID. */ + getIssue(issueId: number): Promise; + + /** Transition an issue from one state label to another (atomic unlabel + label). */ + transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise; + + /** Close an issue. */ + closeIssue(issueId: number): Promise; + + /** Reopen an issue. */ + reopenIssue(issueId: number): Promise; + + /** 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; + + /** Verify the provider is working (CLI available, auth valid, repo accessible). */ + healthCheck(): Promise; +} diff --git a/lib/projects.ts b/lib/projects.ts index 23ad8d2..fef94a6 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -7,10 +7,10 @@ import path from "node:path"; export type WorkerState = { active: boolean; - sessionId: string | null; issueId: string | null; startTime: string | null; model: string | null; + sessions: Record; }; export type Project = { @@ -20,6 +20,7 @@ export type Project = { deployUrl: string; baseBranch: string; deployBranch: string; + autoChain: boolean; dev: WorkerState; qa: WorkerState; }; @@ -28,13 +29,84 @@ export type ProjectsData = { projects: Record; }; +/** + * 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): 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 = {}; + + 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 = {}; + 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 { return path.join(workspaceDir, "memory", "projects.json"); } export async function readProjects(workspaceDir: string): Promise { 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) + : emptyWorkerState([]); + project.qa = project.qa + ? migrateWorkerState(project.qa as unknown as Record) + : emptyWorkerState([]); + if (project.autoChain === undefined) { + project.autoChain = false; + } + } + + return data; } export async function writeProjects( @@ -79,6 +151,10 @@ export async function updateWorker( } 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 }; await writeProjects(workspaceDir, data); @@ -87,7 +163,7 @@ export async function updateWorker( /** * 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( workspaceDir: string, @@ -96,7 +172,7 @@ export async function activateWorker( params: { issueId: string; model: string; - sessionId?: string; + sessionKey?: string; startTime?: string; }, ): Promise { @@ -105,9 +181,9 @@ export async function activateWorker( issueId: params.issueId, model: params.model, }; - // Only set sessionId and startTime if provided (new spawn) - if (params.sessionId !== undefined) { - updates.sessionId = params.sessionId; + // Store session key in the sessions map for this model + if (params.sessionKey !== undefined) { + updates.sessions = { [params.model]: params.sessionKey }; } if (params.startTime !== undefined) { updates.startTime = params.startTime; @@ -117,7 +193,7 @@ export async function activateWorker( /** * 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( workspaceDir: string, diff --git a/lib/providers/github.ts b/lib/providers/github.ts new file mode 100644 index 0000000..47a5c78 --- /dev/null +++ b/lib/providers/github.ts @@ -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 { + const { stdout } = await execFileAsync(this.ghPath, args, { + cwd: this.repoPath, + timeout: 30_000, + }); + return stdout.trim(); + } + + async ensureLabel(name: string, color: string): Promise { + // 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 { + for (const label of STATE_LABELS) { + await this.ensureLabel(label, LABEL_COLORS[label]); + } + } + + async createIssue( + title: string, + description: string, + label: StateLabel, + assignees?: string[], + ): Promise { + // 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 { + 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 { + 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 { + await this.gh([ + "issue", "edit", String(issueId), + "--remove-label", from, + "--add-label", to, + ]); + } + + async closeIssue(issueId: number): Promise { + await this.gh(["issue", "close", String(issueId)]); + } + + async reopenIssue(issueId: number): Promise { + 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 { + 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 { + try { + await this.gh(["auth", "status"]); + return true; + } catch { + return false; + } + } +} diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts new file mode 100644 index 0000000..418e6fc --- /dev/null +++ b/lib/providers/gitlab.ts @@ -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 { + const { stdout } = await execFileAsync(this.glabPath, args, { + cwd: this.repoPath, + timeout: 30_000, + }); + return stdout.trim(); + } + + async ensureLabel(name: string, color: string): Promise { + 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 { + for (const label of STATE_LABELS) { + await this.ensureLabel(label, LABEL_COLORS[label]); + } + } + + async createIssue( + title: string, + description: string, + label: StateLabel, + assignees?: string[], + ): Promise { + // 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 { + 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 { + 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 { + await this.glab([ + "issue", "update", String(issueId), + "--unlabel", from, + "--label", to, + ]); + } + + async closeIssue(issueId: number): Promise { + await this.glab(["issue", "close", String(issueId)]); + } + + async reopenIssue(issueId: number): Promise { + 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 { + 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 { + try { + await this.glab(["auth", "status"]); + return true; + } catch { + return false; + } + } +} diff --git a/lib/providers/index.ts b/lib/providers/index.ts new file mode 100644 index 0000000..2975449 --- /dev/null +++ b/lib/providers/index.ts @@ -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", + }; +} diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts new file mode 100644 index 0000000..7a4e608 --- /dev/null +++ b/lib/tools/project-register.ts @@ -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 { + 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) { + 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)?.glabPath as string | undefined; + const ghPath = (api.pluginConfig as Record)?.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), + }], + }; + }, + }); +} diff --git a/lib/tools/queue-status.ts b/lib/tools/queue-status.ts index 8744bcb..47cfcd1 100644 --- a/lib/tools/queue-status.ts +++ b/lib/tools/queue-status.ts @@ -63,15 +63,15 @@ export function createQueueStatusTool(api: OpenClawPluginApi) { groupId: pid, dev: { active: project.dev.active, - sessionId: project.dev.sessionId, issueId: project.dev.issueId, model: project.dev.model, + sessions: project.dev.sessions, }, qa: { active: project.qa.active, - sessionId: project.qa.sessionId, issueId: project.qa.issueId, model: project.qa.model, + sessions: project.qa.sessions, }, queue: { toImprove: queue["To Improve"], diff --git a/lib/tools/session-health.ts b/lib/tools/session-health.ts index b8aab55..3d0b76e 100644 --- a/lib/tools/session-health.ts +++ b/lib/tools/session-health.ts @@ -2,21 +2,17 @@ * session_health — Check and fix session state consistency. * * Detects zombie sessions (active=true but session dead) and stale workers. - * Replaces manual HEARTBEAT.md step 1. - * - * 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). + * Checks the sessions map for each worker's current model. */ 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 { log as auditLog } from "../audit.js"; export function createSessionHealthTool(api: OpenClawPluginApi) { return (ctx: OpenClawPluginToolContext) => ({ 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: { type: "object", properties: { @@ -53,16 +49,20 @@ export function createSessionHealthTool(api: OpenClawPluginApi) { for (const role of ["dev", "qa"] as const) { const worker = project[role]; + const currentSessionKey = worker.model + ? getSessionForModel(worker, worker.model) + : null; - // Check 1: Active but no sessionId - if (worker.active && !worker.sessionId) { + // Check 1: Active but no session key for current model + if (worker.active && !currentSessionKey) { const issue: Record = { type: "active_no_session", severity: "critical", project: project.name, groupId, 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) { @@ -76,12 +76,12 @@ export function createSessionHealthTool(api: OpenClawPluginApi) { issues.push(issue); } - // Check 2: Active with sessionId but session is dead (zombie) + // Check 2: Active with session but session is dead (zombie) if ( worker.active && - worker.sessionId && + currentSessionKey && activeSessions.length > 0 && - !activeSessions.includes(worker.sessionId) + !activeSessions.includes(currentSessionKey) ) { const issue: Record = { type: "zombie_session", @@ -89,8 +89,9 @@ export function createSessionHealthTool(api: OpenClawPluginApi) { project: project.name, groupId, role, - sessionId: worker.sessionId, - message: `${role.toUpperCase()} session ${worker.sessionId} not found in active sessions`, + sessionKey: currentSessionKey, + model: worker.model, + message: `${role.toUpperCase()} session ${currentSessionKey} not found in active sessions`, }; if (autoFix) { @@ -107,9 +108,16 @@ export function createSessionHealthTool(api: OpenClawPluginApi) { 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, { active: false, issueId: null, + sessions: updatedSessions, }); issue.fixed = true; fixesApplied++; @@ -131,7 +139,7 @@ export function createSessionHealthTool(api: OpenClawPluginApi) { groupId, role, hoursActive: Math.round(hoursActive * 10) / 10, - sessionId: worker.sessionId, + sessionKey: currentSessionKey, issueId: worker.issueId, message: `${role.toUpperCase()} has been active for ${Math.round(hoursActive * 10) / 10}h — may need attention`, }); diff --git a/lib/tools/task-complete.ts b/lib/tools/task-complete.ts index 14f9e13..360f4a3 100644 --- a/lib/tools/task-complete.ts +++ b/lib/tools/task-complete.ts @@ -1,16 +1,20 @@ /** * task_complete — Atomically complete a task (DEV done, QA pass/fail/refine). * - * Handles: validation, GitLab label transition, projects.json state update, - * issue close/reopen, and audit logging. + * Handles: validation, label transition, projects.json state update, + * 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 { readProjects, getProject, getWorker, + getSessionForModel, deactivateWorker, - activateWorker, } from "../projects.js"; import { getIssue, @@ -20,8 +24,8 @@ import { resolveRepoPath, type StateLabel, } from "../gitlab.js"; -import { selectModel } from "../model-selector.js"; import { log as auditLog } from "../audit.js"; +import { dispatchTask } from "../dispatch.js"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -30,7 +34,7 @@ const execFileAsync = promisify(execFile); export function createTaskCompleteTool(api: OpenClawPluginApi) { return (ctx: OpenClawPluginToolContext) => ({ 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: { type: "object", required: ["role", "result", "projectGroupId"], @@ -101,7 +105,6 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) { // === DEV DONE === if (role === "dev" && result === "done") { - // Pull latest on the project repo try { await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: 30_000 }); output.gitPull = "success"; @@ -109,22 +112,49 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) { output.gitPull = `warning: ${(err as Error).message}`; } - // Deactivate DEV (preserves sessionId, model, startTime) await deactivateWorker(workspaceDir, groupId, "dev"); - - // Transition label: Doing → To Test await transitionLabel(issueId, "Doing", "To Test", glabOpts); output.labelTransition = "Doing → To Test"; 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 === if (role === "qa" && result === "pass") { - // Deactivate QA await deactivateWorker(workspaceDir, groupId, "qa"); - - // Transition label: Testing → Done, close issue await transitionLabel(issueId, "Testing", "Done", glabOpts); await closeIssue(issueId, glabOpts); @@ -135,44 +165,57 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) { // === QA FAIL === if (role === "qa" && result === "fail") { - // Deactivate QA await deactivateWorker(workspaceDir, groupId, "qa"); - - // Transition label: Testing → To Improve, reopen issue await transitionLabel(issueId, "Testing", "To Improve", 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 devModel = devWorker.model; + const devSessionKey = devModel ? getSessionForModel(devWorker, devModel) : null; output.labelTransition = "Testing → To Improve"; output.issueReopened = true; 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 (devWorker.sessionId) { - output.devFixInstructions = - `Send QA feedback to existing DEV session ${devWorker.sessionId}. ` + - `If model "${devModel.alias}" differs from "${devWorker.model}", call sessions.patch first. ` + - `Then sessions_send with QA failure details. ` + - `DEV will pick up from To Improve → Doing automatically.`; - output.devSessionId = devWorker.sessionId; - output.devModel = devModel.alias; + if (project.autoChain && devModel) { + 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: "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 { - output.devFixInstructions = - `No existing DEV session. Spawn new DEV worker with model "${devModel.alias}" to fix #${issueId}.`; - output.devModel = devModel.alias; + output.nextAction = "dev_fix"; } } // === QA REFINE === if (role === "qa" && result === "refine") { - // Deactivate QA await deactivateWorker(workspaceDir, groupId, "qa"); - - // Transition label: Testing → Refining await transitionLabel(issueId, "Testing", "Refining", glabOpts); output.labelTransition = "Testing → Refining"; @@ -188,6 +231,7 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) { result, summary: summary ?? null, labelTransition: output.labelTransition, + autoChain: output.autoChain ?? null, }); return { diff --git a/lib/tools/task-create.ts b/lib/tools/task-create.ts new file mode 100644 index 0000000..5add951 --- /dev/null +++ b/lib/tools/task-create.ts @@ -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) { + 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 | 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), + }], + }; + }, + }); +} diff --git a/lib/tools/task-pickup.ts b/lib/tools/task-pickup.ts index cd93f3e..46a6f04 100644 --- a/lib/tools/task-pickup.ts +++ b/lib/tools/task-pickup.ts @@ -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, - * projects.json state update, and audit logging. + * Handles: validation, model selection, then delegates to dispatchTask() + * 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 { - readProjects, - getProject, - getWorker, - activateWorker, -} from "../projects.js"; +import { readProjects, getProject, getWorker } from "../projects.js"; import { getIssue, getCurrentStateLabel, @@ -21,25 +18,25 @@ import { type StateLabel, } from "../gitlab.js"; import { selectModel } from "../model-selector.js"; -import { log as auditLog } from "../audit.js"; +import { dispatchTask } from "../dispatch.js"; export function createTaskPickupTool(api: OpenClawPluginApi) { return (ctx: OpenClawPluginToolContext) => ({ 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: { type: "object", required: ["issueId", "role", "projectGroupId"], 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" }, projectGroupId: { type: "string", description: "Telegram group ID (key in projects.json). Required — pass the group ID from the current conversation.", }, - modelOverride: { + model: { 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 role = params.role as "dev" | "qa"; const groupId = params.projectGroupId as string; - const modelOverride = params.modelOverride as string | undefined; + const modelParam = params.model as string | undefined; const workspaceDir = ctx.workspaceDir; if (!workspaceDir) { @@ -68,11 +65,11 @@ export function createTaskPickupTool(api: OpenClawPluginApi) { const worker = getWorker(project, role); if (worker.active) { 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 glabOpts = { glabPath: (api.pluginConfig as Record)?.glabPath as string | undefined, @@ -82,7 +79,6 @@ export function createTaskPickupTool(api: OpenClawPluginApi) { const issue = await getIssue(issueId, glabOpts); const currentLabel = getCurrentStateLabel(issue); - // Validate label matches expected state for the role const validLabelsForDev: StateLabel[] = ["To Do", "To Improve"]; const validLabelsForQa: StateLabel[] = ["To Test"]; const validLabels = role === "dev" ? validLabelsForDev : validLabelsForQa; @@ -95,70 +91,40 @@ export function createTaskPickupTool(api: OpenClawPluginApi) { // 4. Select model const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; - let selectedModel = selectModel(issue.title, issue.description ?? "", role); - if (modelOverride) { - selectedModel = { - model: modelOverride, - alias: modelOverride, - reason: `User override: ${modelOverride}`, - }; - } + let modelAlias: string; + let modelReason: string; + let modelSource: string; - // 5. Determine session action (spawn vs reuse) - const existingSessionId = worker.sessionId; - const sessionAction = existingSessionId ? "send" : "spawn"; - - // 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, - }); + if (modelParam) { + modelAlias = modelParam; + modelReason = "LLM-selected by orchestrator"; + modelSource = "llm"; } else { - // Reuse existing session — preserve sessionId and startTime - await activateWorker(workspaceDir, groupId, role, { - issueId: String(issueId), - model: selectedModel.alias, - }); + const selected = selectModel(issue.title, issue.description ?? "", role); + modelAlias = selected.alias; + modelReason = selected.reason; + modelSource = "heuristic"; } - // 8. Audit log - await auditLog(workspaceDir, "task_pickup", { - project: project.name, + // 5. Dispatch via shared logic + const dispatchResult = await dispatchTask({ + workspaceDir, + agentId: ctx.agentId, groupId, - issue: issueId, + project, + issueId, issueTitle: issue.title, + issueDescription: issue.description ?? "", + issueUrl: issue.web_url, role, - model: selectedModel.alias, - modelReason: selectedModel.reason, - sessionAction, - sessionId: existingSessionId, - labelTransition: `${currentLabel} → ${targetLabel}`, + modelAlias, + fromLabel: currentLabel, + toLabel: targetLabel, + transitionLabel: (id, from, to) => + transitionLabel(id, from as StateLabel, to as StateLabel, glabOpts), }); - await auditLog(workspaceDir, "model_selection", { - 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}`; - + // 6. Build result const result: Record = { success: true, project: project.name, @@ -166,26 +132,17 @@ export function createTaskPickupTool(api: OpenClawPluginApi) { issueId, issueTitle: issue.title, role, - model: selectedModel.alias, - fullModel: selectedModel.model, - modelReason: selectedModel.reason, - sessionAction, - announcement, + model: dispatchResult.modelAlias, + fullModel: dispatchResult.fullModel, + sessionAction: dispatchResult.sessionAction, + announcement: dispatchResult.announcement, labelTransition: `${currentLabel} → ${targetLabel}`, + modelReason, + modelSource, }; - if (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.`; + if (dispatchResult.sessionAction === "send") { 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 {