Merge upstream/main with Gitea support preserved
- design_task → research_task (breaking change) - QA role → Tester role (renamed) - Auto-merge for approved PRs - Enhanced workspace scaffolding - Preserved Gitea provider support (github|gitlab|gitea) - Preserved business hours scheduling (stashed)
This commit is contained in:
@@ -10,22 +10,22 @@ graph TB
|
|||||||
direction TB
|
direction TB
|
||||||
A_O["Orchestrator"]
|
A_O["Orchestrator"]
|
||||||
A_GL[GitHub/GitLab Issues]
|
A_GL[GitHub/GitLab Issues]
|
||||||
A_DEV["DEV (worker session)"]
|
A_DEV["DEVELOPER (worker session)"]
|
||||||
A_QA["QA (worker session)"]
|
A_TST["TESTER (worker session)"]
|
||||||
A_O -->|work_start| A_GL
|
A_O -->|work_start| A_GL
|
||||||
A_O -->|dispatches| A_DEV
|
A_O -->|dispatches| A_DEV
|
||||||
A_O -->|dispatches| A_QA
|
A_O -->|dispatches| A_TST
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Group Chat B"
|
subgraph "Group Chat B"
|
||||||
direction TB
|
direction TB
|
||||||
B_O["Orchestrator"]
|
B_O["Orchestrator"]
|
||||||
B_GL[GitHub/GitLab Issues]
|
B_GL[GitHub/GitLab Issues]
|
||||||
B_DEV["DEV (worker session)"]
|
B_DEV["DEVELOPER (worker session)"]
|
||||||
B_QA["QA (worker session)"]
|
B_TST["TESTER (worker session)"]
|
||||||
B_O -->|work_start| B_GL
|
B_O -->|work_start| B_GL
|
||||||
B_O -->|dispatches| B_DEV
|
B_O -->|dispatches| B_DEV
|
||||||
B_O -->|dispatches| B_QA
|
B_O -->|dispatches| B_TST
|
||||||
end
|
end
|
||||||
|
|
||||||
AGENT["Single OpenClaw Agent"]
|
AGENT["Single OpenClaw Agent"]
|
||||||
@@ -33,7 +33,7 @@ graph TB
|
|||||||
AGENT --- B_O
|
AGENT --- B_O
|
||||||
```
|
```
|
||||||
|
|
||||||
Worker sessions are expensive to start — each new spawn reads the full codebase (~50K tokens). DevClaw maintains **separate sessions per level per role** ([session-per-level design](#session-per-level-design)). When a medior dev finishes task A and picks up task B on the same project, the accumulated context carries over — no re-reading the repo. The plugin handles all session dispatch internally via OpenClaw CLI; the orchestrator agent never calls `sessions_spawn` or `sessions_send`.
|
Worker sessions are expensive to start — each new spawn reads the full codebase (~50K tokens). DevClaw maintains **separate sessions per level per role** ([session-per-level design](#session-per-level-design)). When a medior developer finishes task A and picks up task B on the same project, the accumulated context carries over — no re-reading the repo. The plugin handles all session dispatch internally via OpenClaw CLI; the orchestrator agent never calls `sessions_spawn` or `sessions_send`.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -42,7 +42,7 @@ sequenceDiagram
|
|||||||
participant IT as Issue Tracker
|
participant IT as Issue Tracker
|
||||||
participant S as Worker Session
|
participant S as Worker Session
|
||||||
|
|
||||||
O->>DC: work_start({ issueId: 42, role: "dev" })
|
O->>DC: work_start({ issueId: 42, role: "developer" })
|
||||||
DC->>IT: Fetch issue, verify label
|
DC->>IT: Fetch issue, verify label
|
||||||
DC->>DC: Assign level (junior/medior/senior)
|
DC->>DC: Assign level (junior/medior/senior)
|
||||||
DC->>DC: Check existing session for assigned level
|
DC->>DC: Check existing session for assigned level
|
||||||
@@ -62,19 +62,20 @@ Understanding the OpenClaw model is key to understanding how DevClaw works:
|
|||||||
|
|
||||||
### Session-per-level design
|
### Session-per-level design
|
||||||
|
|
||||||
Each project maintains **separate sessions per developer level per role**. A project's DEV might have a junior session, a medior session, and a senior session — each accumulating its own codebase context over time.
|
Each project maintains **separate sessions per developer level per role**. A project's DEVELOPER might have a junior session, a medior session, and a senior session — each accumulating its own codebase context over time.
|
||||||
|
|
||||||
```
|
```
|
||||||
Orchestrator Agent (configured in openclaw.json)
|
Orchestrator Agent (configured in openclaw.json)
|
||||||
└─ Main session (long-lived, handles all projects)
|
└─ Main session (long-lived, handles all projects)
|
||||||
│
|
│
|
||||||
├─ Project A
|
├─ Project A
|
||||||
│ ├─ DEV sessions: { junior: <key>, medior: <key>, senior: null }
|
│ ├─ DEVELOPER sessions: { junior: <key>, medior: <key>, senior: null }
|
||||||
│ └─ QA sessions: { reviewer: <key>, tester: null }
|
│ ├─ TESTER sessions: { junior: null, medior: <key>, senior: null }
|
||||||
|
│ └─ ARCHITECT sessions: { junior: <key>, senior: null }
|
||||||
│
|
│
|
||||||
└─ Project B
|
└─ Project B
|
||||||
├─ DEV sessions: { junior: null, medior: <key>, senior: null }
|
├─ DEVELOPER sessions: { junior: null, medior: <key>, senior: null }
|
||||||
└─ QA sessions: { reviewer: <key>, tester: null }
|
└─ TESTER sessions: { junior: null, medior: <key>, senior: null }
|
||||||
```
|
```
|
||||||
|
|
||||||
Why per-level instead of switching models on one session:
|
Why per-level instead of switching models on one session:
|
||||||
@@ -114,6 +115,18 @@ The agent's only job after `work_start` returns is to post the announcement to T
|
|||||||
|
|
||||||
DevClaw provides equivalent guardrails for everything except auto-reporting, which the heartbeat handles.
|
DevClaw provides equivalent guardrails for everything except auto-reporting, which the heartbeat handles.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
DevClaw ships with three built-in roles, defined in `lib/roles/registry.ts`. All roles use the same level scheme (junior/medior/senior) — levels describe task complexity, not the role.
|
||||||
|
|
||||||
|
| Role | ID | Levels | Default Level | Completion Results |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Developer | `developer` | junior, medior, senior | medior | done, review, blocked |
|
||||||
|
| Tester | `tester` | junior, medior, senior | medior | pass, fail, refine, blocked |
|
||||||
|
| Architect | `architect` | junior, senior | junior | done, blocked |
|
||||||
|
|
||||||
|
Roles are extensible — add a new entry to `ROLE_REGISTRY` and corresponding workflow states to get a new role. The `workflow.yaml` config can also override levels, models, and emoji per role, or disable a role entirely (`architect: false`).
|
||||||
|
|
||||||
## System overview
|
## System overview
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -127,10 +140,11 @@ graph TB
|
|||||||
MS[Main Session<br/>orchestrator agent]
|
MS[Main Session<br/>orchestrator agent]
|
||||||
GW[Gateway RPC<br/>sessions.patch / sessions.list]
|
GW[Gateway RPC<br/>sessions.patch / sessions.list]
|
||||||
CLI[openclaw gateway call agent]
|
CLI[openclaw gateway call agent]
|
||||||
DEV_J[DEV session<br/>junior]
|
DEV_J[DEVELOPER session<br/>junior]
|
||||||
DEV_M[DEV session<br/>medior]
|
DEV_M[DEVELOPER session<br/>medior]
|
||||||
DEV_S[DEV session<br/>senior]
|
DEV_S[DEVELOPER session<br/>senior]
|
||||||
QA_R[QA session<br/>reviewer]
|
TST_M[TESTER session<br/>medior]
|
||||||
|
ARCH[ARCHITECT session<br/>junior]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "DevClaw Plugin"
|
subgraph "DevClaw Plugin"
|
||||||
@@ -196,12 +210,13 @@ graph TB
|
|||||||
CLI -->|sends task| DEV_J
|
CLI -->|sends task| DEV_J
|
||||||
CLI -->|sends task| DEV_M
|
CLI -->|sends task| DEV_M
|
||||||
CLI -->|sends task| DEV_S
|
CLI -->|sends task| DEV_S
|
||||||
CLI -->|sends task| QA_R
|
CLI -->|sends task| TST_M
|
||||||
|
CLI -->|sends task| ARCH
|
||||||
|
|
||||||
DEV_J -->|writes code, creates MRs| REPO
|
DEV_J -->|writes code, creates PRs| REPO
|
||||||
DEV_M -->|writes code, creates MRs| REPO
|
DEV_M -->|writes code, creates PRs| REPO
|
||||||
DEV_S -->|writes code, creates MRs| REPO
|
DEV_S -->|writes code, creates PRs| REPO
|
||||||
QA_R -->|reviews code, tests| REPO
|
TST_M -->|reviews code, tests| REPO
|
||||||
```
|
```
|
||||||
|
|
||||||
## End-to-end flow: human to sub-agent
|
## End-to-end flow: human to sub-agent
|
||||||
@@ -216,7 +231,7 @@ sequenceDiagram
|
|||||||
participant DC as DevClaw Plugin
|
participant DC as DevClaw Plugin
|
||||||
participant GW as Gateway RPC
|
participant GW as Gateway RPC
|
||||||
participant CLI as openclaw gateway call agent
|
participant CLI as openclaw gateway call agent
|
||||||
participant DEV as DEV Session<br/>(medior)
|
participant DEV as DEVELOPER Session<br/>(medior)
|
||||||
participant GL as Issue Tracker
|
participant GL as Issue Tracker
|
||||||
|
|
||||||
Note over H,GL: Issue exists in queue (To Do)
|
Note over H,GL: Issue exists in queue (To Do)
|
||||||
@@ -225,51 +240,51 @@ sequenceDiagram
|
|||||||
TG->>MS: delivers message
|
TG->>MS: delivers message
|
||||||
MS->>DC: status()
|
MS->>DC: status()
|
||||||
DC->>GL: list issues by label "To Do"
|
DC->>GL: list issues by label "To Do"
|
||||||
DC-->>MS: { toDo: [#42], dev: idle }
|
DC-->>MS: { toDo: [#42], developer: idle }
|
||||||
|
|
||||||
Note over MS: Decides to pick up #42 for DEV as medior
|
Note over MS: Decides to pick up #42 for DEVELOPER as medior
|
||||||
|
|
||||||
MS->>DC: work_start({ issueId: 42, role: "dev", level: "medior", ... })
|
MS->>DC: work_start({ issueId: 42, role: "developer", level: "medior", ... })
|
||||||
DC->>DC: resolve level "medior" → model ID
|
DC->>DC: resolve level "medior" → model ID
|
||||||
DC->>DC: lookup dev.sessions.medior → null (first time)
|
DC->>DC: lookup developer.sessions.medior → null (first time)
|
||||||
DC->>GL: transition label "To Do" → "Doing"
|
DC->>GL: transition label "To Do" → "Doing"
|
||||||
DC->>GW: sessions.patch({ key: new-session-key, model: "anthropic/claude-sonnet-4-5" })
|
DC->>GW: sessions.patch({ key: new-session-key, model: "anthropic/claude-sonnet-4-5" })
|
||||||
DC->>CLI: openclaw gateway call agent --params { sessionKey, message }
|
DC->>CLI: openclaw gateway call agent --params { sessionKey, message }
|
||||||
CLI->>DEV: creates session, delivers task
|
CLI->>DEV: creates session, delivers task
|
||||||
DC->>DC: store session key in projects.json + append audit.log
|
DC->>DC: store session key in projects.json + append audit.log
|
||||||
DC-->>MS: { success: true, announcement: "🔧 Spawning DEV (medior) for #42" }
|
DC-->>MS: { success: true, announcement: "🔧 Spawning DEVELOPER (medior) for #42" }
|
||||||
|
|
||||||
MS->>TG: "🔧 Spawning DEV (medior) for #42: Add login page"
|
MS->>TG: "🔧 Spawning DEVELOPER (medior) for #42: Add login page"
|
||||||
TG->>H: sees announcement
|
TG->>H: sees announcement
|
||||||
|
|
||||||
Note over DEV: Works autonomously — reads code, writes code, creates MR
|
Note over DEV: Works autonomously — reads code, writes code, creates PR
|
||||||
Note over DEV: Calls work_finish when done
|
Note over DEV: Calls work_finish when done
|
||||||
|
|
||||||
DEV->>DC: work_finish({ role: "dev", result: "done", ... })
|
DEV->>DC: work_finish({ role: "developer", result: "done", ... })
|
||||||
DC->>GL: transition label "Doing" → "To Test"
|
DC->>GL: transition label "Doing" → "To Test"
|
||||||
DC->>DC: deactivate worker (sessions preserved)
|
DC->>DC: deactivate worker (sessions preserved)
|
||||||
DC-->>DEV: { announcement: "✅ DEV DONE #42" }
|
DC-->>DEV: { announcement: "✅ DEVELOPER DONE #42" }
|
||||||
|
|
||||||
MS->>TG: "✅ DEV DONE #42 — moved to QA queue"
|
MS->>TG: "✅ DEVELOPER DONE #42 — moved to TESTER queue"
|
||||||
TG->>H: sees announcement
|
TG->>H: sees announcement
|
||||||
```
|
```
|
||||||
|
|
||||||
On the **next DEV task** for this project that also assigns medior:
|
On the **next DEVELOPER task** for this project that also assigns medior:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant MS as Main Session
|
participant MS as Main Session
|
||||||
participant DC as DevClaw Plugin
|
participant DC as DevClaw Plugin
|
||||||
participant CLI as openclaw gateway call agent
|
participant CLI as openclaw gateway call agent
|
||||||
participant DEV as DEV Session<br/>(medior, existing)
|
participant DEV as DEVELOPER Session<br/>(medior, existing)
|
||||||
|
|
||||||
MS->>DC: work_start({ issueId: 57, role: "dev", level: "medior", ... })
|
MS->>DC: work_start({ issueId: 57, role: "developer", level: "medior", ... })
|
||||||
DC->>DC: resolve level "medior" → model ID
|
DC->>DC: resolve level "medior" → model ID
|
||||||
DC->>DC: lookup dev.sessions.medior → existing key!
|
DC->>DC: lookup developer.sessions.medior → existing key!
|
||||||
Note over DC: No sessions.patch needed — session already exists
|
Note over DC: No sessions.patch needed — session already exists
|
||||||
DC->>CLI: openclaw gateway call agent --params { sessionKey, message }
|
DC->>CLI: openclaw gateway call agent --params { sessionKey, message }
|
||||||
CLI->>DEV: delivers task to existing session (has full codebase context)
|
CLI->>DEV: delivers task to existing session (has full codebase context)
|
||||||
DC-->>MS: { success: true, announcement: "⚡ Sending DEV (medior) for #57" }
|
DC-->>MS: { success: true, announcement: "⚡ Sending DEVELOPER (medior) for #57" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Session reuse saves ~50K tokens per task by not re-reading the codebase.
|
Session reuse saves ~50K tokens per task by not re-reading the codebase.
|
||||||
@@ -304,7 +319,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
A->>QS: status({ projectGroupId: "-123" })
|
A->>QS: status({ projectGroupId: "-123" })
|
||||||
QS->>PJ: readProjects()
|
QS->>PJ: readProjects()
|
||||||
PJ-->>QS: { dev: idle, qa: idle }
|
PJ-->>QS: { developer: idle, tester: idle }
|
||||||
QS->>GL: list issues by label "To Do"
|
QS->>GL: list issues by label "To Do"
|
||||||
GL-->>QS: [{ id: 42, title: "Add login page" }]
|
GL-->>QS: [{ id: 42, title: "Add login page" }]
|
||||||
QS->>GL: list issues by label "To Test"
|
QS->>GL: list issues by label "To Test"
|
||||||
@@ -312,12 +327,12 @@ sequenceDiagram
|
|||||||
QS->>GL: list issues by label "To Improve"
|
QS->>GL: list issues by label "To Improve"
|
||||||
GL-->>QS: []
|
GL-->>QS: []
|
||||||
QS->>AL: append { event: "status", ... }
|
QS->>AL: append { event: "status", ... }
|
||||||
QS-->>A: { dev: idle, queue: { toDo: [#42] } }
|
QS-->>A: { developer: idle, queue: { toDo: [#42] } }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Orchestrator decides:** DEV is idle, issue #42 is in To Do → pick it up. Evaluates complexity → assigns medior level.
|
**Orchestrator decides:** DEVELOPER is idle, issue #42 is in To Do → pick it up. Evaluates complexity → assigns medior level.
|
||||||
|
|
||||||
### Phase 3: DEV pickup
|
### Phase 3: DEVELOPER pickup
|
||||||
|
|
||||||
The plugin handles everything end-to-end — level resolution, session lookup, label transition, state update, **and** task dispatch to the worker session. The agent's only job after is to post the announcement.
|
The plugin handles everything end-to-end — level resolution, session lookup, label transition, state update, **and** task dispatch to the worker session. The agent's only job after is to post the announcement.
|
||||||
|
|
||||||
@@ -332,13 +347,13 @@ sequenceDiagram
|
|||||||
participant PJ as projects.json
|
participant PJ as projects.json
|
||||||
participant AL as audit.log
|
participant AL as audit.log
|
||||||
|
|
||||||
A->>WS: work_start({ issueId: 42, role: "dev", projectGroupId: "-123", level: "medior" })
|
A->>WS: work_start({ issueId: 42, role: "developer", projectGroupId: "-123", level: "medior" })
|
||||||
WS->>PJ: readProjects()
|
WS->>PJ: readProjects()
|
||||||
WS->>GL: getIssue(42)
|
WS->>GL: getIssue(42)
|
||||||
GL-->>WS: { title: "Add login page", labels: ["To Do"] }
|
GL-->>WS: { title: "Add login page", labels: ["To Do"] }
|
||||||
WS->>WS: Verify label is "To Do"
|
WS->>WS: Verify label is "To Do"
|
||||||
WS->>TIER: resolve "medior" → "anthropic/claude-sonnet-4-5"
|
WS->>TIER: resolve "medior" → "anthropic/claude-sonnet-4-5"
|
||||||
WS->>PJ: lookup dev.sessions.medior
|
WS->>PJ: lookup developer.sessions.medior
|
||||||
WS->>GL: transitionLabel(42, "To Do", "Doing")
|
WS->>GL: transitionLabel(42, "To Do", "Doing")
|
||||||
alt New session
|
alt New session
|
||||||
WS->>GW: sessions.patch({ key: new-key, model: "anthropic/claude-sonnet-4-5" })
|
WS->>GW: sessions.patch({ key: new-key, model: "anthropic/claude-sonnet-4-5" })
|
||||||
@@ -351,98 +366,116 @@ sequenceDiagram
|
|||||||
|
|
||||||
**Writes:**
|
**Writes:**
|
||||||
- `Issue Tracker`: label "To Do" → "Doing"
|
- `Issue Tracker`: label "To Do" → "Doing"
|
||||||
- `projects.json`: dev.active=true, dev.issueId="42", dev.level="medior", dev.sessions.medior=key
|
- `projects.json`: workers.developer.active=true, issueId="42", level="medior", sessions.medior=key
|
||||||
- `audit.log`: 2 entries (work_start, model_selection)
|
- `audit.log`: 2 entries (work_start, model_selection)
|
||||||
- `Session`: task message delivered to worker session via CLI
|
- `Session`: task message delivered to worker session via CLI
|
||||||
|
|
||||||
### Phase 4: DEV works
|
### Phase 4: DEVELOPER works
|
||||||
|
|
||||||
```
|
```
|
||||||
DEV sub-agent session → reads codebase, writes code, creates MR
|
DEVELOPER sub-agent session → reads codebase, writes code, creates PR
|
||||||
DEV sub-agent session → calls work_finish({ role: "dev", result: "done", ... })
|
DEVELOPER sub-agent session → calls work_finish({ role: "developer", result: "done", ... })
|
||||||
```
|
```
|
||||||
|
|
||||||
This happens inside the OpenClaw session. The worker calls `work_finish` directly for atomic state updates. If the worker discovers unrelated bugs, it calls `task_create` to file them.
|
This happens inside the OpenClaw session. The worker calls `work_finish` directly for atomic state updates. If the worker discovers unrelated bugs, it calls `task_create` to file them.
|
||||||
|
|
||||||
### Phase 5: DEV complete (worker self-reports)
|
### Phase 5: DEVELOPER complete (worker self-reports)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant DEV as DEV Session
|
participant DEV as DEVELOPER Session
|
||||||
participant WF as work_finish
|
participant WF as work_finish
|
||||||
participant GL as Issue Tracker
|
participant GL as Issue Tracker
|
||||||
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
|
|
||||||
|
|
||||||
DEV->>WF: work_finish({ role: "dev", result: "done", projectGroupId: "-123", summary: "Login page with OAuth" })
|
DEV->>WF: work_finish({ role: "developer", result: "done", projectGroupId: "-123", summary: "Login page with OAuth" })
|
||||||
WF->>PJ: readProjects()
|
WF->>PJ: readProjects()
|
||||||
PJ-->>WF: { dev: { active: true, issueId: "42" } }
|
PJ-->>WF: { developer: { active: true, issueId: "42" } }
|
||||||
WF->>REPO: git pull
|
WF->>REPO: git pull
|
||||||
WF->>PJ: deactivateWorker(-123, dev)
|
WF->>PJ: deactivateWorker(-123, developer)
|
||||||
Note over PJ: active→false, issueId→null<br/>sessions map PRESERVED
|
Note over PJ: active→false, issueId→null<br/>sessions map PRESERVED
|
||||||
WF->>GL: transitionLabel "Doing" → "To Test"
|
WF->>GL: transitionLabel "Doing" → "To Test"
|
||||||
WF->>AL: append { event: "work_finish", role: "dev", result: "done" }
|
WF->>AL: append { event: "work_finish", role: "developer", result: "done" }
|
||||||
|
|
||||||
WF->>WF: tick queue (fill free slots)
|
WF->>WF: tick queue (fill free slots)
|
||||||
Note over WF: Scheduler sees "To Test" issue, QA slot free → dispatches QA
|
Note over WF: Scheduler sees "To Test" issue, TESTER slot free → dispatches TESTER
|
||||||
WF-->>DEV: { announcement: "✅ DEV DONE #42", tickPickups: [...] }
|
WF-->>DEV: { announcement: "✅ DEVELOPER DONE #42", tickPickups: [...] }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Writes:**
|
**Writes:**
|
||||||
- `Git repo`: pulled latest (has DEV's merged code)
|
- `Git repo`: pulled latest (has DEVELOPER's merged code)
|
||||||
- `projects.json`: dev.active=false, dev.issueId=null (sessions map preserved for reuse)
|
- `projects.json`: workers.developer.active=false, issueId=null (sessions map preserved for reuse)
|
||||||
- `Issue Tracker`: label "Doing" → "To Test"
|
- `Issue Tracker`: label "Doing" → "To Test"
|
||||||
- `audit.log`: 1 entry (work_finish) + tick entries if workers dispatched
|
- `audit.log`: 1 entry (work_finish) + tick entries if workers dispatched
|
||||||
|
|
||||||
### Phase 6: QA pickup
|
### Phase 5b: DEVELOPER requests review (alternative path)
|
||||||
|
|
||||||
Same as Phase 3, but with `role: "qa"`. Label transitions "To Test" → "Testing". Uses the reviewer level.
|
Instead of merging the PR themselves, a developer can leave it open for human review:
|
||||||
|
|
||||||
### Phase 7: QA result (4 possible outcomes)
|
|
||||||
|
|
||||||
#### 7a. QA Pass
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant QA as QA Session
|
participant DEV as DEVELOPER Session
|
||||||
|
participant WF as work_finish
|
||||||
|
participant GL as Issue Tracker
|
||||||
|
participant PJ as projects.json
|
||||||
|
|
||||||
|
DEV->>WF: work_finish({ role: "developer", result: "review", ... })
|
||||||
|
WF->>GL: transitionLabel "Doing" → "In Review"
|
||||||
|
WF->>PJ: deactivateWorker (sessions preserved)
|
||||||
|
WF-->>DEV: { announcement: "👀 DEVELOPER REVIEW #42" }
|
||||||
|
```
|
||||||
|
|
||||||
|
The issue sits in "In Review" until the heartbeat's **review pass** detects the PR has been approved. DevClaw then auto-merges the PR and transitions to "To Test". If the merge fails (e.g. conflicts), the issue moves to "To Improve" where a developer is auto-dispatched to resolve conflicts.
|
||||||
|
|
||||||
|
### Phase 6: TESTER pickup
|
||||||
|
|
||||||
|
Same as Phase 3, but with `role: "tester"`. Label transitions "To Test" → "Testing". Level selection determines which tester session is used.
|
||||||
|
|
||||||
|
### Phase 7: TESTER result (4 possible outcomes)
|
||||||
|
|
||||||
|
#### 7a. TESTER Pass
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant TST as TESTER Session
|
||||||
participant WF as work_finish
|
participant WF as work_finish
|
||||||
participant GL as Issue Tracker
|
participant GL as Issue Tracker
|
||||||
participant PJ as projects.json
|
participant PJ as projects.json
|
||||||
participant AL as audit.log
|
participant AL as audit.log
|
||||||
|
|
||||||
QA->>WF: work_finish({ role: "qa", result: "pass", projectGroupId: "-123" })
|
TST->>WF: work_finish({ role: "tester", result: "pass", projectGroupId: "-123" })
|
||||||
WF->>PJ: deactivateWorker(-123, qa)
|
WF->>PJ: deactivateWorker(-123, tester)
|
||||||
WF->>GL: transitionLabel(42, "Testing", "Done")
|
WF->>GL: transitionLabel(42, "Testing", "Done")
|
||||||
WF->>GL: closeIssue(42)
|
WF->>GL: closeIssue(42)
|
||||||
WF->>AL: append { event: "work_finish", role: "qa", result: "pass" }
|
WF->>AL: append { event: "work_finish", role: "tester", result: "pass" }
|
||||||
WF-->>QA: { announcement: "🎉 QA PASS #42. Issue closed." }
|
WF-->>TST: { announcement: "🎉 TESTER PASS #42. Issue closed." }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ticket complete.** Issue closed, label "Done".
|
**Ticket complete.** Issue closed, label "Done".
|
||||||
|
|
||||||
#### 7b. QA Fail
|
#### 7b. TESTER Fail
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant QA as QA Session
|
participant TST as TESTER Session
|
||||||
participant WF as work_finish
|
participant WF as work_finish
|
||||||
participant GL as Issue Tracker
|
participant GL as Issue Tracker
|
||||||
participant PJ as projects.json
|
participant PJ as projects.json
|
||||||
participant AL as audit.log
|
participant AL as audit.log
|
||||||
|
|
||||||
QA->>WF: work_finish({ role: "qa", result: "fail", projectGroupId: "-123", summary: "OAuth redirect broken" })
|
TST->>WF: work_finish({ role: "tester", result: "fail", projectGroupId: "-123", summary: "OAuth redirect broken" })
|
||||||
WF->>PJ: deactivateWorker(-123, qa)
|
WF->>PJ: deactivateWorker(-123, tester)
|
||||||
WF->>GL: transitionLabel(42, "Testing", "To Improve")
|
WF->>GL: transitionLabel(42, "Testing", "To Improve")
|
||||||
WF->>GL: reopenIssue(42)
|
WF->>GL: reopenIssue(42)
|
||||||
WF->>AL: append { event: "work_finish", role: "qa", result: "fail" }
|
WF->>AL: append { event: "work_finish", role: "tester", result: "fail" }
|
||||||
WF-->>QA: { announcement: "❌ QA FAIL #42 — OAuth redirect broken. Sent back to DEV." }
|
WF-->>TST: { announcement: "❌ TESTER FAIL #42 — OAuth redirect broken. Sent back to DEVELOPER." }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cycle restarts:** Issue goes to "To Improve". Next heartbeat, DEV picks it up again (Phase 3, but from "To Improve" instead of "To Do").
|
**Cycle restarts:** Issue goes to "To Improve". Next heartbeat, DEVELOPER picks it up again (Phase 3, but from "To Improve" instead of "To Do").
|
||||||
|
|
||||||
#### 7c. QA Refine
|
#### 7c. TESTER Refine
|
||||||
|
|
||||||
```
|
```
|
||||||
Label: "Testing" → "Refining"
|
Label: "Testing" → "Refining"
|
||||||
@@ -450,14 +483,14 @@ Label: "Testing" → "Refining"
|
|||||||
|
|
||||||
Issue needs human decision. Pipeline pauses until human moves it to "To Do" or closes it.
|
Issue needs human decision. Pipeline pauses until human moves it to "To Do" or closes it.
|
||||||
|
|
||||||
#### 7d. Blocked (DEV or QA)
|
#### 7d. Blocked (DEVELOPER or TESTER)
|
||||||
|
|
||||||
```
|
```
|
||||||
DEV Blocked: "Doing" → "To Do"
|
DEVELOPER Blocked: "Doing" → "Refining"
|
||||||
QA Blocked: "Testing" → "To Test"
|
TESTER Blocked: "Testing" → "Refining"
|
||||||
```
|
```
|
||||||
|
|
||||||
Worker cannot complete (missing info, environment errors, etc.). Issue returns to queue for retry. The task is available for the next heartbeat pickup.
|
Worker cannot complete (missing info, environment errors, etc.). Issue enters hold state for human decision. The human can move it back to "To Do" to retry or take other action.
|
||||||
|
|
||||||
### Completion enforcement
|
### Completion enforcement
|
||||||
|
|
||||||
@@ -465,18 +498,19 @@ Three layers guarantee that `work_finish` always runs:
|
|||||||
|
|
||||||
1. **Completion contract** — Every task message sent to a worker session includes a mandatory `## MANDATORY: Task Completion` section listing available results and requiring `work_finish` even on failure. Workers are instructed to use `"blocked"` if stuck.
|
1. **Completion contract** — Every task message sent to a worker session includes a mandatory `## MANDATORY: Task Completion` section listing available results and requiring `work_finish` even on failure. Workers are instructed to use `"blocked"` if stuck.
|
||||||
|
|
||||||
2. **Blocked result** — Both DEV and QA can use `"blocked"` to gracefully return a task to queue without losing work. DEV blocked: `Doing → To Do`. QA blocked: `Testing → To Test`. This gives workers an escape hatch instead of silently dying.
|
2. **Blocked result** — All roles can use `"blocked"` to gracefully hand off to a human. Developer blocked: `Doing → Refining`. Tester blocked: `Testing → Refining`. This gives workers an escape hatch instead of silently dying.
|
||||||
|
|
||||||
3. **Stale worker watchdog** — The heartbeat's health check detects workers active for >2 hours. With `fix=true`, it deactivates the worker and reverts the label back to queue. This catches sessions that crashed, ran out of context, or otherwise failed without calling `work_finish`. The `health` tool provides the same check for manual invocation.
|
3. **Stale worker watchdog** — The heartbeat's health check detects workers active for >2 hours. With `fix=true`, it deactivates the worker and reverts the label back to queue. This catches sessions that crashed, ran out of context, or otherwise failed without calling `work_finish`. The `health` tool provides the same check for manual invocation.
|
||||||
|
|
||||||
### Phase 8: Heartbeat (continuous)
|
### Phase 8: Heartbeat (continuous)
|
||||||
|
|
||||||
The heartbeat runs periodically (via background service or manual `work_heartbeat` trigger). It combines health check + queue scan:
|
The heartbeat runs periodically (via background service or manual `work_heartbeat` trigger). It combines health check + review polling + queue scan:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant HB as Heartbeat Service
|
participant HB as Heartbeat Service
|
||||||
participant SH as health check
|
participant SH as health check
|
||||||
|
participant RV as review pass
|
||||||
participant TK as projectTick
|
participant TK as projectTick
|
||||||
participant WS as work_start (dispatch)
|
participant WS as work_start (dispatch)
|
||||||
Note over HB: Tick triggered (every 60s)
|
Note over HB: Tick triggered (every 60s)
|
||||||
@@ -485,6 +519,10 @@ sequenceDiagram
|
|||||||
Note over SH: Checks for zombies, stale workers
|
Note over SH: Checks for zombies, stale workers
|
||||||
SH-->>HB: { fixes applied }
|
SH-->>HB: { fixes applied }
|
||||||
|
|
||||||
|
HB->>RV: reviewPass per project
|
||||||
|
Note over RV: Polls PR status for "In Review" issues
|
||||||
|
RV-->>HB: { transitions made }
|
||||||
|
|
||||||
HB->>TK: projectTick per project
|
HB->>TK: projectTick per project
|
||||||
Note over TK: Scans queue: To Improve > To Test > To Do
|
Note over TK: Scans queue: To Improve > To Test > To Do
|
||||||
TK->>WS: dispatchTask (fill free slots)
|
TK->>WS: dispatchTask (fill free slots)
|
||||||
@@ -492,6 +530,31 @@ sequenceDiagram
|
|||||||
TK-->>HB: { pickups, skipped }
|
TK-->>HB: { pickups, skipped }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Worker instructions (bootstrap hook)
|
||||||
|
|
||||||
|
Role-specific instructions (coding standards, deployment steps, completion rules) are injected into worker sessions via the `agent:bootstrap` hook — not appended to the task message.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant GW as Gateway
|
||||||
|
participant BH as Bootstrap Hook
|
||||||
|
participant FS as Filesystem
|
||||||
|
|
||||||
|
Note over GW: Worker session starts
|
||||||
|
GW->>BH: agent:bootstrap event (sessionKey, bootstrapFiles[])
|
||||||
|
BH->>BH: Parse session key → { projectName, role }
|
||||||
|
BH->>FS: Load role instructions (project-specific → default)
|
||||||
|
FS-->>BH: content + source path
|
||||||
|
BH->>BH: Push WORKER_INSTRUCTIONS.md into bootstrapFiles
|
||||||
|
BH-->>GW: bootstrapFiles now includes role instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resolution order:**
|
||||||
|
1. `devclaw/projects/<project>/prompts/<role>.md` (project-specific)
|
||||||
|
2. `devclaw/prompts/<role>.md` (workspace default)
|
||||||
|
|
||||||
|
The source path is logged for production traceability: `Bootstrap hook: injected developer instructions for project "my-app" from /path/to/prompts/developer.md`.
|
||||||
|
|
||||||
## Data flow map
|
## Data flow map
|
||||||
|
|
||||||
Every piece of data and where it lives:
|
Every piece of data and where it lives:
|
||||||
@@ -503,15 +566,16 @@ Every piece of data and where it lives:
|
|||||||
│ Issue #42: "Add login page" │
|
│ Issue #42: "Add login page" │
|
||||||
│ Labels: [Planning | To Do | Doing | To Test | Testing | ...] │
|
│ Labels: [Planning | To Do | Doing | To Test | Testing | ...] │
|
||||||
│ State: open / closed │
|
│ State: open / closed │
|
||||||
│ MRs/PRs: linked merge/pull requests │
|
│ PRs: linked pull/merge requests (status polled for In Review) │
|
||||||
│ Created by: orchestrator (task_create), workers, or humans │
|
│ Created by: orchestrator (task_create), workers, or humans │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
↕ gh/glab CLI (read/write, auto-detected)
|
↕ gh/glab CLI (read/write, auto-detected)
|
||||||
|
↕ cockatiel resilience: retry + circuit breaker
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ DevClaw Plugin (orchestration logic) │
|
│ DevClaw Plugin (orchestration logic) │
|
||||||
│ │
|
│ │
|
||||||
│ setup → agent creation + workspace + model config │
|
│ setup → agent creation + workspace + model config │
|
||||||
│ work_start → level + label + dispatch + role instr (e2e) │
|
│ work_start → level + label + dispatch (e2e) │
|
||||||
│ work_finish → label + state + git pull + tick queue │
|
│ work_finish → label + state + git pull + tick queue │
|
||||||
│ task_create → create issue in tracker │
|
│ task_create → create issue in tracker │
|
||||||
│ task_update → manual label state change │
|
│ task_update → manual label state change │
|
||||||
@@ -519,27 +583,38 @@ Every piece of data and where it lives:
|
|||||||
│ status → read labels + read state │
|
│ status → read labels + read state │
|
||||||
│ health → check sessions + fix zombies │
|
│ health → check sessions + fix zombies │
|
||||||
│ project_register → labels + prompts + state init (one-time) │
|
│ project_register → labels + prompts + state init (one-time) │
|
||||||
|
│ research_task → architect dispatch │
|
||||||
|
│ │
|
||||||
|
│ Bootstrap hook → injects role instructions into worker sessions│
|
||||||
|
│ Review pass → polls PR status, auto-merges approved PRs │
|
||||||
|
│ Config loader → three-layer merge + Zod validation │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
↕ atomic file I/O ↕ OpenClaw CLI (plugin shells out)
|
↕ atomic file I/O ↕ OpenClaw CLI (plugin shells out)
|
||||||
┌────────────────────────────────┐ ┌──────────────────────────────┐
|
┌────────────────────────────────┐ ┌──────────────────────────────┐
|
||||||
│ projects/projects.json │ │ OpenClaw Gateway + CLI │
|
│ devclaw/projects.json │ │ OpenClaw Gateway + CLI │
|
||||||
│ │ │ (called by plugin, not agent)│
|
│ │ │ (called by plugin, not agent)│
|
||||||
│ Per project: │ │ │
|
│ Per project: │ │ │
|
||||||
│ dev: │ │ openclaw gateway call │
|
│ workers: │ │ openclaw gateway call │
|
||||||
│ active, issueId, level │ │ sessions.patch → create │
|
│ developer: │ │ sessions.patch → create │
|
||||||
│ sessions: │ │ sessions.list → health │
|
│ active, issueId, level │ │ sessions.list → health │
|
||||||
│ junior: <key> │ │ sessions.delete → cleanup │
|
│ sessions: │ │ sessions.delete → cleanup │
|
||||||
|
│ junior: <key> │ │ │
|
||||||
|
│ medior: <key> │ │ openclaw gateway call agent │
|
||||||
|
│ senior: <key> │ │ --params { sessionKey, │
|
||||||
|
│ tester: │ │ message, agentId } │
|
||||||
|
│ active, issueId, level │ │ → dispatches to session │
|
||||||
|
│ sessions: │ │ │
|
||||||
|
│ junior: <key> │ │ │
|
||||||
│ medior: <key> │ │ │
|
│ medior: <key> │ │ │
|
||||||
│ senior: <key> │ │ openclaw gateway call agent │
|
│ senior: <key> │ │ │
|
||||||
│ qa: │ │ --params { sessionKey, │
|
│ architect: │ │ │
|
||||||
│ active, issueId, level │ │ message, agentId } │
|
│ sessions: │ │ │
|
||||||
│ sessions: │ │ → dispatches to session │
|
│ junior: <key> │ │ │
|
||||||
│ reviewer: <key> │ │ │
|
│ senior: <key> │ │ │
|
||||||
│ tester: <key> │ │ │
|
|
||||||
└────────────────────────────────┘ └──────────────────────────────┘
|
└────────────────────────────────┘ └──────────────────────────────┘
|
||||||
↕ append-only
|
↕ append-only
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ log/audit.log (observability) │
|
│ devclaw/log/audit.log (observability) │
|
||||||
│ │
|
│ │
|
||||||
│ NDJSON, one line per event: │
|
│ NDJSON, one line per event: │
|
||||||
│ work_start, work_finish, model_selection, │
|
│ work_start, work_finish, model_selection, │
|
||||||
@@ -553,21 +628,23 @@ Every piece of data and where it lives:
|
|||||||
│ Telegram / WhatsApp (user-facing messages) │
|
│ Telegram / WhatsApp (user-facing messages) │
|
||||||
│ │
|
│ │
|
||||||
│ Per group chat: │
|
│ Per group chat: │
|
||||||
│ "🔧 Spawning DEV (medior) for #42: Add login page" │
|
│ "🔧 Spawning DEVELOPER (medior) for #42: Add login page" │
|
||||||
│ "⚡ Sending DEV (medior) for #57: Fix validation" │
|
│ "⚡ Sending DEVELOPER (medior) for #57: Fix validation" │
|
||||||
│ "✅ DEV DONE #42 — Login page with OAuth." │
|
│ "✅ DEVELOPER DONE #42 — Login page with OAuth." │
|
||||||
│ "🎉 QA PASS #42. Issue closed." │
|
│ "👀 DEVELOPER REVIEW #42 — PR open for review." │
|
||||||
│ "❌ QA FAIL #42 — OAuth redirect broken." │
|
│ "🎉 TESTER PASS #42. Issue closed." │
|
||||||
│ "🚫 DEV BLOCKED #42 — Missing dependencies." │
|
│ "❌ TESTER FAIL #42 — OAuth redirect broken." │
|
||||||
│ "🚫 QA BLOCKED #42 — Env not available." │
|
│ "🚫 DEVELOPER BLOCKED #42 — Missing dependencies." │
|
||||||
|
│ "🚫 TESTER BLOCKED #42 — Env not available." │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ Git Repository (codebase) │
|
│ Git Repository (codebase) │
|
||||||
│ │
|
│ │
|
||||||
│ DEV sub-agent sessions: read code, write code, create MRs │
|
│ DEVELOPER sub-agent sessions: read code, write code, create PRs│
|
||||||
│ QA sub-agent sessions: read code, run tests, review MRs │
|
│ TESTER sub-agent sessions: read code, run tests, review PRs │
|
||||||
│ work_finish (DEV done): git pull to sync latest │
|
│ ARCHITECT sub-agent sessions: research, design, recommend │
|
||||||
|
│ work_finish (developer done): git pull to sync latest │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -584,9 +661,12 @@ graph LR
|
|||||||
SETUP[Agent + workspace setup]
|
SETUP[Agent + workspace setup]
|
||||||
SD[Session dispatch<br/>create + send via CLI]
|
SD[Session dispatch<br/>create + send via CLI]
|
||||||
AC[Scheduling<br/>tick queue after work_finish]
|
AC[Scheduling<br/>tick queue after work_finish]
|
||||||
RI[Role instructions<br/>loaded per project]
|
RI[Role instructions<br/>injected via bootstrap hook]
|
||||||
|
RV[Review polling<br/>PR approved → auto-merge]
|
||||||
A[Audit logging]
|
A[Audit logging]
|
||||||
Z[Zombie cleanup]
|
Z[Zombie cleanup]
|
||||||
|
CFG[Config validation<br/>Zod + integrity checks]
|
||||||
|
RES[Provider resilience<br/>retry + circuit breaker]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Orchestrator handles (planning only)"
|
subgraph "Orchestrator handles (planning only)"
|
||||||
@@ -600,7 +680,7 @@ graph LR
|
|||||||
|
|
||||||
subgraph "Sub-agent sessions handle"
|
subgraph "Sub-agent sessions handle"
|
||||||
CR[Code writing]
|
CR[Code writing]
|
||||||
MR[MR creation/review]
|
MR[PR creation/review]
|
||||||
WF_W[Task completion<br/>via work_finish]
|
WF_W[Task completion<br/>via work_finish]
|
||||||
BUG[Bug filing<br/>via task_create]
|
BUG[Bug filing<br/>via task_create]
|
||||||
end
|
end
|
||||||
@@ -611,7 +691,7 @@ graph LR
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key boundary:** The orchestrator is a planner and dispatcher — it never writes code. All implementation work (code edits, git operations, tests) must go through sub-agent sessions via the `task_create` → `work_start` pipeline. This ensures audit trails, tier selection, and QA review for every code change.
|
**Key boundary:** The orchestrator is a planner and dispatcher — it never writes code. All implementation work (code edits, git operations, tests) must go through sub-agent sessions via the `task_create` → `work_start` pipeline. This ensures audit trails, level selection, and testing for every code change.
|
||||||
|
|
||||||
## IssueProvider abstraction
|
## IssueProvider abstraction
|
||||||
|
|
||||||
@@ -624,10 +704,13 @@ All issue tracker operations go through the `IssueProvider` interface, defined i
|
|||||||
- `transitionLabel` — atomic label state transition (unlabel + label)
|
- `transitionLabel` — atomic label state transition (unlabel + label)
|
||||||
- `closeIssue` / `reopenIssue` — issue lifecycle
|
- `closeIssue` / `reopenIssue` — issue lifecycle
|
||||||
- `hasStateLabel` / `getCurrentStateLabel` — label inspection
|
- `hasStateLabel` / `getCurrentStateLabel` — label inspection
|
||||||
|
- `getPrStatus` — get PR/MR state (open, merged, approved, none)
|
||||||
- `hasMergedMR` / `getMergedMRUrl` — MR/PR verification
|
- `hasMergedMR` / `getMergedMRUrl` — MR/PR verification
|
||||||
- `addComment` — add comment to issue
|
- `addComment` — add comment to issue
|
||||||
- `healthCheck` — verify provider connectivity
|
- `healthCheck` — verify provider connectivity
|
||||||
|
|
||||||
|
**Provider resilience:** All provider calls are wrapped with cockatiel retry (3 attempts, exponential backoff) + circuit breaker (opens after 5 consecutive failures, half-opens after 30s). See `lib/providers/resilience.ts`.
|
||||||
|
|
||||||
**Current providers:**
|
**Current providers:**
|
||||||
- **GitHub** (`lib/providers/github.ts`) — wraps `gh` CLI
|
- **GitHub** (`lib/providers/github.ts`) — wraps `gh` CLI
|
||||||
- **GitLab** (`lib/providers/gitlab.ts`) — wraps `glab` CLI
|
- **GitLab** (`lib/providers/gitlab.ts`) — wraps `glab` CLI
|
||||||
@@ -637,19 +720,34 @@ All issue tracker operations go through the `IssueProvider` interface, defined i
|
|||||||
|
|
||||||
Provider selection is handled by `createProvider()` in `lib/providers/index.ts`. Auto-detects GitHub vs GitLab from the git remote URL.
|
Provider selection is handled by `createProvider()` in `lib/providers/index.ts`. Auto-detects GitHub vs GitLab from the git remote URL.
|
||||||
|
|
||||||
|
## Configuration system
|
||||||
|
|
||||||
|
DevClaw uses a three-layer config system with `workflow.yaml` files:
|
||||||
|
|
||||||
|
```
|
||||||
|
Layer 1: Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW)
|
||||||
|
Layer 2: Workspace: <workspace>/devclaw/workflow.yaml
|
||||||
|
Layer 3: Project: <workspace>/devclaw/projects/<project>/workflow.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Each layer can override roles (levels, models, emoji), workflow states/transitions, and timeouts. Config is validated with Zod schemas at load time, with cross-reference integrity checks (transition targets exist, queue states have roles, terminal states have no outgoing transitions).
|
||||||
|
|
||||||
|
See [CONFIGURATION.md](CONFIGURATION.md) for the full reference.
|
||||||
|
|
||||||
## Error recovery
|
## Error recovery
|
||||||
|
|
||||||
| Failure | Detection | Recovery |
|
| Failure | Detection | Recovery |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Session dies mid-task | `health` checks via `sessions.list` Gateway RPC | `fix=true`: reverts label, clears active state. Next heartbeat picks up task again (creates fresh session for that level). |
|
| Session dies mid-task | `health` checks via `sessions.list` Gateway RPC | `fix=true`: reverts label, clears active state. Next heartbeat picks up task again (creates fresh session for that level). |
|
||||||
| gh/glab command fails | Plugin tool throws error, returns to agent | Agent retries or reports to Telegram group |
|
| gh/glab command fails | Cockatiel retry (3 attempts), then circuit breaker | Circuit opens after 5 consecutive failures, prevents hammering. Plugin catches and returns error. |
|
||||||
| `openclaw gateway call agent` fails | Plugin catches error during dispatch | Plugin rolls back: reverts label, clears active state. Returns error. No orphaned state. |
|
| `openclaw gateway call agent` fails | Plugin catches error during dispatch | Plugin rolls back: reverts label, clears active state. Returns error. No orphaned state. |
|
||||||
| `sessions.patch` fails | Plugin catches error during session creation | Plugin rolls back label transition. Returns error. |
|
| `sessions.patch` fails | Plugin catches error during session creation | Plugin rolls back label transition. Returns error. |
|
||||||
| projects.json corrupted | Tool can't parse JSON | Manual fix needed. Atomic writes (temp+rename) prevent partial writes. |
|
| projects.json corrupted | Tool can't parse JSON | Manual fix needed. Atomic writes (temp+rename) prevent partial writes. File locking prevents concurrent races. |
|
||||||
| Label out of sync | `work_start` verifies label before transitioning | Throws error if label doesn't match expected state. |
|
| Label out of sync | `work_start` verifies label before transitioning | Throws error if label doesn't match expected state. |
|
||||||
| Worker already active | `work_start` checks `active` flag | Throws error: "DEV already active on project". Must complete current task first. |
|
| Worker already active | `work_start` checks `active` flag | Throws error: "DEVELOPER already active on project". Must complete current task first. |
|
||||||
| Stale worker (>2h) | `health` and heartbeat health check | `fix=true`: deactivates worker, reverts label to queue. Task available for next pickup. |
|
| Stale worker (>2h) | `health` and heartbeat health check | `fix=true`: deactivates worker, reverts label to queue. Task available for next pickup. |
|
||||||
| Worker stuck/blocked | Worker calls `work_finish` with `"blocked"` | Deactivates worker, reverts label to queue. Issue available for retry. |
|
| Worker stuck/blocked | Worker calls `work_finish` with `"blocked"` | Deactivates worker, transitions to "Refining" (hold state). Requires human decision to proceed. |
|
||||||
|
| Config invalid | Zod schema validation at load time | Clear error message with field path. Prevents startup with broken config. |
|
||||||
| `project_register` fails | Plugin catches error during label creation or state write | Clean error returned. Labels are idempotent, projects.json not written until all labels succeed. |
|
| `project_register` fails | Plugin catches error during label creation or state write | Clean error returned. Labels are idempotent, projects.json not written until all labels succeed. |
|
||||||
|
|
||||||
## File locations
|
## File locations
|
||||||
@@ -659,8 +757,11 @@ Provider selection is handled by `createProvider()` in `lib/providers/index.ts`.
|
|||||||
| Plugin source | `~/.openclaw/extensions/devclaw/` | Plugin code |
|
| Plugin source | `~/.openclaw/extensions/devclaw/` | Plugin code |
|
||||||
| Plugin manifest | `~/.openclaw/extensions/devclaw/openclaw.plugin.json` | Plugin registration |
|
| Plugin manifest | `~/.openclaw/extensions/devclaw/openclaw.plugin.json` | Plugin registration |
|
||||||
| Agent config | `~/.openclaw/openclaw.json` | Agent definition + tool permissions + model config |
|
| Agent config | `~/.openclaw/openclaw.json` | Agent definition + tool permissions + model config |
|
||||||
| Worker state | `~/.openclaw/workspace-<agent>/projects/projects.json` | Per-project DEV/QA state |
|
| Worker state | `<workspace>/devclaw/projects.json` | Per-project worker state |
|
||||||
| Role instructions | `~/.openclaw/workspace-<agent>/projects/roles/<project>/` | Per-project `dev.md` and `qa.md` |
|
| Workflow config (workspace) | `<workspace>/devclaw/workflow.yaml` | Workspace-level role/workflow overrides |
|
||||||
| Audit log | `~/.openclaw/workspace-<agent>/log/audit.log` | NDJSON event log |
|
| Workflow config (project) | `<workspace>/devclaw/projects/<project>/workflow.yaml` | Project-specific overrides |
|
||||||
|
| Default role instructions | `<workspace>/devclaw/prompts/<role>.md` | Default `developer.md`, `tester.md`, `architect.md` |
|
||||||
|
| Project role instructions | `<workspace>/devclaw/projects/<project>/prompts/<role>.md` | Per-project role instruction overrides |
|
||||||
|
| Audit log | `<workspace>/devclaw/log/audit.log` | NDJSON event log |
|
||||||
| Session transcripts | `~/.openclaw/agents/<agent>/sessions/<uuid>.jsonl` | Conversation history per session |
|
| Session transcripts | `~/.openclaw/agents/<agent>/sessions/<uuid>.jsonl` | Conversation history per session |
|
||||||
| Git repos | `~/git/<project>/` | Project source code |
|
| Git repos | `~/git/<project>/` | Project source code |
|
||||||
|
|||||||
@@ -1,54 +1,224 @@
|
|||||||
# DevClaw — Configuration Reference
|
# DevClaw — Configuration Reference
|
||||||
|
|
||||||
All DevClaw configuration lives in two places: `openclaw.json` (plugin-level settings) and `projects.json` (per-project state).
|
DevClaw uses a three-layer configuration system. All role, workflow, and timeout settings live in `workflow.yaml` files — not in `openclaw.json`.
|
||||||
|
|
||||||
## Plugin Configuration (`openclaw.json`)
|
## Three-Layer Config Resolution
|
||||||
|
|
||||||
DevClaw is configured under `plugins.entries.devclaw.config` in `openclaw.json`.
|
```
|
||||||
|
Layer 1: Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW)
|
||||||
### Model Tiers
|
Layer 2: Workspace: <workspace>/devclaw/workflow.yaml
|
||||||
|
Layer 3: Project: <workspace>/devclaw/projects/<project>/workflow.yaml
|
||||||
Override which LLM model powers each developer level:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"plugins": {
|
|
||||||
"entries": {
|
|
||||||
"devclaw": {
|
|
||||||
"config": {
|
|
||||||
"models": {
|
|
||||||
"dev": {
|
|
||||||
"junior": "anthropic/claude-haiku-4-5",
|
|
||||||
"medior": "anthropic/claude-sonnet-4-5",
|
|
||||||
"senior": "anthropic/claude-opus-4-5"
|
|
||||||
},
|
|
||||||
"qa": {
|
|
||||||
"reviewer": "anthropic/claude-sonnet-4-5",
|
|
||||||
"tester": "anthropic/claude-haiku-4-5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Resolution order** (per `lib/tiers.ts:resolveModel`):
|
Each layer can partially override the one below it. Only the fields you specify are merged — everything else inherits from the layer below.
|
||||||
|
|
||||||
1. Plugin config `models.<role>.<level>` — explicit override
|
**Source:** [`lib/config/loader.ts`](../lib/config/loader.ts)
|
||||||
2. `DEFAULT_MODELS[role][level]` — built-in defaults (table below)
|
|
||||||
3. Passthrough — treat the level string as a raw model ID
|
**Validation:** Config is validated at load time with Zod schemas ([`lib/config/schema.ts`](../lib/config/schema.ts)). Integrity checks verify transition targets exist, queue states have roles, and terminal states have no outgoing transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Config (`workflow.yaml`)
|
||||||
|
|
||||||
|
The `workflow.yaml` file configures roles, workflow states, and timeouts. Place it at `<workspace>/devclaw/workflow.yaml` for workspace-wide settings, or at `<workspace>/devclaw/projects/<project>/workflow.yaml` for project-specific overrides.
|
||||||
|
|
||||||
|
### Role Configuration
|
||||||
|
|
||||||
|
Override which LLM model powers each level, customize levels, or disable roles entirely:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
roles:
|
||||||
|
developer:
|
||||||
|
models:
|
||||||
|
junior: anthropic/claude-haiku-4-5
|
||||||
|
medior: anthropic/claude-sonnet-4-5
|
||||||
|
senior: anthropic/claude-opus-4-6
|
||||||
|
tester:
|
||||||
|
models:
|
||||||
|
junior: anthropic/claude-haiku-4-5
|
||||||
|
medior: anthropic/claude-sonnet-4-5
|
||||||
|
senior: anthropic/claude-opus-4-6
|
||||||
|
architect:
|
||||||
|
models:
|
||||||
|
junior: anthropic/claude-sonnet-4-5
|
||||||
|
senior: anthropic/claude-opus-4-6
|
||||||
|
# Disable a role entirely:
|
||||||
|
# architect: false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Role override fields** (all optional — only override what you need):
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `levels` | string[] | Available levels for this role |
|
||||||
|
| `defaultLevel` | string | Default level when not specified |
|
||||||
|
| `models` | Record<string, string> | Model ID per level |
|
||||||
|
| `emoji` | Record<string, string> | Emoji per level for announcements |
|
||||||
|
| `completionResults` | string[] | Valid completion results |
|
||||||
|
|
||||||
**Default models:**
|
**Default models:**
|
||||||
|
|
||||||
| Role | Level | Default model |
|
| Role | Level | Default Model |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| dev | junior | `anthropic/claude-haiku-4-5` |
|
| developer | junior | `anthropic/claude-haiku-4-5` |
|
||||||
| dev | medior | `anthropic/claude-sonnet-4-5` |
|
| developer | medior | `anthropic/claude-sonnet-4-5` |
|
||||||
| dev | senior | `anthropic/claude-opus-4-5` |
|
| developer | senior | `anthropic/claude-opus-4-6` |
|
||||||
| qa | reviewer | `anthropic/claude-sonnet-4-5` |
|
| tester | junior | `anthropic/claude-haiku-4-5` |
|
||||||
| qa | tester | `anthropic/claude-haiku-4-5` |
|
| tester | medior | `anthropic/claude-sonnet-4-5` |
|
||||||
|
| tester | senior | `anthropic/claude-opus-4-6` |
|
||||||
|
| architect | junior | `anthropic/claude-sonnet-4-5` |
|
||||||
|
| architect | senior | `anthropic/claude-opus-4-6` |
|
||||||
|
|
||||||
|
**Source:** [`lib/roles/registry.ts`](../lib/roles/registry.ts)
|
||||||
|
|
||||||
|
**Model resolution order:**
|
||||||
|
|
||||||
|
1. Project `workflow.yaml` → `roles.<role>.models.<level>`
|
||||||
|
2. Workspace `workflow.yaml` → `roles.<role>.models.<level>`
|
||||||
|
3. Built-in defaults from `ROLE_REGISTRY`
|
||||||
|
4. Passthrough — treat the level string as a raw model ID
|
||||||
|
|
||||||
|
### Workflow States
|
||||||
|
|
||||||
|
The workflow section defines the state machine for issue lifecycle. Each state has a type, label, color, and optional transitions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
workflow:
|
||||||
|
initial: planning
|
||||||
|
states:
|
||||||
|
planning:
|
||||||
|
type: hold
|
||||||
|
label: Planning
|
||||||
|
color: "#95a5a6"
|
||||||
|
on:
|
||||||
|
APPROVE: todo
|
||||||
|
todo:
|
||||||
|
type: queue
|
||||||
|
role: developer
|
||||||
|
label: To Do
|
||||||
|
color: "#428bca"
|
||||||
|
priority: 1
|
||||||
|
on:
|
||||||
|
PICKUP: doing
|
||||||
|
doing:
|
||||||
|
type: active
|
||||||
|
role: developer
|
||||||
|
label: Doing
|
||||||
|
color: "#f0ad4e"
|
||||||
|
on:
|
||||||
|
COMPLETE:
|
||||||
|
target: toTest
|
||||||
|
actions: [gitPull, detectPr]
|
||||||
|
REVIEW:
|
||||||
|
target: reviewing
|
||||||
|
actions: [detectPr]
|
||||||
|
BLOCKED: refining
|
||||||
|
toTest:
|
||||||
|
type: queue
|
||||||
|
role: tester
|
||||||
|
label: To Test
|
||||||
|
color: "#5bc0de"
|
||||||
|
priority: 2
|
||||||
|
on:
|
||||||
|
PICKUP: testing
|
||||||
|
testing:
|
||||||
|
type: active
|
||||||
|
role: tester
|
||||||
|
label: Testing
|
||||||
|
color: "#9b59b6"
|
||||||
|
on:
|
||||||
|
PASS:
|
||||||
|
target: done
|
||||||
|
actions: [closeIssue]
|
||||||
|
FAIL:
|
||||||
|
target: toImprove
|
||||||
|
actions: [reopenIssue]
|
||||||
|
REFINE: refining
|
||||||
|
BLOCKED: refining
|
||||||
|
toImprove:
|
||||||
|
type: queue
|
||||||
|
role: developer
|
||||||
|
label: To Improve
|
||||||
|
color: "#d9534f"
|
||||||
|
priority: 3
|
||||||
|
on:
|
||||||
|
PICKUP: doing
|
||||||
|
refining:
|
||||||
|
type: hold
|
||||||
|
label: Refining
|
||||||
|
color: "#f39c12"
|
||||||
|
on:
|
||||||
|
APPROVE: todo
|
||||||
|
reviewing:
|
||||||
|
type: review
|
||||||
|
label: In Review
|
||||||
|
color: "#c5def5"
|
||||||
|
check: prApproved
|
||||||
|
on:
|
||||||
|
APPROVED:
|
||||||
|
target: toTest
|
||||||
|
actions: [mergePr, gitPull]
|
||||||
|
MERGE_FAILED: toImprove
|
||||||
|
BLOCKED: refining
|
||||||
|
done:
|
||||||
|
type: terminal
|
||||||
|
label: Done
|
||||||
|
color: "#5cb85c"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The architect role has no dedicated workflow states. Design tasks are triggered via `research_task` tool only — issues go directly to Planning.
|
||||||
|
|
||||||
|
**State types:**
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|---|---|
|
||||||
|
| `queue` | Waiting for pickup. Must have a `role`. Has `priority` for ordering. |
|
||||||
|
| `active` | Worker is currently working on it. Must have a `role`. |
|
||||||
|
| `hold` | Paused, awaiting human decision. |
|
||||||
|
| `review` | Awaiting external check (PR approved/merged). Has `check` field. Heartbeat polls and auto-transitions. |
|
||||||
|
| `terminal` | Completed. No outgoing transitions. |
|
||||||
|
|
||||||
|
**Built-in actions:**
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|---|---|
|
||||||
|
| `gitPull` | Pull latest from the base branch |
|
||||||
|
| `detectPr` | Auto-detect PR URL from the issue |
|
||||||
|
| `mergePr` | Merge the PR associated with the issue. Critical in review states (aborts on failure). |
|
||||||
|
| `closeIssue` | Close the issue |
|
||||||
|
| `reopenIssue` | Reopen the issue |
|
||||||
|
|
||||||
|
**Review checks:**
|
||||||
|
|
||||||
|
| Check | Description |
|
||||||
|
|---|---|
|
||||||
|
| `prMerged` | Transition when the issue's PR is merged |
|
||||||
|
| `prApproved` | Transition when the issue's PR is approved or merged |
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
timeouts:
|
||||||
|
gitPullMs: 30000
|
||||||
|
gatewayMs: 120000
|
||||||
|
sessionPatchMs: 120000
|
||||||
|
dispatchMs: 120000
|
||||||
|
staleWorkerHours: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
| Setting | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `gitPullMs` | 30000 | Timeout for git pull operations |
|
||||||
|
| `gatewayMs` | 120000 | Timeout for gateway RPC calls |
|
||||||
|
| `sessionPatchMs` | 120000 | Timeout for session creation |
|
||||||
|
| `dispatchMs` | 120000 | Timeout for task dispatch |
|
||||||
|
| `staleWorkerHours` | 2 | Hours before a worker is considered stale |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin Configuration (`openclaw.json`)
|
||||||
|
|
||||||
|
Some settings still live in `openclaw.json` under `plugins.entries.devclaw.config`:
|
||||||
|
|
||||||
### Project Execution Mode
|
### Project Execution Mode
|
||||||
|
|
||||||
@@ -73,8 +243,6 @@ Controls cross-project parallelism:
|
|||||||
| `"parallel"` (default) | Multiple projects can have active workers simultaneously |
|
| `"parallel"` (default) | Multiple projects can have active workers simultaneously |
|
||||||
| `"sequential"` | Only one project's workers active at a time. Useful for single-agent deployments. |
|
| `"sequential"` | Only one project's workers active at a time. Useful for single-agent deployments. |
|
||||||
|
|
||||||
Enforced in `work_heartbeat` and the heartbeat service before dispatching.
|
|
||||||
|
|
||||||
### Heartbeat Service
|
### Heartbeat Service
|
||||||
|
|
||||||
Token-free interval-based health checks + queue dispatch:
|
Token-free interval-based health checks + queue dispatch:
|
||||||
@@ -105,7 +273,7 @@ Token-free interval-based health checks + queue dispatch:
|
|||||||
|
|
||||||
**Source:** [`lib/services/heartbeat.ts`](../lib/services/heartbeat.ts)
|
**Source:** [`lib/services/heartbeat.ts`](../lib/services/heartbeat.ts)
|
||||||
|
|
||||||
The heartbeat service runs as a plugin service tied to the gateway lifecycle. Every tick: health pass (auto-fix zombies, stale workers) → tick pass (fill free slots by priority). Zero LLM tokens consumed.
|
The heartbeat service runs as a plugin service tied to the gateway lifecycle. Every tick: health pass (auto-fix zombies, stale workers) → review pass (poll PR status for "In Review" issues) → tick pass (fill free slots by priority). Zero LLM tokens consumed.
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
@@ -157,7 +325,8 @@ Restrict DevClaw tools to your orchestrator agent:
|
|||||||
"work_heartbeat",
|
"work_heartbeat",
|
||||||
"project_register",
|
"project_register",
|
||||||
"setup",
|
"setup",
|
||||||
"onboard"
|
"onboard",
|
||||||
|
"research_task"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +339,7 @@ Restrict DevClaw tools to your orchestrator agent:
|
|||||||
|
|
||||||
## Project State (`projects.json`)
|
## Project State (`projects.json`)
|
||||||
|
|
||||||
All project state lives in `<workspace>/projects/projects.json`, keyed by group ID.
|
All project state lives in `<workspace>/devclaw/projects.json`, keyed by group ID.
|
||||||
|
|
||||||
**Source:** [`lib/projects.ts`](../lib/projects.ts)
|
**Source:** [`lib/projects.ts`](../lib/projects.ts)
|
||||||
|
|
||||||
@@ -187,26 +356,40 @@ All project state lives in `<workspace>/projects/projects.json`, keyed by group
|
|||||||
"deployBranch": "development",
|
"deployBranch": "development",
|
||||||
"deployUrl": "https://my-webapp.example.com",
|
"deployUrl": "https://my-webapp.example.com",
|
||||||
"channel": "telegram",
|
"channel": "telegram",
|
||||||
|
"provider": "github",
|
||||||
"roleExecution": "parallel",
|
"roleExecution": "parallel",
|
||||||
"dev": {
|
"workers": {
|
||||||
|
"developer": {
|
||||||
"active": false,
|
"active": false,
|
||||||
"issueId": null,
|
"issueId": null,
|
||||||
"startTime": null,
|
"startTime": null,
|
||||||
"level": null,
|
"level": null,
|
||||||
"sessions": {
|
"sessions": {
|
||||||
"junior": null,
|
"junior": null,
|
||||||
"medior": "agent:orchestrator:subagent:my-webapp-dev-medior",
|
"medior": "agent:orchestrator:subagent:my-webapp-developer-medior",
|
||||||
"senior": null
|
"senior": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"qa": {
|
"tester": {
|
||||||
"active": false,
|
"active": false,
|
||||||
"issueId": null,
|
"issueId": null,
|
||||||
"startTime": null,
|
"startTime": null,
|
||||||
"level": null,
|
"level": null,
|
||||||
"sessions": {
|
"sessions": {
|
||||||
"reviewer": "agent:orchestrator:subagent:my-webapp-qa-reviewer",
|
"junior": null,
|
||||||
"tester": null
|
"medior": "agent:orchestrator:subagent:my-webapp-tester-medior",
|
||||||
|
"senior": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"architect": {
|
||||||
|
"active": false,
|
||||||
|
"issueId": null,
|
||||||
|
"startTime": null,
|
||||||
|
"level": null,
|
||||||
|
"sessions": {
|
||||||
|
"junior": null,
|
||||||
|
"senior": null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,29 +408,28 @@ All project state lives in `<workspace>/projects/projects.json`, keyed by group
|
|||||||
| `deployBranch` | string | Branch that triggers deployment |
|
| `deployBranch` | string | Branch that triggers deployment |
|
||||||
| `deployUrl` | string | Deployment URL |
|
| `deployUrl` | string | Deployment URL |
|
||||||
| `channel` | string | Messaging channel (`"telegram"`, `"whatsapp"`, etc.) |
|
| `channel` | string | Messaging channel (`"telegram"`, `"whatsapp"`, etc.) |
|
||||||
| `roleExecution` | `"parallel"` \| `"sequential"` | DEV/QA parallelism for this project |
|
| `provider` | `"github"` \| `"gitlab"` | Issue tracker provider (auto-detected, stored for reuse) |
|
||||||
|
| `roleExecution` | `"parallel"` \| `"sequential"` | DEVELOPER/TESTER parallelism for this project |
|
||||||
|
|
||||||
### Worker state fields
|
### Worker state fields
|
||||||
|
|
||||||
Each project has `dev` and `qa` worker state objects:
|
Each role in the `workers` record has a `WorkerState` object:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `active` | boolean | Whether this role has an active worker |
|
| `active` | boolean | Whether this role has an active worker |
|
||||||
| `issueId` | string \| null | Issue being worked on (as string) |
|
| `issueId` | string \| null | Issue being worked on (as string) |
|
||||||
| `startTime` | string \| null | ISO timestamp when worker became active |
|
| `startTime` | string \| null | ISO timestamp when worker became active |
|
||||||
| `level` | string \| null | Current level (`junior`, `medior`, `senior`, `reviewer`, `tester`) |
|
| `level` | string \| null | Current level (`junior`, `medior`, `senior`) |
|
||||||
| `sessions` | Record<string, string \| null> | Per-level session keys |
|
| `sessions` | Record<string, string \| null> | Per-level session keys |
|
||||||
|
|
||||||
**DEV session keys:** `junior`, `medior`, `senior`
|
|
||||||
**QA session keys:** `reviewer`, `tester`
|
|
||||||
|
|
||||||
### Key design decisions
|
### Key design decisions
|
||||||
|
|
||||||
- **Session-per-level** — each level gets its own worker session, accumulating context independently. Level selection maps directly to a session key.
|
- **Session-per-level** — each level gets its own worker session, accumulating context independently. Level selection maps directly to a session key.
|
||||||
- **Sessions preserved on completion** — when a worker completes a task, the sessions map is preserved (only `active`, `issueId`, and `startTime` are cleared). This enables session reuse.
|
- **Sessions preserved on completion** — when a worker completes a task, the sessions map is preserved (only `active`, `issueId`, and `startTime` are cleared). This enables session reuse.
|
||||||
- **Atomic writes** — all writes go through temp-file-then-rename to prevent corruption.
|
- **Atomic writes** — all writes go through temp-file-then-rename to prevent corruption. File locking prevents concurrent read-modify-write races.
|
||||||
- **Sessions persist indefinitely** — no auto-cleanup. The `health` tool handles manual cleanup.
|
- **Sessions persist indefinitely** — no auto-cleanup. The `health` tool handles manual cleanup.
|
||||||
|
- **Dynamic workers** — the `workers` record is keyed by role ID (e.g., `developer`, `tester`, `architect`). New roles are created automatically when dispatched.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -255,19 +437,25 @@ Each project has `dev` and `qa` worker state objects:
|
|||||||
|
|
||||||
```
|
```
|
||||||
<workspace>/
|
<workspace>/
|
||||||
├── projects/
|
├── devclaw/
|
||||||
│ ├── projects.json ← Project state (auto-managed)
|
│ ├── projects.json ← Project state (auto-managed)
|
||||||
│ └── roles/
|
│ ├── workflow.yaml ← Workspace-level config overrides
|
||||||
│ ├── my-webapp/ ← Per-project role instructions (editable)
|
│ ├── prompts/
|
||||||
│ │ ├── dev.md
|
│ │ ├── developer.md ← Default developer instructions
|
||||||
│ │ └── qa.md
|
│ │ ├── tester.md ← Default tester instructions
|
||||||
│ ├── another-project/
|
│ │ └── architect.md ← Default architect instructions
|
||||||
│ │ ├── dev.md
|
│ ├── projects/
|
||||||
│ │ └── qa.md
|
│ │ ├── my-webapp/
|
||||||
│ └── default/ ← Fallback role instructions
|
│ │ │ ├── workflow.yaml ← Project-specific config overrides
|
||||||
│ ├── dev.md
|
│ │ │ └── prompts/
|
||||||
│ └── qa.md
|
│ │ │ ├── developer.md ← Project-specific developer instructions
|
||||||
├── log/
|
│ │ │ ├── tester.md ← Project-specific tester instructions
|
||||||
|
│ │ │ └── architect.md ← Project-specific architect instructions
|
||||||
|
│ │ └── another-project/
|
||||||
|
│ │ └── prompts/
|
||||||
|
│ │ ├── developer.md
|
||||||
|
│ │ └── tester.md
|
||||||
|
│ └── log/
|
||||||
│ └── audit.log ← NDJSON event log (auto-managed)
|
│ └── audit.log ← NDJSON event log (auto-managed)
|
||||||
├── AGENTS.md ← Agent identity documentation
|
├── AGENTS.md ← Agent identity documentation
|
||||||
└── HEARTBEAT.md ← Heartbeat operation guide
|
└── HEARTBEAT.md ← Heartbeat operation guide
|
||||||
@@ -275,17 +463,17 @@ Each project has `dev` and `qa` worker state objects:
|
|||||||
|
|
||||||
### Role instruction files
|
### Role instruction files
|
||||||
|
|
||||||
`work_start` loads role instructions from `projects/roles/<project>/<role>.md` at dispatch time, falling back to `projects/roles/default/<role>.md`. These files are appended to the task message sent to worker sessions.
|
Role instructions are injected into worker sessions via the `agent:bootstrap` hook at session startup. The hook loads instructions from `devclaw/projects/<project>/prompts/<role>.md`, falling back to `devclaw/prompts/<role>.md`.
|
||||||
|
|
||||||
Edit to customize: deployment steps, test commands, acceptance criteria, coding standards.
|
Edit to customize: deployment steps, test commands, acceptance criteria, coding standards.
|
||||||
|
|
||||||
**Source:** [`lib/dispatch.ts:loadRoleInstructions`](../lib/dispatch.ts)
|
**Source:** [`lib/bootstrap-hook.ts`](../lib/bootstrap-hook.ts)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Audit Log
|
## Audit Log
|
||||||
|
|
||||||
Append-only NDJSON at `<workspace>/log/audit.log`. Auto-truncated to 250 lines.
|
Append-only NDJSON at `<workspace>/devclaw/log/audit.log`. Auto-truncated to 250 lines.
|
||||||
|
|
||||||
**Source:** [`lib/audit.ts`](../lib/audit.ts)
|
**Source:** [`lib/audit.ts`](../lib/audit.ts)
|
||||||
|
|
||||||
@@ -331,6 +519,8 @@ DevClaw uses an `IssueProvider` interface (`lib/providers/provider.ts`) to abstr
|
|||||||
| GitHub | `gh` | Remote contains `github.com` |
|
| GitHub | `gh` | Remote contains `github.com` |
|
||||||
| GitLab | `glab` | Remote contains `gitlab` |
|
| GitLab | `glab` | Remote contains `gitlab` |
|
||||||
|
|
||||||
|
**Provider resilience:** All calls are wrapped with cockatiel retry (3 attempts, exponential backoff) + circuit breaker (opens after 5 consecutive failures, half-opens after 30s). See [`lib/providers/resilience.ts`](../lib/providers/resilience.ts).
|
||||||
|
|
||||||
**Planned:** Jira (via REST API)
|
**Planned:** Jira (via REST API)
|
||||||
|
|
||||||
**Source:** [`lib/providers/index.ts`](../lib/providers/index.ts)
|
**Source:** [`lib/providers/index.ts`](../lib/providers/index.ts)
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ DevClaw's level selection does exactly this. When a task comes in, the plugin ro
|
|||||||
| Simple (typos, renames, copy) | Junior | The intern — just execute |
|
| Simple (typos, renames, copy) | Junior | The intern — just execute |
|
||||||
| Standard (features, bug fixes) | Medior | Mid-level — think and build |
|
| Standard (features, bug fixes) | Medior | Mid-level — think and build |
|
||||||
| Complex (architecture, security) | Senior | The architect — design and reason |
|
| Complex (architecture, security) | Senior | The architect — design and reason |
|
||||||
| Review | Reviewer | Independent code reviewer |
|
|
||||||
|
All three roles — DEVELOPER, TESTER, and ARCHITECT — use the same junior/medior/senior scheme (architect uses junior/senior). The orchestrator picks the level per task, and the plugin resolves it to the appropriate model via the role registry and workflow config.
|
||||||
|
|
||||||
This isn't just cost optimization. It mirrors what effective managers do instinctively: match the delegation level to the task, not to a fixed assumption about the delegate.
|
This isn't just cost optimization. It mirrors what effective managers do instinctively: match the delegation level to the task, not to a fixed assumption about the delegate.
|
||||||
|
|
||||||
@@ -27,14 +28,15 @@ This isn't just cost optimization. It mirrors what effective managers do instinc
|
|||||||
|
|
||||||
Classical management theory — later formalized by Bernard Bass in his work on Transformational Leadership — introduced a concept called Management by Exception (MBE). The principle: a manager should only be pulled back into a workstream when something deviates from the expected path.
|
Classical management theory — later formalized by Bernard Bass in his work on Transformational Leadership — introduced a concept called Management by Exception (MBE). The principle: a manager should only be pulled back into a workstream when something deviates from the expected path.
|
||||||
|
|
||||||
DevClaw's task lifecycle is built on this. The orchestrator delegates a task via `work_start`, then steps away. It only re-engages in three scenarios:
|
DevClaw's task lifecycle is built on this. The orchestrator delegates a task via `work_start`, then steps away. It only re-engages in specific scenarios:
|
||||||
|
|
||||||
1. **DEV completes work** → The label moves to `To Test`. The scheduler dispatches QA on the next tick. No orchestrator involvement needed.
|
1. **DEVELOPER completes work** → The label moves to `To Test`. The scheduler dispatches TESTER on the next tick. No orchestrator involvement needed.
|
||||||
2. **QA passes** → The issue closes. Pipeline complete.
|
2. **DEVELOPER requests review** → The label moves to `In Review`. The heartbeat polls PR status. When merged, the scheduler dispatches TESTER. No orchestrator involvement needed.
|
||||||
3. **QA fails** → The label moves to `To Improve`. The scheduler dispatches DEV on the next tick. The orchestrator may need to adjust the model level.
|
3. **TESTER passes** → The issue closes. Pipeline complete.
|
||||||
4. **QA refines** → The task enters a holding state that _requires human decision_. This is the explicit escalation boundary.
|
4. **TESTER fails** → The label moves to `To Improve`. The scheduler dispatches DEVELOPER on the next tick. The orchestrator may need to adjust the level.
|
||||||
|
5. **Any role is blocked** → The task enters `Refining` — a holding state that _requires human decision_. This is the explicit escalation boundary.
|
||||||
|
|
||||||
The "refine" state is the most interesting from a delegation perspective. It's a conscious architectural decision that says: some judgments should not be automated. When the QA agent determines that a task needs rethinking rather than just fixing, it escalates to the only actor who has the full business context — the human.
|
The "Refining" state is the most interesting from a delegation perspective. It's a conscious architectural decision that says: some judgments should not be automated. When a TESTER determines that a task needs rethinking rather than just fixing, or when a DEVELOPER hits an obstacle that requires business context, it escalates to the only actor who has the full picture — the human.
|
||||||
|
|
||||||
This is textbook MBE. The person behind the keyboard isn't monitoring every task. They're only pulled in when the system encounters something beyond its delegation authority.
|
This is textbook MBE. The person behind the keyboard isn't monitoring every task. They're only pulled in when the system encounters something beyond its delegation authority.
|
||||||
|
|
||||||
@@ -42,14 +44,17 @@ This is textbook MBE. The person behind the keyboard isn't monitoring every task
|
|||||||
|
|
||||||
Henry Mintzberg's work on organizational structure identified five coordination mechanisms. The one most relevant to DevClaw is **standardization of work processes** — when coordination happens not through direct supervision but through predetermined procedures that everyone follows.
|
Henry Mintzberg's work on organizational structure identified five coordination mechanisms. The one most relevant to DevClaw is **standardization of work processes** — when coordination happens not through direct supervision but through predetermined procedures that everyone follows.
|
||||||
|
|
||||||
DevClaw enforces a single, fixed lifecycle for every task across every project:
|
DevClaw enforces a configurable but consistent lifecycle for every task. The default workflow:
|
||||||
|
|
||||||
```
|
```
|
||||||
Planning → To Do → Doing → To Test → Testing → Done
|
Planning → To Do → Doing → To Test → Testing → Done
|
||||||
↘ To Improve → Doing (fix cycle)
|
↘ In Review → (PR approved → auto-merge) → To Test
|
||||||
|
↘ To Improve → Doing (merge conflict / fix cycle)
|
||||||
↘ Refining → (human decision)
|
↘ Refining → (human decision)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The ARCHITECT role is tool-triggered only via `research_task` — no queue states. Issues go directly to Planning, the architect researches and posts findings, then the issue stays in Planning for human review.
|
||||||
|
|
||||||
Every label transition, state update, and audit log entry happens atomically inside the plugin. The orchestrator agent cannot skip a step, forget a label, or corrupt session state — because those operations are deterministic code, not instructions an LLM follows imperfectly.
|
Every label transition, state update, and audit log entry happens atomically inside the plugin. The orchestrator agent cannot skip a step, forget a label, or corrupt session state — because those operations are deterministic code, not instructions an LLM follows imperfectly.
|
||||||
|
|
||||||
This is what allows a single orchestrator to manage multiple projects simultaneously. Management research has long debated the ideal span of control — typically cited as 5-9 direct reports for knowledge work. DevClaw sidesteps the constraint entirely by making every project follow identical processes. The orchestrator doesn't need to remember how Project A works versus Project B. They all work the same way.
|
This is what allows a single orchestrator to manage multiple projects simultaneously. Management research has long debated the ideal span of control — typically cited as 5-9 direct reports for knowledge work. DevClaw sidesteps the constraint entirely by making every project follow identical processes. The orchestrator doesn't need to remember how Project A works versus Project B. They all work the same way.
|
||||||
@@ -60,9 +65,11 @@ One of the most common delegation failures is self-review. You don't ask the per
|
|||||||
|
|
||||||
DevClaw enforces structural separation between development and review by design:
|
DevClaw enforces structural separation between development and review by design:
|
||||||
|
|
||||||
- DEV and QA are separate sub-agent sessions with separate state.
|
- DEVELOPER and TESTER are separate sub-agent sessions with separate state.
|
||||||
- QA uses the reviewer level, which can be a different model entirely, introducing genuine independence.
|
- TESTER can use a different model entirely (e.g. senior for security reviews, junior for smoke tests), introducing genuine independence.
|
||||||
- The review happens after a clean label transition — QA picks up from `To Test`, not from watching DEV work in real time.
|
- The review happens after a clean label transition — TESTER picks up from `To Test`, not from watching DEVELOPER work in real time.
|
||||||
|
|
||||||
|
For higher-stakes changes, the DEVELOPER can submit a PR for human review (`result: "review"`). The issue enters `In Review` and the heartbeat polls the PR until it's merged — only then does TESTER receive the work. This adds a human checkpoint without breaking the automated flow.
|
||||||
|
|
||||||
This mirrors a principle from organizational design: effective controls require independence between execution and verification. It's the same reason companies separate their audit function from their operations.
|
This mirrors a principle from organizational design: effective controls require independence between execution and verification. It's the same reason companies separate their audit function from their operations.
|
||||||
|
|
||||||
@@ -72,7 +79,7 @@ Ronald Coase won a Nobel Prize for explaining why firms exist: transaction costs
|
|||||||
|
|
||||||
DevClaw applies the same logic to AI sessions. Spawning a new sub-agent session costs approximately 50,000 tokens of context loading — the agent needs to read the full codebase before it can do useful work. That's the onboarding cost.
|
DevClaw applies the same logic to AI sessions. Spawning a new sub-agent session costs approximately 50,000 tokens of context loading — the agent needs to read the full codebase before it can do useful work. That's the onboarding cost.
|
||||||
|
|
||||||
The plugin tracks session keys across task completions. When a DEV finishes task A and task B is ready on the same project, DevClaw detects the existing session and reuses it instead of spawning a new one. No re-onboarding. No context reload.
|
The plugin tracks session keys across task completions. When a DEVELOPER finishes task A and task B is ready on the same project, DevClaw detects the existing session and reuses it instead of spawning a new one. No re-onboarding. No context reload. Each role maintains separate sessions per level, so a "medior developer" session accumulates project context independently from the "senior developer" session.
|
||||||
|
|
||||||
In management terms: keep your team stable. Reassigning the same person to the next task on their project is almost always cheaper than bringing in someone new — even if the new person is theoretically better qualified.
|
In management terms: keep your team stable. Reassigning the same person to the next task on their project is almost always cheaper than bringing in someone new — even if the new person is theoretically better qualified.
|
||||||
|
|
||||||
@@ -85,15 +92,15 @@ The obvious saving is execution time: AI writes code faster than a human. But th
|
|||||||
Without DevClaw, every task requires a human to make a series of small decisions:
|
Without DevClaw, every task requires a human to make a series of small decisions:
|
||||||
|
|
||||||
- Which model should handle this?
|
- Which model should handle this?
|
||||||
- Is the DEV session still alive, or do I need a new one?
|
- Is the DEVELOPER session still alive, or do I need a new one?
|
||||||
- What label should this issue have now?
|
- What label should this issue have now?
|
||||||
- Did I update the state file?
|
- Did I update the state file?
|
||||||
- Did I log this transition?
|
- Did I log this transition?
|
||||||
- Is the QA session free, or is it still working on something?
|
- Is the TESTER session free, or is it still working on something?
|
||||||
|
|
||||||
None of these decisions are hard. But they accumulate. Each one consumes a small amount of the same cognitive resource you need for the decisions that actually matter — product direction, architecture choices, business priorities.
|
None of these decisions are hard. But they accumulate. Each one consumes a small amount of the same cognitive resource you need for the decisions that actually matter — product direction, architecture choices, business priorities.
|
||||||
|
|
||||||
DevClaw eliminates entire categories of decisions by making them deterministic. The plugin picks the model. The plugin manages sessions. The plugin transitions labels. The plugin writes audit logs. The person behind the keyboard is left with only the decisions that require human judgment: what to build, what to prioritize, and what to do when QA says "this needs rethinking."
|
DevClaw eliminates entire categories of decisions by making them deterministic. The plugin picks the model. The plugin manages sessions. The plugin transitions labels. The plugin writes audit logs. The person behind the keyboard is left with only the decisions that require human judgment: what to build, what to prioritize, and what to do when a worker says "this needs rethinking."
|
||||||
|
|
||||||
This is the deepest lesson from delegation theory: **good delegation isn't about getting someone else to do your work. It's about protecting your attention for the work only you can do.**
|
This is the deepest lesson from delegation theory: **good delegation isn't about getting someone else to do your work. It's about protecting your attention for the work only you can do.**
|
||||||
|
|
||||||
@@ -101,11 +108,11 @@ This is the deepest lesson from delegation theory: **good delegation isn't about
|
|||||||
|
|
||||||
Management research points to a few directions that could extend DevClaw's delegation model:
|
Management research points to a few directions that could extend DevClaw's delegation model:
|
||||||
|
|
||||||
**Progressive delegation.** Blanchard's model suggests increasing task complexity for delegates as they prove competent. DevClaw could track QA pass rates per model level and automatically promote — if junior consistently passes QA on borderline tasks, start routing more work to it. This is how good managers develop their people, and it reduces cost over time.
|
**Progressive delegation.** Blanchard's model suggests increasing task complexity for delegates as they prove competent. DevClaw could track TESTER pass rates per model level and automatically promote — if junior consistently passes TESTER on borderline tasks, start routing more work to it. This is how good managers develop their people, and it reduces cost over time.
|
||||||
|
|
||||||
**Delegation authority expansion.** The Vroom-Yetton decision model maps when a leader should decide alone versus consulting the team. Currently, sub-agents have narrow authority — they execute tasks but can't restructure the backlog. Selectively expanding this (e.g., allowing a DEV agent to split a task it judges too large) would reduce orchestrator bottlenecks, mirroring how managers gradually give high-performers more autonomy.
|
**Delegation authority expansion.** The Vroom-Yetton decision model maps when a leader should decide alone versus consulting the team. Currently, sub-agents have narrow authority — they execute tasks but can't restructure the backlog. Selectively expanding this (e.g., allowing a DEVELOPER agent to split a task it judges too large) would reduce orchestrator bottlenecks, mirroring how managers gradually give high-performers more autonomy.
|
||||||
|
|
||||||
**Outcome-based learning.** Delegation research emphasizes that the _delegator_ learns from outcomes too. Aggregated metrics — QA fail rate by model level, average cycles to Done, time-in-state distributions — would help both the orchestrator agent and the human calibrate their delegation patterns over time.
|
**Outcome-based learning.** Delegation research emphasizes that the _delegator_ learns from outcomes too. Aggregated metrics — TESTER fail rate by model level, average cycles to Done, time-in-state distributions — would help both the orchestrator agent and the human calibrate their delegation patterns over time.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -52,13 +52,16 @@ openclaw devclaw setup
|
|||||||
The setup wizard walks you through:
|
The setup wizard walks you through:
|
||||||
|
|
||||||
1. **Agent** — Create a new orchestrator agent or configure an existing one
|
1. **Agent** — Create a new orchestrator agent or configure an existing one
|
||||||
2. **Developer team** — Choose which LLM model powers each developer level:
|
2. **Developer team** — Choose which LLM model powers each level:
|
||||||
- **DEV junior** (fast, cheap tasks) — default: `anthropic/claude-haiku-4-5`
|
- **Developer junior** (fast, cheap tasks) — default: `anthropic/claude-haiku-4-5`
|
||||||
- **DEV medior** (standard tasks) — default: `anthropic/claude-sonnet-4-5`
|
- **Developer medior** (standard tasks) — default: `anthropic/claude-sonnet-4-5`
|
||||||
- **DEV senior** (complex tasks) — default: `anthropic/claude-opus-4-5`
|
- **Developer senior** (complex tasks) — default: `anthropic/claude-opus-4-6`
|
||||||
- **QA reviewer** (code review) — default: `anthropic/claude-sonnet-4-5`
|
- **Tester junior** (quick checks) — default: `anthropic/claude-haiku-4-5`
|
||||||
- **QA tester** (manual testing) — default: `anthropic/claude-haiku-4-5`
|
- **Tester medior** (standard review) — default: `anthropic/claude-sonnet-4-5`
|
||||||
3. **Workspace** — Writes AGENTS.md, HEARTBEAT.md, role templates, and initializes state
|
- **Tester senior** (thorough review) — default: `anthropic/claude-opus-4-6`
|
||||||
|
- **Architect junior** (standard design) — default: `anthropic/claude-sonnet-4-5`
|
||||||
|
- **Architect senior** (complex architecture) — default: `anthropic/claude-opus-4-6`
|
||||||
|
3. **Workspace** — Writes AGENTS.md, HEARTBEAT.md, workflow.yaml, role templates, and initializes state
|
||||||
|
|
||||||
Non-interactive mode:
|
Non-interactive mode:
|
||||||
```bash
|
```bash
|
||||||
@@ -68,7 +71,7 @@ openclaw devclaw setup --new-agent "My Dev Orchestrator"
|
|||||||
# Configure existing agent with custom models
|
# Configure existing agent with custom models
|
||||||
openclaw devclaw setup --agent my-orchestrator \
|
openclaw devclaw setup --agent my-orchestrator \
|
||||||
--junior "anthropic/claude-haiku-4-5" \
|
--junior "anthropic/claude-haiku-4-5" \
|
||||||
--senior "anthropic/claude-opus-4-5"
|
--senior "anthropic/claude-opus-4-6"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option C: Tool call (agent-driven)
|
### Option C: Tool call (agent-driven)
|
||||||
@@ -86,12 +89,12 @@ setup({
|
|||||||
"newAgentName": "My Dev Orchestrator",
|
"newAgentName": "My Dev Orchestrator",
|
||||||
"channelBinding": "telegram",
|
"channelBinding": "telegram",
|
||||||
"models": {
|
"models": {
|
||||||
"dev": {
|
"developer": {
|
||||||
"junior": "anthropic/claude-haiku-4-5",
|
"junior": "anthropic/claude-haiku-4-5",
|
||||||
"senior": "anthropic/claude-opus-4-5"
|
"senior": "anthropic/claude-opus-4-6"
|
||||||
},
|
},
|
||||||
"qa": {
|
"tester": {
|
||||||
"reviewer": "anthropic/claude-sonnet-4-5"
|
"medior": "anthropic/claude-sonnet-4-5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -151,8 +154,8 @@ Go to the Telegram/WhatsApp group for the project and tell the orchestrator agen
|
|||||||
|
|
||||||
The agent calls `project_register`, which atomically:
|
The agent calls `project_register`, which atomically:
|
||||||
- Validates the repo and auto-detects GitHub/GitLab from remote
|
- Validates the repo and auto-detects GitHub/GitLab from remote
|
||||||
- Creates all 8 state labels (idempotent)
|
- Creates all 11 state labels (idempotent)
|
||||||
- Scaffolds role instruction files (`projects/roles/<project>/dev.md` and `qa.md`)
|
- Scaffolds role instruction files (`devclaw/projects/<project>/prompts/developer.md`, `tester.md`, `architect.md`)
|
||||||
- Adds the project entry to `projects.json`
|
- Adds the project entry to `projects.json`
|
||||||
- Logs the registration event
|
- Logs the registration event
|
||||||
|
|
||||||
@@ -168,20 +171,30 @@ The agent calls `project_register`, which atomically:
|
|||||||
"baseBranch": "development",
|
"baseBranch": "development",
|
||||||
"deployBranch": "development",
|
"deployBranch": "development",
|
||||||
"channel": "telegram",
|
"channel": "telegram",
|
||||||
|
"provider": "github",
|
||||||
"roleExecution": "parallel",
|
"roleExecution": "parallel",
|
||||||
"dev": {
|
"workers": {
|
||||||
|
"developer": {
|
||||||
"active": false,
|
"active": false,
|
||||||
"issueId": null,
|
"issueId": null,
|
||||||
"startTime": null,
|
"startTime": null,
|
||||||
"level": null,
|
"level": null,
|
||||||
"sessions": { "junior": null, "medior": null, "senior": null }
|
"sessions": { "junior": null, "medior": null, "senior": null }
|
||||||
},
|
},
|
||||||
"qa": {
|
"tester": {
|
||||||
"active": false,
|
"active": false,
|
||||||
"issueId": null,
|
"issueId": null,
|
||||||
"startTime": null,
|
"startTime": null,
|
||||||
"level": null,
|
"level": null,
|
||||||
"sessions": { "reviewer": null, "tester": null }
|
"sessions": { "junior": null, "medior": null, "senior": null }
|
||||||
|
},
|
||||||
|
"architect": {
|
||||||
|
"active": false,
|
||||||
|
"issueId": null,
|
||||||
|
"startTime": null,
|
||||||
|
"level": null,
|
||||||
|
"sessions": { "junior": null, "senior": null }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,7 +207,7 @@ The agent calls `project_register`, which atomically:
|
|||||||
|
|
||||||
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" (uses `task_create`)
|
- **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 workers** — DEVELOPER/TESTER workers can call `task_create` to file follow-up bugs they discover
|
||||||
- **Via CLI** — `cd ~/git/my-project && gh issue create --title "My first task" --label "To Do"` (or `glab issue create`)
|
- **Via CLI** — `cd ~/git/my-project && gh issue create --title "My first task" --label "To Do"` (or `glab issue create`)
|
||||||
- **Via web UI** — Create an issue and add the "To Do" label
|
- **Via web UI** — Create an issue and add the "To Do" label
|
||||||
|
|
||||||
@@ -208,9 +221,9 @@ Ask the agent in the Telegram group:
|
|||||||
|
|
||||||
The agent should call `status` and report the "To Do" issue. Then:
|
The agent should call `status` and report the "To Do" issue. Then:
|
||||||
|
|
||||||
> "Pick up issue #1 for DEV"
|
> "Pick up issue #1 for developer"
|
||||||
|
|
||||||
The agent calls `work_start`, which assigns a developer level, transitions the label to "Doing", creates or reuses a worker session, and dispatches the task — all in one call. The agent posts the announcement.
|
The agent calls `work_start`, which assigns a level, transitions the label to "Doing", creates or reuses a worker session, and dispatches the task — all in one call. The agent posts the announcement.
|
||||||
|
|
||||||
## Adding more projects
|
## Adding more projects
|
||||||
|
|
||||||
@@ -220,17 +233,20 @@ Each project is fully isolated — separate queue, separate workers, separate st
|
|||||||
|
|
||||||
## Developer levels
|
## Developer levels
|
||||||
|
|
||||||
DevClaw assigns tasks to developer levels instead of raw model names. This makes the system intuitive — you're assigning a "junior dev" to fix a typo, not configuring model parameters.
|
DevClaw assigns tasks to developer levels instead of raw model names. This makes the system intuitive — you're assigning a "junior" to fix a typo, not configuring model parameters. All roles use the same level scheme.
|
||||||
|
|
||||||
| Role | Level | Default model | When to assign |
|
| Role | Level | Default Model | When to assign |
|
||||||
|------|-------|---------------|----------------|
|
|------|-------|---------------|----------------|
|
||||||
| DEV | **junior** | `anthropic/claude-haiku-4-5` | Typos, single-file fixes, CSS changes |
|
| Developer | **junior** | `anthropic/claude-haiku-4-5` | Typos, single-file fixes, CSS changes |
|
||||||
| DEV | **medior** | `anthropic/claude-sonnet-4-5` | Features, bug fixes, multi-file changes |
|
| Developer | **medior** | `anthropic/claude-sonnet-4-5` | Features, bug fixes, multi-file changes |
|
||||||
| DEV | **senior** | `anthropic/claude-opus-4-5` | Architecture, migrations, system-wide refactoring |
|
| Developer | **senior** | `anthropic/claude-opus-4-6` | Architecture, migrations, system-wide refactoring |
|
||||||
| QA | **reviewer** | `anthropic/claude-sonnet-4-5` | Code review, test validation |
|
| Tester | **junior** | `anthropic/claude-haiku-4-5` | Quick smoke tests, basic checks |
|
||||||
| QA | **tester** | `anthropic/claude-haiku-4-5` | Manual testing, smoke tests |
|
| Tester | **medior** | `anthropic/claude-sonnet-4-5` | Standard code review, test validation |
|
||||||
|
| Tester | **senior** | `anthropic/claude-opus-4-6` | Thorough security review, complex edge cases |
|
||||||
|
| Architect | **junior** | `anthropic/claude-sonnet-4-5` | Standard design investigation |
|
||||||
|
| Architect | **senior** | `anthropic/claude-opus-4-6` | Complex architecture decisions |
|
||||||
|
|
||||||
Change which model powers each level in `openclaw.json` — see [Configuration](CONFIGURATION.md#model-tiers).
|
Change which model powers each level in `workflow.yaml` — see [Configuration](CONFIGURATION.md#role-configuration).
|
||||||
|
|
||||||
## What the plugin handles vs. what you handle
|
## What the plugin handles vs. what you handle
|
||||||
|
|
||||||
@@ -239,17 +255,19 @@ Change which model powers each level in `openclaw.json` — see [Configuration](
|
|||||||
| Plugin installation | You (once) | `openclaw plugins install @laurentenhoor/devclaw` |
|
| Plugin installation | You (once) | `openclaw plugins install @laurentenhoor/devclaw` |
|
||||||
| Agent + workspace setup | Plugin (`setup`) | Creates agent, configures models, writes workspace files |
|
| Agent + workspace setup | Plugin (`setup`) | Creates agent, configures models, writes workspace files |
|
||||||
| Channel binding migration | Plugin (`setup` with `migrateFrom`) | Automatically moves channel-wide bindings between agents |
|
| Channel binding migration | Plugin (`setup` with `migrateFrom`) | Automatically moves channel-wide bindings between agents |
|
||||||
| Label setup | Plugin (`project_register`) | 8 labels, created idempotently via IssueProvider |
|
| Label setup | Plugin (`project_register`) | 11 labels, created idempotently via IssueProvider |
|
||||||
| Prompt file scaffolding | Plugin (`project_register`) | Creates `projects/roles/<project>/dev.md` and `qa.md` |
|
| Prompt file scaffolding | Plugin (`project_register`) | Creates `devclaw/projects/<project>/prompts/<role>.md` for each role |
|
||||||
| Project registration | Plugin (`project_register`) | Entry in `projects.json` with empty worker state |
|
| Project registration | Plugin (`project_register`) | Entry in `projects.json` with empty worker state |
|
||||||
| Telegram group setup | You (once per project) | Add bot to group |
|
| Telegram group setup | You (once per project) | Add bot to group |
|
||||||
| Issue creation | Plugin (`task_create`) | Orchestrator or workers create issues from chat |
|
| Issue creation | Plugin (`task_create`) | Orchestrator or workers create issues from chat |
|
||||||
| Label transitions | Plugin | Atomic transitions via issue tracker CLI |
|
| Label transitions | Plugin | Atomic transitions via issue tracker CLI |
|
||||||
| Developer assignment | Plugin | LLM-selected level by orchestrator, keyword heuristic fallback |
|
| Developer assignment | Plugin | LLM-selected level by orchestrator, keyword heuristic fallback |
|
||||||
| State management | Plugin | Atomic read/write to `projects.json` |
|
| State management | Plugin | Atomic read/write to `projects.json` with file locking |
|
||||||
| 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 (`work_finish`) | Workers self-report. Scheduler dispatches next role. |
|
| Task completion | Plugin (`work_finish`) | Workers self-report. Scheduler dispatches next role. |
|
||||||
| Prompt instructions | Plugin (`work_start`) | Loaded from `projects/roles/<project>/<role>.md`, appended to task message |
|
| Role instructions | Plugin (bootstrap hook) | Injected into worker sessions via `agent:bootstrap` hook at session startup |
|
||||||
|
| Review polling | Plugin (heartbeat) | Auto-merges and advances "In Review" issues when PR is approved |
|
||||||
|
| Config validation | Plugin | Zod schemas validate `workflow.yaml` at load time |
|
||||||
| Audit logging | Plugin | Automatic NDJSON append per tool call |
|
| Audit logging | Plugin | Automatic NDJSON append per tool call |
|
||||||
| Zombie detection | Plugin | `health` checks active vs alive |
|
| Zombie detection | Plugin | `health` checks active vs alive |
|
||||||
| Queue scanning | Plugin | `status` queries issue tracker per project |
|
| Queue scanning | Plugin | `status` queries issue tracker per project |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ task_comment({
|
|||||||
projectGroupId: "<group-id>",
|
projectGroupId: "<group-id>",
|
||||||
issueId: <issue-number>,
|
issueId: <issue-number>,
|
||||||
body: "## QA Review\n\n**Tested:**\n- [List what you tested]\n\n**Results:**\n- [Pass/fail details]\n\n**Environment:**\n- [Test environment details]",
|
body: "## QA Review\n\n**Tested:**\n- [List what you tested]\n\n**Results:**\n- [Pass/fail details]\n\n**Environment:**\n- [Test environment details]",
|
||||||
authorRole: "qa"
|
authorRole: "tester"
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -30,21 +30,21 @@ After posting your comment, call `work_finish`:
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
work_finish({
|
work_finish({
|
||||||
role: "qa",
|
role: "tester",
|
||||||
projectGroupId: "<group-id>",
|
projectGroupId: "<group-id>",
|
||||||
result: "pass", // or "fail", "refine", "blocked"
|
result: "pass", // or "fail", "refine", "blocked"
|
||||||
summary: "Brief summary of review outcome"
|
summary: "Brief summary of review outcome"
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## QA Results
|
## TESTER Results
|
||||||
|
|
||||||
| Result | Label transition | Meaning |
|
| Result | Label transition | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `"pass"` | Testing → Done | Approved. Issue closed. |
|
| `"pass"` | Testing → Done | Approved. Issue closed. |
|
||||||
| `"fail"` | Testing → To Improve | Issues found. Issue reopened, sent back to DEV. |
|
| `"fail"` | Testing → To Improve | Issues found. Issue reopened, sent back to DEVELOPER. |
|
||||||
| `"refine"` | Testing → Refining | Needs human decision. Pipeline pauses. |
|
| `"refine"` | Testing → Refining | Needs human decision. Pipeline pauses. |
|
||||||
| `"blocked"` | Testing → To Test | Cannot complete (env issues, etc.). Returns to QA queue. |
|
| `"blocked"` | Testing → Refining | Cannot complete (env issues, etc.). Awaits human decision. |
|
||||||
|
|
||||||
## Why Comments Are Required
|
## Why Comments Are Required
|
||||||
|
|
||||||
@@ -96,14 +96,14 @@ work_finish({
|
|||||||
|
|
||||||
## Enforcement
|
## Enforcement
|
||||||
|
|
||||||
QA workers receive instructions via role templates to:
|
TESTER workers receive instructions via role templates to:
|
||||||
- Always call `task_comment` BEFORE `work_finish`
|
- Always call `task_comment` BEFORE `work_finish`
|
||||||
- Include specific details about what was tested
|
- Include specific details about what was tested
|
||||||
- Document results, environment, and any notes
|
- Document results, environment, and any notes
|
||||||
|
|
||||||
Prompt templates affected:
|
Prompt templates affected:
|
||||||
- `projects/roles/<project>/qa.md`
|
- `devclaw/projects/<project>/prompts/tester.md`
|
||||||
- All project-specific QA templates should follow this pattern
|
- `devclaw/prompts/tester.md` (default)
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
@@ -116,5 +116,5 @@ Prompt templates affected:
|
|||||||
## Related
|
## Related
|
||||||
|
|
||||||
- Tool: [`task_comment`](TOOLS.md#task_comment) — Add comments to issues
|
- Tool: [`task_comment`](TOOLS.md#task_comment) — Add comments to issues
|
||||||
- Tool: [`work_finish`](TOOLS.md#work_finish) — Complete QA tasks
|
- Tool: [`work_finish`](TOOLS.md#work_finish) — Complete TESTER tasks
|
||||||
- Config: [`projects/roles/<project>/qa.md`](CONFIGURATION.md#role-instruction-files) — QA role instructions
|
- Config: [`devclaw/projects/<project>/prompts/tester.md`](CONFIGURATION.md#role-instruction-files) — Tester role instructions
|
||||||
|
|||||||
@@ -1,53 +1,77 @@
|
|||||||
# DevClaw — Roadmap
|
# DevClaw — Roadmap
|
||||||
|
|
||||||
## Configurable Roles
|
## Recently Completed
|
||||||
|
|
||||||
Currently DevClaw has two hardcoded roles: **DEV** and **QA**. Each project gets one worker slot per role. The pipeline is fixed: DEV writes code, QA reviews it.
|
### Dynamic Roles and Role Registry
|
||||||
|
|
||||||
This works for the common case but breaks down when you want:
|
Roles are no longer hardcoded. The `ROLE_REGISTRY` in `lib/roles/registry.ts` defines three built-in roles — **developer**, **tester**, **architect** — each with configurable levels, models, emoji, and completion results. Adding a new role means adding one entry to the registry; everything else (workers, sessions, labels, prompts) derives from it.
|
||||||
- A **design** role that creates mockups before DEV starts
|
|
||||||
- A **devops** role that handles deployment after QA passes
|
|
||||||
- A **PM** role that triages and prioritizes the backlog
|
|
||||||
- Multiple DEV workers in parallel (e.g. frontend + backend)
|
|
||||||
- A project with no QA step at all
|
|
||||||
|
|
||||||
### Planned: role configuration per project
|
All roles use a unified junior/medior/senior level scheme (architect uses junior/senior). Per-role model overrides live in `workflow.yaml`.
|
||||||
|
|
||||||
Roles become a configurable list instead of a hardcoded pair. Each role defines:
|
### Workflow State Machine
|
||||||
- **Name** — e.g. `design`, `dev`, `qa`, `devops`
|
|
||||||
- **Levels** — which developer levels can be assigned (e.g. design only needs `medior`)
|
|
||||||
- **Pipeline position** — where it sits in the task lifecycle
|
|
||||||
- **Worker count** — how many concurrent workers (default: 1)
|
|
||||||
|
|
||||||
```json
|
The issue lifecycle is now a configurable state machine defined in `workflow.yaml`. The default workflow defines 11 states:
|
||||||
{
|
|
||||||
"roles": {
|
```
|
||||||
"dev": { "levels": ["junior", "medior", "senior"], "workers": 1 },
|
Planning → To Do → Doing → To Test → Testing → Done
|
||||||
"qa": { "levels": ["reviewer", "tester"], "workers": 1 },
|
↘ In Review → (PR approved → auto-merge) → To Test
|
||||||
"devops": { "levels": ["medior", "senior"], "workers": 1 }
|
↘ To Improve → Doing (merge conflict / fix cycle)
|
||||||
},
|
↘ Refining → (human decision)
|
||||||
"pipeline": ["dev", "qa", "devops"]
|
research_task → Planning (architect researches, stays in Planning)
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The pipeline definition replaces the hardcoded `Doing → To Test → Testing → Done` flow. Labels and transitions are generated from the pipeline config. The scheduler follows the pipeline order when filling free slots.
|
States have types (`queue`, `active`, `hold`, `review`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `mergePr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`).
|
||||||
|
|
||||||
### Open questions
|
### Three-Layer Configuration
|
||||||
|
|
||||||
- How do custom labels map? Generate from role names, or let users define?
|
Config resolution follows three layers, each partially overriding the one below:
|
||||||
- Should roles have their own instruction files (`projects/roles/<project>/<role>.md`) — yes, this already works
|
|
||||||
- How to handle parallel roles (e.g. frontend + backend DEV in parallel before QA)?
|
1. **Built-in defaults** — `ROLE_REGISTRY` + `DEFAULT_WORKFLOW`
|
||||||
|
2. **Workspace** — `<workspace>/devclaw/workflow.yaml`
|
||||||
|
3. **Project** — `<workspace>/devclaw/projects/<project>/workflow.yaml`
|
||||||
|
|
||||||
|
Validated at load time with Zod schemas (`lib/config/schema.ts`). Integrity checks verify transition targets exist, queue states have roles, and terminal states have no outgoing transitions.
|
||||||
|
|
||||||
|
### Provider Resilience
|
||||||
|
|
||||||
|
All issue tracker calls (GitHub via `gh`, GitLab via `glab`) are wrapped with cockatiel retry (3 attempts, exponential backoff) and circuit breaker (opens after 5 consecutive failures, half-opens after 30s). See `lib/providers/resilience.ts`.
|
||||||
|
|
||||||
|
### Bootstrap Hook for Role Instructions
|
||||||
|
|
||||||
|
Worker sessions receive role-specific instructions via the `agent:bootstrap` hook at session startup, not appended to the task message. The hook reads from `devclaw/projects/<project>/prompts/<role>.md`, falling back to `devclaw/prompts/<role>.md`. Supports source tracking with `loadRoleInstructions(dir, { withSource: true })`.
|
||||||
|
|
||||||
|
### In Review State and PR Polling
|
||||||
|
|
||||||
|
DEVELOPER can submit a PR for human review (`result: "review"`), which transitions the issue to `In Review`. The heartbeat's review pass polls PR status via `getPrStatus()` on the provider. When the PR is approved, DevClaw auto-merges via `mergePr()` and transitions to `To Test` for TESTER pickup. If the merge fails (e.g. conflicts), the issue moves to `To Improve` where a developer is auto-dispatched to resolve conflicts.
|
||||||
|
|
||||||
|
### Architect Role
|
||||||
|
|
||||||
|
The architect role enables design investigations. `research_task` creates a Planning issue with rich context and dispatches an architect worker directly (no queue states). The architect posts findings as comments, then completes with `done` (stays in Planning for human review) or `blocked` (→ Refining).
|
||||||
|
|
||||||
|
### Workspace Layout Migration
|
||||||
|
|
||||||
|
Data directory moved from `<workspace>/projects/` to `<workspace>/devclaw/`. Automatic migration on first load — see `lib/setup/migrate-layout.ts`.
|
||||||
|
|
||||||
|
### E2E Test Infrastructure
|
||||||
|
|
||||||
|
Purpose-built test harness (`lib/testing/`) with:
|
||||||
|
- `TestProvider` — in-memory `IssueProvider` with call tracking
|
||||||
|
- `createTestHarness()` — scaffolds temp workspace, mock `runCommand`, test provider
|
||||||
|
- `simulateBootstrap()` — tests the full bootstrap hook chain without a live gateway
|
||||||
|
- `CommandInterceptor` — captures and filters CLI calls
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Channel-agnostic Groups
|
## Planned
|
||||||
|
|
||||||
|
### Channel-agnostic Groups
|
||||||
|
|
||||||
Currently DevClaw maps projects to **Telegram group IDs**. The `projectGroupId` is a Telegram-specific negative number. This means:
|
Currently DevClaw maps projects to **Telegram group IDs**. The `projectGroupId` is a Telegram-specific negative number. This means:
|
||||||
- WhatsApp groups can't be used as project channels (partially supported now via `channel` field)
|
- WhatsApp groups can't be used as project channels (partially supported now via `channel` field)
|
||||||
- Discord, Slack, or other channels are excluded
|
- Discord, Slack, or other channels are excluded
|
||||||
- The naming (`groupId`, `groupName`) is Telegram-specific
|
- The naming (`groupId`, `groupName`) is Telegram-specific
|
||||||
|
|
||||||
### Planned: abstract channel binding
|
**Planned: abstract channel binding**
|
||||||
|
|
||||||
Replace Telegram-specific group IDs with a generic channel identifier that works across any OpenClaw channel.
|
Replace Telegram-specific group IDs with a generic channel identifier that works across any OpenClaw channel.
|
||||||
|
|
||||||
@@ -57,14 +81,12 @@ Replace Telegram-specific group IDs with a generic channel identifier that works
|
|||||||
"whatsapp:120363140032870788@g.us": {
|
"whatsapp:120363140032870788@g.us": {
|
||||||
"name": "my-project",
|
"name": "my-project",
|
||||||
"channel": "whatsapp",
|
"channel": "whatsapp",
|
||||||
"peer": "120363140032870788@g.us",
|
"peer": "120363140032870788@g.us"
|
||||||
...
|
|
||||||
},
|
},
|
||||||
"telegram:-1234567890": {
|
"telegram:-1234567890": {
|
||||||
"name": "other-project",
|
"name": "other-project",
|
||||||
"channel": "telegram",
|
"channel": "telegram",
|
||||||
"peer": "-1234567890",
|
"peer": "-1234567890"
|
||||||
...
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +101,7 @@ Key changes:
|
|||||||
|
|
||||||
This enables any OpenClaw channel (Telegram, WhatsApp, Discord, Slack, etc.) to host a project.
|
This enables any OpenClaw channel (Telegram, WhatsApp, Discord, Slack, etc.) to host a project.
|
||||||
|
|
||||||
### Open questions
|
#### Open questions
|
||||||
|
|
||||||
- Should one project be bindable to multiple channels? (e.g. Telegram for devs, WhatsApp for stakeholder updates)
|
- Should one project be bindable to multiple channels? (e.g. Telegram for devs, WhatsApp for stakeholder updates)
|
||||||
- How does the orchestrator agent handle cross-channel context?
|
- How does the orchestrator agent handle cross-channel context?
|
||||||
@@ -89,8 +111,9 @@ This enables any OpenClaw channel (Telegram, WhatsApp, Discord, Slack, etc.) to
|
|||||||
## Other Ideas
|
## Other Ideas
|
||||||
|
|
||||||
- **Jira provider** — `IssueProvider` interface already abstracts GitHub/GitLab; Jira is the obvious next addition
|
- **Jira provider** — `IssueProvider` interface already abstracts GitHub/GitLab; Jira is the obvious next addition
|
||||||
- **Deployment integration** — `work_finish` QA pass could trigger a deploy step via webhook or CLI
|
- **Deployment integration** — `work_finish` TESTER pass could trigger a deploy step via webhook or CLI
|
||||||
- **Cost tracking** — log token usage per task/level, surface in `status`
|
- **Cost tracking** — log token usage per task/level, surface in `status`
|
||||||
- **Priority scoring** — automatic priority assignment based on labels, age, and dependencies
|
- **Priority scoring** — automatic priority assignment based on labels, age, and dependencies
|
||||||
- **Session archival** — auto-archive idle sessions after configurable timeout (currently indefinite)
|
- **Session archival** — auto-archive idle sessions after configurable timeout (currently indefinite)
|
||||||
- **Progressive delegation** — track QA pass rates per level and auto-promote (see [Management Theory](MANAGEMENT.md))
|
- **Progressive delegation** — track TESTER pass rates per level and auto-promote (see [Management Theory](MANAGEMENT.md))
|
||||||
|
- **Custom workflow actions** — user-defined actions in `workflow.yaml` (e.g. deploy scripts, notifications)
|
||||||
|
|||||||
459
docs/TESTING.md
459
docs/TESTING.md
@@ -1,216 +1,215 @@
|
|||||||
# DevClaw Testing Guide
|
# DevClaw — Testing Guide
|
||||||
|
|
||||||
Comprehensive automated testing for DevClaw onboarding and setup.
|
DevClaw uses Node.js built-in test runner (`node:test`) with `node:assert/strict` for all tests.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
npm test
|
npx tsx --test lib/**/*.test.ts
|
||||||
|
|
||||||
# Run with coverage report
|
# Run a specific test file
|
||||||
npm run test:coverage
|
npx tsx --test lib/roles/registry.test.ts
|
||||||
|
|
||||||
# Run in watch mode (auto-rerun on changes)
|
# Run E2E tests only
|
||||||
npm run test:watch
|
npx tsx --test lib/services/*.e2e.test.ts
|
||||||
|
|
||||||
# Run with UI (browser-based test explorer)
|
# Build (also type-checks all test files)
|
||||||
npm run test:ui
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test Coverage
|
## Test Files
|
||||||
|
|
||||||
### Scenario 1: New User (No Prior DevClaw Setup)
|
### Unit Tests
|
||||||
**File:** `tests/setup/new-user.test.ts`
|
|
||||||
|
|
||||||
**What's tested:**
|
| File | What it tests |
|
||||||
- First-time agent creation with default models
|
|---|---|
|
||||||
- Channel binding creation (telegram/whatsapp)
|
| [lib/roles/registry.test.ts](../lib/roles/registry.test.ts) | Role registry: role lookup, level resolution, model defaults |
|
||||||
- Workspace file generation (AGENTS.md, HEARTBEAT.md, projects/, log/)
|
| [lib/projects.test.ts](../lib/projects.test.ts) | Project state: read/write, worker state, atomic file operations |
|
||||||
- Plugin configuration initialization
|
| [lib/bootstrap-hook.test.ts](../lib/bootstrap-hook.test.ts) | Bootstrap hook: role instruction loading, source tracking, overloads |
|
||||||
- Error handling: channel not configured
|
| [lib/tools/task-update.test.ts](../lib/tools/task-update.test.ts) | Task update tool: label transitions, validation |
|
||||||
- Error handling: channel disabled
|
| [lib/tools/research-task.test.ts](../lib/tools/research-task.test.ts) | Research task tool: architect dispatch |
|
||||||
|
| [lib/tools/queue-status.test.ts](../lib/tools/queue-status.test.ts) | Queue status formatting |
|
||||||
|
| [lib/setup/migrate-layout.test.ts](../lib/setup/migrate-layout.test.ts) | Workspace layout migration: `projects/` → `devclaw/` |
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
| File | What it tests |
|
||||||
|
|---|---|
|
||||||
|
| [lib/services/pipeline.e2e.test.ts](../lib/services/pipeline.e2e.test.ts) | Full pipeline: completion rules, label transitions, actions |
|
||||||
|
| [lib/services/bootstrap.e2e.test.ts](../lib/services/bootstrap.e2e.test.ts) | Bootstrap hook chain: session key → parse → load instructions → inject |
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
### Test Harness (`lib/testing/`)
|
||||||
|
|
||||||
|
The [`lib/testing/`](../lib/testing/) module provides E2E test infrastructure:
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```typescript
|
```typescript
|
||||||
// Before: openclaw.json has no DevClaw agents
|
import { createTestHarness } from "../testing/index.js";
|
||||||
{
|
|
||||||
"agents": { "list": [{ "id": "main", ... }] },
|
|
||||||
"bindings": [],
|
|
||||||
"plugins": { "entries": {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
// After: New orchestrator created
|
const h = await createTestHarness({
|
||||||
{
|
projectName: "my-project",
|
||||||
"agents": {
|
groupId: "-1234567890",
|
||||||
"list": [
|
workflow: DEFAULT_WORKFLOW,
|
||||||
{ "id": "main", ... },
|
workers: {
|
||||||
{ "id": "my-first-orchestrator", ... }
|
developer: { active: true, issueId: "42", level: "medior" },
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"bindings": [
|
});
|
||||||
{ "agentId": "my-first-orchestrator", "match": { "channel": "telegram" } }
|
try {
|
||||||
],
|
// ... run tests against h.provider, h.commands, etc.
|
||||||
"plugins": {
|
} finally {
|
||||||
"entries": {
|
await h.cleanup();
|
||||||
"devclaw": {
|
|
||||||
"config": {
|
|
||||||
"models": {
|
|
||||||
"dev": {
|
|
||||||
"junior": "anthropic/claude-haiku-4-5",
|
|
||||||
"medior": "anthropic/claude-sonnet-4-5",
|
|
||||||
"senior": "anthropic/claude-opus-4-5"
|
|
||||||
},
|
|
||||||
"qa": {
|
|
||||||
"reviewer": "anthropic/claude-sonnet-4-5",
|
|
||||||
"tester": "anthropic/claude-haiku-4-5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Scenario 2: Existing User (Migration)
|
**`createTestHarness()`** scaffolds:
|
||||||
**File:** `tests/setup/existing-user.test.ts`
|
- Temporary workspace directory with `devclaw/` data dir and `log/` subdirectory
|
||||||
|
- `projects.json` with test project and configurable worker state
|
||||||
|
- Mock `runCommand` via `CommandInterceptor` (captures all CLI calls)
|
||||||
|
- `TestProvider` — in-memory `IssueProvider` with call tracking
|
||||||
|
|
||||||
**What's tested:**
|
### TestProvider
|
||||||
- Channel conflict detection (existing channel-wide binding)
|
|
||||||
- Binding migration from old agent to new agent
|
In-memory implementation of `IssueProvider` for testing. Tracks all provider method calls and maintains in-memory issue state:
|
||||||
- Custom model preservation during migration
|
|
||||||
- Old agent preservation (not deleted)
|
|
||||||
- Error handling: migration source doesn't exist
|
|
||||||
- Error handling: migration source has no binding
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```typescript
|
```typescript
|
||||||
// Before: Old orchestrator has telegram binding
|
const h = await createTestHarness();
|
||||||
{
|
h.provider.seedIssue(42, {
|
||||||
"agents": {
|
title: "Fix the bug",
|
||||||
"list": [
|
labels: ["Doing"],
|
||||||
{ "id": "main", ... },
|
state: "open",
|
||||||
{ "id": "old-orchestrator", ... }
|
});
|
||||||
]
|
|
||||||
},
|
|
||||||
"bindings": [
|
|
||||||
{ "agentId": "old-orchestrator", "match": { "channel": "telegram" } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// After: Binding migrated to new orchestrator
|
// After running pipeline code:
|
||||||
{
|
const calls = h.provider.calls; // All method invocations
|
||||||
"agents": {
|
|
||||||
"list": [
|
|
||||||
{ "id": "main", ... },
|
|
||||||
{ "id": "old-orchestrator", ... },
|
|
||||||
{ "id": "new-orchestrator", ... }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"bindings": [
|
|
||||||
{ "agentId": "new-orchestrator", "match": { "channel": "telegram" } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Scenario 3: Power User (Multiple Agents)
|
### CommandInterceptor
|
||||||
**File:** `tests/setup/power-user.test.ts`
|
|
||||||
|
|
||||||
**What's tested:**
|
Captures all `runCommand` calls during tests. Provides filtering and extraction helpers:
|
||||||
- No conflicts with group-specific bindings
|
|
||||||
- Channel-wide binding creation alongside group bindings
|
|
||||||
- Multiple orchestrators coexisting
|
|
||||||
- Routing logic (specific bindings win over channel-wide)
|
|
||||||
- WhatsApp support
|
|
||||||
- Scale testing (12+ orchestrators)
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```typescript
|
```typescript
|
||||||
// Before: Two project orchestrators with group-specific bindings
|
// All captured commands
|
||||||
{
|
h.commands.commands;
|
||||||
"agents": {
|
|
||||||
"list": [
|
|
||||||
{ "id": "project-a-orchestrator", ... },
|
|
||||||
{ "id": "project-b-orchestrator", ... }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"bindings": [
|
|
||||||
{
|
|
||||||
"agentId": "project-a-orchestrator",
|
|
||||||
"match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1001234567890" } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agentId": "project-b-orchestrator",
|
|
||||||
"match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1009876543210" } }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// After: Channel-wide orchestrator added (no conflicts)
|
// Filter by command name
|
||||||
{
|
h.commands.commandsFor("openclaw");
|
||||||
"agents": {
|
|
||||||
"list": [
|
|
||||||
{ "id": "project-a-orchestrator", ... },
|
|
||||||
{ "id": "project-b-orchestrator", ... },
|
|
||||||
{ "id": "global-orchestrator", ... }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"bindings": [
|
|
||||||
{
|
|
||||||
"agentId": "project-a-orchestrator",
|
|
||||||
"match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1001234567890" } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agentId": "project-b-orchestrator",
|
|
||||||
"match": { "channel": "telegram", "peer": { "kind": "group", "id": "-1009876543210" } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agentId": "global-orchestrator",
|
|
||||||
"match": { "channel": "telegram" } // Channel-wide (no peer)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Routing: Group messages go to specific agents, everything else goes to global
|
// Extract task messages dispatched to workers
|
||||||
|
h.commands.taskMessages();
|
||||||
|
|
||||||
|
// Extract session creation patches
|
||||||
|
h.commands.sessionPatches();
|
||||||
|
|
||||||
|
// Reset between test cases
|
||||||
|
h.commands.reset();
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test Architecture
|
### simulateBootstrap
|
||||||
|
|
||||||
### Mock File System
|
Tests the full bootstrap hook chain without a live OpenClaw gateway:
|
||||||
The tests use an in-memory mock file system (`MockFileSystem`) that simulates:
|
|
||||||
- Reading/writing openclaw.json
|
|
||||||
- Creating/reading workspace files
|
|
||||||
- Tracking command executions (openclaw agents add)
|
|
||||||
|
|
||||||
**Why?** Tests run in isolation without touching the real file system, making them:
|
|
||||||
- Fast (no I/O)
|
|
||||||
- Reliable (no file conflicts)
|
|
||||||
- Repeatable (clean state every test)
|
|
||||||
|
|
||||||
### Fixtures
|
|
||||||
Pre-built configurations for different user types:
|
|
||||||
- `createNewUserConfig()` - Empty slate
|
|
||||||
- `createCommonUserConfig()` - One orchestrator with binding
|
|
||||||
- `createPowerUserConfig()` - Multiple orchestrators with group bindings
|
|
||||||
- `createNoChannelConfig()` - Channel not configured
|
|
||||||
- `createDisabledChannelConfig()` - Channel disabled
|
|
||||||
|
|
||||||
### Assertions
|
|
||||||
Reusable assertion helpers that make tests readable:
|
|
||||||
```typescript
|
```typescript
|
||||||
assertAgentExists(mockFs, "my-agent", "My Agent");
|
// Write a project-specific prompt
|
||||||
assertChannelBinding(mockFs, "my-agent", "telegram");
|
await h.writePrompt("developer", "Custom dev instructions", "my-project");
|
||||||
assertWorkspaceFilesExist(mockFs, "my-agent");
|
|
||||||
assertDevClawConfig(mockFs, { junior: "anthropic/claude-haiku-4-5" });
|
// Simulate bootstrap for a developer session
|
||||||
|
const files = await h.simulateBootstrap(
|
||||||
|
"agent:orchestrator:subagent:my-project-developer-medior"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify injected bootstrap files
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.strictEqual(files[0].content, "Custom dev instructions");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
### Pattern: Unit Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
describe("my feature", () => {
|
||||||
|
it("should do something", () => {
|
||||||
|
const result = myFunction("input");
|
||||||
|
assert.strictEqual(result, "expected");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern: E2E Pipeline Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, afterEach } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createTestHarness, type TestHarness } from "../testing/index.js";
|
||||||
|
import { executeCompletion } from "./pipeline.js";
|
||||||
|
|
||||||
|
describe("pipeline completion", () => {
|
||||||
|
let h: TestHarness;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (h) await h.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("developer:done transitions Doing → To Test", async () => {
|
||||||
|
h = await createTestHarness({
|
||||||
|
workers: {
|
||||||
|
developer: { active: true, issueId: "42", level: "medior" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
h.provider.seedIssue(42, { labels: ["Doing"], state: "open" });
|
||||||
|
|
||||||
|
const result = await executeCompletion({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
workflow: h.workflow,
|
||||||
|
provider: h.provider,
|
||||||
|
role: "developer",
|
||||||
|
result: "done",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.rule.to, "To Test");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern: Bootstrap Hook Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, afterEach } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createTestHarness, type TestHarness } from "../testing/index.js";
|
||||||
|
|
||||||
|
describe("bootstrap instructions", () => {
|
||||||
|
let h: TestHarness;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (h) await h.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects project-specific prompt for developer", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "webapp" });
|
||||||
|
await h.writePrompt("developer", "Build with React", "webapp");
|
||||||
|
|
||||||
|
const files = await h.simulateBootstrap(
|
||||||
|
"agent:orchestrator:subagent:webapp-developer-medior"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.ok(files[0].content?.includes("React"));
|
||||||
|
});
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## CI/CD Integration
|
## CI/CD Integration
|
||||||
|
|
||||||
### GitHub Actions
|
### GitHub Actions
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: Test
|
name: Test
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
@@ -218,122 +217,52 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm test
|
- run: npm run build
|
||||||
- run: npm run test:coverage
|
- run: npx tsx --test lib/**/*.test.ts
|
||||||
- uses: codecov/codecov-action@v3
|
|
||||||
with:
|
|
||||||
files: ./coverage/coverage-final.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### GitLab CI
|
### GitLab CI
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
test:
|
test:
|
||||||
image: node:20
|
image: node:20
|
||||||
script:
|
script:
|
||||||
- npm ci
|
- npm ci
|
||||||
- npm test
|
- npm run build
|
||||||
- npm run test:coverage
|
- npx tsx --test lib/**/*.test.ts
|
||||||
coverage: '/Lines\s*:\s*(\d+\.\d+)%/'
|
|
||||||
artifacts:
|
|
||||||
reports:
|
|
||||||
coverage_report:
|
|
||||||
coverage_format: cobertura
|
|
||||||
path: coverage/cobertura-coverage.xml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Debugging Tests
|
## Debugging Tests
|
||||||
|
|
||||||
### Run specific test
|
### Run specific test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test -- new-user # Run all new-user tests
|
# Run by file
|
||||||
npm test -- "should create agent" # Run tests matching pattern
|
npx tsx --test lib/roles/registry.test.ts
|
||||||
|
|
||||||
|
# Run by name pattern
|
||||||
|
npx tsx --test --test-name-pattern "should have all expected roles" lib/**/*.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debug with Node inspector
|
### Debug with Node inspector
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node --inspect-brk node_modules/.bin/vitest run
|
node --inspect-brk node_modules/.bin/tsx --test lib/roles/registry.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open Chrome DevTools at `chrome://inspect`
|
Then open Chrome DevTools at `chrome://inspect`.
|
||||||
|
|
||||||
### View coverage report
|
|
||||||
```bash
|
|
||||||
npm run test:coverage
|
|
||||||
open coverage/index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding Tests
|
|
||||||
|
|
||||||
### 1. Choose the right test file
|
|
||||||
- New feature → `tests/setup/new-user.test.ts`
|
|
||||||
- Migration feature → `tests/setup/existing-user.test.ts`
|
|
||||||
- Multi-agent feature → `tests/setup/power-user.test.ts`
|
|
||||||
|
|
||||||
### 2. Write the test
|
|
||||||
```typescript
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import { MockFileSystem } from "../helpers/mock-fs.js";
|
|
||||||
import { createNewUserConfig } from "../helpers/fixtures.js";
|
|
||||||
import { assertAgentExists } from "../helpers/assertions.js";
|
|
||||||
|
|
||||||
describe("My new feature", () => {
|
|
||||||
let mockFs: MockFileSystem;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFs = new MockFileSystem(createNewUserConfig());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should do something useful", async () => {
|
|
||||||
// GIVEN: initial state (via fixture)
|
|
||||||
const beforeCount = countAgents(mockFs);
|
|
||||||
|
|
||||||
// WHEN: execute the operation
|
|
||||||
const config = mockFs.getConfig();
|
|
||||||
config.agents.list.push({
|
|
||||||
id: "test-agent",
|
|
||||||
name: "Test Agent",
|
|
||||||
workspace: "/home/test/.openclaw/workspace-test-agent",
|
|
||||||
agentDir: "/home/test/.openclaw/agents/test-agent/agent",
|
|
||||||
});
|
|
||||||
mockFs.setConfig(config);
|
|
||||||
|
|
||||||
// THEN: verify the outcome
|
|
||||||
assertAgentExists(mockFs, "test-agent", "Test Agent");
|
|
||||||
expect(countAgents(mockFs)).toBe(beforeCount + 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Run your test
|
|
||||||
```bash
|
|
||||||
npm test -- "should do something useful"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### ✅ DO
|
- **Use `node:test` + `node:assert/strict`** — no test framework dependencies
|
||||||
- Test one thing per test
|
- **Use `createTestHarness()`** for any test that needs workspace state, providers, or command interception
|
||||||
- Use descriptive test names ("should create agent with telegram binding")
|
- **Always call `h.cleanup()`** in `afterEach` to remove temp directories
|
||||||
- Use fixtures for initial state
|
- **Seed provider state** with `h.provider.seedIssue()` before testing pipeline operations
|
||||||
- Use assertion helpers for readability
|
- **Use `h.commands`** to verify what CLI commands were dispatched without actually running them
|
||||||
- Test error cases
|
- **One assertion focus per test** — test one behavior, not the whole pipeline
|
||||||
|
- **Test error cases** — invalid roles, missing projects, bad state transitions
|
||||||
### ❌ DON'T
|
|
||||||
- Test implementation details (test behavior, not internals)
|
|
||||||
- Share state between tests (use beforeEach)
|
|
||||||
- Mock everything (only mock file system and commands)
|
|
||||||
- Write brittle tests (avoid hard-coded UUIDs, timestamps)
|
|
||||||
|
|
||||||
## Test Metrics
|
|
||||||
|
|
||||||
Current coverage:
|
|
||||||
- **Lines:** Target 80%+
|
|
||||||
- **Functions:** Target 90%+
|
|
||||||
- **Branches:** Target 75%+
|
|
||||||
|
|
||||||
Run `npm run test:coverage` to see detailed metrics.
|
|
||||||
|
|||||||
121
docs/TOOLS.md
121
docs/TOOLS.md
@@ -1,6 +1,6 @@
|
|||||||
# DevClaw — Tools Reference
|
# DevClaw — Tools Reference
|
||||||
|
|
||||||
Complete reference for all 11 tools registered by DevClaw. See [`index.ts`](../index.ts) for registration.
|
Complete reference for all tools registered by DevClaw. See [`index.ts`](../index.ts) for registration.
|
||||||
|
|
||||||
## Worker Lifecycle
|
## Worker Lifecycle
|
||||||
|
|
||||||
@@ -17,9 +17,9 @@ Pick up a task from the issue queue. Handles level assignment, label transition,
|
|||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `issueId` | number | No | Issue ID. If omitted, picks next by priority. |
|
| `issueId` | number | No | Issue ID. If omitted, picks next by priority. |
|
||||||
| `role` | `"dev"` \| `"qa"` | No | Worker role. Auto-detected from issue label if omitted. |
|
| `role` | `"developer"` \| `"tester"` \| `"architect"` | No | Worker role. Auto-detected from issue label if omitted. |
|
||||||
| `projectGroupId` | string | No | Project group ID. Auto-detected from group context. |
|
| `projectGroupId` | string | No | Project group ID. Auto-detected from group context. |
|
||||||
| `level` | string | No | Developer level (`junior`, `medior`, `senior`, `reviewer`). Auto-detected if omitted. |
|
| `level` | string | No | Level (`junior`, `medior`, `senior`). Auto-detected if omitted. |
|
||||||
|
|
||||||
**What it does atomically:**
|
**What it does atomically:**
|
||||||
|
|
||||||
@@ -28,15 +28,14 @@ Pick up a task from the issue queue. Handles level assignment, label transition,
|
|||||||
3. Fetches issue from tracker, verifies correct label state
|
3. Fetches issue from tracker, verifies correct label state
|
||||||
4. Assigns level (LLM-chosen via `level` param → label detection → keyword heuristic fallback)
|
4. Assigns level (LLM-chosen via `level` param → label detection → keyword heuristic fallback)
|
||||||
5. Resolves level to model ID via config or defaults
|
5. Resolves level to model ID via config or defaults
|
||||||
6. Loads prompt instructions from `projects/roles/<project>/<role>.md`
|
6. Looks up existing session for assigned level (session-per-level)
|
||||||
7. Looks up existing session for assigned level (session-per-level)
|
7. Transitions label (e.g. `To Do` → `Doing`)
|
||||||
8. Transitions label (e.g. `To Do` → `Doing`)
|
8. Creates session via Gateway RPC if new (`sessions.patch`)
|
||||||
9. Creates session via Gateway RPC if new (`sessions.patch`)
|
9. Dispatches task to worker session via CLI (`openclaw gateway call agent`)
|
||||||
10. Dispatches task to worker session via CLI (`openclaw gateway call agent`)
|
10. Updates `projects.json` state (active, issueId, level, session key)
|
||||||
11. Updates `projects.json` state (active, issueId, level, session key)
|
11. Writes audit log entries (work_start + model_selection)
|
||||||
12. Writes audit log entries (work_start + model_selection)
|
12. Sends notification
|
||||||
13. Sends notification
|
13. Returns announcement text
|
||||||
14. Returns announcement text
|
|
||||||
|
|
||||||
**Level selection priority:**
|
**Level selection priority:**
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ Pick up a task from the issue queue. Handles level assignment, label transition,
|
|||||||
|
|
||||||
### `work_finish`
|
### `work_finish`
|
||||||
|
|
||||||
Complete a task with a result. Called by workers (DEV/QA sub-agent sessions) directly, or by the orchestrator.
|
Complete a task with a result. Called by workers (DEVELOPER/TESTER/ARCHITECT sub-agent sessions) directly, or by the orchestrator.
|
||||||
|
|
||||||
**Source:** [`lib/tools/work-finish.ts`](../lib/tools/work-finish.ts)
|
**Source:** [`lib/tools/work-finish.ts`](../lib/tools/work-finish.ts)
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ Complete a task with a result. Called by workers (DEV/QA sub-agent sessions) dir
|
|||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `role` | `"dev"` \| `"qa"` | Yes | Worker role |
|
| `role` | `"developer"` \| `"tester"` \| `"architect"` | Yes | Worker role |
|
||||||
| `result` | string | Yes | Completion result (see table below) |
|
| `result` | string | Yes | Completion result (see table below) |
|
||||||
| `projectGroupId` | string | Yes | Project group ID |
|
| `projectGroupId` | string | Yes | Project group ID |
|
||||||
| `summary` | string | No | Brief summary for the announcement |
|
| `summary` | string | No | Brief summary for the announcement |
|
||||||
@@ -73,12 +72,15 @@ Complete a task with a result. Called by workers (DEV/QA sub-agent sessions) dir
|
|||||||
|
|
||||||
| Role | Result | Label transition | Side effects |
|
| Role | Result | Label transition | Side effects |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| DEV | `"done"` | Doing → To Test | git pull, auto-detect PR URL |
|
| developer | `"done"` | Doing → To Test | git pull, auto-detect PR URL |
|
||||||
| DEV | `"blocked"` | Doing → To Do | Task returns to queue |
|
| developer | `"review"` | Doing → In Review | auto-detect PR URL, heartbeat polls for merge |
|
||||||
| QA | `"pass"` | Testing → Done | Issue closed |
|
| developer | `"blocked"` | Doing → Refining | Awaits human decision |
|
||||||
| QA | `"fail"` | Testing → To Improve | Issue reopened |
|
| tester | `"pass"` | Testing → Done | Issue closed |
|
||||||
| QA | `"refine"` | Testing → Refining | Awaits human decision |
|
| tester | `"fail"` | Testing → To Improve | Issue reopened |
|
||||||
| QA | `"blocked"` | Testing → To Test | Task returns to QA queue |
|
| tester | `"refine"` | Testing → Refining | Awaits human decision |
|
||||||
|
| tester | `"blocked"` | Testing → Refining | Awaits human decision |
|
||||||
|
| architect | `"done"` | stays in Planning | Design complete, ready for human review |
|
||||||
|
| architect | `"blocked"` | Planning → Refining | Awaits human decision |
|
||||||
|
|
||||||
**What it does atomically:**
|
**What it does atomically:**
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ Create a new issue in the project's issue tracker.
|
|||||||
| `description` | string | No | Full issue body (markdown) |
|
| `description` | string | No | Full issue body (markdown) |
|
||||||
| `label` | StateLabel | No | State label. Defaults to `"Planning"`. |
|
| `label` | StateLabel | No | State label. Defaults to `"Planning"`. |
|
||||||
| `assignees` | string[] | No | GitHub/GitLab usernames to assign |
|
| `assignees` | string[] | No | GitHub/GitLab usernames to assign |
|
||||||
| `pickup` | boolean | No | If true, immediately pick up for DEV after creation |
|
| `pickup` | boolean | No | If true, immediately pick up for DEVELOPER after creation |
|
||||||
|
|
||||||
**Use cases:**
|
**Use cases:**
|
||||||
|
|
||||||
@@ -138,7 +140,7 @@ Change an issue's state label manually without going through the full pickup/com
|
|||||||
| `state` | StateLabel | Yes | New state label |
|
| `state` | StateLabel | Yes | New state label |
|
||||||
| `reason` | string | No | Audit log reason for the change |
|
| `reason` | string | No | Audit log reason for the change |
|
||||||
|
|
||||||
**Valid states:** `Planning`, `To Do`, `Doing`, `To Test`, `Testing`, `Done`, `To Improve`, `Refining`
|
**Valid states:** `Planning`, `To Do`, `Doing`, `To Test`, `Testing`, `Done`, `To Improve`, `Refining`, `In Review`
|
||||||
|
|
||||||
**Use cases:**
|
**Use cases:**
|
||||||
|
|
||||||
@@ -161,12 +163,12 @@ Add a comment to an issue for feedback, notes, or discussion.
|
|||||||
| `projectGroupId` | string | Yes | Project group ID |
|
| `projectGroupId` | string | Yes | Project group ID |
|
||||||
| `issueId` | number | Yes | Issue ID to comment on |
|
| `issueId` | number | Yes | Issue ID to comment on |
|
||||||
| `body` | string | Yes | Comment body (markdown) |
|
| `body` | string | Yes | Comment body (markdown) |
|
||||||
| `authorRole` | `"dev"` \| `"qa"` \| `"orchestrator"` | No | Attribution role prefix |
|
| `authorRole` | `"developer"` \| `"tester"` \| `"orchestrator"` | No | Attribution role prefix |
|
||||||
|
|
||||||
**Use cases:**
|
**Use cases:**
|
||||||
|
|
||||||
- QA adds review feedback before pass/fail decision
|
- TESTER adds review feedback before pass/fail decision
|
||||||
- DEV posts implementation notes or progress updates
|
- DEVELOPER posts implementation notes or progress updates
|
||||||
- Orchestrator adds summary comments
|
- Orchestrator adds summary comments
|
||||||
|
|
||||||
When `authorRole` is provided, the comment is prefixed with a role emoji and attribution label.
|
When `authorRole` is provided, the comment is prefixed with a role emoji and attribution label.
|
||||||
@@ -191,7 +193,7 @@ Lightweight queue + worker state dashboard.
|
|||||||
|
|
||||||
**Returns per project:**
|
**Returns per project:**
|
||||||
|
|
||||||
- Worker state: active/idle, current issue, level, start time
|
- Worker state per role: active/idle, current issue, level, start time
|
||||||
- Queue counts: To Do, To Test, To Improve
|
- Queue counts: To Do, To Test, To Improve
|
||||||
- Role execution mode
|
- Role execution mode
|
||||||
|
|
||||||
@@ -226,7 +228,7 @@ Worker health scan with optional auto-fix.
|
|||||||
|
|
||||||
### `work_heartbeat`
|
### `work_heartbeat`
|
||||||
|
|
||||||
Manual trigger for heartbeat: health fix + queue dispatch. Same logic as the background heartbeat service, but invoked on demand.
|
Manual trigger for heartbeat: health fix + review polling + queue dispatch. Same logic as the background heartbeat service, but invoked on demand.
|
||||||
|
|
||||||
**Source:** [`lib/tools/work-heartbeat.ts`](../lib/tools/work-heartbeat.ts)
|
**Source:** [`lib/tools/work-heartbeat.ts`](../lib/tools/work-heartbeat.ts)
|
||||||
|
|
||||||
@@ -239,15 +241,16 @@ Manual trigger for heartbeat: health fix + queue dispatch. Same logic as the bac
|
|||||||
| `maxPickups` | number | No | Max worker dispatches per tick. |
|
| `maxPickups` | number | No | Max worker dispatches per tick. |
|
||||||
| `activeSessions` | string[] | No | Active session IDs for zombie detection. |
|
| `activeSessions` | string[] | No | Active session IDs for zombie detection. |
|
||||||
|
|
||||||
**Two-pass sweep:**
|
**Three-pass sweep:**
|
||||||
|
|
||||||
1. **Health pass** — Runs `checkWorkerHealth` per project per role. Auto-fixes zombies, stale workers, orphaned state.
|
1. **Health pass** — Runs `checkWorkerHealth` per project per role. Auto-fixes zombies, stale workers, orphaned state.
|
||||||
2. **Tick pass** — Calls `projectTick` per project. Fills free worker slots by priority (To Improve > To Test > To Do).
|
2. **Review pass** — Polls PR status for issues in "In Review" state. Auto-merges and transitions to "To Test" when PR is approved. If merge fails (conflicts), transitions to "To Improve" for developer to fix.
|
||||||
|
3. **Tick pass** — Calls `projectTick` per project. Fills free worker slots by priority (To Improve > To Test > To Do).
|
||||||
|
|
||||||
**Execution guards:**
|
**Execution guards:**
|
||||||
|
|
||||||
- `projectExecution: "sequential"` — only one project active at a time
|
- `projectExecution: "sequential"` — only one project active at a time
|
||||||
- `roleExecution: "sequential"` — only one role (DEV or QA) active at a time per project (enforced in `projectTick`)
|
- `roleExecution: "sequential"` — only one role active at a time per project
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -272,18 +275,16 @@ One-time project setup. Creates state labels, scaffolds prompt files, adds proje
|
|||||||
| `baseBranch` | string | Yes | Base branch for development |
|
| `baseBranch` | string | Yes | Base branch for development |
|
||||||
| `deployBranch` | string | No | Deploy branch. Defaults to baseBranch. |
|
| `deployBranch` | string | No | Deploy branch. Defaults to baseBranch. |
|
||||||
| `deployUrl` | string | No | Deployment URL |
|
| `deployUrl` | string | No | Deployment URL |
|
||||||
| `roleExecution` | `"parallel"` \| `"sequential"` | No | DEV/QA parallelism. Default: `"parallel"`. |
|
| `roleExecution` | `"parallel"` \| `"sequential"` | No | DEVELOPER/TESTER parallelism. Default: `"parallel"`. |
|
||||||
|
|
||||||
**What it does atomically:**
|
**What it does atomically:**
|
||||||
|
|
||||||
1. Validates project not already registered
|
1. Validates project not already registered
|
||||||
2. Resolves repo path, auto-detects GitHub/GitLab from git remote
|
2. Resolves repo path, auto-detects GitHub/GitLab from git remote
|
||||||
3. Verifies provider health (CLI installed and authenticated)
|
3. Verifies provider health (CLI installed and authenticated)
|
||||||
4. Creates all 8 state labels (idempotent — safe to run again)
|
4. Creates all 11 state labels (idempotent — safe to run again)
|
||||||
5. Adds project entry to `projects.json` with empty worker state
|
5. Adds project entry to `projects.json` with empty worker state for all registered roles
|
||||||
- DEV sessions: `{ junior: null, medior: null, senior: null }`
|
6. Scaffolds prompt files: `devclaw/projects/<project>/prompts/<role>.md` for each role
|
||||||
- QA sessions: `{ reviewer: null, tester: null }`
|
|
||||||
6. Scaffolds prompt files: `projects/roles/<project>/dev.md` and `qa.md`
|
|
||||||
7. Writes audit log
|
7. Writes audit log
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -301,7 +302,7 @@ Agent + workspace initialization.
|
|||||||
| `newAgentName` | string | No | Create a new agent. Omit to configure current workspace. |
|
| `newAgentName` | string | No | Create a new agent. Omit to configure current workspace. |
|
||||||
| `channelBinding` | `"telegram"` \| `"whatsapp"` | No | Channel to bind (with `newAgentName` only) |
|
| `channelBinding` | `"telegram"` \| `"whatsapp"` | No | Channel to bind (with `newAgentName` only) |
|
||||||
| `migrateFrom` | string | No | Agent ID to migrate channel binding from |
|
| `migrateFrom` | string | No | Agent ID to migrate channel binding from |
|
||||||
| `models` | object | No | Model overrides per role and level (see [Configuration](CONFIGURATION.md#model-tiers)) |
|
| `models` | object | No | Model overrides per role and level (see [Configuration](CONFIGURATION.md#role-configuration)) |
|
||||||
| `projectExecution` | `"parallel"` \| `"sequential"` | No | Project execution mode |
|
| `projectExecution` | `"parallel"` \| `"sequential"` | No | Project execution mode |
|
||||||
|
|
||||||
**What it does:**
|
**What it does:**
|
||||||
@@ -309,8 +310,8 @@ Agent + workspace initialization.
|
|||||||
1. Creates a new agent or configures existing workspace
|
1. Creates a new agent or configures existing workspace
|
||||||
2. Optionally binds messaging channel (Telegram/WhatsApp)
|
2. Optionally binds messaging channel (Telegram/WhatsApp)
|
||||||
3. Optionally migrates channel binding from another agent
|
3. Optionally migrates channel binding from another agent
|
||||||
4. Writes workspace files: AGENTS.md, HEARTBEAT.md, `projects/projects.json`
|
4. Writes workspace files: AGENTS.md, HEARTBEAT.md, `devclaw/projects.json`, `devclaw/workflow.yaml`
|
||||||
5. Configures model tiers in `openclaw.json`
|
5. Scaffolds default prompt files for all roles
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -328,34 +329,48 @@ Conversational onboarding guide. Returns step-by-step instructions for the agent
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `mode` | `"first-run"` \| `"reconfigure"` | No | Auto-detected from current state |
|
| `mode` | `"first-run"` \| `"reconfigure"` | No | Auto-detected from current state |
|
||||||
|
|
||||||
**Flow:**
|
---
|
||||||
|
|
||||||
1. Call `onboard` — returns QA-style step-by-step instructions
|
### `research_task`
|
||||||
2. Agent walks user through: agent selection, channel binding, model tiers
|
|
||||||
3. Agent calls `setup` with collected answers
|
Spawn an architect for a design investigation. Creates a Planning issue with rich context and dispatches an architect worker. No queue states — tool-triggered only.
|
||||||
4. User registers projects via `project_register` in group chats
|
|
||||||
|
**Source:** [`lib/tools/research-task.ts`](../lib/tools/research-task.ts)
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `projectGroupId` | string | Yes | Project group ID |
|
||||||
|
| `title` | string | Yes | Design task title |
|
||||||
|
| `description` | string | Yes | Detailed background context for the architect |
|
||||||
|
| `focusAreas` | string[] | No | Specific areas to investigate |
|
||||||
|
| `complexity` | `"simple"` \| `"medium"` \| `"complex"` | No | Guides level selection. Default: `"medium"`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Completion Rules Reference
|
## Completion Rules Reference
|
||||||
|
|
||||||
The pipeline service (`lib/services/pipeline.ts`) defines declarative completion rules:
|
The pipeline service (`lib/services/pipeline.ts`) derives completion rules from the workflow config:
|
||||||
|
|
||||||
```
|
```
|
||||||
dev:done → Doing → To Test (git pull, detect PR)
|
developer:done → Doing → To Test (git pull, detect PR)
|
||||||
dev:blocked → Doing → To Do (return to queue)
|
developer:review → Doing → In Review (detect PR, heartbeat polls for merge)
|
||||||
qa:pass → Testing → Done (close issue)
|
developer:blocked → Doing → Refining (awaits human decision)
|
||||||
qa:fail → Testing → To Improve (reopen issue)
|
tester:pass → Testing → Done (close issue)
|
||||||
qa:refine → Testing → Refining (await human decision)
|
tester:fail → Testing → To Improve (reopen issue)
|
||||||
qa:blocked → Testing → To Test (return to QA queue)
|
tester:refine → Testing → Refining (awaits human decision)
|
||||||
|
tester:blocked → Testing → Refining (awaits human decision)
|
||||||
|
architect:done → stays in Planning (design complete, ready for human review)
|
||||||
|
architect:blocked → Planning → Refining (awaits human decision)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Issue Priority Order
|
## Issue Priority Order
|
||||||
|
|
||||||
When the heartbeat or `work_heartbeat` fills free worker slots, issues are prioritized:
|
When the heartbeat or `work_heartbeat` fills free worker slots, issues are prioritized:
|
||||||
|
|
||||||
1. **To Improve** — QA failures get fixed first (highest priority)
|
1. **To Improve** — Tester failures get fixed first (highest priority)
|
||||||
2. **To Test** — Completed DEV work gets reviewed next
|
2. **To Test** — Completed developer work gets reviewed next
|
||||||
3. **To Do** — Fresh tasks are picked up last
|
3. **To Do** — Fresh tasks are picked up last
|
||||||
|
|
||||||
This ensures the pipeline clears its backlog before starting new work.
|
This ensures the pipeline clears its backlog before starting new work.
|
||||||
|
|||||||
35
index.ts
35
index.ts
@@ -10,7 +10,7 @@ import { createProjectRegisterTool } from "./lib/tools/project-register.js";
|
|||||||
import { createSetupTool } from "./lib/tools/setup.js";
|
import { createSetupTool } from "./lib/tools/setup.js";
|
||||||
import { createOnboardTool } from "./lib/tools/onboard.js";
|
import { createOnboardTool } from "./lib/tools/onboard.js";
|
||||||
import { createAutoConfigureModelsTool } from "./lib/tools/autoconfigure-models.js";
|
import { createAutoConfigureModelsTool } from "./lib/tools/autoconfigure-models.js";
|
||||||
import { createDesignTaskTool } from "./lib/tools/design-task.js";
|
import { createResearchTaskTool } from "./lib/tools/research-task.js";
|
||||||
import { registerCli } from "./lib/cli.js";
|
import { registerCli } from "./lib/cli.js";
|
||||||
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
|
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
|
||||||
import { registerBootstrapHook } from "./lib/bootstrap-hook.js";
|
import { registerBootstrapHook } from "./lib/bootstrap-hook.js";
|
||||||
@@ -24,37 +24,6 @@ const plugin = {
|
|||||||
configSchema: {
|
configSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
models: {
|
|
||||||
type: "object",
|
|
||||||
description: "Model mapping per role and tier",
|
|
||||||
properties: {
|
|
||||||
dev: {
|
|
||||||
type: "object",
|
|
||||||
description: "Developer tier models",
|
|
||||||
properties: {
|
|
||||||
junior: { type: "string" },
|
|
||||||
medior: { type: "string" },
|
|
||||||
senior: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
qa: {
|
|
||||||
type: "object",
|
|
||||||
description: "QA tier models",
|
|
||||||
properties: {
|
|
||||||
reviewer: { type: "string" },
|
|
||||||
tester: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
architect: {
|
|
||||||
type: "object",
|
|
||||||
description: "Architect tier models",
|
|
||||||
properties: {
|
|
||||||
opus: { type: "string" },
|
|
||||||
sonnet: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
projectExecution: {
|
projectExecution: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["parallel", "sequential"],
|
enum: ["parallel", "sequential"],
|
||||||
@@ -109,7 +78,7 @@ const plugin = {
|
|||||||
api.registerTool(createTaskCommentTool(api), { names: ["task_comment"] });
|
api.registerTool(createTaskCommentTool(api), { names: ["task_comment"] });
|
||||||
|
|
||||||
// Architect
|
// Architect
|
||||||
api.registerTool(createDesignTaskTool(api), { names: ["design_task"] });
|
api.registerTool(createResearchTaskTool(api), { names: ["research_task"] });
|
||||||
|
|
||||||
// Operations
|
// Operations
|
||||||
api.registerTool(createStatusTool(api), { names: ["status"] });
|
api.registerTool(createStatusTool(api), { names: ["status"] });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
|
import { DATA_DIR } from "./setup/migrate-layout.js";
|
||||||
|
|
||||||
const MAX_LOG_LINES = 50;
|
const MAX_LOG_LINES = 50;
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ export async function log(
|
|||||||
event: string,
|
event: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const filePath = join(workspaceDir, "log", "audit.log");
|
const filePath = join(workspaceDir, DATA_DIR, "log", "audit.log");
|
||||||
const entry = JSON.stringify({
|
const entry = JSON.stringify({
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -10,24 +10,24 @@ import path from "node:path";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
describe("parseDevClawSessionKey", () => {
|
describe("parseDevClawSessionKey", () => {
|
||||||
it("should parse a standard dev session key", () => {
|
it("should parse a standard developer session key", () => {
|
||||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-medior");
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-developer-medior");
|
||||||
assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" });
|
assert.deepStrictEqual(result, { projectName: "my-project", role: "developer" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse a qa session key", () => {
|
it("should parse a tester session key", () => {
|
||||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-reviewer");
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-tester-medior");
|
||||||
assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" });
|
assert.deepStrictEqual(result, { projectName: "webapp", role: "tester" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle project names with hyphens", () => {
|
it("should handle project names with hyphens", () => {
|
||||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-dev-junior");
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-developer-junior");
|
||||||
assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "dev" });
|
assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "developer" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle project names with multiple hyphens and qa role", () => {
|
it("should handle project names with multiple hyphens and tester role", () => {
|
||||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-tester");
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-tester-junior");
|
||||||
assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" });
|
assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "tester" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return null for non-subagent session keys", () => {
|
it("should return null for non-subagent session keys", () => {
|
||||||
@@ -45,38 +45,38 @@ describe("parseDevClawSessionKey", () => {
|
|||||||
assert.strictEqual(result, null);
|
assert.strictEqual(result, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse senior dev level", () => {
|
it("should parse senior developer level", () => {
|
||||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-dev-senior");
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-developer-senior");
|
||||||
assert.deepStrictEqual(result, { projectName: "devclaw", role: "dev" });
|
assert.deepStrictEqual(result, { projectName: "devclaw", role: "developer" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse simple project name", () => {
|
it("should parse simple project name", () => {
|
||||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:api-dev-junior");
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:api-developer-junior");
|
||||||
assert.deepStrictEqual(result, { projectName: "api", role: "dev" });
|
assert.deepStrictEqual(result, { projectName: "api", role: "developer" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadRoleInstructions", () => {
|
describe("loadRoleInstructions", () => {
|
||||||
it("should load project-specific instructions", async () => {
|
it("should load project-specific instructions from devclaw/projects/<project>/prompts/", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
const projectDir = path.join(tmpDir, "projects", "roles", "test-project");
|
const projectDir = path.join(tmpDir, "devclaw", "projects", "test-project", "prompts");
|
||||||
await fs.mkdir(projectDir, { recursive: true });
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
await fs.writeFile(path.join(projectDir, "dev.md"), "# Dev Instructions\nDo the thing.");
|
await fs.writeFile(path.join(projectDir, "developer.md"), "# Developer Instructions\nDo the thing.");
|
||||||
|
|
||||||
const result = await loadRoleInstructions(tmpDir, "test-project", "dev");
|
const result = await loadRoleInstructions(tmpDir, "test-project", "developer");
|
||||||
assert.strictEqual(result, "# Dev Instructions\nDo the thing.");
|
assert.strictEqual(result, "# Developer Instructions\nDo the thing.");
|
||||||
|
|
||||||
await fs.rm(tmpDir, { recursive: true });
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to default instructions", async () => {
|
it("should fall back to default instructions from devclaw/prompts/", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
|
const promptsDir = path.join(tmpDir, "devclaw", "prompts");
|
||||||
await fs.mkdir(defaultDir, { recursive: true });
|
await fs.mkdir(promptsDir, { recursive: true });
|
||||||
await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA Default\nReview carefully.");
|
await fs.writeFile(path.join(promptsDir, "tester.md"), "# Tester Default\nReview carefully.");
|
||||||
|
|
||||||
const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "qa");
|
const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "tester");
|
||||||
assert.strictEqual(result, "# QA Default\nReview carefully.");
|
assert.strictEqual(result, "# Tester Default\nReview carefully.");
|
||||||
|
|
||||||
await fs.rm(tmpDir, { recursive: true });
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
});
|
});
|
||||||
@@ -84,7 +84,7 @@ describe("loadRoleInstructions", () => {
|
|||||||
it("should return empty string when no instructions exist", async () => {
|
it("should return empty string when no instructions exist", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
|
|
||||||
const result = await loadRoleInstructions(tmpDir, "missing", "dev");
|
const result = await loadRoleInstructions(tmpDir, "missing", "developer");
|
||||||
assert.strictEqual(result, "");
|
assert.strictEqual(result, "");
|
||||||
|
|
||||||
await fs.rm(tmpDir, { recursive: true });
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
@@ -92,16 +92,28 @@ describe("loadRoleInstructions", () => {
|
|||||||
|
|
||||||
it("should prefer project-specific over default", async () => {
|
it("should prefer project-specific over default", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
const projectDir = path.join(tmpDir, "projects", "roles", "my-project");
|
const projectPromptsDir = path.join(tmpDir, "devclaw", "projects", "my-project", "prompts");
|
||||||
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
|
const defaultPromptsDir = path.join(tmpDir, "devclaw", "prompts");
|
||||||
await fs.mkdir(projectDir, { recursive: true });
|
await fs.mkdir(projectPromptsDir, { recursive: true });
|
||||||
await fs.mkdir(defaultDir, { recursive: true });
|
await fs.mkdir(defaultPromptsDir, { recursive: true });
|
||||||
await fs.writeFile(path.join(projectDir, "dev.md"), "Project-specific instructions");
|
await fs.writeFile(path.join(projectPromptsDir, "developer.md"), "Project-specific instructions");
|
||||||
await fs.writeFile(path.join(defaultDir, "dev.md"), "Default instructions");
|
await fs.writeFile(path.join(defaultPromptsDir, "developer.md"), "Default instructions");
|
||||||
|
|
||||||
const result = await loadRoleInstructions(tmpDir, "my-project", "dev");
|
const result = await loadRoleInstructions(tmpDir, "my-project", "developer");
|
||||||
assert.strictEqual(result, "Project-specific instructions");
|
assert.strictEqual(result, "Project-specific instructions");
|
||||||
|
|
||||||
await fs.rm(tmpDir, { recursive: true });
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should fall back to old path for unmigrated workspaces", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
|
const oldDir = path.join(tmpDir, "projects", "roles", "old-project");
|
||||||
|
await fs.mkdir(oldDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(oldDir, "developer.md"), "Old layout instructions");
|
||||||
|
|
||||||
|
const result = await loadRoleInstructions(tmpDir, "old-project", "developer");
|
||||||
|
assert.strictEqual(result, "Old layout instructions");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { getSessionKeyRolePattern } from "./roles/index.js";
|
import { getSessionKeyRolePattern } from "./roles/index.js";
|
||||||
|
import { DATA_DIR } from "./setup/migrate-layout.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a DevClaw subagent session key to extract project name and role.
|
* Parse a DevClaw subagent session key to extract project name and role.
|
||||||
*
|
*
|
||||||
* Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}`
|
* Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}`
|
||||||
* Examples:
|
* Examples:
|
||||||
* - `agent:devclaw:subagent:my-project-dev-medior` → { projectName: "my-project", role: "dev" }
|
* - `agent:devclaw:subagent:my-project-developer-medior` → { projectName: "my-project", role: "developer" }
|
||||||
* - `agent:devclaw:subagent:webapp-qa-reviewer` → { projectName: "webapp", role: "qa" }
|
* - `agent:devclaw:subagent:webapp-tester-medior` → { projectName: "webapp", role: "tester" }
|
||||||
*
|
*
|
||||||
* Note: projectName may contain hyphens, so we match role from the end.
|
* Note: projectName may contain hyphens, so we match role from the end.
|
||||||
*/
|
*/
|
||||||
@@ -32,30 +33,61 @@ export function parseDevClawSessionKey(
|
|||||||
return { projectName: match[1], role: match[2] };
|
return { projectName: match[1], role: match[2] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of loading role instructions — includes the source for traceability.
|
||||||
|
*/
|
||||||
|
export type RoleInstructionsResult = {
|
||||||
|
content: string;
|
||||||
|
/** Which file the instructions were loaded from, or null if none found. */
|
||||||
|
source: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load role-specific instructions from workspace.
|
* Load role-specific instructions from workspace.
|
||||||
* Tries project-specific file first, then falls back to default.
|
* Tries project-specific file first, then falls back to default.
|
||||||
|
* Returns both the content and the source path for logging/traceability.
|
||||||
*
|
*
|
||||||
* This is the same logic previously in dispatch.ts loadRoleInstructions(),
|
* Resolution order:
|
||||||
* now called from the bootstrap hook instead of during dispatch.
|
* 1. devclaw/projects/<project>/prompts/<role>.md (project-specific)
|
||||||
|
* 2. projects/roles/<project>/<role>.md (old project-specific)
|
||||||
|
* 3. devclaw/prompts/<role>.md (workspace default)
|
||||||
|
* 4. projects/roles/default/<role>.md (old default)
|
||||||
*/
|
*/
|
||||||
export async function loadRoleInstructions(
|
export async function loadRoleInstructions(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
role: string,
|
role: string,
|
||||||
): Promise<string> {
|
): Promise<string>;
|
||||||
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
export async function loadRoleInstructions(
|
||||||
|
workspaceDir: string,
|
||||||
|
projectName: string,
|
||||||
|
role: string,
|
||||||
|
opts: { withSource: true },
|
||||||
|
): Promise<RoleInstructionsResult>;
|
||||||
|
export async function loadRoleInstructions(
|
||||||
|
workspaceDir: string,
|
||||||
|
projectName: string,
|
||||||
|
role: string,
|
||||||
|
opts?: { withSource: true },
|
||||||
|
): Promise<string | RoleInstructionsResult> {
|
||||||
|
const dataDir = path.join(workspaceDir, DATA_DIR);
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
path.join(dataDir, "projects", projectName, "prompts", `${role}.md`),
|
||||||
|
path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`),
|
||||||
|
path.join(dataDir, "prompts", `${role}.md`),
|
||||||
|
path.join(workspaceDir, "projects", "roles", "default", `${role}.md`),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const filePath of candidates) {
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(projectFile, "utf-8");
|
const content = await fs.readFile(filePath, "utf-8");
|
||||||
} catch {
|
if (opts?.withSource) return { content, source: filePath };
|
||||||
/* not found — try default */
|
return content;
|
||||||
}
|
} catch { /* not found, try next */ }
|
||||||
const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
|
|
||||||
try {
|
|
||||||
return await fs.readFile(defaultFile, "utf-8");
|
|
||||||
} catch {
|
|
||||||
/* not found */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts?.withSource) return { content: "", source: null };
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +106,14 @@ export async function loadRoleInstructions(
|
|||||||
export function registerBootstrapHook(api: OpenClawPluginApi): void {
|
export function registerBootstrapHook(api: OpenClawPluginApi): void {
|
||||||
api.registerHook("agent:bootstrap", async (event) => {
|
api.registerHook("agent:bootstrap", async (event) => {
|
||||||
const sessionKey = event.sessionKey;
|
const sessionKey = event.sessionKey;
|
||||||
|
api.logger.debug(`Bootstrap hook fired: sessionKey=${sessionKey ?? "undefined"}, event keys=${Object.keys(event).join(",")}`);
|
||||||
if (!sessionKey) return;
|
if (!sessionKey) return;
|
||||||
|
|
||||||
const parsed = parseDevClawSessionKey(sessionKey);
|
const parsed = parseDevClawSessionKey(sessionKey);
|
||||||
if (!parsed) return;
|
if (!parsed) {
|
||||||
|
api.logger.debug(`Bootstrap hook: not a DevClaw session key: ${sessionKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const context = event.context as {
|
const context = event.context as {
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
@@ -90,30 +126,40 @@ export function registerBootstrapHook(api: OpenClawPluginApi): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const workspaceDir = context.workspaceDir;
|
const workspaceDir = context.workspaceDir;
|
||||||
if (!workspaceDir || typeof workspaceDir !== "string") return;
|
if (!workspaceDir || typeof workspaceDir !== "string") {
|
||||||
|
api.logger.warn(`Bootstrap hook: no workspaceDir in context for ${sessionKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const bootstrapFiles = context.bootstrapFiles;
|
const bootstrapFiles = context.bootstrapFiles;
|
||||||
if (!Array.isArray(bootstrapFiles)) return;
|
if (!Array.isArray(bootstrapFiles)) {
|
||||||
|
api.logger.warn(`Bootstrap hook: no bootstrapFiles array in context for ${sessionKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const instructions = await loadRoleInstructions(
|
const { content, source } = await loadRoleInstructions(
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
parsed.projectName,
|
parsed.projectName,
|
||||||
parsed.role,
|
parsed.role,
|
||||||
|
{ withSource: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!instructions) return;
|
if (!content) {
|
||||||
|
api.logger.warn(`Bootstrap hook: no content found for ${parsed.role} in project "${parsed.projectName}" (workspace: ${workspaceDir})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Inject as a virtual bootstrap file. OpenClaw includes these in the
|
// Inject as a virtual bootstrap file. OpenClaw includes these in the
|
||||||
// agent's system prompt automatically (via buildBootstrapContextFiles).
|
// agent's system prompt automatically (via buildBootstrapContextFiles).
|
||||||
bootstrapFiles.push({
|
bootstrapFiles.push({
|
||||||
name: "WORKER_INSTRUCTIONS.md" as any,
|
name: "WORKER_INSTRUCTIONS.md" as any,
|
||||||
path: `<devclaw:${parsed.projectName}:${parsed.role}>`,
|
path: `<devclaw:${parsed.projectName}:${parsed.role}>`,
|
||||||
content: instructions.trim(),
|
content: content.trim(),
|
||||||
missing: false,
|
missing: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
`Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}"`,
|
`Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}" from ${source}`,
|
||||||
);
|
);
|
||||||
});
|
}, { name: "devclaw-worker-instructions", description: "Injects role-specific instructions into DevClaw worker sessions" } as any);
|
||||||
}
|
}
|
||||||
|
|||||||
53
lib/cli.ts
53
lib/cli.ts
@@ -6,7 +6,7 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { runSetup } from "./setup/index.js";
|
import { runSetup } from "./setup/index.js";
|
||||||
import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "./tiers.js";
|
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "./roles/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the `devclaw` CLI command group on a Commander program.
|
* Register the `devclaw` CLI command group on a Commander program.
|
||||||
@@ -16,37 +16,41 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|||||||
.command("devclaw")
|
.command("devclaw")
|
||||||
.description("DevClaw development pipeline tools");
|
.description("DevClaw development pipeline tools");
|
||||||
|
|
||||||
devclaw
|
const setupCmd = devclaw
|
||||||
.command("setup")
|
.command("setup")
|
||||||
.description("Set up DevClaw: create agent, configure models, write workspace files")
|
.description("Set up DevClaw: create agent, configure models, write workspace files")
|
||||||
.option("--new-agent <name>", "Create a new agent with this name")
|
.option("--new-agent <name>", "Create a new agent with this name")
|
||||||
.option("--agent <id>", "Use an existing agent by ID")
|
.option("--agent <id>", "Use an existing agent by ID")
|
||||||
.option("--workspace <path>", "Direct workspace path")
|
.option("--workspace <path>", "Direct workspace path");
|
||||||
.option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`)
|
|
||||||
.option("--medior <model>", `Medior dev model (default: ${DEFAULT_MODELS.dev.medior})`)
|
|
||||||
.option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`)
|
|
||||||
.option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`)
|
|
||||||
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`)
|
|
||||||
.action(async (opts) => {
|
|
||||||
const dev: Record<string, string> = {};
|
|
||||||
const qa: Record<string, string> = {};
|
|
||||||
if (opts.junior) dev.junior = opts.junior;
|
|
||||||
if (opts.medior) dev.medior = opts.medior;
|
|
||||||
if (opts.senior) dev.senior = opts.senior;
|
|
||||||
if (opts.reviewer) qa.reviewer = opts.reviewer;
|
|
||||||
if (opts.tester) qa.tester = opts.tester;
|
|
||||||
|
|
||||||
const hasOverrides = Object.keys(dev).length > 0 || Object.keys(qa).length > 0;
|
// Register dynamic --<role>-<level> options from registry
|
||||||
const models = hasOverrides
|
const defaults = getAllDefaultModels();
|
||||||
? { ...(Object.keys(dev).length > 0 && { dev }), ...(Object.keys(qa).length > 0 && { qa }) }
|
for (const role of getAllRoleIds()) {
|
||||||
: undefined;
|
for (const level of getLevelsForRole(role)) {
|
||||||
|
const flag = `--${role}-${level}`;
|
||||||
|
setupCmd.option(`${flag} <model>`, `${role.toUpperCase()} ${level} model (default: ${defaults[role]?.[level] ?? "auto"})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCmd.action(async (opts) => {
|
||||||
|
// Build model overrides from CLI flags dynamically
|
||||||
|
const models: Record<string, Record<string, string>> = {};
|
||||||
|
for (const role of getAllRoleIds()) {
|
||||||
|
const roleModels: Record<string, string> = {};
|
||||||
|
for (const level of getLevelsForRole(role)) {
|
||||||
|
// camelCase key: "testerJunior" for --tester-junior, "developerMedior" for --developer-medior
|
||||||
|
const key = `${role}${level.charAt(0).toUpperCase()}${level.slice(1)}`;
|
||||||
|
if (opts[key]) roleModels[level] = opts[key];
|
||||||
|
}
|
||||||
|
if (Object.keys(roleModels).length > 0) models[role] = roleModels;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await runSetup({
|
const result = await runSetup({
|
||||||
api,
|
api,
|
||||||
newAgentName: opts.newAgent,
|
newAgentName: opts.newAgent,
|
||||||
agentId: opts.agent,
|
agentId: opts.agent,
|
||||||
workspacePath: opts.workspace,
|
workspacePath: opts.workspace,
|
||||||
models,
|
models: Object.keys(models).length > 0 ? models : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.agentCreated) {
|
if (result.agentCreated) {
|
||||||
@@ -54,8 +58,11 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log("Models configured:");
|
console.log("Models configured:");
|
||||||
for (const t of DEV_LEVELS) console.log(` dev.${t}: ${result.models.dev[t]}`);
|
for (const [role, levels] of Object.entries(result.models)) {
|
||||||
for (const t of QA_LEVELS) console.log(` qa.${t}: ${result.models.qa[t]}`);
|
for (const [level, model] of Object.entries(levels)) {
|
||||||
|
console.log(` ${role}.${level}: ${model}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Files written:");
|
console.log("Files written:");
|
||||||
for (const file of result.filesWritten) {
|
for (const file of result.filesWritten) {
|
||||||
|
|||||||
17
lib/config/index.ts
Normal file
17
lib/config/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* config/ — Unified DevClaw configuration.
|
||||||
|
*
|
||||||
|
* Single workflow.yaml per workspace/project combining roles, models, and workflow.
|
||||||
|
*/
|
||||||
|
export type {
|
||||||
|
DevClawConfig,
|
||||||
|
RoleOverride,
|
||||||
|
ResolvedConfig,
|
||||||
|
ResolvedRoleConfig,
|
||||||
|
ResolvedTimeouts,
|
||||||
|
TimeoutConfig,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export { loadConfig } from "./loader.js";
|
||||||
|
export { mergeConfig } from "./merge.js";
|
||||||
|
export { validateConfig, validateWorkflowIntegrity } from "./schema.js";
|
||||||
202
lib/config/loader.ts
Normal file
202
lib/config/loader.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* config/loader.ts — Three-layer config loading.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. Built-in defaults (ROLE_REGISTRY + DEFAULT_WORKFLOW)
|
||||||
|
* 2. Workspace: <workspace>/devclaw/workflow.yaml
|
||||||
|
* 3. Project: <workspace>/devclaw/projects/<project>/workflow.yaml
|
||||||
|
*
|
||||||
|
* Also supports legacy config.yaml and workflow.json for backward compat.
|
||||||
|
*/
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import YAML from "yaml";
|
||||||
|
import { ROLE_REGISTRY } from "../roles/registry.js";
|
||||||
|
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||||
|
import { mergeConfig } from "./merge.js";
|
||||||
|
import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, ResolvedTimeouts, RoleOverride } from "./types.js";
|
||||||
|
import { validateConfig, validateWorkflowIntegrity } from "./schema.js";
|
||||||
|
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and resolve the full DevClaw config for a project.
|
||||||
|
*
|
||||||
|
* Merges: built-in → workspace workflow.yaml → project workflow.yaml.
|
||||||
|
*/
|
||||||
|
export async function loadConfig(
|
||||||
|
workspaceDir: string,
|
||||||
|
projectName?: string,
|
||||||
|
): Promise<ResolvedConfig> {
|
||||||
|
const dataDir = path.join(workspaceDir, DATA_DIR);
|
||||||
|
const projectsDir = path.join(dataDir, "projects");
|
||||||
|
|
||||||
|
// Layer 1: built-in defaults
|
||||||
|
const builtIn = buildDefaultConfig();
|
||||||
|
|
||||||
|
// Layer 2: workspace workflow.yaml (in devclaw/ data dir)
|
||||||
|
let merged = builtIn;
|
||||||
|
const workspaceConfig =
|
||||||
|
await readWorkflowFile(dataDir) ??
|
||||||
|
await readLegacyConfigFile(path.join(workspaceDir, "projects"));
|
||||||
|
if (workspaceConfig) {
|
||||||
|
merged = mergeConfig(merged, workspaceConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: standalone workflow.json (only if no workflow section found)
|
||||||
|
if (!workspaceConfig?.workflow) {
|
||||||
|
const legacyWorkflow = await readLegacyWorkflowJson(projectsDir);
|
||||||
|
if (legacyWorkflow) {
|
||||||
|
merged = mergeConfig(merged, { workflow: legacyWorkflow });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 3: project workflow.yaml
|
||||||
|
if (projectName) {
|
||||||
|
const projectDir = path.join(projectsDir, projectName);
|
||||||
|
const projectConfig =
|
||||||
|
await readWorkflowFile(projectDir) ??
|
||||||
|
await readLegacyConfigFile(projectDir);
|
||||||
|
if (projectConfig) {
|
||||||
|
merged = mergeConfig(merged, projectConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectConfig?.workflow) {
|
||||||
|
const legacyWorkflow = await readLegacyWorkflowJson(projectDir);
|
||||||
|
if (legacyWorkflow) {
|
||||||
|
merged = mergeConfig(merged, { workflow: legacyWorkflow });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the default config from the built-in ROLE_REGISTRY and DEFAULT_WORKFLOW.
|
||||||
|
*/
|
||||||
|
function buildDefaultConfig(): DevClawConfig {
|
||||||
|
const roles: Record<string, RoleOverride> = {};
|
||||||
|
for (const [id, reg] of Object.entries(ROLE_REGISTRY)) {
|
||||||
|
roles[id] = {
|
||||||
|
levels: [...reg.levels],
|
||||||
|
defaultLevel: reg.defaultLevel,
|
||||||
|
models: { ...reg.models },
|
||||||
|
emoji: { ...reg.emoji },
|
||||||
|
completionResults: [...reg.completionResults],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { roles, workflow: DEFAULT_WORKFLOW };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a merged DevClawConfig into a fully-typed ResolvedConfig.
|
||||||
|
*/
|
||||||
|
function resolve(config: DevClawConfig): ResolvedConfig {
|
||||||
|
const roles: Record<string, ResolvedRoleConfig> = {};
|
||||||
|
|
||||||
|
if (config.roles) {
|
||||||
|
for (const [id, override] of Object.entries(config.roles)) {
|
||||||
|
if (override === false) {
|
||||||
|
// Disabled role — include with enabled: false for visibility
|
||||||
|
const reg = ROLE_REGISTRY[id];
|
||||||
|
roles[id] = {
|
||||||
|
levels: reg ? [...reg.levels] : [],
|
||||||
|
defaultLevel: reg?.defaultLevel ?? "",
|
||||||
|
models: reg ? { ...reg.models } : {},
|
||||||
|
emoji: reg ? { ...reg.emoji } : {},
|
||||||
|
completionResults: reg ? [...reg.completionResults] : [],
|
||||||
|
enabled: false,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reg = ROLE_REGISTRY[id];
|
||||||
|
roles[id] = {
|
||||||
|
levels: override.levels ?? (reg ? [...reg.levels] : []),
|
||||||
|
defaultLevel: override.defaultLevel ?? reg?.defaultLevel ?? "",
|
||||||
|
models: { ...(reg?.models ?? {}), ...(override.models ?? {}) },
|
||||||
|
emoji: { ...(reg?.emoji ?? {}), ...(override.emoji ?? {}) },
|
||||||
|
completionResults: override.completionResults ?? (reg ? [...reg.completionResults] : []),
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all built-in roles exist even if not in config
|
||||||
|
for (const [id, reg] of Object.entries(ROLE_REGISTRY)) {
|
||||||
|
if (!roles[id]) {
|
||||||
|
roles[id] = {
|
||||||
|
levels: [...reg.levels],
|
||||||
|
defaultLevel: reg.defaultLevel,
|
||||||
|
models: { ...reg.models },
|
||||||
|
emoji: { ...reg.emoji },
|
||||||
|
completionResults: [...reg.completionResults],
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow: WorkflowConfig = {
|
||||||
|
initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial,
|
||||||
|
reviewPolicy: config.workflow?.reviewPolicy ?? DEFAULT_WORKFLOW.reviewPolicy,
|
||||||
|
states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate structural integrity (cross-references between states)
|
||||||
|
const integrityErrors = validateWorkflowIntegrity(workflow);
|
||||||
|
if (integrityErrors.length > 0) {
|
||||||
|
throw new Error(`Workflow config integrity errors:\n - ${integrityErrors.join("\n - ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeouts: ResolvedTimeouts = {
|
||||||
|
gitPullMs: config.timeouts?.gitPullMs ?? 30_000,
|
||||||
|
gatewayMs: config.timeouts?.gatewayMs ?? 15_000,
|
||||||
|
sessionPatchMs: config.timeouts?.sessionPatchMs ?? 30_000,
|
||||||
|
dispatchMs: config.timeouts?.dispatchMs ?? 600_000,
|
||||||
|
staleWorkerHours: config.timeouts?.staleWorkerHours ?? 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { roles, workflow, timeouts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File reading helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Read workflow.yaml (new primary config file). Validates structure via Zod. */
|
||||||
|
async function readWorkflowFile(dir: string): Promise<DevClawConfig | null> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8");
|
||||||
|
const parsed = YAML.parse(content);
|
||||||
|
if (parsed) validateConfig(parsed);
|
||||||
|
return parsed as DevClawConfig;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === "ENOENT") return null;
|
||||||
|
// Re-throw validation errors with file context
|
||||||
|
if (err?.name === "ZodError") {
|
||||||
|
throw new Error(`Invalid workflow.yaml in ${dir}: ${err.message}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read config.yaml (old name, fallback for unmigrated workspaces). */
|
||||||
|
async function readLegacyConfigFile(dir: string): Promise<DevClawConfig | null> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(path.join(dir, "config.yaml"), "utf-8");
|
||||||
|
return YAML.parse(content) as DevClawConfig;
|
||||||
|
} catch { /* not found */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read legacy workflow.json (standalone workflow section only). */
|
||||||
|
async function readLegacyWorkflowJson(dir: string): Promise<Partial<WorkflowConfig> | null> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(path.join(dir, "workflow.json"), "utf-8");
|
||||||
|
const parsed = JSON.parse(content) as
|
||||||
|
| Partial<WorkflowConfig>
|
||||||
|
| { workflow?: Partial<WorkflowConfig> };
|
||||||
|
return (parsed as any).workflow ?? parsed;
|
||||||
|
} catch { /* not found */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
88
lib/config/merge.ts
Normal file
88
lib/config/merge.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* config/merge.ts — Deep merge for DevClaw config layers.
|
||||||
|
*
|
||||||
|
* Merge semantics:
|
||||||
|
* - Objects: recursively merge (sparse override)
|
||||||
|
* - Arrays: replace entirely (no merging array elements)
|
||||||
|
* - `false` for a role: marks it as disabled
|
||||||
|
* - Primitives: override
|
||||||
|
*/
|
||||||
|
import type { DevClawConfig, RoleOverride } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a config overlay on top of a base config.
|
||||||
|
* Returns a new config — does not mutate inputs.
|
||||||
|
*/
|
||||||
|
export function mergeConfig(
|
||||||
|
base: DevClawConfig,
|
||||||
|
overlay: DevClawConfig,
|
||||||
|
): DevClawConfig {
|
||||||
|
const merged: DevClawConfig = {};
|
||||||
|
|
||||||
|
// Merge roles
|
||||||
|
if (base.roles || overlay.roles) {
|
||||||
|
merged.roles = { ...base.roles };
|
||||||
|
if (overlay.roles) {
|
||||||
|
for (const [roleId, overrideValue] of Object.entries(overlay.roles)) {
|
||||||
|
if (overrideValue === false) {
|
||||||
|
// Disable role
|
||||||
|
merged.roles[roleId] = false;
|
||||||
|
} else if (merged.roles[roleId] === false) {
|
||||||
|
// Re-enable with override
|
||||||
|
merged.roles[roleId] = overrideValue;
|
||||||
|
} else {
|
||||||
|
// Merge role override on top of base role
|
||||||
|
const baseRole = merged.roles[roleId];
|
||||||
|
merged.roles[roleId] = mergeRoleOverride(
|
||||||
|
typeof baseRole === "object" ? baseRole : {},
|
||||||
|
overrideValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge workflow
|
||||||
|
if (base.workflow || overlay.workflow) {
|
||||||
|
merged.workflow = {
|
||||||
|
initial: overlay.workflow?.initial ?? base.workflow?.initial,
|
||||||
|
reviewPolicy: overlay.workflow?.reviewPolicy ?? base.workflow?.reviewPolicy,
|
||||||
|
states: {
|
||||||
|
...base.workflow?.states,
|
||||||
|
...overlay.workflow?.states,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Clean up undefined initial
|
||||||
|
if (merged.workflow.initial === undefined) {
|
||||||
|
delete merged.workflow.initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge timeouts
|
||||||
|
if (base.timeouts || overlay.timeouts) {
|
||||||
|
merged.timeouts = { ...base.timeouts, ...overlay.timeouts };
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRoleOverride(
|
||||||
|
base: RoleOverride,
|
||||||
|
overlay: RoleOverride,
|
||||||
|
): RoleOverride {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...overlay,
|
||||||
|
// Models: merge (don't replace)
|
||||||
|
models: base.models || overlay.models
|
||||||
|
? { ...base.models, ...overlay.models }
|
||||||
|
: undefined,
|
||||||
|
// Emoji: merge (don't replace)
|
||||||
|
emoji: base.emoji || overlay.emoji
|
||||||
|
? { ...base.emoji, ...overlay.emoji }
|
||||||
|
: undefined,
|
||||||
|
// Arrays replace entirely
|
||||||
|
...(overlay.levels ? { levels: overlay.levels } : {}),
|
||||||
|
...(overlay.completionResults ? { completionResults: overlay.completionResults } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
115
lib/config/schema.ts
Normal file
115
lib/config/schema.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* config/schema.ts — Zod validation for DevClaw workflow config.
|
||||||
|
*
|
||||||
|
* Validates workflow YAML at load time with clear error messages.
|
||||||
|
* Enforces: transition targets exist, queue states have roles,
|
||||||
|
* terminal states have no outgoing transitions.
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
import { StateType } from "../workflow.js";
|
||||||
|
|
||||||
|
const STATE_TYPES = Object.values(StateType) as [string, ...string[]];
|
||||||
|
|
||||||
|
const TransitionTargetSchema = z.union([
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
target: z.string(),
|
||||||
|
actions: z.array(z.string()).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const StateConfigSchema = z.object({
|
||||||
|
type: z.enum(STATE_TYPES),
|
||||||
|
role: z.string().optional(),
|
||||||
|
label: z.string(),
|
||||||
|
color: z.string(),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
check: z.string().optional(),
|
||||||
|
on: z.record(z.string(), TransitionTargetSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const WorkflowConfigSchema = z.object({
|
||||||
|
initial: z.string(),
|
||||||
|
reviewPolicy: z.enum(["human", "agent", "auto"]).optional(),
|
||||||
|
states: z.record(z.string(), StateConfigSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RoleOverrideSchema = z.union([
|
||||||
|
z.literal(false),
|
||||||
|
z.object({
|
||||||
|
levels: z.array(z.string()).optional(),
|
||||||
|
defaultLevel: z.string().optional(),
|
||||||
|
models: z.record(z.string(), z.string()).optional(),
|
||||||
|
emoji: z.record(z.string(), z.string()).optional(),
|
||||||
|
completionResults: z.array(z.string()).optional(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TimeoutConfigSchema = z.object({
|
||||||
|
gitPullMs: z.number().positive().optional(),
|
||||||
|
gatewayMs: z.number().positive().optional(),
|
||||||
|
sessionPatchMs: z.number().positive().optional(),
|
||||||
|
dispatchMs: z.number().positive().optional(),
|
||||||
|
staleWorkerHours: z.number().positive().optional(),
|
||||||
|
}).optional();
|
||||||
|
|
||||||
|
export const DevClawConfigSchema = z.object({
|
||||||
|
roles: z.record(z.string(), RoleOverrideSchema).optional(),
|
||||||
|
workflow: WorkflowConfigSchema.partial().optional(),
|
||||||
|
timeouts: TimeoutConfigSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a raw parsed config object.
|
||||||
|
* Returns the validated config or throws with a descriptive error.
|
||||||
|
*/
|
||||||
|
export function validateConfig(raw: unknown): void {
|
||||||
|
DevClawConfigSchema.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate structural integrity of a fully-resolved workflow config.
|
||||||
|
* Checks cross-references that Zod schema alone can't enforce:
|
||||||
|
* - All transition targets point to existing states
|
||||||
|
* - Queue states have a role assigned
|
||||||
|
* - Terminal states have no outgoing transitions
|
||||||
|
*/
|
||||||
|
export function validateWorkflowIntegrity(
|
||||||
|
workflow: { initial: string; states: Record<string, { type: string; role?: string; on?: Record<string, unknown> }> },
|
||||||
|
): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const stateKeys = new Set(Object.keys(workflow.states));
|
||||||
|
|
||||||
|
if (!stateKeys.has(workflow.initial)) {
|
||||||
|
errors.push(`Initial state "${workflow.initial}" does not exist in states`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, state] of Object.entries(workflow.states)) {
|
||||||
|
if (state.type === StateType.QUEUE && !state.role) {
|
||||||
|
errors.push(`Queue state "${key}" must have a role assigned`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.type === StateType.ACTIVE && !state.role) {
|
||||||
|
errors.push(`Active state "${key}" must have a role assigned`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.type === StateType.TERMINAL && state.on && Object.keys(state.on).length > 0) {
|
||||||
|
errors.push(`Terminal state "${key}" should not have outgoing transitions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.on) {
|
||||||
|
for (const [event, transition] of Object.entries(state.on)) {
|
||||||
|
const target = typeof transition === "string"
|
||||||
|
? transition
|
||||||
|
: (transition as { target: string }).target;
|
||||||
|
if (!stateKeys.has(target)) {
|
||||||
|
errors.push(`State "${key}" transition "${event}" targets non-existent state "${target}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
74
lib/config/types.ts
Normal file
74
lib/config/types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* config/types.ts — Types for the unified DevClaw configuration.
|
||||||
|
*
|
||||||
|
* A single workflow.yaml combines roles, models, and workflow.
|
||||||
|
* Three-layer resolution: built-in → workspace → per-project.
|
||||||
|
*/
|
||||||
|
import type { WorkflowConfig } from "../workflow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role override in workflow.yaml. All fields optional — only override what you need.
|
||||||
|
* Set to `false` to disable a role entirely for a project.
|
||||||
|
*/
|
||||||
|
export type RoleOverride = {
|
||||||
|
levels?: string[];
|
||||||
|
defaultLevel?: string;
|
||||||
|
models?: Record<string, string>;
|
||||||
|
emoji?: Record<string, string>;
|
||||||
|
completionResults?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configurable timeout values (in milliseconds).
|
||||||
|
* All fields optional — defaults applied at resolution time.
|
||||||
|
*/
|
||||||
|
export type TimeoutConfig = {
|
||||||
|
gitPullMs?: number;
|
||||||
|
gatewayMs?: number;
|
||||||
|
sessionPatchMs?: number;
|
||||||
|
dispatchMs?: number;
|
||||||
|
staleWorkerHours?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full workflow.yaml shape.
|
||||||
|
* All fields optional — missing fields inherit from the layer below.
|
||||||
|
*/
|
||||||
|
export type DevClawConfig = {
|
||||||
|
roles?: Record<string, RoleOverride | false>;
|
||||||
|
workflow?: Partial<WorkflowConfig>;
|
||||||
|
timeouts?: TimeoutConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully resolved timeout config — all fields present with defaults.
|
||||||
|
*/
|
||||||
|
export type ResolvedTimeouts = {
|
||||||
|
gitPullMs: number;
|
||||||
|
gatewayMs: number;
|
||||||
|
sessionPatchMs: number;
|
||||||
|
dispatchMs: number;
|
||||||
|
staleWorkerHours: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully resolved config — all fields guaranteed present.
|
||||||
|
* Built by merging three layers over the built-in defaults.
|
||||||
|
*/
|
||||||
|
export type ResolvedConfig = {
|
||||||
|
roles: Record<string, ResolvedRoleConfig>;
|
||||||
|
workflow: WorkflowConfig;
|
||||||
|
timeouts: ResolvedTimeouts;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully resolved role config — all fields present.
|
||||||
|
*/
|
||||||
|
export type ResolvedRoleConfig = {
|
||||||
|
levels: string[];
|
||||||
|
defaultLevel: string;
|
||||||
|
models: Record<string, string>;
|
||||||
|
emoji: Record<string, string>;
|
||||||
|
completionResults: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
121
lib/dispatch.ts
121
lib/dispatch.ts
@@ -13,8 +13,10 @@ import {
|
|||||||
getSessionForLevel,
|
getSessionForLevel,
|
||||||
getWorker,
|
getWorker,
|
||||||
} from "./projects.js";
|
} from "./projects.js";
|
||||||
import { resolveModel, getEmoji, getFallbackEmoji } from "./roles/index.js";
|
import { resolveModel, getFallbackEmoji } from "./roles/index.js";
|
||||||
import { notify, getNotificationConfig } from "./notify.js";
|
import { notify, getNotificationConfig } from "./notify.js";
|
||||||
|
import { loadConfig, type ResolvedRoleConfig } from "./config/index.js";
|
||||||
|
import { ReviewPolicy, resolveReviewRouting } from "./workflow.js";
|
||||||
|
|
||||||
export type DispatchOpts = {
|
export type DispatchOpts = {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
@@ -25,8 +27,8 @@ export type DispatchOpts = {
|
|||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
issueDescription: string;
|
issueDescription: string;
|
||||||
issueUrl: string;
|
issueUrl: string;
|
||||||
role: "dev" | "qa" | "architect";
|
role: string;
|
||||||
/** Developer level (junior, medior, senior, reviewer, opus, sonnet) or raw model ID */
|
/** Developer level (junior, mid, senior) or raw model ID */
|
||||||
level: string;
|
level: string;
|
||||||
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
|
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
|
||||||
fromLabel: string;
|
fromLabel: string;
|
||||||
@@ -63,7 +65,7 @@ export type DispatchResult = {
|
|||||||
*/
|
*/
|
||||||
export function buildTaskMessage(opts: {
|
export function buildTaskMessage(opts: {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
role: "dev" | "qa" | "architect";
|
role: string;
|
||||||
issueId: number;
|
issueId: number;
|
||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
issueDescription: string;
|
issueDescription: string;
|
||||||
@@ -72,16 +74,17 @@ export function buildTaskMessage(opts: {
|
|||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
comments?: Array<{ author: string; body: string; created_at: string }>;
|
comments?: Array<{ author: string; body: string; created_at: string }>;
|
||||||
|
resolvedRole?: ResolvedRoleConfig;
|
||||||
|
/** PR context for reviewer role (URL + diff) */
|
||||||
|
prContext?: { url: string; diff?: string };
|
||||||
}): string {
|
}): string {
|
||||||
const {
|
const {
|
||||||
projectName, role, issueId, issueTitle,
|
projectName, role, issueId, issueTitle,
|
||||||
issueDescription, issueUrl, repo, baseBranch, groupId,
|
issueDescription, issueUrl, repo, baseBranch, groupId,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const availableResults =
|
const results = opts.resolvedRole?.completionResults ?? [];
|
||||||
role === "dev" || role === "architect"
|
const availableResults = results.map((r: string) => `"${r}"`).join(", ");
|
||||||
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
|
|
||||||
: '"pass" (approved), "fail" (issues found), "refine" (needs human input), or "blocked" (cannot complete)';
|
|
||||||
|
|
||||||
const parts = [
|
const parts = [
|
||||||
`${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`,
|
`${role.toUpperCase()} task for project "${projectName}" — Issue #${issueId}`,
|
||||||
@@ -101,6 +104,19 @@ export function buildTaskMessage(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include PR context for reviewer role
|
||||||
|
if (opts.prContext) {
|
||||||
|
parts.push(``, `## Pull Request`, `🔗 ${opts.prContext.url}`);
|
||||||
|
if (opts.prContext.diff) {
|
||||||
|
// Truncate large diffs to avoid bloating context
|
||||||
|
const maxDiffLen = 50_000;
|
||||||
|
const diff = opts.prContext.diff.length > maxDiffLen
|
||||||
|
? opts.prContext.diff.slice(0, maxDiffLen) + "\n... (diff truncated, see PR for full changes)"
|
||||||
|
: opts.prContext.diff;
|
||||||
|
parts.push(``, `### Diff`, "```diff", diff, "```");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
parts.push(
|
parts.push(
|
||||||
``,
|
``,
|
||||||
`Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`,
|
`Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`,
|
||||||
@@ -149,7 +165,10 @@ export async function dispatchTask(
|
|||||||
transitionLabel, provider, pluginConfig, runtime,
|
transitionLabel, provider, pluginConfig, runtime,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const model = resolveModel(role, level, pluginConfig);
|
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||||
|
const resolvedRole = resolvedConfig.roles[role];
|
||||||
|
const { timeouts } = resolvedConfig;
|
||||||
|
const model = resolveModel(role, level, resolvedRole);
|
||||||
const worker = getWorker(project, role);
|
const worker = getWorker(project, role);
|
||||||
const existingSessionKey = getSessionForLevel(worker, level);
|
const existingSessionKey = getSessionForLevel(worker, level);
|
||||||
const sessionAction = existingSessionKey ? "send" : "spawn";
|
const sessionAction = existingSessionKey ? "send" : "spawn";
|
||||||
@@ -160,16 +179,52 @@ export async function dispatchTask(
|
|||||||
// Fetch comments to include in task context
|
// Fetch comments to include in task context
|
||||||
const comments = await provider.listComments(issueId);
|
const comments = await provider.listComments(issueId);
|
||||||
|
|
||||||
|
// Fetch PR context for reviewer role
|
||||||
|
let prContext: { url: string; diff?: string } | undefined;
|
||||||
|
if (role === "reviewer") {
|
||||||
|
try {
|
||||||
|
const prStatus = await provider.getPrStatus(issueId);
|
||||||
|
if (prStatus.url) {
|
||||||
|
const diff = await provider.getPrDiff(issueId) ?? undefined;
|
||||||
|
prContext = { url: prStatus.url, diff };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort — reviewer can still work from issue context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const taskMessage = buildTaskMessage({
|
const taskMessage = buildTaskMessage({
|
||||||
projectName: project.name, role, issueId,
|
projectName: project.name, role, issueId,
|
||||||
issueTitle, issueDescription, issueUrl,
|
issueTitle, issueDescription, issueUrl,
|
||||||
repo: project.repo, baseBranch: project.baseBranch, groupId,
|
repo: project.repo, baseBranch: project.baseBranch, groupId,
|
||||||
comments,
|
comments, resolvedRole, prContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 1: Transition label (this is the commitment point)
|
// Step 1: Transition label (this is the commitment point)
|
||||||
await transitionLabel(issueId, fromLabel, toLabel);
|
await transitionLabel(issueId, fromLabel, toLabel);
|
||||||
|
|
||||||
|
// Step 1b: Apply role:level label (best-effort — failure must not abort dispatch)
|
||||||
|
try {
|
||||||
|
const issue = await provider.getIssue(issueId);
|
||||||
|
const oldRoleLabels = issue.labels.filter((l) => l.startsWith(`${role}:`));
|
||||||
|
if (oldRoleLabels.length > 0) {
|
||||||
|
await provider.removeLabels(issueId, oldRoleLabels);
|
||||||
|
}
|
||||||
|
await provider.addLabel(issueId, `${role}:${level}`);
|
||||||
|
|
||||||
|
// Step 1c: Apply review routing label when developer dispatched (best-effort)
|
||||||
|
if (role === "developer") {
|
||||||
|
const reviewLabel = resolveReviewRouting(
|
||||||
|
resolvedConfig.workflow.reviewPolicy ?? ReviewPolicy.AUTO, level,
|
||||||
|
);
|
||||||
|
const oldRouting = issue.labels.filter((l) => l.startsWith("review:"));
|
||||||
|
if (oldRouting.length > 0) await provider.removeLabels(issueId, oldRouting);
|
||||||
|
await provider.addLabel(issueId, reviewLabel);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort — label failure must not abort dispatch
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2: Send notification early (before session dispatch which can timeout)
|
// Step 2: Send notification early (before session dispatch which can timeout)
|
||||||
// This ensures users see the notification even if gateway is slow
|
// This ensures users see the notification even if gateway is slow
|
||||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||||
@@ -192,16 +247,22 @@ export async function dispatchTask(
|
|||||||
channel: opts.channel ?? "telegram",
|
channel: opts.channel ?? "telegram",
|
||||||
runtime,
|
runtime,
|
||||||
},
|
},
|
||||||
).catch(() => { /* non-fatal */ });
|
).catch((err) => {
|
||||||
|
auditLog(workspaceDir, "dispatch_warning", {
|
||||||
|
step: "notify", issue: issueId, role,
|
||||||
|
error: (err as Error).message ?? String(err),
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
// Step 3: Ensure session exists (fire-and-forget — don't wait for gateway)
|
// Step 3: Ensure session exists (fire-and-forget — don't wait for gateway)
|
||||||
// Session key is deterministic, so we can proceed immediately
|
// Session key is deterministic, so we can proceed immediately
|
||||||
ensureSessionFireAndForget(sessionKey, model);
|
ensureSessionFireAndForget(sessionKey, model, workspaceDir, timeouts.sessionPatchMs);
|
||||||
|
|
||||||
// Step 4: Send task to agent (fire-and-forget)
|
// Step 4: Send task to agent (fire-and-forget)
|
||||||
sendToAgent(sessionKey, taskMessage, {
|
sendToAgent(sessionKey, taskMessage, {
|
||||||
agentId, projectName: project.name, issueId, role,
|
agentId, projectName: project.name, issueId, role, level,
|
||||||
orchestratorSessionKey: opts.sessionKey,
|
orchestratorSessionKey: opts.sessionKey, workspaceDir,
|
||||||
|
dispatchTimeoutMs: timeouts.dispatchMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 5: Update worker state
|
// Step 5: Update worker state
|
||||||
@@ -225,7 +286,7 @@ export async function dispatchTask(
|
|||||||
fromLabel, toLabel,
|
fromLabel, toLabel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl);
|
const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl, resolvedRole);
|
||||||
|
|
||||||
return { sessionAction, sessionKey, level, model, announcement };
|
return { sessionAction, sessionKey, level, model, announcement };
|
||||||
}
|
}
|
||||||
@@ -239,19 +300,24 @@ export async function dispatchTask(
|
|||||||
* Session key is deterministic, so we don't need to wait for confirmation.
|
* Session key is deterministic, so we don't need to wait for confirmation.
|
||||||
* If this fails, health check will catch orphaned state later.
|
* If this fails, health check will catch orphaned state later.
|
||||||
*/
|
*/
|
||||||
function ensureSessionFireAndForget(sessionKey: string, model: string): void {
|
function ensureSessionFireAndForget(sessionKey: string, model: string, workspaceDir: string, timeoutMs = 30_000): void {
|
||||||
runCommand(
|
runCommand(
|
||||||
["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model })],
|
["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model })],
|
||||||
{ timeoutMs: 30_000 },
|
{ timeoutMs },
|
||||||
).catch(() => { /* fire-and-forget */ });
|
).catch((err) => {
|
||||||
|
auditLog(workspaceDir, "dispatch_warning", {
|
||||||
|
step: "ensureSession", sessionKey,
|
||||||
|
error: (err as Error).message ?? String(err),
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendToAgent(
|
function sendToAgent(
|
||||||
sessionKey: string, taskMessage: string,
|
sessionKey: string, taskMessage: string,
|
||||||
opts: { agentId?: string; projectName: string; issueId: number; role: string; orchestratorSessionKey?: string },
|
opts: { agentId?: string; projectName: string; issueId: number; role: string; level?: string; orchestratorSessionKey?: string; workspaceDir: string; dispatchTimeoutMs?: number },
|
||||||
): void {
|
): void {
|
||||||
const gatewayParams = JSON.stringify({
|
const gatewayParams = JSON.stringify({
|
||||||
idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${Date.now()}`,
|
idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${opts.level ?? "unknown"}-${sessionKey}`,
|
||||||
agentId: opts.agentId ?? "devclaw",
|
agentId: opts.agentId ?? "devclaw",
|
||||||
sessionKey,
|
sessionKey,
|
||||||
message: taskMessage,
|
message: taskMessage,
|
||||||
@@ -262,12 +328,18 @@ function sendToAgent(
|
|||||||
// Fire-and-forget: long-running agent turn, don't await
|
// Fire-and-forget: long-running agent turn, don't await
|
||||||
runCommand(
|
runCommand(
|
||||||
["openclaw", "gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
|
["openclaw", "gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
|
||||||
{ timeoutMs: 600_000 },
|
{ timeoutMs: opts.dispatchTimeoutMs ?? 600_000 },
|
||||||
).catch(() => { /* fire-and-forget */ });
|
).catch((err) => {
|
||||||
|
auditLog(opts.workspaceDir, "dispatch_warning", {
|
||||||
|
step: "sendToAgent", sessionKey,
|
||||||
|
issue: opts.issueId, role: opts.role,
|
||||||
|
error: (err as Error).message ?? String(err),
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordWorkerState(
|
async function recordWorkerState(
|
||||||
workspaceDir: string, groupId: string, role: "dev" | "qa" | "architect",
|
workspaceDir: string, groupId: string, role: string,
|
||||||
opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" },
|
opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await activateWorker(workspaceDir, groupId, role, {
|
await activateWorker(workspaceDir, groupId, role, {
|
||||||
@@ -301,8 +373,9 @@ async function auditDispatch(
|
|||||||
function buildAnnouncement(
|
function buildAnnouncement(
|
||||||
level: string, role: string, sessionAction: "spawn" | "send",
|
level: string, role: string, sessionAction: "spawn" | "send",
|
||||||
issueId: number, issueTitle: string, issueUrl: string,
|
issueId: number, issueTitle: string, issueUrl: string,
|
||||||
|
resolvedRole?: ResolvedRoleConfig,
|
||||||
): string {
|
): string {
|
||||||
const emoji = getEmoji(role, level) ?? getFallbackEmoji(role);
|
const emoji = resolvedRole?.emoji[level] ?? getFallbackEmoji(role);
|
||||||
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
|
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
|
||||||
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
|
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
|
||||||
}
|
}
|
||||||
|
|||||||
141
lib/migrations.ts
Normal file
141
lib/migrations.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* migrations.ts — Backward-compatibility aliases and migration logic.
|
||||||
|
*
|
||||||
|
* Contains all role/level renaming aliases and projects.json format migration.
|
||||||
|
* This file can be removed once all users have migrated to the new format.
|
||||||
|
*
|
||||||
|
* Migrations handled:
|
||||||
|
* - Role renames: dev → developer, qa → tester
|
||||||
|
* - Level renames: mid → medior, reviewer → medior, tester → junior, opus → senior, sonnet → junior
|
||||||
|
* - projects.json format: old hardcoded dev/qa/architect fields → workers map
|
||||||
|
* - projects.json format: old role keys in workers map → canonical role keys
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WorkerState, Project } from "./projects.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Role aliases — old role IDs → canonical IDs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Maps old role IDs to canonical IDs. */
|
||||||
|
export const ROLE_ALIASES: Record<string, string> = {
|
||||||
|
dev: "developer",
|
||||||
|
qa: "tester",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a role ID, applying aliases for backward compatibility. */
|
||||||
|
export function canonicalRole(role: string): string {
|
||||||
|
return ROLE_ALIASES[role] ?? role;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Level aliases — old level names → canonical names, per role
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Maps old level names to canonical names, per role. */
|
||||||
|
export const LEVEL_ALIASES: Record<string, Record<string, string>> = {
|
||||||
|
developer: { mid: "medior", medior: "medior" },
|
||||||
|
dev: { mid: "medior", medior: "medior" },
|
||||||
|
tester: { mid: "medior", reviewer: "medior", tester: "junior" },
|
||||||
|
qa: { mid: "medior", reviewer: "medior", tester: "junior" },
|
||||||
|
architect: { opus: "senior", sonnet: "junior" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a level name, applying aliases for backward compatibility. */
|
||||||
|
export function canonicalLevel(role: string, level: string): string {
|
||||||
|
return LEVEL_ALIASES[role]?.[level] ?? level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// projects.json migration helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function migrateLevel(level: string | null, role: string): string | null {
|
||||||
|
if (!level) return null;
|
||||||
|
return LEVEL_ALIASES[role]?.[level] ?? level;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateSessions(
|
||||||
|
sessions: Record<string, string | null>,
|
||||||
|
role: string,
|
||||||
|
): Record<string, string | null> {
|
||||||
|
const aliases = LEVEL_ALIASES[role];
|
||||||
|
if (!aliases) return sessions;
|
||||||
|
|
||||||
|
const migrated: Record<string, string | null> = {};
|
||||||
|
for (const [key, value] of Object.entries(sessions)) {
|
||||||
|
const newKey = aliases[key] ?? key;
|
||||||
|
migrated[newKey] = value;
|
||||||
|
}
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkerState(worker: Record<string, unknown>, role: string): WorkerState {
|
||||||
|
const level = (worker.level ?? worker.tier ?? null) as string | null;
|
||||||
|
const sessions = (worker.sessions as Record<string, string | null>) ?? {};
|
||||||
|
return {
|
||||||
|
active: worker.active as boolean,
|
||||||
|
issueId: worker.issueId as string | null,
|
||||||
|
startTime: worker.startTime as string | null,
|
||||||
|
level: migrateLevel(level, role),
|
||||||
|
sessions: migrateSessions(sessions, role),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty worker state with null sessions for given levels. */
|
||||||
|
function emptyWorkerState(levels: string[]): WorkerState {
|
||||||
|
const sessions: Record<string, string | null> = {};
|
||||||
|
for (const l of levels) {
|
||||||
|
sessions[l] = null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
issueId: null,
|
||||||
|
startTime: null,
|
||||||
|
level: null,
|
||||||
|
sessions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a raw project object from old format to current format.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* 1. Old format: hardcoded dev/qa/architect fields → workers map
|
||||||
|
* 2. Old role keys in workers map (dev → developer, qa → tester)
|
||||||
|
* 3. Old level names in worker state
|
||||||
|
* 4. Missing channel field defaults to "telegram"
|
||||||
|
*/
|
||||||
|
export function migrateProject(project: Project): void {
|
||||||
|
const raw = project as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!raw.workers && (raw.dev || raw.qa || raw.architect)) {
|
||||||
|
// Old format: hardcoded dev/qa/architect fields → workers map
|
||||||
|
project.workers = {};
|
||||||
|
for (const role of ["dev", "qa", "architect"]) {
|
||||||
|
const canonical = ROLE_ALIASES[role] ?? role;
|
||||||
|
project.workers[canonical] = raw[role]
|
||||||
|
? parseWorkerState(raw[role] as Record<string, unknown>, role)
|
||||||
|
: emptyWorkerState([]);
|
||||||
|
}
|
||||||
|
// Clean up old fields from the in-memory object
|
||||||
|
delete raw.dev;
|
||||||
|
delete raw.qa;
|
||||||
|
delete raw.architect;
|
||||||
|
} else if (raw.workers) {
|
||||||
|
// New format: parse each worker with role-aware migration
|
||||||
|
const workers = raw.workers as Record<string, Record<string, unknown>>;
|
||||||
|
project.workers = {};
|
||||||
|
for (const [role, worker] of Object.entries(workers)) {
|
||||||
|
// Migrate old role keys (dev→developer, qa→tester)
|
||||||
|
const canonical = ROLE_ALIASES[role] ?? role;
|
||||||
|
project.workers[canonical] = parseWorkerState(worker, role);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
project.workers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project.channel) {
|
||||||
|
project.channel = "telegram";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Model selection for dev/qa tasks.
|
* Model selection heuristic fallback — used when the orchestrator doesn't specify a level.
|
||||||
* Keyword heuristic fallback — used when the orchestrator doesn't specify a level.
|
* Returns plain level names (junior, medior, senior).
|
||||||
* Returns plain level names (junior, medior, senior, reviewer, tester).
|
*
|
||||||
|
* Adapts to any role's level count:
|
||||||
|
* - 1 level: always returns that level
|
||||||
|
* - 2 levels: simple binary (complex → last, else first)
|
||||||
|
* - 3+ levels: full heuristic (simple → first, complex → last, default → middle)
|
||||||
*/
|
*/
|
||||||
|
import { getLevelsForRole, getDefaultLevel } from "./roles/index.js";
|
||||||
|
|
||||||
export type LevelSelection = {
|
export type LevelSelection = {
|
||||||
level: string;
|
level: string;
|
||||||
@@ -39,62 +44,59 @@ const COMPLEX_KEYWORDS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select appropriate developer level based on task description.
|
* Select appropriate level based on task description and role.
|
||||||
*
|
*
|
||||||
* Developer levels:
|
* Adapts to the role's available levels:
|
||||||
* - junior: very simple (typos, single-file fixes, CSS tweaks)
|
* - Roles with 1 level → always that level
|
||||||
* - medior: standard DEV (features, bug fixes, multi-file changes)
|
* - Roles with 2 levels → binary: complex keywords → highest, else lowest
|
||||||
* - senior: deep/architectural (system-wide refactoring, novel design)
|
* - Roles with 3+ levels → full heuristic: simple → lowest, complex → highest, else default
|
||||||
* - reviewer: QA code inspection and validation
|
|
||||||
* - tester: QA manual testing
|
|
||||||
*/
|
*/
|
||||||
export function selectLevel(
|
export function selectLevel(
|
||||||
issueTitle: string,
|
issueTitle: string,
|
||||||
issueDescription: string,
|
issueDescription: string,
|
||||||
role: "dev" | "qa" | "architect",
|
role: string,
|
||||||
): LevelSelection {
|
): LevelSelection {
|
||||||
if (role === "qa") {
|
const levels = getLevelsForRole(role);
|
||||||
return {
|
const defaultLvl = getDefaultLevel(role);
|
||||||
level: "reviewer",
|
|
||||||
reason: "Default QA level for code inspection and validation",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === "architect") {
|
// Roles with only 1 level — always return it
|
||||||
const text = `${issueTitle} ${issueDescription}`.toLowerCase();
|
if (levels.length <= 1) {
|
||||||
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
|
const level = levels[0] ?? defaultLvl ?? "medior";
|
||||||
return {
|
return { level, reason: `Only level for ${role}` };
|
||||||
level: isComplex ? "opus" : "sonnet",
|
|
||||||
reason: isComplex
|
|
||||||
? "Complex design task — using opus for depth"
|
|
||||||
: "Standard design task — using sonnet",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `${issueTitle} ${issueDescription}`.toLowerCase();
|
const text = `${issueTitle} ${issueDescription}`.toLowerCase();
|
||||||
const wordCount = text.split(/\s+/).length;
|
const wordCount = text.split(/\s+/).length;
|
||||||
|
|
||||||
// Check for simple task indicators
|
|
||||||
const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw));
|
const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw));
|
||||||
|
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
|
||||||
|
|
||||||
|
const lowest = levels[0];
|
||||||
|
const highest = levels[levels.length - 1];
|
||||||
|
|
||||||
|
// Roles with 2 levels — binary decision
|
||||||
|
if (levels.length === 2) {
|
||||||
|
if (isComplex) {
|
||||||
|
return { level: highest, reason: `Complex task — using ${highest}` };
|
||||||
|
}
|
||||||
|
return { level: lowest, reason: `Standard task — using ${lowest}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles with 3+ levels — full heuristic
|
||||||
if (isSimple && wordCount < 100) {
|
if (isSimple && wordCount < 100) {
|
||||||
return {
|
return {
|
||||||
level: "junior",
|
level: lowest,
|
||||||
reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`,
|
reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for complex task indicators
|
|
||||||
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
|
|
||||||
if (isComplex || wordCount > 500) {
|
if (isComplex || wordCount > 500) {
|
||||||
return {
|
return {
|
||||||
level: "senior",
|
level: highest,
|
||||||
reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`,
|
reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: medior for standard dev work
|
// Default level for the role
|
||||||
return {
|
const level = defaultLvl ?? levels[Math.floor(levels.length / 2)];
|
||||||
level: "medior",
|
return { level, reason: `Standard ${role} task` };
|
||||||
reason: "Standard dev task — multi-file changes, features, bug fixes",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* Event types:
|
* Event types:
|
||||||
* - workerStart: Worker spawned/resumed for a task (→ project group)
|
* - workerStart: Worker spawned/resumed for a task (→ project group)
|
||||||
* - workerComplete: Worker completed task (→ project group)
|
* - workerComplete: Worker completed task (→ project group)
|
||||||
|
* - reviewNeeded: Issue needs review — human or agent (→ project group)
|
||||||
*/
|
*/
|
||||||
import { log as auditLog } from "./audit.js";
|
import { log as auditLog } from "./audit.js";
|
||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
@@ -21,7 +22,7 @@ export type NotifyEvent =
|
|||||||
issueId: number;
|
issueId: number;
|
||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
issueUrl: string;
|
issueUrl: string;
|
||||||
role: "dev" | "qa" | "architect";
|
role: string;
|
||||||
level: string;
|
level: string;
|
||||||
sessionAction: "spawn" | "send";
|
sessionAction: "spawn" | "send";
|
||||||
}
|
}
|
||||||
@@ -31,10 +32,20 @@ export type NotifyEvent =
|
|||||||
groupId: string;
|
groupId: string;
|
||||||
issueId: number;
|
issueId: number;
|
||||||
issueUrl: string;
|
issueUrl: string;
|
||||||
role: "dev" | "qa" | "architect";
|
role: string;
|
||||||
result: "done" | "pass" | "fail" | "refine" | "blocked";
|
result: "done" | "pass" | "fail" | "refine" | "blocked";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
nextState?: string;
|
nextState?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "reviewNeeded";
|
||||||
|
project: string;
|
||||||
|
groupId: string;
|
||||||
|
issueId: number;
|
||||||
|
issueUrl: string;
|
||||||
|
issueTitle: string;
|
||||||
|
routing: "human" | "agent";
|
||||||
|
prUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +85,15 @@ function buildMessage(event: NotifyEvent): string {
|
|||||||
msg += `\n🔗 ${event.issueUrl}`;
|
msg += `\n🔗 ${event.issueUrl}`;
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "reviewNeeded": {
|
||||||
|
const icon = event.routing === "human" ? "👀" : "🤖";
|
||||||
|
const who = event.routing === "human" ? "Human review needed" : "Agent review queued";
|
||||||
|
let msg = `${icon} ${who} for #${event.issueId}: ${event.issueTitle}`;
|
||||||
|
if (event.prUrl) msg += `\n🔗 PR: ${event.prUrl}`;
|
||||||
|
msg += `\n📋 Issue: ${event.issueUrl}`;
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { DEFAULT_MODELS } from "./tiers.js";
|
import { getAllDefaultModels } from "./roles/index.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Detection
|
// Detection
|
||||||
@@ -14,8 +14,8 @@ import { DEFAULT_MODELS } from "./tiers.js";
|
|||||||
export function isPluginConfigured(
|
export function isPluginConfigured(
|
||||||
pluginConfig?: Record<string, unknown>,
|
pluginConfig?: Record<string, unknown>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const models = (pluginConfig as { models?: Record<string, string> })?.models;
|
// Models moved to workflow.yaml — check for any devclaw plugin config (heartbeat, notifications, etc.)
|
||||||
return !!models && Object.keys(models).length > 0;
|
return !!pluginConfig && Object.keys(pluginConfig).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hasWorkspaceFiles(
|
export async function hasWorkspaceFiles(
|
||||||
@@ -37,34 +37,26 @@ export async function hasWorkspaceFiles(
|
|||||||
// Context templates
|
// Context templates
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function buildModelTable(pluginConfig?: Record<string, unknown>): string {
|
function buildModelTable(): string {
|
||||||
const cfg = (
|
|
||||||
pluginConfig as {
|
|
||||||
models?: { dev?: Record<string, string>; qa?: Record<string, string> };
|
|
||||||
}
|
|
||||||
)?.models;
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
|
for (const [role, levels] of Object.entries(getAllDefaultModels())) {
|
||||||
for (const [level, defaultModel] of Object.entries(levels)) {
|
for (const [level, model] of Object.entries(levels)) {
|
||||||
const model = cfg?.[role as "dev" | "qa"]?.[level] || defaultModel;
|
lines.push(` - **${role} ${level}**: ${model}`);
|
||||||
lines.push(
|
|
||||||
` - **${role} ${level}**: ${model} (default: ${defaultModel})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReconfigContext(
|
export function buildReconfigContext(): string {
|
||||||
pluginConfig?: Record<string, unknown>,
|
const modelTable = buildModelTable();
|
||||||
): string {
|
|
||||||
const modelTable = buildModelTable(pluginConfig);
|
|
||||||
return `# DevClaw Reconfiguration
|
return `# DevClaw Reconfiguration
|
||||||
|
|
||||||
The user wants to reconfigure DevClaw. Current model configuration:
|
The user wants to reconfigure DevClaw. Default model configuration:
|
||||||
|
|
||||||
${modelTable}
|
${modelTable}
|
||||||
|
|
||||||
|
Models are configured in \`devclaw/workflow.yaml\`. Edit that file directly or call \`setup\` with a \`models\` object to update.
|
||||||
|
|
||||||
## What can be changed
|
## What can be changed
|
||||||
1. **Model levels** — call \`setup\` with a \`models\` object containing only the levels to change
|
1. **Model levels** — call \`setup\` with a \`models\` object containing only the levels to change
|
||||||
2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files)
|
2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files)
|
||||||
@@ -76,16 +68,14 @@ Ask what they want to change, then call the appropriate tool.
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildOnboardToolContext(): string {
|
export function buildOnboardToolContext(): string {
|
||||||
// Build the model table dynamically from DEFAULT_MODELS
|
// Build the model table dynamically from getAllDefaultModels()
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
const purposes: Record<string, string> = {
|
const purposes: Record<string, string> = {
|
||||||
junior: "Typos, single-file fixes",
|
junior: "Simple tasks, single-file fixes",
|
||||||
medior: "Features, bug fixes",
|
medior: "Features, bug fixes, code review",
|
||||||
senior: "Architecture, refactoring",
|
senior: "Architecture, refactoring, complex tasks",
|
||||||
reviewer: "Code review",
|
|
||||||
tester: "Testing",
|
|
||||||
};
|
};
|
||||||
for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
|
for (const [role, levels] of Object.entries(getAllDefaultModels())) {
|
||||||
for (const [level, model] of Object.entries(levels)) {
|
for (const [level, model] of Object.entries(levels)) {
|
||||||
rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`);
|
rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`);
|
||||||
}
|
}
|
||||||
@@ -97,8 +87,8 @@ export function buildOnboardToolContext(): string {
|
|||||||
## What is DevClaw?
|
## What is DevClaw?
|
||||||
DevClaw turns each Telegram group into an autonomous development team:
|
DevClaw turns each Telegram group into an autonomous development team:
|
||||||
- An **orchestrator** that manages backlogs and delegates work
|
- An **orchestrator** that manages backlogs and delegates work
|
||||||
- **DEV workers** (junior/medior/senior levels) that write code in isolated sessions
|
- **Developer workers** (junior/medior/senior levels) that write code in isolated sessions
|
||||||
- **QA workers** that review code and run tests
|
- **Tester workers** that review code and run tests
|
||||||
- Atomic tools for label transitions, session dispatch, state management, and audit logging
|
- Atomic tools for label transitions, session dispatch, state management, and audit logging
|
||||||
|
|
||||||
## Setup Steps
|
## Setup Steps
|
||||||
@@ -143,7 +133,7 @@ Ask: "Do you want to configure DevClaw for the current agent, or create a new de
|
|||||||
|
|
||||||
**Step 3: Run Setup**
|
**Step 3: Run Setup**
|
||||||
Call \`setup\` with the collected answers:
|
Call \`setup\` with the collected answers:
|
||||||
- Current agent: \`setup({})\` or \`setup({ models: { dev: { ... }, qa: { ... } } })\`
|
- Current agent: \`setup({})\` or \`setup({ models: { developer: { ... }, tester: { ... } } })\`
|
||||||
- New agent: \`setup({ newAgentName: "<name>", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: "<agentId>"|null, models: { ... } })\`
|
- New agent: \`setup({ newAgentName: "<name>", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: "<agentId>"|null, models: { ... } })\`
|
||||||
- \`migrateFrom\`: Include if user wants to migrate an existing channel-wide binding
|
- \`migrateFrom\`: Include if user wants to migrate an existing channel-wide binding
|
||||||
|
|
||||||
|
|||||||
248
lib/projects.test.ts
Normal file
248
lib/projects.test.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Tests for projects.ts — worker state, migration, and accessors.
|
||||||
|
* Run with: npx tsx --test lib/projects.test.ts
|
||||||
|
*/
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { readProjects, getWorker, emptyWorkerState, writeProjects, type ProjectsData } from "./projects.js";
|
||||||
|
|
||||||
|
describe("readProjects migration", () => {
|
||||||
|
it("should migrate old format (dev/qa/architect fields) to workers map", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||||
|
const projDir = path.join(tmpDir, "projects");
|
||||||
|
await fs.mkdir(projDir, { recursive: true });
|
||||||
|
|
||||||
|
// Old format: hardcoded dev/qa/architect fields
|
||||||
|
const oldFormat = {
|
||||||
|
projects: {
|
||||||
|
"group-1": {
|
||||||
|
name: "test-project",
|
||||||
|
repo: "~/git/test",
|
||||||
|
groupName: "Test",
|
||||||
|
deployUrl: "",
|
||||||
|
baseBranch: "main",
|
||||||
|
deployBranch: "main",
|
||||||
|
dev: { active: true, issueId: "42", startTime: null, level: "mid", sessions: { mid: "key-1" } },
|
||||||
|
qa: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
|
||||||
|
architect: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(oldFormat), "utf-8");
|
||||||
|
|
||||||
|
const data = await readProjects(tmpDir);
|
||||||
|
const project = data.projects["group-1"];
|
||||||
|
|
||||||
|
// Should have workers map with migrated role keys
|
||||||
|
assert.ok(project.workers, "should have workers map");
|
||||||
|
assert.ok(project.workers.developer, "should have developer worker (migrated from dev)");
|
||||||
|
assert.ok(project.workers.tester, "should have tester worker (migrated from qa)");
|
||||||
|
assert.ok(project.workers.architect, "should have architect worker");
|
||||||
|
|
||||||
|
// Developer worker should be active with migrated level
|
||||||
|
assert.strictEqual(project.workers.developer.active, true);
|
||||||
|
assert.strictEqual(project.workers.developer.issueId, "42");
|
||||||
|
assert.strictEqual(project.workers.developer.level, "medior");
|
||||||
|
|
||||||
|
// Old fields should not exist on the object
|
||||||
|
assert.strictEqual((project as any).dev, undefined);
|
||||||
|
assert.strictEqual((project as any).qa, undefined);
|
||||||
|
assert.strictEqual((project as any).architect, undefined);
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should migrate old level names in old format", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||||
|
const projDir = path.join(tmpDir, "projects");
|
||||||
|
await fs.mkdir(projDir, { recursive: true });
|
||||||
|
|
||||||
|
const oldFormat = {
|
||||||
|
projects: {
|
||||||
|
"group-1": {
|
||||||
|
name: "legacy",
|
||||||
|
repo: "~/git/legacy",
|
||||||
|
groupName: "Legacy",
|
||||||
|
deployUrl: "",
|
||||||
|
baseBranch: "main",
|
||||||
|
deployBranch: "main",
|
||||||
|
dev: { active: false, issueId: null, startTime: null, level: "medior", sessions: { medior: "key-1" } },
|
||||||
|
qa: { active: false, issueId: null, startTime: null, level: "reviewer", sessions: { reviewer: "key-2" } },
|
||||||
|
architect: { active: false, issueId: null, startTime: null, level: "opus", sessions: { opus: "key-3" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(oldFormat), "utf-8");
|
||||||
|
|
||||||
|
const data = await readProjects(tmpDir);
|
||||||
|
const project = data.projects["group-1"];
|
||||||
|
|
||||||
|
// Level names should be migrated (dev→developer, qa→tester, medior→medior, reviewer→medior)
|
||||||
|
assert.strictEqual(project.workers.developer.level, "medior");
|
||||||
|
assert.strictEqual(project.workers.tester.level, "medior");
|
||||||
|
assert.strictEqual(project.workers.architect.level, "senior");
|
||||||
|
|
||||||
|
// Session keys should be migrated
|
||||||
|
assert.strictEqual(project.workers.developer.sessions.medior, "key-1");
|
||||||
|
assert.strictEqual(project.workers.tester.sessions.medior, "key-2");
|
||||||
|
assert.strictEqual(project.workers.architect.sessions.senior, "key-3");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should read new format (workers map) correctly", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||||
|
const dataDir = path.join(tmpDir, "devclaw");
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
const newFormat = {
|
||||||
|
projects: {
|
||||||
|
"group-1": {
|
||||||
|
name: "modern",
|
||||||
|
repo: "~/git/modern",
|
||||||
|
groupName: "Modern",
|
||||||
|
deployUrl: "",
|
||||||
|
baseBranch: "main",
|
||||||
|
deployBranch: "main",
|
||||||
|
workers: {
|
||||||
|
developer: { active: true, issueId: "10", startTime: null, level: "senior", sessions: { senior: "key-s" } },
|
||||||
|
tester: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(newFormat), "utf-8");
|
||||||
|
|
||||||
|
const data = await readProjects(tmpDir);
|
||||||
|
const project = data.projects["group-1"];
|
||||||
|
|
||||||
|
assert.ok(project.workers.developer);
|
||||||
|
assert.strictEqual(project.workers.developer.active, true);
|
||||||
|
assert.strictEqual(project.workers.developer.level, "senior");
|
||||||
|
assert.ok(project.workers.tester);
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should migrate old worker keys in new format", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||||
|
const dataDir = path.join(tmpDir, "devclaw");
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
// Workers map but with old role keys
|
||||||
|
const mixedFormat = {
|
||||||
|
projects: {
|
||||||
|
"group-1": {
|
||||||
|
name: "mixed",
|
||||||
|
repo: "~/git/mixed",
|
||||||
|
groupName: "Mixed",
|
||||||
|
deployUrl: "",
|
||||||
|
baseBranch: "main",
|
||||||
|
deployBranch: "main",
|
||||||
|
workers: {
|
||||||
|
dev: { active: true, issueId: "10", startTime: null, level: "mid", sessions: { mid: "key-m" } },
|
||||||
|
qa: { active: false, issueId: null, startTime: null, level: null, sessions: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(mixedFormat), "utf-8");
|
||||||
|
|
||||||
|
const data = await readProjects(tmpDir);
|
||||||
|
const project = data.projects["group-1"];
|
||||||
|
|
||||||
|
// Old keys should be migrated
|
||||||
|
assert.ok(project.workers.developer, "dev should be migrated to developer");
|
||||||
|
assert.ok(project.workers.tester, "qa should be migrated to tester");
|
||||||
|
assert.strictEqual(project.workers.developer.level, "medior");
|
||||||
|
assert.strictEqual(project.workers.developer.sessions.medior, "key-m");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getWorker", () => {
|
||||||
|
it("should return worker from workers map", () => {
|
||||||
|
const data: ProjectsData = {
|
||||||
|
projects: {
|
||||||
|
"g1": {
|
||||||
|
name: "test",
|
||||||
|
repo: "~/git/test",
|
||||||
|
groupName: "Test",
|
||||||
|
deployUrl: "",
|
||||||
|
baseBranch: "main",
|
||||||
|
deployBranch: "main",
|
||||||
|
workers: {
|
||||||
|
developer: { active: true, issueId: "5", startTime: null, level: "medior", sessions: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const worker = getWorker(data.projects["g1"], "developer");
|
||||||
|
assert.strictEqual(worker.active, true);
|
||||||
|
assert.strictEqual(worker.issueId, "5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty worker for unknown role", () => {
|
||||||
|
const data: ProjectsData = {
|
||||||
|
projects: {
|
||||||
|
"g1": {
|
||||||
|
name: "test",
|
||||||
|
repo: "~/git/test",
|
||||||
|
groupName: "Test",
|
||||||
|
deployUrl: "",
|
||||||
|
baseBranch: "main",
|
||||||
|
deployBranch: "main",
|
||||||
|
workers: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const worker = getWorker(data.projects["g1"], "nonexistent");
|
||||||
|
assert.strictEqual(worker.active, false);
|
||||||
|
assert.strictEqual(worker.issueId, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeProjects round-trip", () => {
|
||||||
|
it("should preserve workers map through write/read cycle", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-"));
|
||||||
|
const dataDir = path.join(tmpDir, "devclaw");
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
const data: ProjectsData = {
|
||||||
|
projects: {
|
||||||
|
"g1": {
|
||||||
|
name: "roundtrip",
|
||||||
|
repo: "~/git/rt",
|
||||||
|
groupName: "RT",
|
||||||
|
deployUrl: "",
|
||||||
|
baseBranch: "main",
|
||||||
|
deployBranch: "main",
|
||||||
|
workers: {
|
||||||
|
developer: emptyWorkerState(["junior", "medior", "senior"]),
|
||||||
|
tester: emptyWorkerState(["junior", "medior", "senior"]),
|
||||||
|
architect: emptyWorkerState(["junior", "senior"]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeProjects(tmpDir, data);
|
||||||
|
const loaded = await readProjects(tmpDir);
|
||||||
|
const project = loaded.projects["g1"];
|
||||||
|
|
||||||
|
assert.ok(project.workers.developer);
|
||||||
|
assert.ok(project.workers.tester);
|
||||||
|
assert.ok(project.workers.architect);
|
||||||
|
assert.strictEqual(project.workers.developer.sessions.junior, null);
|
||||||
|
assert.strictEqual(project.workers.developer.sessions.medior, null);
|
||||||
|
assert.strictEqual(project.workers.developer.sessions.senior, null);
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,8 +6,11 @@ import {
|
|||||||
type Issue,
|
type Issue,
|
||||||
type StateLabel,
|
type StateLabel,
|
||||||
type IssueComment,
|
type IssueComment,
|
||||||
|
type PrStatus,
|
||||||
|
PrState,
|
||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
|
import { withResilience } from "./resilience.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
getStateLabels,
|
getStateLabels,
|
||||||
@@ -41,8 +44,38 @@ export class GitHubProvider implements IssueProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async gh(args: string[]): Promise<string> {
|
private async gh(args: string[]): Promise<string> {
|
||||||
|
return withResilience(async () => {
|
||||||
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||||
return result.stdout.trim();
|
return result.stdout.trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find PRs associated with an issue.
|
||||||
|
* Primary: match by head branch pattern (fix/123-, feature/123-, etc.)
|
||||||
|
* Fallback: word-boundary match on #123 in title/body.
|
||||||
|
*/
|
||||||
|
private async findPrsForIssue<T extends { title: string; body: string; headRefName?: string }>(
|
||||||
|
issueId: number,
|
||||||
|
state: "open" | "merged" | "all",
|
||||||
|
fields: string,
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const args = ["pr", "list", "--json", fields, "--limit", "50"];
|
||||||
|
if (state !== "all") args.push("--state", state);
|
||||||
|
const raw = await this.gh(args);
|
||||||
|
if (!raw) return [];
|
||||||
|
const prs = JSON.parse(raw) as T[];
|
||||||
|
const branchPat = new RegExp(`^(?:fix|feature|chore|bugfix|hotfix)/${issueId}-`);
|
||||||
|
const titlePat = new RegExp(`\\b#${issueId}\\b`);
|
||||||
|
|
||||||
|
// Primary: match by branch name
|
||||||
|
const byBranch = prs.filter((pr) => pr.headRefName && branchPat.test(pr.headRefName));
|
||||||
|
if (byBranch.length > 0) return byBranch;
|
||||||
|
|
||||||
|
// Fallback: word-boundary match in title/body
|
||||||
|
return prs.filter((pr) => titlePat.test(pr.title) || titlePat.test(pr.body ?? ""));
|
||||||
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureLabel(name: string, color: string): Promise<void> {
|
async ensureLabel(name: string, color: string): Promise<void> {
|
||||||
@@ -97,6 +130,17 @@ export class GitHubProvider implements IssueProvider {
|
|||||||
await this.gh(args);
|
await this.gh(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addLabel(issueId: number, label: string): Promise<void> {
|
||||||
|
await this.gh(["issue", "edit", String(issueId), "--add-label", label]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLabels(issueId: number, labels: string[]): Promise<void> {
|
||||||
|
if (labels.length === 0) return;
|
||||||
|
const args = ["issue", "edit", String(issueId)];
|
||||||
|
for (const l of labels) args.push("--remove-label", l);
|
||||||
|
await this.gh(args);
|
||||||
|
}
|
||||||
|
|
||||||
async closeIssue(issueId: number): Promise<void> { await this.gh(["issue", "close", String(issueId)]); }
|
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)]); }
|
async reopenIssue(issueId: number): Promise<void> { await this.gh(["issue", "reopen", String(issueId)]); }
|
||||||
|
|
||||||
@@ -108,20 +152,47 @@ export class GitHubProvider implements IssueProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async hasMergedMR(issueId: number): Promise<boolean> {
|
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||||
try {
|
const prs = await this.findPrsForIssue(issueId, "merged", "title,body,headRefName");
|
||||||
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body"]);
|
return prs.length > 0;
|
||||||
const prs = JSON.parse(raw) as Array<{ title: string; body: string }>;
|
|
||||||
const pat = `#${issueId}`;
|
|
||||||
return prs.some((pr) => pr.title.includes(pat) || (pr.body ?? "").includes(pat));
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||||
|
type MergedPr = { title: string; body: string; headRefName: string; url: string; mergedAt: string };
|
||||||
|
const prs = await this.findPrsForIssue<MergedPr>(issueId, "merged", "title,body,headRefName,url,mergedAt");
|
||||||
|
if (prs.length === 0) return null;
|
||||||
|
prs.sort((a, b) => new Date(b.mergedAt).getTime() - new Date(a.mergedAt).getTime());
|
||||||
|
return prs[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||||
|
// Check open PRs first
|
||||||
|
type OpenPr = { title: string; body: string; headRefName: string; url: string; reviewDecision: string };
|
||||||
|
const open = await this.findPrsForIssue<OpenPr>(issueId, "open", "title,body,headRefName,url,reviewDecision");
|
||||||
|
if (open.length > 0) {
|
||||||
|
const pr = open[0];
|
||||||
|
const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN;
|
||||||
|
return { state, url: pr.url };
|
||||||
|
}
|
||||||
|
// Check merged PRs
|
||||||
|
type MergedPr = { title: string; body: string; headRefName: string; url: string };
|
||||||
|
const merged = await this.findPrsForIssue<MergedPr>(issueId, "merged", "title,body,headRefName,url");
|
||||||
|
if (merged.length > 0) return { state: PrState.MERGED, url: merged[0].url };
|
||||||
|
return { state: PrState.CLOSED, url: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergePr(issueId: number): Promise<void> {
|
||||||
|
type OpenPr = { title: string; body: string; headRefName: string; url: string };
|
||||||
|
const prs = await this.findPrsForIssue<OpenPr>(issueId, "open", "title,body,headRefName,url");
|
||||||
|
if (prs.length === 0) throw new Error(`No open PR found for issue #${issueId}`);
|
||||||
|
await this.gh(["pr", "merge", prs[0].url, "--merge"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrDiff(issueId: number): Promise<string | null> {
|
||||||
|
type OpenPr = { title: string; body: string; headRefName: string; number: number };
|
||||||
|
const prs = await this.findPrsForIssue<OpenPr>(issueId, "open", "title,body,headRefName,number");
|
||||||
|
if (prs.length === 0) return null;
|
||||||
try {
|
try {
|
||||||
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "number,title,body,url,mergedAt", "--limit", "20"]);
|
return await this.gh(["pr", "diff", String(prs[0].number)]);
|
||||||
const prs = JSON.parse(raw) as Array<{ number: number; title: string; body: string; url: string; mergedAt: string }>;
|
|
||||||
const pat = `#${issueId}`;
|
|
||||||
return prs.find((pr) => pr.title.includes(pat) || (pr.body ?? "").includes(pat))?.url ?? null;
|
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import {
|
|||||||
type Issue,
|
type Issue,
|
||||||
type StateLabel,
|
type StateLabel,
|
||||||
type IssueComment,
|
type IssueComment,
|
||||||
|
type PrStatus,
|
||||||
|
PrState,
|
||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
|
import { withResilience } from "./resilience.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
getStateLabels,
|
getStateLabels,
|
||||||
@@ -15,6 +18,16 @@ import {
|
|||||||
type WorkflowConfig,
|
type WorkflowConfig,
|
||||||
} from "../workflow.js";
|
} from "../workflow.js";
|
||||||
|
|
||||||
|
type GitLabMR = {
|
||||||
|
iid: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
web_url: string;
|
||||||
|
state: string;
|
||||||
|
merged_at: string | null;
|
||||||
|
approved_by?: Array<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export class GitLabProvider implements IssueProvider {
|
export class GitLabProvider implements IssueProvider {
|
||||||
private repoPath: string;
|
private repoPath: string;
|
||||||
private workflow: WorkflowConfig;
|
private workflow: WorkflowConfig;
|
||||||
@@ -25,8 +38,19 @@ export class GitLabProvider implements IssueProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async glab(args: string[]): Promise<string> {
|
private async glab(args: string[]): Promise<string> {
|
||||||
|
return withResilience(async () => {
|
||||||
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||||
return result.stdout.trim();
|
return result.stdout.trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get MRs linked to an issue via GitLab's native related_merge_requests API. */
|
||||||
|
private async getRelatedMRs(issueId: number): Promise<GitLabMR[]> {
|
||||||
|
try {
|
||||||
|
const raw = await this.glab(["api", `projects/:id/issues/${issueId}/related_merge_requests`, "--paginate"]);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as GitLabMR[];
|
||||||
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureLabel(name: string, color: string): Promise<void> {
|
async ensureLabel(name: string, color: string): Promise<void> {
|
||||||
@@ -91,6 +115,17 @@ export class GitLabProvider implements IssueProvider {
|
|||||||
await this.glab(args);
|
await this.glab(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addLabel(issueId: number, label: string): Promise<void> {
|
||||||
|
await this.glab(["issue", "update", String(issueId), "--label", label]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLabels(issueId: number, labels: string[]): Promise<void> {
|
||||||
|
if (labels.length === 0) return;
|
||||||
|
const args = ["issue", "update", String(issueId)];
|
||||||
|
for (const l of labels) args.push("--unlabel", l);
|
||||||
|
await this.glab(args);
|
||||||
|
}
|
||||||
|
|
||||||
async closeIssue(issueId: number): Promise<void> { await this.glab(["issue", "close", String(issueId)]); }
|
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)]); }
|
async reopenIssue(issueId: number): Promise<void> { await this.glab(["issue", "reopen", String(issueId)]); }
|
||||||
|
|
||||||
@@ -102,23 +137,55 @@ export class GitLabProvider implements IssueProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async hasMergedMR(issueId: number): Promise<boolean> {
|
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||||
try {
|
const mrs = await this.getRelatedMRs(issueId);
|
||||||
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]);
|
return mrs.some((mr) => mr.state === "merged");
|
||||||
const mrs = JSON.parse(raw) as Array<{ title: string; description: string }>;
|
|
||||||
const pat = `#${issueId}`;
|
|
||||||
return mrs.some((mr) => mr.title.includes(pat) || (mr.description ?? "").includes(pat));
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||||
|
const mrs = await this.getRelatedMRs(issueId);
|
||||||
|
const merged = mrs
|
||||||
|
.filter((mr) => mr.state === "merged" && mr.merged_at)
|
||||||
|
.sort((a, b) => new Date(b.merged_at!).getTime() - new Date(a.merged_at!).getTime());
|
||||||
|
return merged[0]?.web_url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||||
|
const mrs = await this.getRelatedMRs(issueId);
|
||||||
|
// Check open MRs first
|
||||||
|
const open = mrs.find((mr) => mr.state === "opened");
|
||||||
|
if (open) {
|
||||||
|
// related_merge_requests doesn't populate approved_by — use dedicated approvals endpoint
|
||||||
|
const approved = await this.isMrApproved(open.iid);
|
||||||
|
return { state: approved ? PrState.APPROVED : PrState.OPEN, url: open.web_url };
|
||||||
|
}
|
||||||
|
// Check merged MRs
|
||||||
|
const merged = mrs.find((mr) => mr.state === "merged");
|
||||||
|
if (merged) return { state: PrState.MERGED, url: merged.web_url };
|
||||||
|
return { state: PrState.CLOSED, url: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if an MR is approved via the dedicated approvals endpoint. */
|
||||||
|
private async isMrApproved(mrIid: number): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]);
|
const raw = await this.glab(["api", `projects/:id/merge_requests/${mrIid}/approvals`]);
|
||||||
const mrs = JSON.parse(raw) as Array<{ iid: number; title: string; description: string; web_url: string; merged_at: string }>;
|
const data = JSON.parse(raw) as { approved?: boolean; approvals_left?: number };
|
||||||
const pat = `#${issueId}`;
|
return data.approved === true || (data.approvals_left ?? 1) === 0;
|
||||||
const mr = mrs
|
} catch { return false; }
|
||||||
.filter((mr) => mr.title.includes(pat) || (mr.description ?? "").includes(pat))
|
}
|
||||||
.sort((a, b) => new Date(b.merged_at).getTime() - new Date(a.merged_at).getTime())[0];
|
|
||||||
return mr?.web_url ?? null;
|
async mergePr(issueId: number): Promise<void> {
|
||||||
|
const mrs = await this.getRelatedMRs(issueId);
|
||||||
|
const open = mrs.find((mr) => mr.state === "opened");
|
||||||
|
if (!open) throw new Error(`No open MR found for issue #${issueId}`);
|
||||||
|
await this.glab(["mr", "merge", String(open.iid)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrDiff(issueId: number): Promise<string | null> {
|
||||||
|
const mrs = await this.getRelatedMRs(issueId);
|
||||||
|
const open = mrs.find((mr) => mr.state === "opened");
|
||||||
|
if (!open) return null;
|
||||||
|
try {
|
||||||
|
return await this.glab(["mr", "diff", String(open.iid)]);
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,34 +2,13 @@
|
|||||||
* IssueProvider — Abstract interface for issue tracker operations.
|
* IssueProvider — Abstract interface for issue tracker operations.
|
||||||
*
|
*
|
||||||
* Implementations: GitHub (gh CLI), GitLab (glab CLI).
|
* Implementations: GitHub (gh CLI), GitLab (glab CLI).
|
||||||
*
|
|
||||||
* Note: STATE_LABELS and LABEL_COLORS are kept for backward compatibility
|
|
||||||
* but new code should use the workflow config via lib/workflow.ts.
|
|
||||||
*/
|
*/
|
||||||
import { DEFAULT_WORKFLOW, getStateLabels, getLabelColors } from "../workflow.js";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// State labels — derived from default workflow for backward compatibility
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Use workflow.getStateLabels() instead.
|
* StateLabel type — string for flexibility with custom workflows.
|
||||||
* Kept for backward compatibility with existing code.
|
|
||||||
*/
|
|
||||||
export const STATE_LABELS = getStateLabels(DEFAULT_WORKFLOW) as readonly string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* StateLabel type — union of all valid state labels.
|
|
||||||
* This remains a string type for flexibility with custom workflows.
|
|
||||||
*/
|
*/
|
||||||
export type StateLabel = string;
|
export type StateLabel = string;
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use workflow.getLabelColors() instead.
|
|
||||||
* Kept for backward compatibility with existing code.
|
|
||||||
*/
|
|
||||||
export const LABEL_COLORS: Record<string, string> = getLabelColors(DEFAULT_WORKFLOW);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Issue types
|
// Issue types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -49,6 +28,20 @@ export type IssueComment = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Built-in PR states. */
|
||||||
|
export const PrState = {
|
||||||
|
OPEN: "open",
|
||||||
|
APPROVED: "approved",
|
||||||
|
MERGED: "merged",
|
||||||
|
CLOSED: "closed",
|
||||||
|
} as const;
|
||||||
|
export type PrState = (typeof PrState)[keyof typeof PrState];
|
||||||
|
|
||||||
|
export type PrStatus = {
|
||||||
|
state: PrState;
|
||||||
|
url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Provider interface
|
// Provider interface
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -61,15 +54,17 @@ export interface IssueProvider {
|
|||||||
getIssue(issueId: number): Promise<Issue>;
|
getIssue(issueId: number): Promise<Issue>;
|
||||||
listComments(issueId: number): Promise<IssueComment[]>;
|
listComments(issueId: number): Promise<IssueComment[]>;
|
||||||
transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void>;
|
transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise<void>;
|
||||||
|
addLabel(issueId: number, label: string): Promise<void>;
|
||||||
|
removeLabels(issueId: number, labels: string[]): Promise<void>;
|
||||||
closeIssue(issueId: number): Promise<void>;
|
closeIssue(issueId: number): Promise<void>;
|
||||||
reopenIssue(issueId: number): Promise<void>;
|
reopenIssue(issueId: number): Promise<void>;
|
||||||
hasStateLabel(issue: Issue, expected: StateLabel): boolean;
|
hasStateLabel(issue: Issue, expected: StateLabel): boolean;
|
||||||
getCurrentStateLabel(issue: Issue): StateLabel | null;
|
getCurrentStateLabel(issue: Issue): StateLabel | null;
|
||||||
hasMergedMR(issueId: number): Promise<boolean>;
|
hasMergedMR(issueId: number): Promise<boolean>;
|
||||||
getMergedMRUrl(issueId: number): Promise<string | null>;
|
getMergedMRUrl(issueId: number): Promise<string | null>;
|
||||||
|
getPrStatus(issueId: number): Promise<PrStatus>;
|
||||||
|
mergePr(issueId: number): Promise<void>;
|
||||||
|
getPrDiff(issueId: number): Promise<string | null>;
|
||||||
addComment(issueId: number, body: string): Promise<void>;
|
addComment(issueId: number, body: string): Promise<void>;
|
||||||
healthCheck(): Promise<boolean>;
|
healthCheck(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use IssueProvider */
|
|
||||||
export type TaskManager = IssueProvider;
|
|
||||||
|
|||||||
49
lib/providers/resilience.ts
Normal file
49
lib/providers/resilience.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* providers/resilience.ts — Retry and circuit breaker policies for provider calls.
|
||||||
|
*
|
||||||
|
* Uses cockatiel for lightweight resilience without heavyweight orchestration.
|
||||||
|
* Applied to GitHub/GitLab CLI calls that can fail due to network, rate limits, or timeouts.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
ExponentialBackoff,
|
||||||
|
retry,
|
||||||
|
circuitBreaker,
|
||||||
|
ConsecutiveBreaker,
|
||||||
|
handleAll,
|
||||||
|
wrap,
|
||||||
|
type IPolicy,
|
||||||
|
} from "cockatiel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default retry policy: 3 attempts with exponential backoff.
|
||||||
|
* Handles all errors (network, timeout, CLI failure).
|
||||||
|
*/
|
||||||
|
const retryPolicy = retry(handleAll, {
|
||||||
|
maxAttempts: 3,
|
||||||
|
backoff: new ExponentialBackoff({
|
||||||
|
initialDelay: 500,
|
||||||
|
maxDelay: 5_000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circuit breaker: opens after 5 consecutive failures, half-opens after 30s.
|
||||||
|
* Prevents hammering a provider that's down.
|
||||||
|
*/
|
||||||
|
const breakerPolicy = circuitBreaker(handleAll, {
|
||||||
|
halfOpenAfter: 30_000,
|
||||||
|
breaker: new ConsecutiveBreaker(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined policy: circuit breaker wrapping retry.
|
||||||
|
* If circuit is open, calls fail fast without retrying.
|
||||||
|
*/
|
||||||
|
export const providerPolicy: IPolicy = wrap(breakerPolicy, retryPolicy);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a provider call with retry + circuit breaker.
|
||||||
|
*/
|
||||||
|
export function withResilience<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
return providerPolicy.execute(() => fn());
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ export {
|
|||||||
isValidRole,
|
isValidRole,
|
||||||
getRole,
|
getRole,
|
||||||
requireRole,
|
requireRole,
|
||||||
|
// Role aliases
|
||||||
|
ROLE_ALIASES,
|
||||||
|
canonicalRole,
|
||||||
|
// Level aliases
|
||||||
|
LEVEL_ALIASES,
|
||||||
|
canonicalLevel,
|
||||||
// Levels
|
// Levels
|
||||||
getLevelsForRole,
|
getLevelsForRole,
|
||||||
getAllLevels,
|
getAllLevels,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getDefaultModel,
|
getDefaultModel,
|
||||||
getAllDefaultModels,
|
getAllDefaultModels,
|
||||||
resolveModel,
|
resolveModel,
|
||||||
|
canonicalLevel,
|
||||||
getEmoji,
|
getEmoji,
|
||||||
getFallbackEmoji,
|
getFallbackEmoji,
|
||||||
getCompletionResults,
|
getCompletionResults,
|
||||||
@@ -28,23 +29,25 @@ import {
|
|||||||
describe("role registry", () => {
|
describe("role registry", () => {
|
||||||
it("should have all expected roles", () => {
|
it("should have all expected roles", () => {
|
||||||
const ids = getAllRoleIds();
|
const ids = getAllRoleIds();
|
||||||
assert.ok(ids.includes("dev"));
|
assert.ok(ids.includes("developer"));
|
||||||
assert.ok(ids.includes("qa"));
|
assert.ok(ids.includes("tester"));
|
||||||
assert.ok(ids.includes("architect"));
|
assert.ok(ids.includes("architect"));
|
||||||
|
assert.ok(ids.includes("reviewer"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should validate role IDs", () => {
|
it("should validate role IDs", () => {
|
||||||
assert.strictEqual(isValidRole("dev"), true);
|
assert.strictEqual(isValidRole("developer"), true);
|
||||||
assert.strictEqual(isValidRole("qa"), true);
|
assert.strictEqual(isValidRole("tester"), true);
|
||||||
assert.strictEqual(isValidRole("architect"), true);
|
assert.strictEqual(isValidRole("architect"), true);
|
||||||
|
assert.strictEqual(isValidRole("reviewer"), true);
|
||||||
assert.strictEqual(isValidRole("nonexistent"), false);
|
assert.strictEqual(isValidRole("nonexistent"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should get role config", () => {
|
it("should get role config", () => {
|
||||||
const dev = getRole("dev");
|
const dev = getRole("developer");
|
||||||
assert.ok(dev);
|
assert.ok(dev);
|
||||||
assert.strictEqual(dev.id, "dev");
|
assert.strictEqual(dev.id, "developer");
|
||||||
assert.strictEqual(dev.displayName, "DEV");
|
assert.strictEqual(dev.displayName, "DEVELOPER");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw for unknown role in requireRole", () => {
|
it("should throw for unknown role in requireRole", () => {
|
||||||
@@ -54,9 +57,10 @@ describe("role registry", () => {
|
|||||||
|
|
||||||
describe("levels", () => {
|
describe("levels", () => {
|
||||||
it("should return levels for each role", () => {
|
it("should return levels for each role", () => {
|
||||||
assert.deepStrictEqual([...getLevelsForRole("dev")], ["junior", "medior", "senior"]);
|
assert.deepStrictEqual([...getLevelsForRole("developer")], ["junior", "medior", "senior"]);
|
||||||
assert.deepStrictEqual([...getLevelsForRole("qa")], ["reviewer", "tester"]);
|
assert.deepStrictEqual([...getLevelsForRole("tester")], ["junior", "medior", "senior"]);
|
||||||
assert.deepStrictEqual([...getLevelsForRole("architect")], ["opus", "sonnet"]);
|
assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]);
|
||||||
|
assert.deepStrictEqual([...getLevelsForRole("reviewer")], ["junior", "senior"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return empty for unknown role", () => {
|
it("should return empty for unknown role", () => {
|
||||||
@@ -66,68 +70,117 @@ describe("levels", () => {
|
|||||||
it("should return all levels", () => {
|
it("should return all levels", () => {
|
||||||
const all = getAllLevels();
|
const all = getAllLevels();
|
||||||
assert.ok(all.includes("junior"));
|
assert.ok(all.includes("junior"));
|
||||||
assert.ok(all.includes("reviewer"));
|
assert.ok(all.includes("medior"));
|
||||||
assert.ok(all.includes("opus"));
|
assert.ok(all.includes("senior"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should check level membership", () => {
|
it("should check level membership", () => {
|
||||||
assert.strictEqual(isLevelForRole("junior", "dev"), true);
|
assert.strictEqual(isLevelForRole("junior", "developer"), true);
|
||||||
assert.strictEqual(isLevelForRole("junior", "qa"), false);
|
assert.strictEqual(isLevelForRole("junior", "tester"), true);
|
||||||
assert.strictEqual(isLevelForRole("opus", "architect"), true);
|
assert.strictEqual(isLevelForRole("junior", "architect"), true);
|
||||||
|
assert.strictEqual(isLevelForRole("medior", "developer"), true);
|
||||||
|
assert.strictEqual(isLevelForRole("medior", "architect"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should find role for level", () => {
|
it("should find role for level", () => {
|
||||||
assert.strictEqual(roleForLevel("junior"), "dev");
|
// "junior" appears in developer first (registry order)
|
||||||
assert.strictEqual(roleForLevel("reviewer"), "qa");
|
assert.strictEqual(roleForLevel("junior"), "developer");
|
||||||
assert.strictEqual(roleForLevel("opus"), "architect");
|
assert.strictEqual(roleForLevel("medior"), "developer");
|
||||||
|
assert.strictEqual(roleForLevel("senior"), "developer");
|
||||||
assert.strictEqual(roleForLevel("nonexistent"), undefined);
|
assert.strictEqual(roleForLevel("nonexistent"), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return default level", () => {
|
it("should return default level", () => {
|
||||||
assert.strictEqual(getDefaultLevel("dev"), "medior");
|
assert.strictEqual(getDefaultLevel("developer"), "medior");
|
||||||
assert.strictEqual(getDefaultLevel("qa"), "reviewer");
|
assert.strictEqual(getDefaultLevel("tester"), "medior");
|
||||||
assert.strictEqual(getDefaultLevel("architect"), "sonnet");
|
assert.strictEqual(getDefaultLevel("architect"), "junior");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("level aliases", () => {
|
||||||
|
it("should map old developer level names", () => {
|
||||||
|
assert.strictEqual(canonicalLevel("developer", "mid"), "medior");
|
||||||
|
assert.strictEqual(canonicalLevel("developer", "junior"), "junior");
|
||||||
|
assert.strictEqual(canonicalLevel("developer", "senior"), "senior");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should map old dev role level names", () => {
|
||||||
|
assert.strictEqual(canonicalLevel("dev", "mid"), "medior");
|
||||||
|
assert.strictEqual(canonicalLevel("dev", "medior"), "medior");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should map old qa/tester level names", () => {
|
||||||
|
assert.strictEqual(canonicalLevel("tester", "mid"), "medior");
|
||||||
|
assert.strictEqual(canonicalLevel("tester", "reviewer"), "medior");
|
||||||
|
assert.strictEqual(canonicalLevel("qa", "reviewer"), "medior");
|
||||||
|
assert.strictEqual(canonicalLevel("qa", "tester"), "junior");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should map old architect level names", () => {
|
||||||
|
assert.strictEqual(canonicalLevel("architect", "opus"), "senior");
|
||||||
|
assert.strictEqual(canonicalLevel("architect", "sonnet"), "junior");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass through unknown levels", () => {
|
||||||
|
assert.strictEqual(canonicalLevel("developer", "custom"), "custom");
|
||||||
|
assert.strictEqual(canonicalLevel("unknown", "whatever"), "whatever");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("models", () => {
|
describe("models", () => {
|
||||||
it("should return default models", () => {
|
it("should return default models", () => {
|
||||||
assert.strictEqual(getDefaultModel("dev", "junior"), "anthropic/claude-haiku-4-5");
|
assert.strictEqual(getDefaultModel("developer", "junior"), "anthropic/claude-haiku-4-5");
|
||||||
assert.strictEqual(getDefaultModel("qa", "reviewer"), "anthropic/claude-sonnet-4-5");
|
assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5");
|
||||||
assert.strictEqual(getDefaultModel("architect", "opus"), "anthropic/claude-opus-4-5");
|
assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5");
|
||||||
|
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return all default models", () => {
|
it("should return all default models", () => {
|
||||||
const models = getAllDefaultModels();
|
const models = getAllDefaultModels();
|
||||||
assert.ok(models.dev);
|
assert.ok(models.developer);
|
||||||
assert.ok(models.qa);
|
assert.ok(models.tester);
|
||||||
assert.ok(models.architect);
|
assert.ok(models.architect);
|
||||||
assert.strictEqual(models.dev.junior, "anthropic/claude-haiku-4-5");
|
assert.strictEqual(models.developer.junior, "anthropic/claude-haiku-4-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve from config override", () => {
|
it("should resolve from resolved role config override", () => {
|
||||||
const config = { models: { dev: { junior: "custom/model" } } };
|
const resolvedRole = { models: { junior: "custom/model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||||
assert.strictEqual(resolveModel("dev", "junior", config), "custom/model");
|
assert.strictEqual(resolveModel("developer", "junior", resolvedRole), "custom/model");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to default", () => {
|
it("should fall back to default", () => {
|
||||||
assert.strictEqual(resolveModel("dev", "junior"), "anthropic/claude-haiku-4-5");
|
assert.strictEqual(resolveModel("developer", "junior"), "anthropic/claude-haiku-4-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should pass through unknown level as model ID", () => {
|
it("should pass through unknown level as model ID", () => {
|
||||||
assert.strictEqual(resolveModel("dev", "anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5");
|
assert.strictEqual(resolveModel("developer", "anthropic/claude-opus-4-6"), "anthropic/claude-opus-4-6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve via level aliases", () => {
|
||||||
|
// "mid" alias maps to "medior" — should resolve to default medior model
|
||||||
|
assert.strictEqual(resolveModel("developer", "mid"), "anthropic/claude-sonnet-4-5");
|
||||||
|
// With explicit override in resolved config
|
||||||
|
const resolvedRole = { models: { medior: "custom/old-config-model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||||
|
assert.strictEqual(resolveModel("developer", "mid", resolvedRole), "custom/old-config-model");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve with resolved role overriding defaults selectively", () => {
|
||||||
|
const resolvedRole = { models: { junior: "custom/model" }, levels: ["junior", "medior", "senior"], defaultLevel: "medior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||||
|
assert.strictEqual(resolveModel("developer", "junior", resolvedRole), "custom/model");
|
||||||
|
// Levels not overridden fall through to registry defaults
|
||||||
|
assert.strictEqual(resolveModel("developer", "medior", resolvedRole), "anthropic/claude-sonnet-4-5");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("emoji", () => {
|
describe("emoji", () => {
|
||||||
it("should return level emoji", () => {
|
it("should return level emoji", () => {
|
||||||
assert.strictEqual(getEmoji("dev", "junior"), "⚡");
|
assert.strictEqual(getEmoji("developer", "junior"), "⚡");
|
||||||
assert.strictEqual(getEmoji("architect", "opus"), "🏗️");
|
assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return fallback emoji", () => {
|
it("should return fallback emoji", () => {
|
||||||
assert.strictEqual(getFallbackEmoji("dev"), "🔧");
|
assert.strictEqual(getFallbackEmoji("developer"), "🔧");
|
||||||
assert.strictEqual(getFallbackEmoji("qa"), "🔍");
|
assert.strictEqual(getFallbackEmoji("tester"), "🔍");
|
||||||
assert.strictEqual(getFallbackEmoji("architect"), "🏗️");
|
assert.strictEqual(getFallbackEmoji("architect"), "🏗️");
|
||||||
assert.strictEqual(getFallbackEmoji("nonexistent"), "📋");
|
assert.strictEqual(getFallbackEmoji("nonexistent"), "📋");
|
||||||
});
|
});
|
||||||
@@ -135,33 +188,40 @@ describe("emoji", () => {
|
|||||||
|
|
||||||
describe("completion results", () => {
|
describe("completion results", () => {
|
||||||
it("should return valid results per role", () => {
|
it("should return valid results per role", () => {
|
||||||
assert.deepStrictEqual([...getCompletionResults("dev")], ["done", "blocked"]);
|
assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]);
|
||||||
assert.deepStrictEqual([...getCompletionResults("qa")], ["pass", "fail", "refine", "blocked"]);
|
assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]);
|
||||||
assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]);
|
assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]);
|
||||||
|
assert.deepStrictEqual([...getCompletionResults("reviewer")], ["approve", "reject", "blocked"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should validate results", () => {
|
it("should validate results", () => {
|
||||||
assert.strictEqual(isValidResult("dev", "done"), true);
|
assert.strictEqual(isValidResult("developer", "done"), true);
|
||||||
assert.strictEqual(isValidResult("dev", "pass"), false);
|
assert.strictEqual(isValidResult("developer", "pass"), false);
|
||||||
assert.strictEqual(isValidResult("qa", "pass"), true);
|
assert.strictEqual(isValidResult("tester", "pass"), true);
|
||||||
assert.strictEqual(isValidResult("qa", "done"), false);
|
assert.strictEqual(isValidResult("tester", "done"), false);
|
||||||
|
assert.strictEqual(isValidResult("reviewer", "approve"), true);
|
||||||
|
assert.strictEqual(isValidResult("reviewer", "reject"), true);
|
||||||
|
assert.strictEqual(isValidResult("reviewer", "escalate"), false);
|
||||||
|
assert.strictEqual(isValidResult("reviewer", "done"), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("session key pattern", () => {
|
describe("session key pattern", () => {
|
||||||
it("should generate pattern matching all roles", () => {
|
it("should generate pattern matching all roles", () => {
|
||||||
const pattern = getSessionKeyRolePattern();
|
const pattern = getSessionKeyRolePattern();
|
||||||
assert.ok(pattern.includes("dev"));
|
assert.ok(pattern.includes("developer"));
|
||||||
assert.ok(pattern.includes("qa"));
|
assert.ok(pattern.includes("tester"));
|
||||||
assert.ok(pattern.includes("architect"));
|
assert.ok(pattern.includes("architect"));
|
||||||
|
assert.ok(pattern.includes("reviewer"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should work as regex", () => {
|
it("should work as regex", () => {
|
||||||
const pattern = getSessionKeyRolePattern();
|
const pattern = getSessionKeyRolePattern();
|
||||||
const regex = new RegExp(`(${pattern})`);
|
const regex = new RegExp(`(${pattern})`);
|
||||||
assert.ok(regex.test("dev"));
|
assert.ok(regex.test("developer"));
|
||||||
assert.ok(regex.test("qa"));
|
assert.ok(regex.test("tester"));
|
||||||
assert.ok(regex.test("architect"));
|
assert.ok(regex.test("architect"));
|
||||||
|
assert.ok(regex.test("reviewer"));
|
||||||
assert.ok(!regex.test("nonexistent"));
|
assert.ok(!regex.test("nonexistent"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,15 +14,15 @@
|
|||||||
import type { RoleConfig } from "./types.js";
|
import type { RoleConfig } from "./types.js";
|
||||||
|
|
||||||
export const ROLE_REGISTRY: Record<string, RoleConfig> = {
|
export const ROLE_REGISTRY: Record<string, RoleConfig> = {
|
||||||
dev: {
|
developer: {
|
||||||
id: "dev",
|
id: "developer",
|
||||||
displayName: "DEV",
|
displayName: "DEVELOPER",
|
||||||
levels: ["junior", "medior", "senior"],
|
levels: ["junior", "medior", "senior"],
|
||||||
defaultLevel: "medior",
|
defaultLevel: "medior",
|
||||||
models: {
|
models: {
|
||||||
junior: "anthropic/claude-haiku-4-5",
|
junior: "anthropic/claude-haiku-4-5",
|
||||||
medior: "anthropic/claude-sonnet-4-5",
|
medior: "anthropic/claude-sonnet-4-5",
|
||||||
senior: "anthropic/claude-opus-4-5",
|
senior: "anthropic/claude-opus-4-6",
|
||||||
},
|
},
|
||||||
emoji: {
|
emoji: {
|
||||||
junior: "⚡",
|
junior: "⚡",
|
||||||
@@ -31,45 +31,66 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
|
|||||||
},
|
},
|
||||||
fallbackEmoji: "🔧",
|
fallbackEmoji: "🔧",
|
||||||
completionResults: ["done", "blocked"],
|
completionResults: ["done", "blocked"],
|
||||||
sessionKeyPattern: "dev",
|
sessionKeyPattern: "developer",
|
||||||
notifications: { onStart: true, onComplete: true },
|
notifications: { onStart: true, onComplete: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
qa: {
|
tester: {
|
||||||
id: "qa",
|
id: "tester",
|
||||||
displayName: "QA",
|
displayName: "TESTER",
|
||||||
levels: ["reviewer", "tester"],
|
levels: ["junior", "medior", "senior"],
|
||||||
defaultLevel: "reviewer",
|
defaultLevel: "medior",
|
||||||
models: {
|
models: {
|
||||||
reviewer: "anthropic/claude-sonnet-4-5",
|
junior: "anthropic/claude-haiku-4-5",
|
||||||
tester: "anthropic/claude-haiku-4-5",
|
medior: "anthropic/claude-sonnet-4-5",
|
||||||
|
senior: "anthropic/claude-opus-4-6",
|
||||||
},
|
},
|
||||||
emoji: {
|
emoji: {
|
||||||
reviewer: "🔍",
|
junior: "⚡",
|
||||||
tester: "👀",
|
medior: "🔍",
|
||||||
|
senior: "🧠",
|
||||||
},
|
},
|
||||||
fallbackEmoji: "🔍",
|
fallbackEmoji: "🔍",
|
||||||
completionResults: ["pass", "fail", "refine", "blocked"],
|
completionResults: ["pass", "fail", "refine", "blocked"],
|
||||||
sessionKeyPattern: "qa",
|
sessionKeyPattern: "tester",
|
||||||
notifications: { onStart: true, onComplete: true },
|
notifications: { onStart: true, onComplete: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
architect: {
|
architect: {
|
||||||
id: "architect",
|
id: "architect",
|
||||||
displayName: "ARCHITECT",
|
displayName: "ARCHITECT",
|
||||||
levels: ["opus", "sonnet"],
|
levels: ["junior", "senior"],
|
||||||
defaultLevel: "sonnet",
|
defaultLevel: "junior",
|
||||||
models: {
|
models: {
|
||||||
opus: "anthropic/claude-opus-4-5",
|
junior: "anthropic/claude-sonnet-4-5",
|
||||||
sonnet: "anthropic/claude-sonnet-4-5",
|
senior: "anthropic/claude-opus-4-6",
|
||||||
},
|
},
|
||||||
emoji: {
|
emoji: {
|
||||||
opus: "🏗️",
|
junior: "📐",
|
||||||
sonnet: "📐",
|
senior: "🏗️",
|
||||||
},
|
},
|
||||||
fallbackEmoji: "🏗️",
|
fallbackEmoji: "🏗️",
|
||||||
completionResults: ["done", "blocked"],
|
completionResults: ["done", "blocked"],
|
||||||
sessionKeyPattern: "architect",
|
sessionKeyPattern: "architect",
|
||||||
notifications: { onStart: true, onComplete: true },
|
notifications: { onStart: true, onComplete: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reviewer: {
|
||||||
|
id: "reviewer",
|
||||||
|
displayName: "REVIEWER",
|
||||||
|
levels: ["junior", "senior"],
|
||||||
|
defaultLevel: "junior",
|
||||||
|
models: {
|
||||||
|
junior: "anthropic/claude-haiku-4-5",
|
||||||
|
senior: "anthropic/claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
emoji: {
|
||||||
|
junior: "👁️",
|
||||||
|
senior: "🔬",
|
||||||
|
},
|
||||||
|
fallbackEmoji: "👁️",
|
||||||
|
completionResults: ["approve", "reject", "blocked"],
|
||||||
|
sessionKeyPattern: "reviewer",
|
||||||
|
notifications: { onStart: true, onComplete: true },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
import { ROLE_REGISTRY } from "./registry.js";
|
import { ROLE_REGISTRY } from "./registry.js";
|
||||||
import type { RoleConfig } from "./types.js";
|
import type { RoleConfig } from "./types.js";
|
||||||
|
import type { ResolvedRoleConfig } from "../config/types.js";
|
||||||
|
import { ROLE_ALIASES as _ROLE_ALIASES, canonicalLevel as _canonicalLevel } from "../migrations.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Role IDs
|
// Role IDs
|
||||||
@@ -36,6 +38,12 @@ export function requireRole(role: string): RoleConfig {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Migration aliases — re-exported from lib/migrations.ts for backward compat
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export { ROLE_ALIASES, canonicalRole, LEVEL_ALIASES, canonicalLevel } from "../migrations.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Levels
|
// Levels
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -90,21 +98,22 @@ export function getAllDefaultModels(): Record<string, Record<string, string>> {
|
|||||||
* Resolve a level to a full model ID.
|
* Resolve a level to a full model ID.
|
||||||
*
|
*
|
||||||
* Resolution order:
|
* Resolution order:
|
||||||
* 1. Plugin config `models.<role>.<level>`
|
* 1. Resolved config from workflow.yaml (three-layer merge)
|
||||||
* 2. Registry default model
|
* 2. Registry default model
|
||||||
* 3. Passthrough (treat level as raw model ID)
|
* 3. Passthrough (treat level as raw model ID)
|
||||||
*/
|
*/
|
||||||
export function resolveModel(
|
export function resolveModel(
|
||||||
role: string,
|
role: string,
|
||||||
level: string,
|
level: string,
|
||||||
pluginConfig?: Record<string, unknown>,
|
resolvedRole?: ResolvedRoleConfig,
|
||||||
): string {
|
): string {
|
||||||
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
|
const canonical = _canonicalLevel(role, level);
|
||||||
if (models && typeof models === "object") {
|
|
||||||
const roleModels = models[role] as Record<string, string> | undefined;
|
// 1. Resolved config (workflow.yaml — includes workspace + project overrides)
|
||||||
if (roleModels?.[level]) return roleModels[level];
|
if (resolvedRole?.models[canonical]) return resolvedRole.models[canonical];
|
||||||
}
|
|
||||||
return getDefaultModel(role, level) ?? level;
|
// 2. Built-in registry default
|
||||||
|
return getDefaultModel(role, canonical) ?? canonical;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
/** Configuration for a single worker role. */
|
/** Configuration for a single worker role. */
|
||||||
export type RoleConfig = {
|
export type RoleConfig = {
|
||||||
/** Unique role identifier (e.g., "dev", "qa", "architect"). */
|
/** Unique role identifier (e.g., "developer", "tester", "architect"). */
|
||||||
id: string;
|
id: string;
|
||||||
/** Human-readable display name. */
|
/** Human-readable display name. */
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -23,7 +23,7 @@ export type RoleConfig = {
|
|||||||
fallbackEmoji: string;
|
fallbackEmoji: string;
|
||||||
/** Valid completion results for this role. */
|
/** Valid completion results for this role. */
|
||||||
completionResults: readonly string[];
|
completionResults: readonly string[];
|
||||||
/** Regex pattern fragment for session key matching (e.g., "dev|qa|architect"). */
|
/** Regex pattern fragment for session key matching (e.g., "developer|tester|architect"). */
|
||||||
sessionKeyPattern: string;
|
sessionKeyPattern: string;
|
||||||
/** Notification config per event type. */
|
/** Notification config per event type. */
|
||||||
notifications: {
|
notifications: {
|
||||||
|
|||||||
253
lib/services/bootstrap.e2e.test.ts
Normal file
253
lib/services/bootstrap.e2e.test.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* E2E bootstrap tests — verifies the full bootstrap hook chain:
|
||||||
|
* dispatchTask() → session key → registerBootstrapHook fires → bootstrapFiles injected
|
||||||
|
*
|
||||||
|
* Uses simulateBootstrap() which registers the real hook with a mock API,
|
||||||
|
* fires it with the session key from dispatch, and returns the resulting
|
||||||
|
* bootstrapFiles array — proving instructions actually reach the worker.
|
||||||
|
*
|
||||||
|
* Run: npx tsx --test lib/services/bootstrap.e2e.test.ts
|
||||||
|
*/
|
||||||
|
import { describe, it, afterEach } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { createTestHarness, type TestHarness } from "../testing/index.js";
|
||||||
|
import { dispatchTask } from "../dispatch.js";
|
||||||
|
|
||||||
|
describe("E2E bootstrap — hook injection", () => {
|
||||||
|
let h: TestHarness;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (h) await h.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should inject project-specific instructions into bootstrapFiles", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "my-app" });
|
||||||
|
h.provider.seedIssue({ iid: 1, title: "Add feature", labels: ["To Do"] });
|
||||||
|
|
||||||
|
// Write both default and project-specific prompts
|
||||||
|
await h.writePrompt("developer", "# Default Developer\nGeneric instructions.");
|
||||||
|
await h.writePrompt("developer", "# My App Developer\nUse React. Follow our design system.", "my-app");
|
||||||
|
|
||||||
|
// Dispatch to get the session key
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 1,
|
||||||
|
issueTitle: "Add feature",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/1",
|
||||||
|
role: "developer",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire the actual bootstrap hook with the dispatch session key
|
||||||
|
const files = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
|
||||||
|
// Should have exactly one injected file
|
||||||
|
assert.strictEqual(files.length, 1, `Expected 1 bootstrap file, got ${files.length}`);
|
||||||
|
assert.strictEqual(files[0].name, "WORKER_INSTRUCTIONS.md");
|
||||||
|
assert.strictEqual(files[0].missing, false);
|
||||||
|
assert.ok(files[0].path.includes("my-app"), `Path should reference project: ${files[0].path}`);
|
||||||
|
assert.ok(files[0].path.includes("developer"), `Path should reference role: ${files[0].path}`);
|
||||||
|
|
||||||
|
// Content should be project-specific, NOT default
|
||||||
|
const content = files[0].content!;
|
||||||
|
assert.ok(content.includes("My App Developer"), `Got: ${content}`);
|
||||||
|
assert.ok(content.includes("Use React"));
|
||||||
|
assert.ok(!content.includes("Generic instructions"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to default instructions when no project override exists", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "other-app" });
|
||||||
|
h.provider.seedIssue({ iid: 2, title: "Fix bug", labels: ["To Do"] });
|
||||||
|
|
||||||
|
// Only write default prompt — no project-specific
|
||||||
|
await h.writePrompt("developer", "# Default Developer\nFollow coding standards.");
|
||||||
|
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 2,
|
||||||
|
issueTitle: "Fix bug",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/2",
|
||||||
|
role: "developer",
|
||||||
|
level: "junior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.ok(files[0].content!.includes("Default Developer"));
|
||||||
|
assert.ok(files[0].content!.includes("Follow coding standards"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should inject scaffolded default instructions when no overrides exist", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "bare-app" });
|
||||||
|
h.provider.seedIssue({ iid: 3, title: "Chore", labels: ["To Do"] });
|
||||||
|
|
||||||
|
// Don't write any custom prompts — ensureWorkspaceMigrated scaffolds defaults
|
||||||
|
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 3,
|
||||||
|
issueTitle: "Chore",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/3",
|
||||||
|
role: "developer",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
|
||||||
|
// Default developer instructions are scaffolded by ensureDefaultFiles
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.ok(files[0].content!.includes("DEVELOPER"), "Should contain DEVELOPER heading");
|
||||||
|
assert.ok(files[0].content!.includes("worktree"), "Should reference git worktree workflow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT inject anything for unknown custom roles", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "custom-app" });
|
||||||
|
|
||||||
|
// Simulate a session key for a custom role that has no prompt file
|
||||||
|
// This key won't parse because "investigator" isn't in the role registry
|
||||||
|
const files = await h.simulateBootstrap(
|
||||||
|
"agent:main:subagent:custom-app-investigator-medior",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(files.length, 0, "Should not inject files for unknown roles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve tester instructions independently from developer", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "multi-role" });
|
||||||
|
h.provider.seedIssue({ iid: 4, title: "Test thing", labels: ["To Test"] });
|
||||||
|
|
||||||
|
// Write project-specific for developer, default for tester
|
||||||
|
await h.writePrompt("developer", "# Dev for multi-role\nSpecific dev rules.", "multi-role");
|
||||||
|
await h.writePrompt("tester", "# Default Tester\nRun integration tests.");
|
||||||
|
|
||||||
|
// Dispatch as tester
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 4,
|
||||||
|
issueTitle: "Test thing",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/4",
|
||||||
|
role: "tester",
|
||||||
|
level: "medior",
|
||||||
|
fromLabel: "To Test",
|
||||||
|
toLabel: "Testing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate bootstrap for the tester session
|
||||||
|
const testerFiles = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
assert.strictEqual(testerFiles.length, 1);
|
||||||
|
assert.ok(testerFiles[0].content!.includes("Default Tester"));
|
||||||
|
assert.ok(!testerFiles[0].content!.includes("Dev for multi-role"));
|
||||||
|
|
||||||
|
// Simulate bootstrap for a developer session on the same project
|
||||||
|
const devKey = result.sessionKey.replace("-tester-", "-developer-");
|
||||||
|
const devFiles = await h.simulateBootstrap(devKey);
|
||||||
|
assert.strictEqual(devFiles.length, 1);
|
||||||
|
assert.ok(devFiles[0].content!.includes("Dev for multi-role"));
|
||||||
|
assert.ok(devFiles[0].content!.includes("Specific dev rules"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle project names with hyphens correctly", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "my-cool-project" });
|
||||||
|
h.provider.seedIssue({ iid: 5, title: "Hyphen test", labels: ["To Do"] });
|
||||||
|
|
||||||
|
await h.writePrompt(
|
||||||
|
"developer",
|
||||||
|
"# Hyphenated Project\nThis project has hyphens in the name.",
|
||||||
|
"my-cool-project",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 5,
|
||||||
|
issueTitle: "Hyphen test",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/5",
|
||||||
|
role: "developer",
|
||||||
|
level: "senior",
|
||||||
|
fromLabel: "To Do",
|
||||||
|
toLabel: "Doing",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.ok(files[0].content!.includes("Hyphenated Project"));
|
||||||
|
assert.ok(files[0].path.includes("my-cool-project"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve architect instructions with project override", async () => {
|
||||||
|
h = await createTestHarness({ projectName: "arch-proj" });
|
||||||
|
h.provider.seedIssue({ iid: 6, title: "Design API", labels: ["Planning"] });
|
||||||
|
|
||||||
|
await h.writePrompt("architect", "# Default Architect\nGeneral design guidelines.");
|
||||||
|
await h.writePrompt("architect", "# Arch Proj Architect\nUse event-driven architecture.", "arch-proj");
|
||||||
|
|
||||||
|
const result = await dispatchTask({
|
||||||
|
workspaceDir: h.workspaceDir,
|
||||||
|
agentId: "main",
|
||||||
|
groupId: h.groupId,
|
||||||
|
project: h.project,
|
||||||
|
issueId: 6,
|
||||||
|
issueTitle: "Design API",
|
||||||
|
issueDescription: "",
|
||||||
|
issueUrl: "https://example.com/issues/6",
|
||||||
|
role: "architect",
|
||||||
|
level: "senior",
|
||||||
|
fromLabel: "Planning",
|
||||||
|
toLabel: "Planning",
|
||||||
|
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
|
||||||
|
provider: h.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await h.simulateBootstrap(result.sessionKey);
|
||||||
|
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.ok(files[0].content!.includes("Arch Proj Architect"));
|
||||||
|
assert.ok(files[0].content!.includes("event-driven"));
|
||||||
|
assert.ok(!files[0].content!.includes("General design guidelines"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not inject when session key is not a DevClaw subagent", async () => {
|
||||||
|
h = await createTestHarness();
|
||||||
|
|
||||||
|
// Non-DevClaw session key — hook should no-op
|
||||||
|
const files = await h.simulateBootstrap("agent:main:orchestrator");
|
||||||
|
assert.strictEqual(files.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
getActiveLabel,
|
getActiveLabel,
|
||||||
getRevertLabel,
|
getRevertLabel,
|
||||||
|
hasWorkflowStates,
|
||||||
type WorkflowConfig,
|
type WorkflowConfig,
|
||||||
type Role,
|
type Role,
|
||||||
} from "../workflow.js";
|
} from "../workflow.js";
|
||||||
@@ -83,13 +84,13 @@ export type SessionLookup = Map<string, GatewaySession>;
|
|||||||
* Returns null if gateway is unavailable (timeout, error, etc).
|
* Returns null if gateway is unavailable (timeout, error, etc).
|
||||||
* Callers should skip session liveness checks if null — unknown ≠ dead.
|
* Callers should skip session liveness checks if null — unknown ≠ dead.
|
||||||
*/
|
*/
|
||||||
export async function fetchGatewaySessions(): Promise<SessionLookup | null> {
|
export async function fetchGatewaySessions(gatewayTimeoutMs = 15_000): Promise<SessionLookup | null> {
|
||||||
const lookup: SessionLookup = new Map();
|
const lookup: SessionLookup = new Map();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runCommand(
|
const result = await runCommand(
|
||||||
["openclaw", "gateway", "call", "status", "--json"],
|
["openclaw", "gateway", "call", "status", "--json"],
|
||||||
{ timeoutMs: 15_000 },
|
{ timeoutMs: gatewayTimeoutMs },
|
||||||
);
|
);
|
||||||
|
|
||||||
const jsonStart = result.stdout.indexOf("{");
|
const jsonStart = result.stdout.indexOf("{");
|
||||||
@@ -151,13 +152,20 @@ export async function checkWorkerHealth(opts: {
|
|||||||
sessions: SessionLookup | null;
|
sessions: SessionLookup | null;
|
||||||
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
|
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
|
||||||
workflow?: WorkflowConfig;
|
workflow?: WorkflowConfig;
|
||||||
|
/** Hours after which an active worker is considered stale (default: 2) */
|
||||||
|
staleWorkerHours?: number;
|
||||||
}): Promise<HealthFix[]> {
|
}): Promise<HealthFix[]> {
|
||||||
const {
|
const {
|
||||||
workspaceDir, groupId, project, role, autoFix, provider, sessions,
|
workspaceDir, groupId, project, role, autoFix, provider, sessions,
|
||||||
workflow = DEFAULT_WORKFLOW,
|
workflow = DEFAULT_WORKFLOW,
|
||||||
|
staleWorkerHours = 2,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const fixes: HealthFix[] = [];
|
const fixes: HealthFix[] = [];
|
||||||
|
|
||||||
|
// Skip roles without workflow states (e.g. architect — tool-triggered only)
|
||||||
|
if (!hasWorkflowStates(workflow, role)) return fixes;
|
||||||
|
|
||||||
const worker = getWorker(project, role);
|
const worker = getWorker(project, role);
|
||||||
const sessionKey = worker.level ? getSessionForLevel(worker, worker.level) : null;
|
const sessionKey = worker.level ? getSessionForLevel(worker, worker.level) : null;
|
||||||
|
|
||||||
@@ -316,7 +324,7 @@ export async function checkWorkerHealth(opts: {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
if (worker.active && worker.startTime && sessionKey && sessions && isSessionAlive(sessionKey, sessions)) {
|
if (worker.active && worker.startTime && sessionKey && sessions && isSessionAlive(sessionKey, sessions)) {
|
||||||
const hours = (Date.now() - new Date(worker.startTime).getTime()) / 3_600_000;
|
const hours = (Date.now() - new Date(worker.startTime).getTime()) / 3_600_000;
|
||||||
if (hours > 2) {
|
if (hours > staleWorkerHours) {
|
||||||
const fix: HealthFix = {
|
const fix: HealthFix = {
|
||||||
issue: {
|
issue: {
|
||||||
type: "stale_worker",
|
type: "stale_worker",
|
||||||
@@ -427,6 +435,10 @@ export async function scanOrphanedLabels(opts: {
|
|||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const fixes: HealthFix[] = [];
|
const fixes: HealthFix[] = [];
|
||||||
|
|
||||||
|
// Skip roles without workflow states (e.g. architect — tool-triggered only)
|
||||||
|
if (!hasWorkflowStates(workflow, role)) return fixes;
|
||||||
|
|
||||||
const worker = getWorker(project, role);
|
const worker = getWorker(project, role);
|
||||||
|
|
||||||
// Get labels from workflow config
|
// Get labels from workflow config
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { readProjects } from "../projects.js";
|
import { readProjects } from "../projects.js";
|
||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
|
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||||
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
|
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
|
||||||
import { projectTick } from "./tick.js";
|
import { projectTick } from "./tick.js";
|
||||||
|
import { reviewPass } from "./review.js";
|
||||||
import { createProvider } from "../providers/index.js";
|
import { createProvider } from "../providers/index.js";
|
||||||
import { getAllRoleIds } from "../roles/index.js";
|
import { loadConfig } from "../config/index.js";
|
||||||
|
import { ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -39,6 +42,7 @@ type TickResult = {
|
|||||||
totalPickups: number;
|
totalPickups: number;
|
||||||
totalHealthFixes: number;
|
totalHealthFixes: number;
|
||||||
totalSkipped: number;
|
totalSkipped: number;
|
||||||
|
totalReviewTransitions: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ServiceContext = {
|
type ServiceContext = {
|
||||||
@@ -116,7 +120,7 @@ export function registerHeartbeatService(api: OpenClawPluginApi) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover DevClaw agents by scanning which agent workspaces have projects.
|
* Discover DevClaw agents by scanning which agent workspaces have projects.
|
||||||
* Self-discovering: any agent whose workspace contains projects/projects.json is processed.
|
* Self-discovering: any agent whose workspace contains projects.json is processed.
|
||||||
* Also checks the default workspace (agents.defaults.workspace) for projects.
|
* Also checks the default workspace (agents.defaults.workspace) for projects.
|
||||||
*/
|
*/
|
||||||
function discoverAgents(config: {
|
function discoverAgents(config: {
|
||||||
@@ -132,7 +136,7 @@ function discoverAgents(config: {
|
|||||||
for (const a of config.agents?.list || []) {
|
for (const a of config.agents?.list || []) {
|
||||||
if (!a.workspace) continue;
|
if (!a.workspace) continue;
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(path.join(a.workspace, "projects", "projects.json"))) {
|
if (hasProjects(a.workspace)) {
|
||||||
agents.push({ agentId: a.id, workspace: a.workspace });
|
agents.push({ agentId: a.id, workspace: a.workspace });
|
||||||
seen.add(a.workspace);
|
seen.add(a.workspace);
|
||||||
}
|
}
|
||||||
@@ -143,7 +147,7 @@ function discoverAgents(config: {
|
|||||||
const defaultWorkspace = config.agents?.defaults?.workspace;
|
const defaultWorkspace = config.agents?.defaults?.workspace;
|
||||||
if (defaultWorkspace && !seen.has(defaultWorkspace)) {
|
if (defaultWorkspace && !seen.has(defaultWorkspace)) {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(path.join(defaultWorkspace, "projects", "projects.json"))) {
|
if (hasProjects(defaultWorkspace)) {
|
||||||
agents.push({ agentId: "main", workspace: defaultWorkspace });
|
agents.push({ agentId: "main", workspace: defaultWorkspace });
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
@@ -152,6 +156,15 @@ function discoverAgents(config: {
|
|||||||
return agents;
|
return agents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check if a workspace has a projects.json (new or old locations). */
|
||||||
|
function hasProjects(workspace: string): boolean {
|
||||||
|
return (
|
||||||
|
fs.existsSync(path.join(workspace, DATA_DIR, "projects.json")) ||
|
||||||
|
fs.existsSync(path.join(workspace, "projects.json")) ||
|
||||||
|
fs.existsSync(path.join(workspace, "projects", "projects.json"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one heartbeat tick for all agents.
|
* Run one heartbeat tick for all agents.
|
||||||
*/
|
*/
|
||||||
@@ -182,6 +195,7 @@ async function processAllAgents(
|
|||||||
totalPickups: 0,
|
totalPickups: 0,
|
||||||
totalHealthFixes: 0,
|
totalHealthFixes: 0,
|
||||||
totalSkipped: 0,
|
totalSkipped: 0,
|
||||||
|
totalReviewTransitions: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch gateway sessions once for all agents/projects
|
// Fetch gateway sessions once for all agents/projects
|
||||||
@@ -200,6 +214,7 @@ async function processAllAgents(
|
|||||||
result.totalPickups += agentResult.totalPickups;
|
result.totalPickups += agentResult.totalPickups;
|
||||||
result.totalHealthFixes += agentResult.totalHealthFixes;
|
result.totalHealthFixes += agentResult.totalHealthFixes;
|
||||||
result.totalSkipped += agentResult.totalSkipped;
|
result.totalSkipped += agentResult.totalSkipped;
|
||||||
|
result.totalReviewTransitions += agentResult.totalReviewTransitions;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -209,9 +224,9 @@ async function processAllAgents(
|
|||||||
* Log tick results if anything happened.
|
* Log tick results if anything happened.
|
||||||
*/
|
*/
|
||||||
function logTickResult(result: TickResult, logger: ServiceContext["logger"]): void {
|
function logTickResult(result: TickResult, logger: ServiceContext["logger"]): void {
|
||||||
if (result.totalPickups > 0 || result.totalHealthFixes > 0) {
|
if (result.totalPickups > 0 || result.totalHealthFixes > 0 || result.totalReviewTransitions > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalSkipped} skipped`,
|
`work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalReviewTransitions} review transitions, ${result.totalSkipped} skipped`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,37 +249,54 @@ export async function tick(opts: {
|
|||||||
const projectIds = Object.keys(data.projects);
|
const projectIds = Object.keys(data.projects);
|
||||||
|
|
||||||
if (projectIds.length === 0) {
|
if (projectIds.length === 0) {
|
||||||
return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0 };
|
return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0, totalReviewTransitions: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: TickResult = {
|
const result: TickResult = {
|
||||||
totalPickups: 0,
|
totalPickups: 0,
|
||||||
totalHealthFixes: 0,
|
totalHealthFixes: 0,
|
||||||
totalSkipped: 0,
|
totalSkipped: 0,
|
||||||
|
totalReviewTransitions: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
|
const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL;
|
||||||
let activeProjects = 0;
|
let activeProjects = 0;
|
||||||
|
|
||||||
for (const groupId of projectIds) {
|
for (const groupId of projectIds) {
|
||||||
|
try {
|
||||||
const project = data.projects[groupId];
|
const project = data.projects[groupId];
|
||||||
if (!project) continue;
|
if (!project) continue;
|
||||||
|
|
||||||
|
const { provider } = await createProvider({ repo: project.repo, provider: project.provider });
|
||||||
|
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||||
|
|
||||||
// Health pass: auto-fix zombies and stale workers
|
// Health pass: auto-fix zombies and stale workers
|
||||||
result.totalHealthFixes += await performHealthPass(
|
result.totalHealthFixes += await performHealthPass(
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
groupId,
|
groupId,
|
||||||
project,
|
project,
|
||||||
sessions,
|
sessions,
|
||||||
|
provider,
|
||||||
|
resolvedConfig.timeouts.staleWorkerHours,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Review pass: transition issues whose PR check condition is met
|
||||||
|
result.totalReviewTransitions += await reviewPass({
|
||||||
|
workspaceDir,
|
||||||
|
groupId,
|
||||||
|
workflow: resolvedConfig.workflow,
|
||||||
|
provider,
|
||||||
|
repoPath: project.repo,
|
||||||
|
gitPullTimeoutMs: resolvedConfig.timeouts.gitPullMs,
|
||||||
|
});
|
||||||
|
|
||||||
// Budget check: stop if we've hit the limit
|
// Budget check: stop if we've hit the limit
|
||||||
const remaining = config.maxPickupsPerTick - result.totalPickups;
|
const remaining = config.maxPickupsPerTick - result.totalPickups;
|
||||||
if (remaining <= 0) break;
|
if (remaining <= 0) break;
|
||||||
|
|
||||||
// Sequential project guard: don't start new projects if one is active
|
// Sequential project guard: don't start new projects if one is active
|
||||||
const isProjectActive = await checkProjectActive(workspaceDir, groupId);
|
const isProjectActive = await checkProjectActive(workspaceDir, groupId);
|
||||||
if (projectExecution === "sequential" && !isProjectActive && activeProjects >= 1) {
|
if (projectExecution === ExecutionMode.SEQUENTIAL && !isProjectActive && activeProjects >= 1) {
|
||||||
result.totalSkipped++;
|
result.totalSkipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -283,11 +315,17 @@ export async function tick(opts: {
|
|||||||
|
|
||||||
// Notifications now handled by dispatchTask
|
// Notifications now handled by dispatchTask
|
||||||
if (isProjectActive || tickResult.pickups.length > 0) activeProjects++;
|
if (isProjectActive || tickResult.pickups.length > 0) activeProjects++;
|
||||||
|
} catch (err) {
|
||||||
|
// Per-project isolation: one failing project doesn't crash the entire tick
|
||||||
|
opts.logger.warn(`Heartbeat tick failed for project ${groupId}: ${(err as Error).message}`);
|
||||||
|
result.totalSkipped++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await auditLog(workspaceDir, "heartbeat_tick", {
|
await auditLog(workspaceDir, "heartbeat_tick", {
|
||||||
projectsScanned: projectIds.length,
|
projectsScanned: projectIds.length,
|
||||||
healthFixes: result.totalHealthFixes,
|
healthFixes: result.totalHealthFixes,
|
||||||
|
reviewTransitions: result.totalReviewTransitions,
|
||||||
pickups: result.totalPickups,
|
pickups: result.totalPickups,
|
||||||
skipped: result.totalSkipped,
|
skipped: result.totalSkipped,
|
||||||
});
|
});
|
||||||
@@ -303,20 +341,22 @@ async function performHealthPass(
|
|||||||
groupId: string,
|
groupId: string,
|
||||||
project: any,
|
project: any,
|
||||||
sessions: SessionLookup | null,
|
sessions: SessionLookup | null,
|
||||||
|
provider: import("../providers/provider.js").IssueProvider,
|
||||||
|
staleWorkerHours?: number,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const { provider } = await createProvider({ repo: project.repo, provider: project.provider });
|
|
||||||
let fixedCount = 0;
|
let fixedCount = 0;
|
||||||
|
|
||||||
for (const role of getAllRoleIds()) {
|
for (const role of Object.keys(project.workers)) {
|
||||||
// Check worker health (session liveness, label consistency, etc)
|
// Check worker health (session liveness, label consistency, etc)
|
||||||
const healthFixes = await checkWorkerHealth({
|
const healthFixes = await checkWorkerHealth({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
groupId,
|
groupId,
|
||||||
project,
|
project,
|
||||||
role: role as any,
|
role,
|
||||||
sessions,
|
sessions,
|
||||||
autoFix: true,
|
autoFix: true,
|
||||||
provider,
|
provider,
|
||||||
|
staleWorkerHours,
|
||||||
});
|
});
|
||||||
fixedCount += healthFixes.filter((f) => f.fixed).length;
|
fixedCount += healthFixes.filter((f) => f.fixed).length;
|
||||||
|
|
||||||
@@ -325,7 +365,7 @@ async function performHealthPass(
|
|||||||
workspaceDir,
|
workspaceDir,
|
||||||
groupId,
|
groupId,
|
||||||
project,
|
project,
|
||||||
role: role as any,
|
role,
|
||||||
autoFix: true,
|
autoFix: true,
|
||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
@@ -336,10 +376,10 @@ async function performHealthPass(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a project has active work (dev or qa).
|
* Check if a project has any active worker.
|
||||||
*/
|
*/
|
||||||
async function checkProjectActive(workspaceDir: string, groupId: string): Promise<boolean> {
|
async function checkProjectActive(workspaceDir: string, groupId: string): Promise<boolean> {
|
||||||
const fresh = (await readProjects(workspaceDir)).projects[groupId];
|
const fresh = (await readProjects(workspaceDir)).projects[groupId];
|
||||||
if (!fresh) return false;
|
if (!fresh) return false;
|
||||||
return fresh.dev.active || fresh.qa.active;
|
return Object.values(fresh.workers).some(w => w.active);
|
||||||
}
|
}
|
||||||
|
|||||||
1053
lib/services/pipeline.e2e.test.ts
Normal file
1053
lib/services/pipeline.e2e.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,12 @@ import type { StateLabel, IssueProvider } from "../providers/provider.js";
|
|||||||
import { deactivateWorker } from "../projects.js";
|
import { deactivateWorker } from "../projects.js";
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
import { notify, getNotificationConfig } from "../notify.js";
|
import { notify, getNotificationConfig } from "../notify.js";
|
||||||
|
import { log as auditLog } from "../audit.js";
|
||||||
|
import { loadConfig } from "../config/index.js";
|
||||||
|
import { detectStepRouting } from "./queue-scan.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
|
Action,
|
||||||
getCompletionRule,
|
getCompletionRule,
|
||||||
getNextStateDescription,
|
getNextStateDescription,
|
||||||
getCompletionEmoji,
|
getCompletionEmoji,
|
||||||
@@ -17,40 +21,6 @@ import {
|
|||||||
type WorkflowConfig,
|
type WorkflowConfig,
|
||||||
} from "../workflow.js";
|
} from "../workflow.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Backward compatibility exports
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getCompletionRule() from workflow.ts instead.
|
|
||||||
* Kept for backward compatibility.
|
|
||||||
*/
|
|
||||||
export const COMPLETION_RULES: Record<string, CompletionRule> = {
|
|
||||||
"dev:done": { from: "Doing", to: "To Test", gitPull: true, detectPr: true },
|
|
||||||
"qa:pass": { from: "Testing", to: "Done", closeIssue: true },
|
|
||||||
"qa:fail": { from: "Testing", to: "To Improve", reopenIssue: true },
|
|
||||||
"qa:refine": { from: "Testing", to: "Refining" },
|
|
||||||
"dev:blocked": { from: "Doing", to: "Refining" },
|
|
||||||
"qa:blocked": { from: "Testing", to: "Refining" },
|
|
||||||
"architect:done": { from: "Designing", to: "Planning" },
|
|
||||||
"architect:blocked": { from: "Designing", to: "Refining" },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getNextStateDescription() from workflow.ts instead.
|
|
||||||
*/
|
|
||||||
export const NEXT_STATE: Record<string, string> = {
|
|
||||||
"dev:done": "QA queue",
|
|
||||||
"dev:blocked": "moved to Refining - needs human input",
|
|
||||||
"qa:pass": "Done!",
|
|
||||||
"qa:fail": "back to DEV",
|
|
||||||
"qa:refine": "awaiting human decision",
|
|
||||||
"qa:blocked": "moved to Refining - needs human input",
|
|
||||||
"architect:done": "Planning — ready for review",
|
|
||||||
"architect:blocked": "moved to Refining - needs clarification",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Re-export CompletionRule type for backward compatibility
|
|
||||||
export type { CompletionRule };
|
export type { CompletionRule };
|
||||||
|
|
||||||
export type CompletionOutput = {
|
export type CompletionOutput = {
|
||||||
@@ -72,7 +42,7 @@ export function getRule(
|
|||||||
result: string,
|
result: string,
|
||||||
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
||||||
): CompletionRule | undefined {
|
): CompletionRule | undefined {
|
||||||
return getCompletionRule(workflow, role as "dev" | "qa", result) ?? undefined;
|
return getCompletionRule(workflow, role, result) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,7 +51,7 @@ export function getRule(
|
|||||||
export async function executeCompletion(opts: {
|
export async function executeCompletion(opts: {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
role: "dev" | "qa" | "architect";
|
role: string;
|
||||||
result: string;
|
result: string;
|
||||||
issueId: number;
|
issueId: number;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
@@ -106,18 +76,28 @@ export async function executeCompletion(opts: {
|
|||||||
const rule = getCompletionRule(workflow, role, result);
|
const rule = getCompletionRule(workflow, role, result);
|
||||||
if (!rule) throw new Error(`No completion rule for ${key}`);
|
if (!rule) throw new Error(`No completion rule for ${key}`);
|
||||||
|
|
||||||
|
const { timeouts } = await loadConfig(workspaceDir, projectName);
|
||||||
let prUrl = opts.prUrl;
|
let prUrl = opts.prUrl;
|
||||||
|
|
||||||
// Git pull (dev:done)
|
// Execute pre-notification actions
|
||||||
if (rule.gitPull) {
|
for (const action of rule.actions) {
|
||||||
try {
|
switch (action) {
|
||||||
await runCommand(["git", "pull"], { timeoutMs: 30_000, cwd: repoPath });
|
case Action.GIT_PULL:
|
||||||
} catch { /* best-effort */ }
|
try { await runCommand(["git", "pull"], { timeoutMs: timeouts.gitPullMs, cwd: repoPath }); } catch (err) {
|
||||||
|
auditLog(workspaceDir, "pipeline_warning", { step: "gitPull", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Action.DETECT_PR:
|
||||||
|
if (!prUrl) { try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch (err) {
|
||||||
|
auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||||
|
} }
|
||||||
|
break;
|
||||||
|
case Action.MERGE_PR:
|
||||||
|
try { await provider.mergePr(issueId); } catch (err) {
|
||||||
|
auditLog(workspaceDir, "pipeline_warning", { step: "mergePr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-detect PR URL (dev:done)
|
|
||||||
if (rule.detectPr && !prUrl) {
|
|
||||||
try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get issue early (for URL in notification)
|
// Get issue early (for URL in notification)
|
||||||
@@ -147,15 +127,55 @@ export async function executeCompletion(opts: {
|
|||||||
channel: channel ?? "telegram",
|
channel: channel ?? "telegram",
|
||||||
runtime,
|
runtime,
|
||||||
},
|
},
|
||||||
).catch(() => { /* non-fatal */ });
|
).catch((err) => {
|
||||||
|
auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
// Deactivate worker + transition label
|
// Deactivate worker + transition label
|
||||||
await deactivateWorker(workspaceDir, groupId, role);
|
await deactivateWorker(workspaceDir, groupId, role);
|
||||||
await provider.transitionLabel(issueId, rule.from as StateLabel, rule.to as StateLabel);
|
await provider.transitionLabel(issueId, rule.from as StateLabel, rule.to as StateLabel);
|
||||||
|
|
||||||
// Close/reopen
|
// Execute post-transition actions
|
||||||
if (rule.closeIssue) await provider.closeIssue(issueId);
|
for (const action of rule.actions) {
|
||||||
if (rule.reopenIssue) await provider.reopenIssue(issueId);
|
switch (action) {
|
||||||
|
case Action.CLOSE_ISSUE:
|
||||||
|
await provider.closeIssue(issueId);
|
||||||
|
break;
|
||||||
|
case Action.REOPEN_ISSUE:
|
||||||
|
await provider.reopenIssue(issueId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send review routing notification when developer completes
|
||||||
|
if (role === "developer" && result === "done") {
|
||||||
|
// Re-fetch issue to get labels after transition
|
||||||
|
const updated = await provider.getIssue(issueId);
|
||||||
|
const routing = detectStepRouting(updated.labels, "review") as "human" | "agent" | null;
|
||||||
|
if (routing === "human" || routing === "agent") {
|
||||||
|
notify(
|
||||||
|
{
|
||||||
|
type: "reviewNeeded",
|
||||||
|
project: projectName,
|
||||||
|
groupId,
|
||||||
|
issueId,
|
||||||
|
issueUrl: updated.web_url,
|
||||||
|
issueTitle: updated.title,
|
||||||
|
routing,
|
||||||
|
prUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workspaceDir,
|
||||||
|
config: notifyConfig,
|
||||||
|
groupId,
|
||||||
|
channel: channel ?? "telegram",
|
||||||
|
runtime,
|
||||||
|
},
|
||||||
|
).catch((err) => {
|
||||||
|
auditLog(workspaceDir, "pipeline_warning", { step: "reviewNotify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build announcement using workflow-derived emoji
|
// Build announcement using workflow-derived emoji
|
||||||
const emoji = getCompletionEmoji(role, result);
|
const emoji = getCompletionEmoji(role, result);
|
||||||
@@ -172,7 +192,7 @@ export async function executeCompletion(opts: {
|
|||||||
nextState,
|
nextState,
|
||||||
prUrl,
|
prUrl,
|
||||||
issueUrl: issue.web_url,
|
issueUrl: issue.web_url,
|
||||||
issueClosed: rule.closeIssue,
|
issueClosed: rule.actions.includes(Action.CLOSE_ISSUE),
|
||||||
issueReopened: rule.reopenIssue,
|
issueReopened: rule.actions.includes(Action.REOPEN_ISSUE),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
127
lib/services/queue-scan.ts
Normal file
127
lib/services/queue-scan.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* queue-scan.ts — Issue queue scanning helpers.
|
||||||
|
*
|
||||||
|
* Shared by: tick (projectTick), work-start (auto-pickup), and other consumers
|
||||||
|
* that need to find queued issues or detect roles/levels from labels.
|
||||||
|
*/
|
||||||
|
import type { Issue, StateLabel } from "../providers/provider.js";
|
||||||
|
import type { IssueProvider } from "../providers/provider.js";
|
||||||
|
import { getLevelsForRole, getAllLevels } from "../roles/index.js";
|
||||||
|
import {
|
||||||
|
getQueueLabels,
|
||||||
|
getAllQueueLabels,
|
||||||
|
detectRoleFromLabel as workflowDetectRole,
|
||||||
|
type WorkflowConfig,
|
||||||
|
type Role,
|
||||||
|
} from "../workflow.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Label detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function detectLevelFromLabels(labels: string[]): string | null {
|
||||||
|
const lower = labels.map((l) => l.toLowerCase());
|
||||||
|
|
||||||
|
// Priority 1: Match role:level labels (e.g., "developer:senior", "tester:junior")
|
||||||
|
for (const l of lower) {
|
||||||
|
const colon = l.indexOf(":");
|
||||||
|
if (colon === -1) continue;
|
||||||
|
const level = l.slice(colon + 1);
|
||||||
|
const all = getAllLevels();
|
||||||
|
if (all.includes(level)) return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Match legacy role.level labels (e.g., "dev.senior", "qa.mid")
|
||||||
|
for (const l of lower) {
|
||||||
|
const dot = l.indexOf(".");
|
||||||
|
if (dot === -1) continue;
|
||||||
|
const role = l.slice(0, dot);
|
||||||
|
const level = l.slice(dot + 1);
|
||||||
|
const roleLevels = getLevelsForRole(role);
|
||||||
|
if (roleLevels.includes(level)) return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: plain level name
|
||||||
|
const all = getAllLevels();
|
||||||
|
return all.find((l) => lower.includes(l)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect role and level from colon-format labels (e.g. "developer:senior").
|
||||||
|
* Returns the first match found, or null if no role:level label exists.
|
||||||
|
*/
|
||||||
|
export function detectRoleLevelFromLabels(
|
||||||
|
labels: string[],
|
||||||
|
): { role: string; level: string } | null {
|
||||||
|
for (const label of labels) {
|
||||||
|
const colon = label.indexOf(":");
|
||||||
|
if (colon === -1) continue;
|
||||||
|
const role = label.slice(0, colon).toLowerCase();
|
||||||
|
const level = label.slice(colon + 1).toLowerCase();
|
||||||
|
const roleLevels = getLevelsForRole(role);
|
||||||
|
if (roleLevels.includes(level)) return { role, level };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect step routing from labels (e.g. "review:human", "test:skip").
|
||||||
|
* Returns the routing value for the given step, or null if no routing label exists.
|
||||||
|
*/
|
||||||
|
export function detectStepRouting(
|
||||||
|
labels: string[], step: string,
|
||||||
|
): string | null {
|
||||||
|
const prefix = `${step}:`;
|
||||||
|
const match = labels.find((l) => l.toLowerCase().startsWith(prefix));
|
||||||
|
return match ? match.slice(prefix.length).toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect role from a label using workflow config.
|
||||||
|
*/
|
||||||
|
export function detectRoleFromLabel(
|
||||||
|
label: StateLabel,
|
||||||
|
workflow: WorkflowConfig,
|
||||||
|
): Role | null {
|
||||||
|
return workflowDetectRole(workflow, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Issue queue queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function findNextIssueForRole(
|
||||||
|
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
||||||
|
role: Role,
|
||||||
|
workflow: WorkflowConfig,
|
||||||
|
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||||
|
const labels = getQueueLabels(workflow, role);
|
||||||
|
for (const label of labels) {
|
||||||
|
try {
|
||||||
|
const issues = await provider.listIssuesByLabel(label);
|
||||||
|
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||||
|
} catch { /* continue */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find next issue for any role (optional filter). Used by work_start for auto-detection.
|
||||||
|
*/
|
||||||
|
export async function findNextIssue(
|
||||||
|
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
||||||
|
role: Role | undefined,
|
||||||
|
workflow: WorkflowConfig,
|
||||||
|
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
||||||
|
const labels = role
|
||||||
|
? getQueueLabels(workflow, role)
|
||||||
|
: getAllQueueLabels(workflow);
|
||||||
|
|
||||||
|
for (const label of labels) {
|
||||||
|
try {
|
||||||
|
const issues = await provider.listIssuesByLabel(label);
|
||||||
|
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
||||||
|
} catch { /* continue */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -9,30 +9,11 @@ import { createProvider } from "../providers/index.js";
|
|||||||
import type { Project } from "../projects.js";
|
import type { Project } from "../projects.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
DEFAULT_WORKFLOW,
|
||||||
|
StateType,
|
||||||
type WorkflowConfig,
|
type WorkflowConfig,
|
||||||
type Role,
|
type Role,
|
||||||
} from "../workflow.js";
|
} from "../workflow.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use string labels from workflow config instead.
|
|
||||||
* Kept for backward compatibility.
|
|
||||||
*/
|
|
||||||
export type QueueLabel = "To Improve" | "To Test" | "To Do";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getQueuePriority() instead.
|
|
||||||
* Kept for backward compatibility.
|
|
||||||
*/
|
|
||||||
export const QUEUE_PRIORITY: Record<string, number> = {
|
|
||||||
"To Improve": 3,
|
|
||||||
"To Test": 2,
|
|
||||||
"To Do": 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Workflow-driven helpers
|
// Workflow-driven helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -47,7 +28,7 @@ export function getQueueLabelsWithPriority(
|
|||||||
const labels: Array<{ label: string; priority: number; role?: Role }> = [];
|
const labels: Array<{ label: string; priority: number; role?: Role }> = [];
|
||||||
|
|
||||||
for (const state of Object.values(workflow.states)) {
|
for (const state of Object.values(workflow.states)) {
|
||||||
if (state.type === "queue") {
|
if (state.type === StateType.QUEUE) {
|
||||||
labels.push({
|
labels.push({
|
||||||
label: state.label,
|
label: state.label,
|
||||||
priority: state.priority ?? 0,
|
priority: state.priority ?? 0,
|
||||||
|
|||||||
133
lib/services/review.ts
Normal file
133
lib/services/review.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* review.ts — Poll review-type states for PR status changes.
|
||||||
|
*
|
||||||
|
* Scans review states in the workflow and transitions issues
|
||||||
|
* whose PR check condition (merged/approved) is met.
|
||||||
|
* Called by the heartbeat service during its periodic sweep.
|
||||||
|
*/
|
||||||
|
import type { IssueProvider } from "../providers/provider.js";
|
||||||
|
import { PrState } from "../providers/provider.js";
|
||||||
|
import {
|
||||||
|
Action,
|
||||||
|
ReviewCheck,
|
||||||
|
WorkflowEvent,
|
||||||
|
type WorkflowConfig,
|
||||||
|
type StateConfig,
|
||||||
|
} from "../workflow.js";
|
||||||
|
import { runCommand } from "../run-command.js";
|
||||||
|
import { log as auditLog } from "../audit.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan review-type states and transition issues whose PR check condition is met.
|
||||||
|
* Returns the number of transitions made.
|
||||||
|
*/
|
||||||
|
export async function reviewPass(opts: {
|
||||||
|
workspaceDir: string;
|
||||||
|
groupId: string;
|
||||||
|
workflow: WorkflowConfig;
|
||||||
|
provider: IssueProvider;
|
||||||
|
repoPath: string;
|
||||||
|
gitPullTimeoutMs?: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
const { workspaceDir, groupId, workflow, provider, repoPath, gitPullTimeoutMs = 30_000 } = opts;
|
||||||
|
let transitions = 0;
|
||||||
|
|
||||||
|
// Find all states with a review check (e.g. toReview with check: prApproved)
|
||||||
|
const reviewStates = Object.entries(workflow.states)
|
||||||
|
.filter(([, s]) => s.check != null) as [string, StateConfig][];
|
||||||
|
|
||||||
|
for (const [stateKey, state] of reviewStates) {
|
||||||
|
if (!state.on || !state.check) continue;
|
||||||
|
|
||||||
|
const issues = await provider.listIssuesByLabel(state.label);
|
||||||
|
for (const issue of issues) {
|
||||||
|
const status = await provider.getPrStatus(issue.iid);
|
||||||
|
|
||||||
|
const conditionMet =
|
||||||
|
(state.check === ReviewCheck.PR_MERGED && status.state === PrState.MERGED) ||
|
||||||
|
(state.check === ReviewCheck.PR_APPROVED && (status.state === PrState.APPROVED || status.state === PrState.MERGED));
|
||||||
|
|
||||||
|
if (!conditionMet) continue;
|
||||||
|
|
||||||
|
// Find the success transition — use the APPROVED event (matches check condition)
|
||||||
|
const successEvent = Object.keys(state.on).find(
|
||||||
|
(e) => e === WorkflowEvent.APPROVED,
|
||||||
|
);
|
||||||
|
if (!successEvent) continue;
|
||||||
|
|
||||||
|
const transition = state.on[successEvent];
|
||||||
|
const targetKey = typeof transition === "string" ? transition : transition.target;
|
||||||
|
const actions = typeof transition === "object" ? transition.actions : undefined;
|
||||||
|
const targetState = workflow.states[targetKey];
|
||||||
|
if (!targetState) continue;
|
||||||
|
|
||||||
|
// Execute transition actions — mergePr is critical (aborts on failure)
|
||||||
|
let aborted = false;
|
||||||
|
if (actions) {
|
||||||
|
for (const action of actions) {
|
||||||
|
switch (action) {
|
||||||
|
case Action.MERGE_PR:
|
||||||
|
try {
|
||||||
|
await provider.mergePr(issue.iid);
|
||||||
|
} catch (err) {
|
||||||
|
// Merge failed → fire MERGE_FAILED transition (developer fixes conflicts)
|
||||||
|
await auditLog(workspaceDir, "review_merge_failed", {
|
||||||
|
groupId,
|
||||||
|
issueId: issue.iid,
|
||||||
|
from: state.label,
|
||||||
|
error: (err as Error).message ?? String(err),
|
||||||
|
});
|
||||||
|
const failedTransition = state.on[WorkflowEvent.MERGE_FAILED];
|
||||||
|
if (failedTransition) {
|
||||||
|
const failedKey = typeof failedTransition === "string" ? failedTransition : failedTransition.target;
|
||||||
|
const failedState = workflow.states[failedKey];
|
||||||
|
if (failedState) {
|
||||||
|
await provider.transitionLabel(issue.iid, state.label, failedState.label);
|
||||||
|
await auditLog(workspaceDir, "review_transition", {
|
||||||
|
groupId,
|
||||||
|
issueId: issue.iid,
|
||||||
|
from: state.label,
|
||||||
|
to: failedState.label,
|
||||||
|
reason: "merge_failed",
|
||||||
|
});
|
||||||
|
transitions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aborted = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Action.GIT_PULL:
|
||||||
|
try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ }
|
||||||
|
break;
|
||||||
|
case Action.CLOSE_ISSUE:
|
||||||
|
await provider.closeIssue(issue.iid);
|
||||||
|
break;
|
||||||
|
case Action.REOPEN_ISSUE:
|
||||||
|
await provider.reopenIssue(issue.iid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (aborted) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aborted) continue; // skip normal transition, move to next issue
|
||||||
|
|
||||||
|
// Transition label
|
||||||
|
await provider.transitionLabel(issue.iid, state.label, targetState.label);
|
||||||
|
|
||||||
|
await auditLog(workspaceDir, "review_transition", {
|
||||||
|
groupId,
|
||||||
|
issueId: issue.iid,
|
||||||
|
from: state.label,
|
||||||
|
to: targetState.label,
|
||||||
|
check: state.check,
|
||||||
|
prState: status.state,
|
||||||
|
prUrl: status.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
transitions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transitions;
|
||||||
|
}
|
||||||
@@ -11,103 +11,16 @@ import { createProvider } from "../providers/index.js";
|
|||||||
import { selectLevel } from "../model-selector.js";
|
import { selectLevel } from "../model-selector.js";
|
||||||
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
|
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
|
||||||
import { dispatchTask } from "../dispatch.js";
|
import { dispatchTask } from "../dispatch.js";
|
||||||
import { getAllRoleIds, getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js";
|
import { getLevelsForRole } from "../roles/index.js";
|
||||||
|
import { loadConfig } from "../config/index.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKFLOW,
|
ExecutionMode,
|
||||||
getQueueLabels,
|
ReviewPolicy,
|
||||||
getAllQueueLabels,
|
|
||||||
getActiveLabel,
|
getActiveLabel,
|
||||||
detectRoleFromLabel as workflowDetectRole,
|
|
||||||
type WorkflowConfig,
|
type WorkflowConfig,
|
||||||
type Role,
|
type Role,
|
||||||
} from "../workflow.js";
|
} from "../workflow.js";
|
||||||
|
import { detectRoleLevelFromLabels, detectStepRouting, findNextIssueForRole } from "./queue-scan.js";
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Backward compatibility exports (deprecated)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getQueueLabels(workflow, "dev") instead.
|
|
||||||
*/
|
|
||||||
export const DEV_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "dev");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getQueueLabels(workflow, "qa") instead.
|
|
||||||
*/
|
|
||||||
export const QA_LABELS: StateLabel[] = getQueueLabels(DEFAULT_WORKFLOW, "qa");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getAllQueueLabels(workflow) instead.
|
|
||||||
*/
|
|
||||||
export const PRIORITY_ORDER: StateLabel[] = getAllQueueLabels(DEFAULT_WORKFLOW);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared helpers (used by tick, work-start, auto-pickup)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function detectLevelFromLabels(labels: string[]): string | null {
|
|
||||||
const lower = labels.map((l) => l.toLowerCase());
|
|
||||||
|
|
||||||
// Match role.level labels (e.g., "dev.senior", "qa.reviewer", "architect.opus")
|
|
||||||
for (const l of lower) {
|
|
||||||
const dot = l.indexOf(".");
|
|
||||||
if (dot === -1) continue;
|
|
||||||
const role = l.slice(0, dot);
|
|
||||||
const level = l.slice(dot + 1);
|
|
||||||
const roleLevels = getLevelsForRole(role);
|
|
||||||
if (roleLevels.includes(level)) return level;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: plain level name
|
|
||||||
const all = getAllLevels();
|
|
||||||
return all.find((l) => lower.includes(l)) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect role from a label using workflow config.
|
|
||||||
*/
|
|
||||||
export function detectRoleFromLabel(
|
|
||||||
label: StateLabel,
|
|
||||||
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
|
||||||
): Role | null {
|
|
||||||
return workflowDetectRole(workflow, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findNextIssueForRole(
|
|
||||||
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
|
||||||
role: Role,
|
|
||||||
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
|
||||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
|
||||||
const labels = getQueueLabels(workflow, role);
|
|
||||||
for (const label of labels) {
|
|
||||||
try {
|
|
||||||
const issues = await provider.listIssuesByLabel(label);
|
|
||||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
|
||||||
} catch { /* continue */ }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find next issue for any role (optional filter). Used by work_start for auto-detection.
|
|
||||||
*/
|
|
||||||
export async function findNextIssue(
|
|
||||||
provider: Pick<IssueProvider, "listIssuesByLabel">,
|
|
||||||
role?: Role,
|
|
||||||
workflow: WorkflowConfig = DEFAULT_WORKFLOW,
|
|
||||||
): Promise<{ issue: Issue; label: StateLabel } | null> {
|
|
||||||
const labels = role
|
|
||||||
? getQueueLabels(workflow, role)
|
|
||||||
: getAllQueueLabels(workflow);
|
|
||||||
|
|
||||||
for (const label of labels) {
|
|
||||||
try {
|
|
||||||
const issues = await provider.listIssuesByLabel(label);
|
|
||||||
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
|
|
||||||
} catch { /* continue */ }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// projectTick
|
// projectTick
|
||||||
@@ -156,15 +69,20 @@ export async function projectTick(opts: {
|
|||||||
const {
|
const {
|
||||||
workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun,
|
workspaceDir, groupId, agentId, sessionKey, pluginConfig, dryRun,
|
||||||
maxPickups, targetRole, runtime,
|
maxPickups, targetRole, runtime,
|
||||||
workflow = DEFAULT_WORKFLOW,
|
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const project = (await readProjects(workspaceDir)).projects[groupId];
|
const project = (await readProjects(workspaceDir)).projects[groupId];
|
||||||
if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] };
|
if (!project) return { pickups: [], skipped: [{ reason: `Project not found: ${groupId}` }] };
|
||||||
|
|
||||||
|
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||||
|
const workflow = opts.workflow ?? resolvedConfig.workflow;
|
||||||
|
|
||||||
const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider;
|
const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider;
|
||||||
const roleExecution = project.roleExecution ?? "parallel";
|
const roleExecution = project.roleExecution ?? ExecutionMode.PARALLEL;
|
||||||
const roles: Role[] = targetRole ? [targetRole] : getAllRoleIds() as Role[];
|
const enabledRoles = Object.entries(resolvedConfig.roles)
|
||||||
|
.filter(([, r]) => r.enabled)
|
||||||
|
.map(([id]) => id);
|
||||||
|
const roles: Role[] = targetRole ? [targetRole] : enabledRoles;
|
||||||
|
|
||||||
const pickups: TickAction[] = [];
|
const pickups: TickAction[] = [];
|
||||||
const skipped: TickResult["skipped"] = [];
|
const skipped: TickResult["skipped"] = [];
|
||||||
@@ -186,18 +104,43 @@ export async function projectTick(opts: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Check sequential role execution: any other role must be inactive
|
// Check sequential role execution: any other role must be inactive
|
||||||
const otherRoles = getAllRoleIds().filter(r => r !== role);
|
const otherRoles = enabledRoles.filter((r: string) => r !== role);
|
||||||
if (roleExecution === "sequential" && otherRoles.some(r => getWorker(fresh, r as any).active)) {
|
if (roleExecution === ExecutionMode.SEQUENTIAL && otherRoles.some((r: string) => getWorker(fresh, r).active)) {
|
||||||
skipped.push({ role, reason: "Sequential: other role active" });
|
skipped.push({ role, reason: "Sequential: other role active" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Review policy gate: fallback for issues dispatched before step routing labels existed
|
||||||
|
if (role === "reviewer") {
|
||||||
|
const policy = workflow.reviewPolicy ?? ReviewPolicy.AUTO;
|
||||||
|
if (policy === ReviewPolicy.HUMAN) {
|
||||||
|
skipped.push({ role, reason: "Review policy: human (heartbeat handles via PR polling)" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const next = await findNextIssueForRole(provider, role, workflow);
|
const next = await findNextIssueForRole(provider, role, workflow);
|
||||||
if (!next) continue;
|
if (!next) continue;
|
||||||
|
|
||||||
const { issue, label: currentLabel } = next;
|
const { issue, label: currentLabel } = next;
|
||||||
const targetLabel = getActiveLabel(workflow, role);
|
const targetLabel = getActiveLabel(workflow, role);
|
||||||
|
|
||||||
|
// Step routing: check for review:human / review:skip / test:skip labels
|
||||||
|
if (role === "reviewer") {
|
||||||
|
const routing = detectStepRouting(issue.labels, "review");
|
||||||
|
if (routing === "human" || routing === "skip") {
|
||||||
|
skipped.push({ role, reason: `review:${routing} label` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (role === "tester") {
|
||||||
|
const routing = detectStepRouting(issue.labels, "test");
|
||||||
|
if (routing === "skip") {
|
||||||
|
skipped.push({ role, reason: "test:skip label" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Level selection: label → heuristic
|
// Level selection: label → heuristic
|
||||||
const selectedLevel = resolveLevelForIssue(issue, role);
|
const selectedLevel = resolveLevelForIssue(issue, role);
|
||||||
|
|
||||||
@@ -241,15 +184,25 @@ export async function projectTick(opts: {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine the level for an issue based on labels, role overrides, and heuristic fallback.
|
* Determine the level for an issue based on labels and heuristic fallback.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. This role's own label (e.g. tester:medior from a previous dispatch)
|
||||||
|
* 2. Inherit from another role's label (e.g. developer:medior → tester uses medior)
|
||||||
|
* 3. Heuristic fallback (first dispatch, no labels yet)
|
||||||
*/
|
*/
|
||||||
function resolveLevelForIssue(issue: Issue, role: Role): string {
|
function resolveLevelForIssue(issue: Issue, role: Role): string {
|
||||||
const labelLevel = detectLevelFromLabels(issue.labels);
|
const roleLevel = detectRoleLevelFromLabels(issue.labels);
|
||||||
if (labelLevel) {
|
|
||||||
const labelRole = roleForLevel(labelLevel);
|
// Own role label
|
||||||
// If label level belongs to a different role, use heuristic for correct role
|
if (roleLevel?.role === role) return roleLevel.level;
|
||||||
if (labelRole && labelRole !== role) return selectLevel(issue.title, issue.description ?? "", role).level;
|
|
||||||
return labelLevel;
|
// Inherit from another role's label if level is valid for this role
|
||||||
|
if (roleLevel) {
|
||||||
|
const levels = getLevelsForRole(role);
|
||||||
|
if (levels.includes(roleLevel.level)) return roleLevel.level;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Heuristic fallback
|
||||||
return selectLevel(issue.title, issue.description ?? "", role).level;
|
return selectLevel(issue.title, issue.description ?? "", role).level;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* setup/config.ts — Plugin config writer (openclaw.json).
|
* setup/config.ts — Plugin config writer (openclaw.json).
|
||||||
*
|
*
|
||||||
* Handles: model level config, tool restrictions, subagent cleanup.
|
* Handles: tool restrictions, subagent cleanup, heartbeat defaults.
|
||||||
|
* Models are stored in workflow.yaml (not openclaw.json).
|
||||||
*/
|
*/
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
|
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
|
||||||
|
import type { ExecutionMode } from "../workflow.js";
|
||||||
type ModelConfig = { dev: Record<string, string>; qa: Record<string, string> };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write DevClaw model level config to openclaw.json plugins section.
|
* Write DevClaw plugin config to openclaw.json plugins section.
|
||||||
*
|
*
|
||||||
* Also configures:
|
* Configures:
|
||||||
* - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents
|
* - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents
|
||||||
* - Subagent cleanup interval (30 days) to keep development sessions alive
|
* - Subagent cleanup interval (30 days) to keep development sessions alive
|
||||||
|
* - Heartbeat defaults
|
||||||
*
|
*
|
||||||
* Read-modify-write to preserve existing config.
|
* Read-modify-write to preserve existing config.
|
||||||
|
* Note: models are NOT stored here — they live in workflow.yaml.
|
||||||
*/
|
*/
|
||||||
export async function writePluginConfig(
|
export async function writePluginConfig(
|
||||||
api: OpenClawPluginApi,
|
api: OpenClawPluginApi,
|
||||||
models: ModelConfig,
|
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
projectExecution?: "parallel" | "sequential",
|
projectExecution?: ExecutionMode,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const config = api.runtime.config.loadConfig() as Record<string, unknown>;
|
const config = api.runtime.config.loadConfig() as Record<string, unknown>;
|
||||||
|
|
||||||
ensurePluginStructure(config);
|
ensurePluginStructure(config);
|
||||||
(config as any).plugins.entries.devclaw.config.models = models;
|
|
||||||
|
|
||||||
if (projectExecution) {
|
if (projectExecution) {
|
||||||
(config as any).plugins.entries.devclaw.config.projectExecution = projectExecution;
|
(config as any).plugins.entries.devclaw.config.projectExecution = projectExecution;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up legacy models from openclaw.json (moved to workflow.yaml)
|
||||||
|
delete (config as any).plugins.entries.devclaw.config.models;
|
||||||
|
|
||||||
|
ensureInternalHooks(config);
|
||||||
ensureHeartbeatDefaults(config);
|
ensureHeartbeatDefaults(config);
|
||||||
configureSubagentCleanup(config);
|
configureSubagentCleanup(config);
|
||||||
|
|
||||||
@@ -74,6 +78,13 @@ function addToolRestrictions(config: Record<string, unknown>, agentId: string):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureInternalHooks(config: Record<string, unknown>): void {
|
||||||
|
if (!config.hooks) config.hooks = {};
|
||||||
|
const hooks = config.hooks as Record<string, unknown>;
|
||||||
|
if (!hooks.internal) hooks.internal = {};
|
||||||
|
(hooks.internal as Record<string, unknown>).enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureHeartbeatDefaults(config: Record<string, unknown>): void {
|
function ensureHeartbeatDefaults(config: Record<string, unknown>): void {
|
||||||
const devclaw = (config as any).plugins.entries.devclaw.config;
|
const devclaw = (config as any).plugins.entries.devclaw.config;
|
||||||
if (!devclaw.work_heartbeat) {
|
if (!devclaw.work_heartbeat) {
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* setup/index.ts — DevClaw setup orchestrator.
|
* setup/index.ts — DevClaw setup orchestrator.
|
||||||
*
|
*
|
||||||
* Coordinates: agent creation → model config → workspace scaffolding.
|
* Coordinates: agent creation → plugin config → workspace scaffolding → model config.
|
||||||
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
|
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
|
||||||
*/
|
*/
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import YAML from "yaml";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { DEFAULT_MODELS } from "../tiers.js";
|
import { getAllDefaultModels } from "../roles/index.js";
|
||||||
import { migrateChannelBinding } from "../binding-manager.js";
|
import { migrateChannelBinding } from "../binding-manager.js";
|
||||||
import { createAgent, resolveWorkspacePath } from "./agent.js";
|
import { createAgent, resolveWorkspacePath } from "./agent.js";
|
||||||
import { writePluginConfig } from "./config.js";
|
import { writePluginConfig } from "./config.js";
|
||||||
import { scaffoldWorkspace } from "./workspace.js";
|
import { scaffoldWorkspace } from "./workspace.js";
|
||||||
|
import { DATA_DIR } from "./migrate-layout.js";
|
||||||
|
import type { ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
export type ModelConfig = { dev: Record<string, string>; qa: Record<string, string> };
|
export type ModelConfig = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
export type SetupOpts = {
|
export type SetupOpts = {
|
||||||
/** OpenClaw plugin API for config access. */
|
/** OpenClaw plugin API for config access. */
|
||||||
@@ -27,9 +32,9 @@ export type SetupOpts = {
|
|||||||
/** Override workspace path (auto-detected from agent if not given). */
|
/** Override workspace path (auto-detected from agent if not given). */
|
||||||
workspacePath?: string;
|
workspacePath?: string;
|
||||||
/** Model overrides per role.level. Missing levels use defaults. */
|
/** Model overrides per role.level. Missing levels use defaults. */
|
||||||
models?: { dev?: Partial<Record<string, string>>; qa?: Partial<Record<string, string>> };
|
models?: Record<string, Partial<Record<string, string>>>;
|
||||||
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
|
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
|
||||||
projectExecution?: "parallel" | "sequential";
|
projectExecution?: ExecutionMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetupResult = {
|
export type SetupResult = {
|
||||||
@@ -49,8 +54,9 @@ export type SetupResult = {
|
|||||||
* Run the full DevClaw setup.
|
* Run the full DevClaw setup.
|
||||||
*
|
*
|
||||||
* 1. Create agent (optional) or resolve existing workspace
|
* 1. Create agent (optional) or resolve existing workspace
|
||||||
* 2. Merge model config and write to openclaw.json
|
* 2. Write plugin config to openclaw.json (heartbeat, tool restrictions — no models)
|
||||||
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, roles, memory)
|
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, workflow.yaml, prompts)
|
||||||
|
* 4. Write model config to workflow.yaml (single source of truth)
|
||||||
*/
|
*/
|
||||||
export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
@@ -58,10 +64,13 @@ export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
|||||||
const { agentId, workspacePath, agentCreated, bindingMigrated } =
|
const { agentId, workspacePath, agentCreated, bindingMigrated } =
|
||||||
await resolveOrCreateAgent(opts, warnings);
|
await resolveOrCreateAgent(opts, warnings);
|
||||||
|
|
||||||
const models = buildModelConfig(opts.models);
|
await writePluginConfig(opts.api, agentId, opts.projectExecution);
|
||||||
await writePluginConfig(opts.api, models, agentId, opts.projectExecution);
|
|
||||||
|
|
||||||
const filesWritten = await scaffoldWorkspace(workspacePath);
|
const defaultWorkspacePath = getDefaultWorkspacePath(opts.api);
|
||||||
|
const filesWritten = await scaffoldWorkspace(workspacePath, defaultWorkspacePath);
|
||||||
|
|
||||||
|
const models = buildModelConfig(opts.models);
|
||||||
|
await writeModelsToWorkflow(workspacePath, models);
|
||||||
|
|
||||||
return { agentId, agentCreated, workspacePath, models, filesWritten, warnings, bindingMigrated };
|
return { agentId, agentCreated, workspacePath, models, filesWritten, warnings, bindingMigrated };
|
||||||
}
|
}
|
||||||
@@ -113,19 +122,59 @@ async function tryMigrateBinding(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
|
function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
|
||||||
const dev: Record<string, string> = { ...DEFAULT_MODELS.dev };
|
const defaults = getAllDefaultModels();
|
||||||
const qa: Record<string, string> = { ...DEFAULT_MODELS.qa };
|
const result: ModelConfig = {};
|
||||||
|
|
||||||
if (overrides?.dev) {
|
for (const [role, levels] of Object.entries(defaults)) {
|
||||||
for (const [level, model] of Object.entries(overrides.dev)) {
|
result[role] = { ...levels };
|
||||||
if (model) dev[level] = model;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (overrides) {
|
||||||
|
for (const [role, roleOverrides] of Object.entries(overrides)) {
|
||||||
|
if (!result[role]) result[role] = {};
|
||||||
|
for (const [level, model] of Object.entries(roleOverrides)) {
|
||||||
|
if (model) result[role][level] = model;
|
||||||
}
|
}
|
||||||
if (overrides?.qa) {
|
|
||||||
for (const [level, model] of Object.entries(overrides.qa)) {
|
|
||||||
if (model) qa[level] = model;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { dev, qa };
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultWorkspacePath(api: OpenClawPluginApi): string | undefined {
|
||||||
|
try {
|
||||||
|
const config = api.runtime.config.loadConfig();
|
||||||
|
return (config as any).agents?.defaults?.workspace ?? undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write model configuration to workflow.yaml (single source of truth).
|
||||||
|
* Reads the existing workflow.yaml, merges model overrides into the roles section, and writes back.
|
||||||
|
*/
|
||||||
|
async function writeModelsToWorkflow(workspacePath: string, models: ModelConfig): Promise<void> {
|
||||||
|
const workflowPath = path.join(workspacePath, DATA_DIR, "workflow.yaml");
|
||||||
|
|
||||||
|
let doc: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(workflowPath, "utf-8");
|
||||||
|
doc = (YAML.parse(content) as Record<string, unknown>) ?? {};
|
||||||
|
} catch { /* file doesn't exist yet — start fresh */ }
|
||||||
|
|
||||||
|
// Merge models into roles section
|
||||||
|
if (!doc.roles) doc.roles = {};
|
||||||
|
const roles = doc.roles as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const [role, levels] of Object.entries(models)) {
|
||||||
|
if (!roles[role] || roles[role] === false) {
|
||||||
|
roles[role] = { models: levels };
|
||||||
|
} else {
|
||||||
|
const roleObj = roles[role] as Record<string, unknown>;
|
||||||
|
roleObj.models = levels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(workflowPath, YAML.stringify(doc, { lineWidth: 120 }), "utf-8");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,63 @@
|
|||||||
* Uses an LLM to understand model capabilities and assign optimal models to DevClaw roles.
|
* Uses an LLM to understand model capabilities and assign optimal models to DevClaw roles.
|
||||||
*/
|
*/
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
|
import { ROLE_REGISTRY } from "../roles/index.js";
|
||||||
|
import type { ModelAssignment } from "./smart-model-selector.js";
|
||||||
|
|
||||||
export type ModelAssignment = {
|
/**
|
||||||
dev: {
|
* Build a ModelAssignment where every role/level maps to the same model.
|
||||||
junior: string;
|
*/
|
||||||
medior: string;
|
function singleModelAssignment(model: string): ModelAssignment {
|
||||||
senior: string;
|
const result: ModelAssignment = {};
|
||||||
};
|
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||||
qa: {
|
result[roleId] = {};
|
||||||
reviewer: string;
|
for (const level of config.levels) {
|
||||||
tester: string;
|
result[roleId][level] = model;
|
||||||
};
|
}
|
||||||
architect: {
|
}
|
||||||
opus: string;
|
return result;
|
||||||
sonnet: string;
|
}
|
||||||
};
|
|
||||||
};
|
/**
|
||||||
|
* Build the JSON format example for the LLM prompt, derived from registry.
|
||||||
|
*/
|
||||||
|
function buildJsonExample(): string {
|
||||||
|
const obj: Record<string, Record<string, string>> = {};
|
||||||
|
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||||
|
obj[roleId] = {};
|
||||||
|
for (const level of config.levels) {
|
||||||
|
obj[roleId][level] = "provider/model-name";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a parsed assignment has all required roles and levels.
|
||||||
|
*/
|
||||||
|
function validateAssignment(assignment: Record<string, unknown>, fallbackModel: string): ModelAssignment | null {
|
||||||
|
const result: ModelAssignment = {};
|
||||||
|
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||||
|
const roleData = assignment[roleId] as Record<string, string> | undefined;
|
||||||
|
if (!roleData) {
|
||||||
|
// Backfill missing roles from the first available role or fallback
|
||||||
|
result[roleId] = {};
|
||||||
|
for (const level of config.levels) {
|
||||||
|
result[roleId][level] = fallbackModel;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[roleId] = {};
|
||||||
|
for (const level of config.levels) {
|
||||||
|
if (!roleData[level]) {
|
||||||
|
console.error(`Missing ${roleId}.${level} in LLM assignment`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
result[roleId][level] = roleData[level];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use an LLM to intelligently select and assign models to DevClaw roles.
|
* Use an LLM to intelligently select and assign models to DevClaw roles.
|
||||||
@@ -34,53 +75,33 @@ export async function selectModelsWithLLM(
|
|||||||
|
|
||||||
// If only one model, assign it to all roles
|
// If only one model, assign it to all roles
|
||||||
if (availableModels.length === 1) {
|
if (availableModels.length === 1) {
|
||||||
const model = availableModels[0].model;
|
return singleModelAssignment(availableModels[0].model);
|
||||||
return {
|
|
||||||
dev: { junior: model, medior: model, senior: model },
|
|
||||||
qa: { reviewer: model, tester: model },
|
|
||||||
architect: { opus: model, sonnet: model },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a prompt for the LLM
|
// Create a prompt for the LLM
|
||||||
const modelList = availableModels.map((m) => m.model).join("\n");
|
const modelList = availableModels.map((m) => m.model).join("\n");
|
||||||
|
const jsonExample = buildJsonExample();
|
||||||
|
|
||||||
const prompt = `You are an AI model expert. Analyze the following authenticated AI models and assign them to DevClaw development roles based on their capabilities.
|
const prompt = `You are an AI model expert. Analyze the following authenticated AI models and assign them to DevClaw development roles based on their capabilities.
|
||||||
|
|
||||||
Available models:
|
Available models:
|
||||||
${modelList}
|
${modelList}
|
||||||
|
|
||||||
Assign models to these roles based on capability:
|
All roles use the same level scheme based on task complexity:
|
||||||
- **senior** (most capable): Complex architecture, refactoring, critical decisions
|
- **senior** (most capable): Complex architecture, refactoring, critical decisions
|
||||||
- **medior** (balanced): Features, bug fixes, code review
|
- **medior** (balanced): Features, bug fixes, code review, standard tasks
|
||||||
- **junior** (fast/efficient): Simple fixes, testing, routine tasks
|
- **junior** (fast/efficient): Simple fixes, routine tasks
|
||||||
- **reviewer** (same as medior): Code review
|
|
||||||
- **tester** (same as junior): Testing
|
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
1. Prefer same provider for consistency
|
1. Prefer same provider for consistency
|
||||||
2. Assign most capable model to senior
|
2. Assign most capable model to senior
|
||||||
3. Assign mid-tier model to medior/reviewer
|
3. Assign mid-tier model to medior
|
||||||
4. Assign fastest/cheapest model to junior/tester
|
4. Assign fastest/cheapest model to junior
|
||||||
5. Consider model version numbers (higher = newer/better)
|
5. Consider model version numbers (higher = newer/better)
|
||||||
6. Stable versions (no date) > snapshot versions (with date like 20250514)
|
6. Stable versions (no date) > snapshot versions (with date like 20250514)
|
||||||
|
|
||||||
Return ONLY a JSON object in this exact format (no markdown, no explanation):
|
Return ONLY a JSON object in this exact format (no markdown, no explanation):
|
||||||
{
|
${jsonExample}`;
|
||||||
"dev": {
|
|
||||||
"junior": "provider/model-name",
|
|
||||||
"medior": "provider/model-name",
|
|
||||||
"senior": "provider/model-name"
|
|
||||||
},
|
|
||||||
"qa": {
|
|
||||||
"reviewer": "provider/model-name",
|
|
||||||
"tester": "provider/model-name"
|
|
||||||
},
|
|
||||||
"architect": {
|
|
||||||
"opus": "provider/model-name",
|
|
||||||
"sonnet": "provider/model-name"
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionId = sessionKey ?? "devclaw-model-selection";
|
const sessionId = sessionKey ?? "devclaw-model-selection";
|
||||||
@@ -127,27 +148,14 @@ Return ONLY a JSON object in this exact format (no markdown, no explanation):
|
|||||||
// Log what we got for debugging
|
// Log what we got for debugging
|
||||||
console.log("LLM returned:", JSON.stringify(assignment, null, 2));
|
console.log("LLM returned:", JSON.stringify(assignment, null, 2));
|
||||||
|
|
||||||
// Validate the structure
|
// Validate and backfill
|
||||||
// Backfill architect if LLM didn't return it (graceful upgrade)
|
const validated = validateAssignment(assignment, availableModels[0].model);
|
||||||
if (!assignment.architect) {
|
if (!validated) {
|
||||||
assignment.architect = {
|
|
||||||
opus: assignment.dev?.senior ?? availableModels[0].model,
|
|
||||||
sonnet: assignment.dev?.medior ?? availableModels[0].model,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!assignment.dev?.junior ||
|
|
||||||
!assignment.dev?.medior ||
|
|
||||||
!assignment.dev?.senior ||
|
|
||||||
!assignment.qa?.reviewer ||
|
|
||||||
!assignment.qa?.tester
|
|
||||||
) {
|
|
||||||
console.error("Invalid assignment structure. Got:", assignment);
|
console.error("Invalid assignment structure. Got:", assignment);
|
||||||
throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`);
|
throw new Error(`Invalid assignment structure from LLM. Missing fields in: ${JSON.stringify(Object.keys(assignment))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignment as ModelAssignment;
|
return validated;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("LLM model selection failed:", (err as Error).message);
|
console.error("LLM model selection failed:", (err as Error).message);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
312
lib/setup/migrate-layout.test.ts
Normal file
312
lib/setup/migrate-layout.test.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* Tests for workspace layout migration.
|
||||||
|
* Run with: npx tsx --test lib/setup/migrate-layout.test.ts
|
||||||
|
*/
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { migrateWorkspaceLayout } from "./migrate-layout.js";
|
||||||
|
|
||||||
|
async function fileExists(p: string): Promise<boolean> {
|
||||||
|
try { await fs.access(p); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("migrateWorkspaceLayout — very old layout → devclaw/", () => {
|
||||||
|
it("should move projects/projects.json to devclaw/projects.json", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const projDir = path.join(tmpDir, "projects");
|
||||||
|
await fs.mkdir(projDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects.json")), "projects.json should be at devclaw/");
|
||||||
|
assert.ok(!await fileExists(path.join(projDir, "projects.json")), "old projects.json should be removed");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rename projects/config.yaml to devclaw/workflow.yaml", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const projDir = path.join(tmpDir, "projects");
|
||||||
|
await fs.mkdir(projDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(projDir, "config.yaml"), "roles:\n dev:\n defaultLevel: medior\n");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "workflow.yaml")), "workflow.yaml should be at devclaw/");
|
||||||
|
assert.ok(!await fileExists(path.join(projDir, "config.yaml")), "old config.yaml should be removed");
|
||||||
|
const content = await fs.readFile(path.join(tmpDir, "devclaw", "workflow.yaml"), "utf-8");
|
||||||
|
assert.ok(content.includes("defaultLevel: medior"), "content should be preserved");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move roles/default/* to devclaw/prompts/ with renames", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const projDir = path.join(tmpDir, "projects");
|
||||||
|
const defaultDir = path.join(projDir, "roles", "default");
|
||||||
|
await fs.mkdir(defaultDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(defaultDir, "dev.md"), "# Dev instructions");
|
||||||
|
await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA instructions");
|
||||||
|
await fs.writeFile(path.join(defaultDir, "architect.md"), "# Architect instructions");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "dev.md should become developer.md");
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "tester.md")), "qa.md should become tester.md");
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "architect.md")), "architect.md should stay");
|
||||||
|
|
||||||
|
const devContent = await fs.readFile(path.join(tmpDir, "devclaw", "prompts", "developer.md"), "utf-8");
|
||||||
|
assert.strictEqual(devContent, "# Dev instructions");
|
||||||
|
|
||||||
|
assert.ok(!await fileExists(defaultDir), "old default dir should be removed");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move roles/<project>/* to devclaw/projects/<project>/prompts/ with renames", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const projDir = path.join(tmpDir, "projects");
|
||||||
|
const roleDir = path.join(projDir, "roles", "my-app");
|
||||||
|
await fs.mkdir(roleDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(roleDir, "dev.md"), "# My App Developer");
|
||||||
|
await fs.writeFile(path.join(roleDir, "qa.md"), "# My App Tester");
|
||||||
|
await fs.writeFile(path.join(roleDir, "architect.md"), "# My App Architect");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "dev.md should become prompts/developer.md");
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "tester.md")), "qa.md should become prompts/tester.md");
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "architect.md")), "architect.md should be in prompts/");
|
||||||
|
|
||||||
|
const content = await fs.readFile(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md"), "utf-8");
|
||||||
|
assert.strictEqual(content, "# My App Developer");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rename projects/<project>/config.yaml to devclaw/projects/<project>/workflow.yaml", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const projDir = path.join(tmpDir, "projects");
|
||||||
|
const appDir = path.join(projDir, "my-app");
|
||||||
|
await fs.mkdir(appDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(appDir, "config.yaml"), "roles:\n dev:\n defaultLevel: senior\n");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "workflow.yaml")), "workflow.yaml should exist");
|
||||||
|
assert.ok(!await fileExists(path.join(appDir, "config.yaml")), "old config.yaml should be removed");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move log/ to devclaw/log/", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const projDir = path.join(tmpDir, "projects");
|
||||||
|
const logDir = path.join(tmpDir, "log");
|
||||||
|
await fs.mkdir(projDir, { recursive: true });
|
||||||
|
await fs.mkdir(logDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(logDir, "audit.log"), '{"ts":"2024-01-01"}');
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "log", "audit.log")), "audit.log should be in devclaw/log/");
|
||||||
|
assert.ok(!await fileExists(path.join(logDir, "audit.log")), "old audit.log should be removed");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migrateWorkspaceLayout — intermediate layout → devclaw/", () => {
|
||||||
|
it("should move projects.json from root to devclaw/", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects.json")), "projects.json should be in devclaw/");
|
||||||
|
assert.ok(!await fileExists(path.join(tmpDir, "projects.json")), "root projects.json should be removed");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move workflow.yaml from root to devclaw/", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(tmpDir, "workflow.yaml"), "roles:\n dev:\n defaultLevel: medior\n");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "workflow.yaml")), "workflow.yaml should be in devclaw/");
|
||||||
|
assert.ok(!await fileExists(path.join(tmpDir, "workflow.yaml")), "root workflow.yaml should be removed");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move prompts/ from root to devclaw/prompts/", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const promptsDir = path.join(tmpDir, "prompts");
|
||||||
|
await fs.mkdir(promptsDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(promptsDir, "developer.md"), "# Dev");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "developer.md should be in devclaw/prompts/");
|
||||||
|
assert.ok(!await fileExists(path.join(promptsDir, "developer.md")), "old prompts/developer.md should be removed");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move project .md files into prompts/ subdir", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const projectDir = path.join(tmpDir, "projects", "my-app");
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(projectDir, "developer.md"), "# My App Dev");
|
||||||
|
await fs.writeFile(path.join(projectDir, "workflow.yaml"), "roles: {}");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "developer.md should be in prompts/ subdir");
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "workflow.yaml")), "workflow.yaml should stay at project root");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rename old role files (dev.md, qa.md) in prompts/", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const promptsDir = path.join(tmpDir, "prompts");
|
||||||
|
await fs.mkdir(promptsDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(promptsDir, "dev.md"), "# Old Dev");
|
||||||
|
await fs.writeFile(path.join(promptsDir, "qa.md"), "# Old QA");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "dev.md should become developer.md");
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "tester.md")), "qa.md should become tester.md");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rename old role files in project prompts/", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const projectDir = path.join(tmpDir, "projects", "my-app");
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(projectDir, "dev.md"), "# My App Dev");
|
||||||
|
await fs.writeFile(path.join(projectDir, "qa.md"), "# My App QA");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "dev.md should become prompts/developer.md");
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "tester.md")), "qa.md should become prompts/tester.md");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move log/ from root to devclaw/log/", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const logDir = path.join(tmpDir, "log");
|
||||||
|
await fs.mkdir(logDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(logDir, "audit.log"), '{"ts":"2024-01-01"}');
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "log", "audit.log")), "audit.log should be in devclaw/log/");
|
||||||
|
assert.ok(!await fileExists(path.join(logDir, "audit.log")), "old log/audit.log should be removed");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migrateWorkspaceLayout — flat project prompts → prompts/ subdir", () => {
|
||||||
|
it("should move flat .md files into prompts/ subdir", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const dataDir = path.join(tmpDir, "devclaw");
|
||||||
|
const projectDir = path.join(dataDir, "projects", "my-app");
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(projectDir, "developer.md"), "# Dev");
|
||||||
|
await fs.writeFile(path.join(projectDir, "tester.md"), "# Tester");
|
||||||
|
await fs.writeFile(path.join(projectDir, "workflow.yaml"), "roles: {}");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(projectDir, "prompts", "developer.md")), "developer.md should be in prompts/");
|
||||||
|
assert.ok(await fileExists(path.join(projectDir, "prompts", "tester.md")), "tester.md should be in prompts/");
|
||||||
|
assert.ok(!await fileExists(path.join(projectDir, "developer.md")), "flat developer.md should be removed");
|
||||||
|
assert.ok(await fileExists(path.join(projectDir, "workflow.yaml")), "workflow.yaml should stay");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rename old role files during subdir migration", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const dataDir = path.join(tmpDir, "devclaw");
|
||||||
|
const projectDir = path.join(dataDir, "projects", "my-app");
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(projectDir, "dev.md"), "# Old Dev");
|
||||||
|
await fs.writeFile(path.join(projectDir, "qa.md"), "# Old QA");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(projectDir, "prompts", "developer.md")), "dev.md should become prompts/developer.md");
|
||||||
|
assert.ok(await fileExists(path.join(projectDir, "prompts", "tester.md")), "qa.md should become prompts/tester.md");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip projects that already have prompts/ subdir", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const dataDir = path.join(tmpDir, "devclaw");
|
||||||
|
const projectDir = path.join(dataDir, "projects", "my-app");
|
||||||
|
const promptsDir = path.join(projectDir, "prompts");
|
||||||
|
await fs.mkdir(promptsDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(promptsDir, "developer.md"), "# Already migrated");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
const content = await fs.readFile(path.join(promptsDir, "developer.md"), "utf-8");
|
||||||
|
assert.strictEqual(content, "# Already migrated", "existing prompts/ should not be touched");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migrateWorkspaceLayout — no-op cases", () => {
|
||||||
|
it("should no-op when already fully migrated", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
const dataDir = path.join(tmpDir, "devclaw");
|
||||||
|
const promptsDir = path.join(dataDir, "projects", "app", "prompts");
|
||||||
|
await fs.mkdir(promptsDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
|
||||||
|
await fs.writeFile(path.join(promptsDir, "developer.md"), "# Dev");
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
assert.ok(await fileExists(path.join(promptsDir, "developer.md")), "prompts should still exist");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should no-op when workspace is empty", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
|
||||||
|
|
||||||
|
await migrateWorkspaceLayout(tmpDir);
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
365
lib/setup/migrate-layout.ts
Normal file
365
lib/setup/migrate-layout.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
/**
|
||||||
|
* setup/migrate-layout.ts — One-time workspace layout migration.
|
||||||
|
*
|
||||||
|
* Migrates from old layouts to the current devclaw/ data directory:
|
||||||
|
*
|
||||||
|
* Very old layout (pre-restructure):
|
||||||
|
* projects/projects.json → devclaw/projects.json
|
||||||
|
* projects/config.yaml → devclaw/workflow.yaml
|
||||||
|
* projects/roles/default/* → devclaw/prompts/* (with dev.md→developer.md, qa.md→tester.md)
|
||||||
|
* projects/roles/<project>/* → devclaw/projects/<project>/prompts/*
|
||||||
|
* projects/<project>/config.yaml → devclaw/projects/<project>/workflow.yaml
|
||||||
|
*
|
||||||
|
* Intermediate layout (post-restructure, pre-devclaw/):
|
||||||
|
* projects.json → devclaw/projects.json
|
||||||
|
* workflow.yaml → devclaw/workflow.yaml
|
||||||
|
* prompts/* → devclaw/prompts/*
|
||||||
|
* projects/<project>/*.md → devclaw/projects/<project>/prompts/*
|
||||||
|
* projects/<project>/workflow.yaml→ devclaw/projects/<project>/workflow.yaml
|
||||||
|
* log/* → devclaw/log/*
|
||||||
|
*
|
||||||
|
* Flat project layout (early devclaw/ without prompts subdir):
|
||||||
|
* devclaw/projects/<project>/*.md → devclaw/projects/<project>/prompts/*
|
||||||
|
*
|
||||||
|
* This file can be removed once all workspaces have been migrated.
|
||||||
|
*/
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/** Role file renames: old filename → new filename. */
|
||||||
|
const ROLE_FILE_RENAMES: Record<string, string> = {
|
||||||
|
"dev.md": "developer.md",
|
||||||
|
"qa.md": "tester.md",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The data directory name inside the workspace. */
|
||||||
|
export const DATA_DIR = "devclaw";
|
||||||
|
|
||||||
|
/** Track which workspaces have been migrated this process. */
|
||||||
|
const migrated = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a workspace has been migrated and default files exist (at most once per process).
|
||||||
|
* Safe to call from any code path — no-ops if already run this process.
|
||||||
|
*/
|
||||||
|
export async function ensureWorkspaceMigrated(workspaceDir: string): Promise<void> {
|
||||||
|
if (migrated.has(workspaceDir)) return;
|
||||||
|
migrated.add(workspaceDir);
|
||||||
|
await migrateWorkspaceLayout(workspaceDir);
|
||||||
|
// Lazy import to avoid circular dependency (workspace.ts imports from this file)
|
||||||
|
const { ensureDefaultFiles } = await import("./workspace.js");
|
||||||
|
await ensureDefaultFiles(workspaceDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate workspace from old layouts to new devclaw/ data directory.
|
||||||
|
*
|
||||||
|
* Detects four states:
|
||||||
|
* 1. Already migrated: devclaw/projects.json exists → check prompt subdir migration
|
||||||
|
* 2. Intermediate layout: projects.json at workspace root → move into devclaw/
|
||||||
|
* 3. Very old layout: projects/projects.json → full migration into devclaw/
|
||||||
|
* 4. Empty workspace → no-op
|
||||||
|
*/
|
||||||
|
export async function migrateWorkspaceLayout(workspaceDir: string): Promise<void> {
|
||||||
|
const dataDir = path.join(workspaceDir, DATA_DIR);
|
||||||
|
const newProjectsJson = path.join(dataDir, "projects.json");
|
||||||
|
|
||||||
|
// Already migrated — but may need prompt subdir migration
|
||||||
|
if (await fileExists(newProjectsJson)) {
|
||||||
|
await migratePromptSubdirs(dataDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for intermediate layout (post-restructure, pre-devclaw/)
|
||||||
|
const rootProjectsJson = path.join(workspaceDir, "projects.json");
|
||||||
|
if (await fileExists(rootProjectsJson)) {
|
||||||
|
await migrateFromIntermediate(workspaceDir, dataDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for very old layout (projects/projects.json)
|
||||||
|
const oldProjectsJson = path.join(workspaceDir, "projects", "projects.json");
|
||||||
|
if (await fileExists(oldProjectsJson)) {
|
||||||
|
await migrateFromOldLayout(workspaceDir, dataDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move flat prompt files in project dirs into prompts/ subdirs.
|
||||||
|
* Handles: devclaw/projects/<project>/<role>.md → devclaw/projects/<project>/prompts/<role>.md
|
||||||
|
*/
|
||||||
|
async function migratePromptSubdirs(dataDir: string): Promise<void> {
|
||||||
|
const projectsDir = path.join(dataDir, "projects");
|
||||||
|
if (!await dirExists(projectsDir)) return;
|
||||||
|
|
||||||
|
const entries = await fs.readdir(projectsDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const projectDir = path.join(projectsDir, entry.name);
|
||||||
|
|
||||||
|
// Skip if already has prompts/ subdir
|
||||||
|
const promptsDir = path.join(projectDir, "prompts");
|
||||||
|
if (await dirExists(promptsDir)) continue;
|
||||||
|
|
||||||
|
// Check if there are .md files at project root
|
||||||
|
const files = await fs.readdir(projectDir);
|
||||||
|
const mdFiles = files.filter(f => f.endsWith(".md"));
|
||||||
|
if (mdFiles.length === 0) continue;
|
||||||
|
|
||||||
|
// Move .md files into prompts/ subdir (with renames)
|
||||||
|
await fs.mkdir(promptsDir, { recursive: true });
|
||||||
|
for (const file of mdFiles) {
|
||||||
|
const newName = ROLE_FILE_RENAMES[file] ?? file;
|
||||||
|
const dest = path.join(promptsDir, newName);
|
||||||
|
if (!await fileExists(dest)) {
|
||||||
|
await safeCopy(path.join(projectDir, file), dest);
|
||||||
|
}
|
||||||
|
await fs.unlink(path.join(projectDir, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate from intermediate layout (files at workspace root) into devclaw/.
|
||||||
|
*/
|
||||||
|
async function migrateFromIntermediate(workspaceDir: string, dataDir: string): Promise<void> {
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
// Move projects.json
|
||||||
|
await moveIfExists(
|
||||||
|
path.join(workspaceDir, "projects.json"),
|
||||||
|
path.join(dataDir, "projects.json"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move workflow.yaml
|
||||||
|
await moveIfExists(
|
||||||
|
path.join(workspaceDir, "workflow.yaml"),
|
||||||
|
path.join(dataDir, "workflow.yaml"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move prompts/ directory (with role file renames)
|
||||||
|
await moveDirWithRenames(
|
||||||
|
path.join(workspaceDir, "prompts"),
|
||||||
|
path.join(dataDir, "prompts"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move projects/ directory — prompt files go into prompts/ subdir
|
||||||
|
await moveProjectDirs(
|
||||||
|
path.join(workspaceDir, "projects"),
|
||||||
|
path.join(dataDir, "projects"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move log/ directory
|
||||||
|
await moveDirIfExists(
|
||||||
|
path.join(workspaceDir, "log"),
|
||||||
|
path.join(dataDir, "log"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate from very old layout (projects/projects.json) directly into devclaw/.
|
||||||
|
*/
|
||||||
|
async function migrateFromOldLayout(workspaceDir: string, dataDir: string): Promise<void> {
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
// 1. Move projects/projects.json → devclaw/projects.json
|
||||||
|
const oldProjectsJson = path.join(workspaceDir, "projects", "projects.json");
|
||||||
|
await safeCopy(oldProjectsJson, path.join(dataDir, "projects.json"));
|
||||||
|
await fs.unlink(oldProjectsJson);
|
||||||
|
|
||||||
|
// 2. Move projects/config.yaml → devclaw/workflow.yaml
|
||||||
|
const oldConfig = path.join(workspaceDir, "projects", "config.yaml");
|
||||||
|
const newConfig = path.join(dataDir, "workflow.yaml");
|
||||||
|
if (await fileExists(oldConfig) && !await fileExists(newConfig)) {
|
||||||
|
await safeCopy(oldConfig, newConfig);
|
||||||
|
await fs.unlink(oldConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Move projects/roles/default/* → devclaw/prompts/* (with renames)
|
||||||
|
const oldDefaultsDir = path.join(workspaceDir, "projects", "roles", "default");
|
||||||
|
const newPromptsDir = path.join(dataDir, "prompts");
|
||||||
|
if (await dirExists(oldDefaultsDir)) {
|
||||||
|
await fs.mkdir(newPromptsDir, { recursive: true });
|
||||||
|
const files = await fs.readdir(oldDefaultsDir);
|
||||||
|
for (const file of files) {
|
||||||
|
const newName = ROLE_FILE_RENAMES[file] ?? file;
|
||||||
|
const dest = path.join(newPromptsDir, newName);
|
||||||
|
if (!await fileExists(dest)) {
|
||||||
|
await safeCopy(path.join(oldDefaultsDir, file), dest);
|
||||||
|
}
|
||||||
|
await fs.unlink(path.join(oldDefaultsDir, file));
|
||||||
|
}
|
||||||
|
await rmEmptyDir(oldDefaultsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Move projects/roles/<project>/* → devclaw/projects/<project>/prompts/* (with renames)
|
||||||
|
const oldRolesDir = path.join(workspaceDir, "projects", "roles");
|
||||||
|
if (await dirExists(oldRolesDir)) {
|
||||||
|
const entries = await fs.readdir(oldRolesDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const projectName = entry.name;
|
||||||
|
const srcDir = path.join(oldRolesDir, projectName);
|
||||||
|
const destDir = path.join(dataDir, "projects", projectName, "prompts");
|
||||||
|
await fs.mkdir(destDir, { recursive: true });
|
||||||
|
|
||||||
|
const roleFiles = await fs.readdir(srcDir);
|
||||||
|
for (const file of roleFiles) {
|
||||||
|
const newName = ROLE_FILE_RENAMES[file] ?? file;
|
||||||
|
const dest = path.join(destDir, newName);
|
||||||
|
if (!await fileExists(dest)) {
|
||||||
|
await safeCopy(path.join(srcDir, file), dest);
|
||||||
|
}
|
||||||
|
await fs.unlink(path.join(srcDir, file));
|
||||||
|
}
|
||||||
|
await rmEmptyDir(srcDir);
|
||||||
|
}
|
||||||
|
await rmEmptyDir(oldRolesDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Rename projects/<project>/config.yaml → devclaw/projects/<project>/workflow.yaml
|
||||||
|
const oldProjectsDir = path.join(workspaceDir, "projects");
|
||||||
|
if (await dirExists(oldProjectsDir)) {
|
||||||
|
const entries = await fs.readdir(oldProjectsDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const oldCfg = path.join(oldProjectsDir, entry.name, "config.yaml");
|
||||||
|
const newCfg = path.join(dataDir, "projects", entry.name, "workflow.yaml");
|
||||||
|
if (await fileExists(oldCfg) && !await fileExists(newCfg)) {
|
||||||
|
await safeCopy(oldCfg, newCfg);
|
||||||
|
await fs.unlink(oldCfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Move log/ directory
|
||||||
|
await moveDirIfExists(
|
||||||
|
path.join(workspaceDir, "log"),
|
||||||
|
path.join(dataDir, "log"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function fileExists(p: string): Promise<boolean> {
|
||||||
|
try { await fs.access(p); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dirExists(p: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(p);
|
||||||
|
return stat.isDirectory();
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeCopy(src: string, dest: string): Promise<void> {
|
||||||
|
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||||
|
await fs.copyFile(src, dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rmEmptyDir(dir: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(dir);
|
||||||
|
if (entries.length === 0) await fs.rmdir(dir);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move a file if it exists and dest doesn't. */
|
||||||
|
async function moveIfExists(src: string, dest: string): Promise<void> {
|
||||||
|
if (await fileExists(src) && !await fileExists(dest)) {
|
||||||
|
await safeCopy(src, dest);
|
||||||
|
await fs.unlink(src);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move an entire directory's contents if it exists. */
|
||||||
|
async function moveDirIfExists(srcDir: string, destDir: string): Promise<void> {
|
||||||
|
if (!await dirExists(srcDir)) return;
|
||||||
|
await fs.mkdir(destDir, { recursive: true });
|
||||||
|
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = path.join(srcDir, entry.name);
|
||||||
|
const destPath = path.join(destDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await moveDirIfExists(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
if (!await fileExists(destPath)) {
|
||||||
|
await safeCopy(srcPath, destPath);
|
||||||
|
}
|
||||||
|
await fs.unlink(srcPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await rmEmptyDir(srcDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move a directory, applying ROLE_FILE_RENAMES to files and recursing into subdirs. */
|
||||||
|
async function moveDirWithRenames(srcDir: string, destDir: string): Promise<void> {
|
||||||
|
if (!await dirExists(srcDir)) return;
|
||||||
|
await fs.mkdir(destDir, { recursive: true });
|
||||||
|
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = path.join(srcDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await moveDirWithRenames(srcPath, path.join(destDir, entry.name));
|
||||||
|
} else {
|
||||||
|
const newName = ROLE_FILE_RENAMES[entry.name] ?? entry.name;
|
||||||
|
const destPath = path.join(destDir, newName);
|
||||||
|
if (!await fileExists(destPath)) {
|
||||||
|
await safeCopy(srcPath, destPath);
|
||||||
|
}
|
||||||
|
await fs.unlink(srcPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await rmEmptyDir(srcDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move project directories: .md files go into prompts/ subdir (with renames),
|
||||||
|
* other files (workflow.yaml) stay at project root.
|
||||||
|
*/
|
||||||
|
async function moveProjectDirs(srcDir: string, destDir: string): Promise<void> {
|
||||||
|
if (!await dirExists(srcDir)) return;
|
||||||
|
await fs.mkdir(destDir, { recursive: true });
|
||||||
|
|
||||||
|
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = path.join(srcDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
// Each subdirectory is a project — move its contents with prompt separation
|
||||||
|
const destProjectDir = path.join(destDir, entry.name);
|
||||||
|
await fs.mkdir(destProjectDir, { recursive: true });
|
||||||
|
|
||||||
|
const projectFiles = await fs.readdir(srcPath);
|
||||||
|
for (const file of projectFiles) {
|
||||||
|
const fileSrc = path.join(srcPath, file);
|
||||||
|
if (file.endsWith(".md")) {
|
||||||
|
// Prompt file → prompts/ subdir (with renames)
|
||||||
|
const newName = ROLE_FILE_RENAMES[file] ?? file;
|
||||||
|
const promptsDest = path.join(destProjectDir, "prompts", newName);
|
||||||
|
if (!await fileExists(promptsDest)) {
|
||||||
|
await safeCopy(fileSrc, promptsDest);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Config file → project root
|
||||||
|
const fileDest = path.join(destProjectDir, file);
|
||||||
|
if (!await fileExists(fileDest)) {
|
||||||
|
await safeCopy(fileSrc, fileDest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fs.unlink(fileSrc);
|
||||||
|
}
|
||||||
|
await rmEmptyDir(srcPath);
|
||||||
|
} else {
|
||||||
|
// Top-level file in projects/ dir — just move
|
||||||
|
const destPath = path.join(destDir, entry.name);
|
||||||
|
if (!await fileExists(destPath)) {
|
||||||
|
await safeCopy(srcPath, destPath);
|
||||||
|
}
|
||||||
|
await fs.unlink(srcPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await rmEmptyDir(srcDir);
|
||||||
|
}
|
||||||
@@ -3,22 +3,25 @@
|
|||||||
*
|
*
|
||||||
* Uses an LLM to intelligently analyze and assign models to DevClaw roles.
|
* Uses an LLM to intelligently analyze and assign models to DevClaw roles.
|
||||||
*/
|
*/
|
||||||
|
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||||
|
import { ROLE_REGISTRY } from "../roles/index.js";
|
||||||
|
|
||||||
export type ModelAssignment = {
|
/** Model assignment: role → level → model ID. Derived from registry structure. */
|
||||||
dev: {
|
export type ModelAssignment = Record<string, Record<string, string>>;
|
||||||
junior: string;
|
|
||||||
medior: string;
|
/**
|
||||||
senior: string;
|
* Build a ModelAssignment where every role/level maps to the same model.
|
||||||
};
|
*/
|
||||||
qa: {
|
function singleModelAssignment(model: string): ModelAssignment {
|
||||||
reviewer: string;
|
const result: ModelAssignment = {};
|
||||||
tester: string;
|
for (const [roleId, config] of Object.entries(ROLE_REGISTRY)) {
|
||||||
};
|
result[roleId] = {};
|
||||||
architect: {
|
for (const level of config.levels) {
|
||||||
opus: string;
|
result[roleId][level] = model;
|
||||||
sonnet: string;
|
}
|
||||||
};
|
}
|
||||||
};
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intelligently assign available models to DevClaw roles using an LLM.
|
* Intelligently assign available models to DevClaw roles using an LLM.
|
||||||
@@ -41,12 +44,7 @@ export async function assignModels(
|
|||||||
|
|
||||||
// If only one model, use it for everything
|
// If only one model, use it for everything
|
||||||
if (authenticated.length === 1) {
|
if (authenticated.length === 1) {
|
||||||
const model = authenticated[0].model;
|
return singleModelAssignment(authenticated[0].model);
|
||||||
return {
|
|
||||||
dev: { junior: model, medior: model, senior: model },
|
|
||||||
qa: { reviewer: model, tester: model },
|
|
||||||
architect: { opus: model, sonnet: model },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple models: use LLM-based selection
|
// Multiple models: use LLM-based selection
|
||||||
@@ -66,15 +64,17 @@ export async function assignModels(
|
|||||||
export function formatAssignment(assignment: ModelAssignment): string {
|
export function formatAssignment(assignment: ModelAssignment): string {
|
||||||
const lines = [
|
const lines = [
|
||||||
"| Role | Level | Model |",
|
"| Role | Level | Model |",
|
||||||
"|------|----------|--------------------------|",
|
"|-----------|----------|--------------------------|",
|
||||||
`| DEV | senior | ${assignment.dev.senior.padEnd(24)} |`,
|
|
||||||
`| DEV | medior | ${assignment.dev.medior.padEnd(24)} |`,
|
|
||||||
`| DEV | junior | ${assignment.dev.junior.padEnd(24)} |`,
|
|
||||||
`| QA | reviewer | ${assignment.qa.reviewer.padEnd(24)} |`,
|
|
||||||
`| QA | tester | ${assignment.qa.tester.padEnd(24)} |`,
|
|
||||||
`| ARCH | opus | ${assignment.architect.opus.padEnd(24)} |`,
|
|
||||||
`| ARCH | sonnet | ${assignment.architect.sonnet.padEnd(24)} |`,
|
|
||||||
];
|
];
|
||||||
|
for (const roleId of getAllRoleIds()) {
|
||||||
|
const roleModels = assignment[roleId];
|
||||||
|
if (!roleModels) continue;
|
||||||
|
const displayName = ROLE_REGISTRY[roleId]?.displayName ?? roleId.toUpperCase();
|
||||||
|
for (const level of getLevelsForRole(roleId)) {
|
||||||
|
const model = roleModels[level] ?? "";
|
||||||
|
lines.push(`| ${displayName.padEnd(9)} | ${level.padEnd(8)} | ${model.padEnd(24)} |`);
|
||||||
|
}
|
||||||
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,109 @@
|
|||||||
/**
|
/**
|
||||||
* setup/workspace.ts — Workspace file scaffolding.
|
* setup/workspace.ts — Workspace file scaffolding.
|
||||||
*
|
*
|
||||||
* Writes AGENTS.md, HEARTBEAT.md, default role instructions, and projects.json.
|
* Writes AGENTS.md, HEARTBEAT.md, default role prompts, and projects.json.
|
||||||
*/
|
*/
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
AGENTS_MD_TEMPLATE,
|
AGENTS_MD_TEMPLATE,
|
||||||
HEARTBEAT_MD_TEMPLATE,
|
HEARTBEAT_MD_TEMPLATE,
|
||||||
DEFAULT_DEV_INSTRUCTIONS,
|
IDENTITY_MD_TEMPLATE,
|
||||||
DEFAULT_QA_INSTRUCTIONS,
|
SOUL_MD_TEMPLATE,
|
||||||
DEFAULT_ARCHITECT_INSTRUCTIONS,
|
WORKFLOW_YAML_TEMPLATE,
|
||||||
|
DEFAULT_ROLE_INSTRUCTIONS,
|
||||||
} from "../templates.js";
|
} from "../templates.js";
|
||||||
|
import { getAllRoleIds } from "../roles/index.js";
|
||||||
|
import { migrateWorkspaceLayout, DATA_DIR } from "./migrate-layout.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure default data files exist in the workspace.
|
||||||
|
* Only creates files that are missing — never overwrites existing ones.
|
||||||
|
* Called automatically after migration (via ensureWorkspaceMigrated).
|
||||||
|
*/
|
||||||
|
export async function ensureDefaultFiles(workspacePath: string): Promise<void> {
|
||||||
|
const dataDir = path.join(workspacePath, DATA_DIR);
|
||||||
|
|
||||||
|
// devclaw/workflow.yaml
|
||||||
|
const workflowPath = path.join(dataDir, "workflow.yaml");
|
||||||
|
if (!await fileExists(workflowPath)) {
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
await fs.writeFile(workflowPath, WORKFLOW_YAML_TEMPLATE, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// devclaw/projects.json
|
||||||
|
const projectsJsonPath = path.join(dataDir, "projects.json");
|
||||||
|
if (!await fileExists(projectsJsonPath)) {
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
await fs.writeFile(projectsJsonPath, JSON.stringify({ projects: {} }, null, 2) + "\n", "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// devclaw/projects/ directory
|
||||||
|
await fs.mkdir(path.join(dataDir, "projects"), { recursive: true });
|
||||||
|
|
||||||
|
// devclaw/prompts/ — default role instructions
|
||||||
|
const promptsDir = path.join(dataDir, "prompts");
|
||||||
|
await fs.mkdir(promptsDir, { recursive: true });
|
||||||
|
for (const role of getAllRoleIds()) {
|
||||||
|
const rolePath = path.join(promptsDir, `${role}.md`);
|
||||||
|
if (!await fileExists(rolePath)) {
|
||||||
|
const content = DEFAULT_ROLE_INSTRUCTIONS[role] ?? `# ${role.toUpperCase()} Worker Instructions\n\nAdd role-specific instructions here.\n`;
|
||||||
|
await fs.writeFile(rolePath, content, "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// devclaw/log/ directory (audit.log created on first write)
|
||||||
|
await fs.mkdir(path.join(dataDir, "log"), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write all workspace files for a DevClaw agent.
|
* Write all workspace files for a DevClaw agent.
|
||||||
* Returns the list of files that were written (skips files that already exist).
|
* Returns the list of files that were written (skips files that already exist).
|
||||||
|
*
|
||||||
|
* @param defaultWorkspacePath — If provided, USER.md is copied from here (only if not already present).
|
||||||
*/
|
*/
|
||||||
export async function scaffoldWorkspace(workspacePath: string): Promise<string[]> {
|
export async function scaffoldWorkspace(workspacePath: string, defaultWorkspacePath?: string): Promise<string[]> {
|
||||||
const filesWritten: string[] = [];
|
// Migrate old layout if detected
|
||||||
|
await migrateWorkspaceLayout(workspacePath);
|
||||||
|
|
||||||
// AGENTS.md (backup existing)
|
const written: string[] = [];
|
||||||
|
|
||||||
|
// AGENTS.md (backup existing — stays at workspace root)
|
||||||
await backupAndWrite(path.join(workspacePath, "AGENTS.md"), AGENTS_MD_TEMPLATE);
|
await backupAndWrite(path.join(workspacePath, "AGENTS.md"), AGENTS_MD_TEMPLATE);
|
||||||
filesWritten.push("AGENTS.md");
|
written.push("AGENTS.md");
|
||||||
|
|
||||||
// HEARTBEAT.md
|
// HEARTBEAT.md (stays at workspace root)
|
||||||
await backupAndWrite(path.join(workspacePath, "HEARTBEAT.md"), HEARTBEAT_MD_TEMPLATE);
|
await backupAndWrite(path.join(workspacePath, "HEARTBEAT.md"), HEARTBEAT_MD_TEMPLATE);
|
||||||
filesWritten.push("HEARTBEAT.md");
|
written.push("HEARTBEAT.md");
|
||||||
|
|
||||||
// projects/projects.json
|
// IDENTITY.md (create-only — never overwrite user customizations)
|
||||||
const projectsDir = path.join(workspacePath, "projects");
|
const identityPath = path.join(workspacePath, "IDENTITY.md");
|
||||||
await fs.mkdir(projectsDir, { recursive: true });
|
if (!await fileExists(identityPath)) {
|
||||||
const projectsJsonPath = path.join(projectsDir, "projects.json");
|
await fs.writeFile(identityPath, IDENTITY_MD_TEMPLATE, "utf-8");
|
||||||
if (!await fileExists(projectsJsonPath)) {
|
written.push("IDENTITY.md");
|
||||||
await fs.writeFile(projectsJsonPath, JSON.stringify({ projects: {} }, null, 2) + "\n", "utf-8");
|
|
||||||
filesWritten.push("projects/projects.json");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// projects/roles/default/ (fallback role instructions)
|
// SOUL.md (create-only — never overwrite user customizations)
|
||||||
const defaultRolesDir = path.join(projectsDir, "roles", "default");
|
const soulPath = path.join(workspacePath, "SOUL.md");
|
||||||
await fs.mkdir(defaultRolesDir, { recursive: true });
|
if (!await fileExists(soulPath)) {
|
||||||
const devRolePath = path.join(defaultRolesDir, "dev.md");
|
await fs.writeFile(soulPath, SOUL_MD_TEMPLATE, "utf-8");
|
||||||
if (!await fileExists(devRolePath)) {
|
written.push("SOUL.md");
|
||||||
await fs.writeFile(devRolePath, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
|
|
||||||
filesWritten.push("projects/roles/default/dev.md");
|
|
||||||
}
|
|
||||||
const qaRolePath = path.join(defaultRolesDir, "qa.md");
|
|
||||||
if (!await fileExists(qaRolePath)) {
|
|
||||||
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
|
|
||||||
filesWritten.push("projects/roles/default/qa.md");
|
|
||||||
}
|
|
||||||
const architectRolePath = path.join(defaultRolesDir, "architect.md");
|
|
||||||
if (!await fileExists(architectRolePath)) {
|
|
||||||
await fs.writeFile(architectRolePath, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8");
|
|
||||||
filesWritten.push("projects/roles/default/architect.md");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// log/ directory (audit.log created on first write)
|
// USER.md — copy from default workspace if available (create-only)
|
||||||
const logDir = path.join(workspacePath, "log");
|
const userPath = path.join(workspacePath, "USER.md");
|
||||||
await fs.mkdir(logDir, { recursive: true });
|
if (!await fileExists(userPath) && defaultWorkspacePath) {
|
||||||
|
const sourceUser = path.join(defaultWorkspacePath, "USER.md");
|
||||||
|
if (await fileExists(sourceUser)) {
|
||||||
|
await fs.copyFile(sourceUser, userPath);
|
||||||
|
written.push("USER.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return filesWritten;
|
// Ensure all data-dir defaults (workflow.yaml, prompts, etc.)
|
||||||
|
await ensureDefaultFiles(workspacePath);
|
||||||
|
|
||||||
|
return written;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
256
lib/templates.ts
256
lib/templates.ts
@@ -2,8 +2,11 @@
|
|||||||
* Shared templates for workspace files.
|
* Shared templates for workspace files.
|
||||||
* Used by setup and project_register.
|
* Used by setup and project_register.
|
||||||
*/
|
*/
|
||||||
|
import YAML from "yaml";
|
||||||
|
import { DEFAULT_WORKFLOW } from "./workflow.js";
|
||||||
|
import { ROLE_REGISTRY } from "./roles/registry.js";
|
||||||
|
|
||||||
export const DEFAULT_DEV_INSTRUCTIONS = `# DEV Worker Instructions
|
export const DEFAULT_DEV_INSTRUCTIONS = `# DEVELOPER Worker Instructions
|
||||||
|
|
||||||
## Context You Receive
|
## Context You Receive
|
||||||
|
|
||||||
@@ -21,22 +24,21 @@ Read the comments carefully — they often contain clarifications, decisions, or
|
|||||||
|
|
||||||
- Work in a git worktree (never switch branches in the main repo)
|
- Work in a git worktree (never switch branches in the main repo)
|
||||||
- Run tests before completing
|
- Run tests before completing
|
||||||
- Create an MR/PR to the base branch and merge it
|
- Create an MR/PR to the base branch
|
||||||
- **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA.
|
- **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA.
|
||||||
- Clean up the worktree after merging
|
- **Do NOT merge the PR yourself** — leave it open for review. The system will auto-merge when approved.
|
||||||
- When done, call work_finish with role "dev", result "done", and a brief summary
|
|
||||||
- If you discover unrelated bugs, call task_create to file them
|
- If you discover unrelated bugs, call task_create to file them
|
||||||
- Do NOT call work_start, status, health, or project_register
|
- Do NOT call work_start, status, health, or project_register
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
|
export const DEFAULT_QA_INSTRUCTIONS = `# TESTER Worker Instructions
|
||||||
|
|
||||||
- Pull latest from the base branch
|
- Pull latest from the base branch
|
||||||
- Run tests and linting
|
- Run tests and linting
|
||||||
- Verify the changes address the issue requirements
|
- Verify the changes address the issue requirements
|
||||||
- Check for regressions in related functionality
|
- Check for regressions in related functionality
|
||||||
- **Always** call task_comment with your review findings — even if everything looks good, leave a brief summary of what you checked
|
- **Always** call task_comment with your review findings — even if everything looks good, leave a brief summary of what you checked
|
||||||
- When done, call work_finish with role "qa" and one of:
|
- When done, call work_finish with role "tester" and one of:
|
||||||
- result "pass" if everything looks good
|
- result "pass" if everything looks good
|
||||||
- result "fail" with specific issues if problems found
|
- result "fail" with specific issues if problems found
|
||||||
- result "refine" if you need human input to decide
|
- result "refine" if you need human input to decide
|
||||||
@@ -46,43 +48,42 @@ export const DEFAULT_QA_INSTRUCTIONS = `# QA Worker Instructions
|
|||||||
|
|
||||||
export const DEFAULT_ARCHITECT_INSTRUCTIONS = `# Architect Worker Instructions
|
export const DEFAULT_ARCHITECT_INSTRUCTIONS = `# Architect Worker Instructions
|
||||||
|
|
||||||
You design and investigate architecture/design questions systematically.
|
You research design/architecture questions and produce detailed, development-ready findings.
|
||||||
|
|
||||||
## Your Job
|
## Your Job
|
||||||
|
|
||||||
Investigate the design problem thoroughly:
|
The issue contains background context and constraints. Your goal is to produce findings detailed enough that a developer can start implementation immediately — no further research needed.
|
||||||
1. **Understand the problem** — Read the issue, comments, and codebase
|
|
||||||
2. **Research alternatives** — Explore >= 3 viable approaches
|
1. **Understand the problem** — Read the issue body carefully. It contains the background context, constraints, and focus areas.
|
||||||
3. **Evaluate tradeoffs** — Consider simplicity, performance, maintainability, architecture fit
|
2. **Research thoroughly** — Explore the codebase, read docs, search the web. Understand the current state deeply.
|
||||||
4. **Recommend** — Pick the best option with clear reasoning
|
3. **Investigate alternatives** — Research >= 3 viable approaches with concrete pros/cons and effort estimates.
|
||||||
5. **Outline implementation** — Break down into dev tasks
|
4. **Recommend** — Pick the best option with clear, evidence-based reasoning.
|
||||||
|
5. **Outline implementation** — Break down into specific, actionable developer tasks with enough detail to start coding.
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|
||||||
Structure your findings as:
|
Post your findings as issue comments. Structure them as:
|
||||||
|
|
||||||
### Problem Statement
|
### Problem Statement
|
||||||
Why is this design decision important?
|
Why is this design decision important? What breaks if we get it wrong?
|
||||||
|
|
||||||
### Current State
|
### Current State
|
||||||
What exists today? Current limitations?
|
What exists today? Current limitations? Relevant code paths.
|
||||||
|
|
||||||
### Alternatives Investigated
|
### Alternatives Investigated
|
||||||
|
|
||||||
**Option A: [Name]**
|
**Option A: [Name]**
|
||||||
|
- Approach: [Concrete description of what this looks like]
|
||||||
- Pros: ...
|
- Pros: ...
|
||||||
- Cons: ...
|
- Cons: ...
|
||||||
- Effort estimate: X hours
|
- Effort estimate: X hours
|
||||||
|
- Key code paths affected: [files/modules]
|
||||||
|
|
||||||
**Option B: [Name]**
|
**Option B: [Name]**
|
||||||
- Pros: ...
|
(same structure)
|
||||||
- Cons: ...
|
|
||||||
- Effort estimate: X hours
|
|
||||||
|
|
||||||
**Option C: [Name]**
|
**Option C: [Name]**
|
||||||
- Pros: ...
|
(same structure)
|
||||||
- Cons: ...
|
|
||||||
- Effort estimate: X hours
|
|
||||||
|
|
||||||
### Recommendation
|
### Recommendation
|
||||||
**Option X** is recommended because:
|
**Option X** is recommended because:
|
||||||
@@ -91,33 +92,81 @@ What exists today? Current limitations?
|
|||||||
- [Long-term implications]
|
- [Long-term implications]
|
||||||
|
|
||||||
### Implementation Outline
|
### Implementation Outline
|
||||||
- [ ] Task 1: [Description]
|
Detailed enough for a developer to start immediately:
|
||||||
|
- [ ] Task 1: [Description — what to change, where, how]
|
||||||
- [ ] Task 2: [Description]
|
- [ ] Task 2: [Description]
|
||||||
- [ ] Task 3: [Description]
|
- [ ] Task 3: [Description]
|
||||||
|
|
||||||
### References
|
### References
|
||||||
- [Code examples, prior art, related issues]
|
- [Code paths, docs, prior art, related issues]
|
||||||
|
|
||||||
## Available Tools
|
## Important
|
||||||
|
|
||||||
- web_search, web_fetch (research patterns)
|
- **Be thorough** — Your output becomes the spec for development. Missing detail = blocked developer.
|
||||||
- Read files (explore codebase)
|
- **If you need user input** — Call work_finish with result "blocked" and explain what you need. Do NOT guess on ambiguous requirements.
|
||||||
- exec (run commands, search code)
|
- **Post findings as issue comments** — Use task_comment to write your analysis on the issue.
|
||||||
|
|
||||||
## Completion
|
## Completion
|
||||||
|
|
||||||
When done, call work_finish with:
|
When done, call work_finish with:
|
||||||
- role: "architect"
|
- role: "architect"
|
||||||
- result: "done"
|
- result: "done" — findings posted, ready for human review
|
||||||
|
- result: "blocked" — you need human input to proceed (goes to Refining)
|
||||||
- summary: Brief summary of your recommendation
|
- summary: Brief summary of your recommendation
|
||||||
|
|
||||||
Your session is persistent — you may be called back for refinements.
|
Your session is persistent — you may be called back for refinements.
|
||||||
Do NOT call work_start, status, health, or project_register.
|
Do NOT call work_start, status, health, or project_register.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const DEFAULT_REVIEWER_INSTRUCTIONS = `# REVIEWER Worker Instructions
|
||||||
|
|
||||||
|
You are a code reviewer. Your job is to review the PR diff for quality, correctness, and style.
|
||||||
|
|
||||||
|
## Context You Receive
|
||||||
|
|
||||||
|
- **Issue:** the original task description and discussion
|
||||||
|
- **PR diff:** the code changes to review
|
||||||
|
- **PR URL:** link to the pull request
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
1. **Correctness** — Does the code do what the issue asks for?
|
||||||
|
2. **Bugs** — Any logic errors, off-by-one, null handling issues?
|
||||||
|
3. **Security** — SQL injection, XSS, hardcoded secrets, command injection?
|
||||||
|
4. **Style** — Consistent with the codebase? Readable?
|
||||||
|
5. **Tests** — Are changes tested? Any missing edge cases?
|
||||||
|
6. **Scope** — Does the PR stay within the issue scope? Any unrelated changes?
|
||||||
|
|
||||||
|
## Your Job
|
||||||
|
|
||||||
|
- Read the PR diff carefully
|
||||||
|
- Check the code against the review checklist
|
||||||
|
- Call task_comment with your review findings
|
||||||
|
- Then call work_finish with role "reviewer" and one of:
|
||||||
|
- result "approve" if the code looks good
|
||||||
|
- result "reject" with specific issues if problems found
|
||||||
|
- result "blocked" if you can't complete the review
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- You do NOT run code or tests — you only review the diff
|
||||||
|
- Be specific about issues: file, line, what's wrong, how to fix
|
||||||
|
- If you approve, briefly note what you checked
|
||||||
|
- If you reject, list actionable items the developer must fix
|
||||||
|
- Do NOT call work_start, status, health, or project_register
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Default role instructions indexed by role ID. Used by project scaffolding. */
|
||||||
|
export const DEFAULT_ROLE_INSTRUCTIONS: Record<string, string> = {
|
||||||
|
developer: DEFAULT_DEV_INSTRUCTIONS,
|
||||||
|
tester: DEFAULT_QA_INSTRUCTIONS,
|
||||||
|
architect: DEFAULT_ARCHITECT_INSTRUCTIONS,
|
||||||
|
reviewer: DEFAULT_REVIEWER_INSTRUCTIONS,
|
||||||
|
};
|
||||||
|
|
||||||
export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw)
|
export const AGENTS_MD_TEMPLATE = `# AGENTS.md - Development Orchestration (DevClaw)
|
||||||
|
|
||||||
## If You Are a Sub-Agent (DEV/QA Worker)
|
## If You Are a Sub-Agent (DEVELOPER/TESTER/REVIEWER Worker)
|
||||||
|
|
||||||
Skip the orchestrator section. Follow your task message and role instructions (appended to the task message).
|
Skip the orchestrator section. Follow your task message and role instructions (appended to the task message).
|
||||||
|
|
||||||
@@ -126,21 +175,23 @@ Skip the orchestrator section. Follow your task message and role instructions (a
|
|||||||
- Conventional commits: \`feat:\`, \`fix:\`, \`chore:\`, \`refactor:\`, \`test:\`, \`docs:\`
|
- Conventional commits: \`feat:\`, \`fix:\`, \`chore:\`, \`refactor:\`, \`test:\`, \`docs:\`
|
||||||
- Include issue number: \`feat: add user authentication (#12)\`
|
- Include issue number: \`feat: add user authentication (#12)\`
|
||||||
- Branch naming: \`feature/<id>-<slug>\` or \`fix/<id>-<slug>\`
|
- Branch naming: \`feature/<id>-<slug>\` or \`fix/<id>-<slug>\`
|
||||||
- **DEV always works in a git worktree** (never switch branches in the main repo)
|
- **DEVELOPER always works in a git worktree** (never switch branches in the main repo)
|
||||||
- **DEV must merge to base branch** before announcing completion
|
- **DEVELOPER must merge to base branch** before announcing completion
|
||||||
- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses QA.
|
- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X". DevClaw manages issue state — auto-closing bypasses testing.
|
||||||
- **QA tests on the deployed version** and inspects code on the base branch
|
- **TESTER tests on the deployed version** and inspects code on the base branch
|
||||||
- **QA always calls task_comment** with review findings before completing
|
- **TESTER always calls task_comment** with review findings before completing
|
||||||
- Always run tests before completing
|
- Always run tests before completing
|
||||||
|
|
||||||
### Completing Your Task
|
### Completing Your Task
|
||||||
|
|
||||||
When you are done, **call \`work_finish\` yourself** — do not just announce in text.
|
When you are done, **call \`work_finish\` yourself** — do not just announce in text.
|
||||||
|
|
||||||
- **DEV done:** \`work_finish({ role: "dev", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
- **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||||
- **QA pass:** \`work_finish({ role: "qa", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
- **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
|
||||||
- **QA fail:** \`work_finish({ role: "qa", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
|
- **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
|
||||||
- **QA refine:** \`work_finish({ role: "qa", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
|
- **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
|
||||||
|
- **REVIEWER approve:** \`work_finish({ role: "reviewer", result: "approve", projectGroupId: "<from task message>", summary: "<what you checked>" })\`
|
||||||
|
- **REVIEWER reject:** \`work_finish({ role: "reviewer", result: "reject", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
|
||||||
- **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "<from task message>", summary: "<recommendation summary>" })\`
|
- **Architect done:** \`work_finish({ role: "architect", result: "done", projectGroupId: "<from task message>", summary: "<recommendation summary>" })\`
|
||||||
|
|
||||||
The \`projectGroupId\` is included in your task message.
|
The \`projectGroupId\` is included in your task message.
|
||||||
@@ -167,14 +218,14 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder
|
|||||||
**Never write code yourself.** All implementation work MUST go through the issue → worker pipeline:
|
**Never write code yourself.** All implementation work MUST go through the issue → worker pipeline:
|
||||||
|
|
||||||
1. Create an issue via \`task_create\`
|
1. Create an issue via \`task_create\`
|
||||||
2. Dispatch a DEV worker via \`work_start\`
|
2. Dispatch a DEVELOPER worker via \`work_start\`
|
||||||
3. Let the worker handle implementation, git, and PRs
|
3. Let the worker handle implementation, git, and PRs
|
||||||
|
|
||||||
**Why this matters:**
|
**Why this matters:**
|
||||||
- **Audit trail** — Every code change is tracked to an issue
|
- **Audit trail** — Every code change is tracked to an issue
|
||||||
- **Tier selection** — Junior/medior/senior models match task complexity
|
- **Level selection** — Junior/medior/senior models match task complexity
|
||||||
- **Parallelization** — Workers run in parallel, you stay free to plan
|
- **Parallelization** — Workers run in parallel, you stay free to plan
|
||||||
- **QA pipeline** — Code goes through review before closing
|
- **Testing pipeline** — Code goes through review before closing
|
||||||
|
|
||||||
**What you CAN do directly:**
|
**What you CAN do directly:**
|
||||||
- Planning, analysis, architecture discussions
|
- Planning, analysis, architecture discussions
|
||||||
@@ -195,7 +246,7 @@ You are a **development orchestrator** — a planner and dispatcher, not a coder
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- ✅ "Created issue #42: Fix login bug 🔗 https://github.com/org/repo/issues/42"
|
- ✅ "Created issue #42: Fix login bug 🔗 https://github.com/org/repo/issues/42"
|
||||||
- ✅ "Picked up #42 for DEV (medior) 🔗 https://github.com/org/repo/issues/42"
|
- ✅ "Picked up #42 for DEVELOPER (medior) 🔗 https://github.com/org/repo/issues/42"
|
||||||
- ❌ "Created issue #42 about the login bug" (missing URL)
|
- ❌ "Created issue #42 about the login bug" (missing URL)
|
||||||
|
|
||||||
### DevClaw Tools
|
### DevClaw Tools
|
||||||
@@ -211,20 +262,30 @@ All orchestration goes through these tools. You do NOT manually manage sessions,
|
|||||||
| \`health\` | Scan worker health: zombies, stale workers, orphaned state. Pass fix=true to auto-fix |
|
| \`health\` | Scan worker health: zombies, stale workers, orphaned state. Pass fix=true to auto-fix |
|
||||||
| \`work_start\` | End-to-end: label transition, level assignment, session create/reuse, dispatch with role instructions |
|
| \`work_start\` | End-to-end: label transition, level assignment, session create/reuse, dispatch with role instructions |
|
||||||
| \`work_finish\` | End-to-end: label transition, state update, issue close/reopen |
|
| \`work_finish\` | End-to-end: label transition, state update, issue close/reopen |
|
||||||
| \`design_task\` | Spawn an architect for design investigation. Creates To Design issue and dispatches architect |
|
| \`research_task\` | Spawn an architect for design investigation. Creates Planning issue with rich context and dispatches architect |
|
||||||
|
|
||||||
|
### First Thing on Session Start
|
||||||
|
|
||||||
|
**Always call \`status\` first** when you start a new session. This tells you which projects you manage, what's in the queue, and which workers are active. Don't guess — check.
|
||||||
|
|
||||||
### Pipeline Flow
|
### Pipeline Flow
|
||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
Planning → To Do → Doing → To Test → Testing → Done
|
Planning → To Do → Doing → To Review ──┬── [agent] → Reviewing → approve → To Test → Testing → Done
|
||||||
↓
|
│ → reject → To Improve
|
||||||
To Improve → Doing (fix cycle)
|
│ → blocked → Refining
|
||||||
↓
|
└── [human] → PR approved → To Test (heartbeat auto-transitions)
|
||||||
Refining (human decision)
|
|
||||||
|
|
||||||
To Design → Designing → Planning (design complete)
|
To Improve → Doing (fix cycle)
|
||||||
|
Refining (human decision)
|
||||||
|
research_task → Planning (architect researches, posts findings, stays in Planning)
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
Review policy (configurable per project in workflow.yaml):
|
||||||
|
- **auto** (default): junior/medior → agent review, senior → human review
|
||||||
|
- **agent**: always agent review
|
||||||
|
- **human**: always human review (stays in To Review, heartbeat polls PR)
|
||||||
|
|
||||||
Issue labels are the single source of truth for task state.
|
Issue labels are the single source of truth for task state.
|
||||||
|
|
||||||
### Developer Assignment
|
### Developer Assignment
|
||||||
@@ -234,9 +295,8 @@ Evaluate each task and pass the appropriate developer level to \`work_start\`:
|
|||||||
- **junior** — trivial: typos, single-file fix, quick change
|
- **junior** — trivial: typos, single-file fix, quick change
|
||||||
- **medior** — standard: features, bug fixes, multi-file changes
|
- **medior** — standard: features, bug fixes, multi-file changes
|
||||||
- **senior** — complex: architecture, system-wide refactoring, 5+ services
|
- **senior** — complex: architecture, system-wide refactoring, 5+ services
|
||||||
- **reviewer** — QA: code inspection, validation, test runs
|
|
||||||
- **opus** — Architect: complex, high-impact design investigations
|
All roles (Developer, Tester, Architect) use the same level scheme. Levels describe task complexity, not the model.
|
||||||
- **sonnet** — Architect: standard feature design investigations
|
|
||||||
|
|
||||||
### Picking Up Work
|
### Picking Up Work
|
||||||
|
|
||||||
@@ -250,28 +310,33 @@ Evaluate each task and pass the appropriate developer level to \`work_start\`:
|
|||||||
|
|
||||||
Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle:
|
Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle:
|
||||||
|
|
||||||
- DEV "done" → issue moves to "To Test" → scheduler dispatches QA
|
- Developer "done" → "To Review" → routes based on review policy:
|
||||||
- QA "fail" → issue moves to "To Improve" → scheduler dispatches DEV
|
- Agent/auto-junior: reviewer agent dispatched → "Reviewing" → approve/reject
|
||||||
- QA "pass" → Done, no further dispatch
|
- Human/auto-senior: heartbeat polls PR status → auto-merges when approved → "To Test"
|
||||||
- QA "refine" / blocked → needs human input
|
- Reviewer "approve" → merges PR → "To Test" → scheduler dispatches Tester
|
||||||
- Architect "done" → issue moves to "Planning" → ready for tech lead review
|
- Reviewer "reject" → "To Improve" → scheduler dispatches Developer
|
||||||
|
- Tester "fail" → "To Improve" → scheduler dispatches Developer
|
||||||
|
- Tester "pass" → Done, no further dispatch
|
||||||
|
- Tester "refine" / blocked → needs human input
|
||||||
|
- Architect "done" → stays in "Planning" → ready for tech lead review
|
||||||
|
- Architect "blocked" → "Refining" → needs human input
|
||||||
|
|
||||||
**Always include issue URLs** in your response — these are in the \`announcement\` fields.
|
**Always include issue URLs** in your response — these are in the \`announcement\` fields.
|
||||||
|
|
||||||
### Prompt Instructions
|
### Prompt Instructions
|
||||||
|
|
||||||
Workers receive role-specific instructions appended to their task message. These are loaded from \`projects/roles/<project-name>/<role>.md\` in the workspace, falling back to \`projects/roles/default/<role>.md\` if no project-specific file exists. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project.
|
Workers receive role-specific instructions appended to their task message. These are loaded from \`devclaw/projects/<project-name>/prompts/<role>.md\` in the workspace, falling back to \`devclaw/prompts/<role>.md\` if no project-specific file exists. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project.
|
||||||
|
|
||||||
### Heartbeats
|
### Heartbeats
|
||||||
|
|
||||||
**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers) and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json.
|
**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers), review polling (auto-advancing "To Review" issues when PRs are approved), and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json.
|
||||||
|
|
||||||
### Safety
|
### Safety
|
||||||
|
|
||||||
- **Never write code yourself** — always dispatch a DEV worker
|
- **Never write code yourself** — always dispatch a Developer worker
|
||||||
- Don't push to main directly
|
- Don't push to main directly
|
||||||
- Don't force-push
|
- Don't force-push
|
||||||
- Don't close issues without QA pass
|
- Don't close issues without Tester pass
|
||||||
- Ask before architectural decisions affecting multiple projects
|
- Ask before architectural decisions affecting multiple projects
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -279,3 +344,70 @@ export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md
|
|||||||
|
|
||||||
Do nothing. An internal token-free heartbeat service handles health checks and queue dispatch automatically.
|
Do nothing. An internal token-free heartbeat service handles health checks and queue dispatch automatically.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const IDENTITY_MD_TEMPLATE = `# IDENTITY.md - Who Am I?
|
||||||
|
|
||||||
|
- **Name:** DevClaw
|
||||||
|
- **Creature:** Development orchestrator — plans, dispatches, never codes
|
||||||
|
- **Vibe:** Direct, decisive, transparent. No fluff.
|
||||||
|
- **Emoji:** 🦞
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SOUL_MD_TEMPLATE = `# SOUL.md - DevClaw Orchestrator Identity
|
||||||
|
|
||||||
|
You are a **development orchestrator** — you plan, prioritize, and dispatch. You never write code yourself.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
**Be direct.** Skip pleasantries, get to the point. Say what you're doing and why.
|
||||||
|
|
||||||
|
**Be decisive.** Evaluate task complexity, pick the right level, dispatch. Don't deliberate when the answer is obvious.
|
||||||
|
|
||||||
|
**Be transparent.** Always include issue URLs. Always explain what happened and what's next. No black boxes.
|
||||||
|
|
||||||
|
**Be resourceful.** Check status before asking. Read the issue before dispatching. Understand the codebase before planning. Come back with answers, not questions.
|
||||||
|
|
||||||
|
## How You Work
|
||||||
|
|
||||||
|
- You receive requests via chat (Telegram, WhatsApp, or web)
|
||||||
|
- You break work into issues, assign complexity levels, and dispatch workers
|
||||||
|
- Workers (developer, reviewer, tester, architect) do the actual work in isolated sessions
|
||||||
|
- You track progress, handle failures, and keep the human informed
|
||||||
|
- The heartbeat runs automatically — you don't manage it
|
||||||
|
|
||||||
|
## Communication Style
|
||||||
|
|
||||||
|
- Concise status updates with issue links
|
||||||
|
- Use the announcement format from tool responses
|
||||||
|
- Flag blockers and failures immediately
|
||||||
|
- Don't over-explain routine operations
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- **Never write code** — dispatch a developer worker
|
||||||
|
- **Never skip testing** — every code change goes through QA
|
||||||
|
- **Never close issues** without a tester pass
|
||||||
|
- **Ask before** architectural decisions affecting multiple projects
|
||||||
|
|
||||||
|
## Continuity
|
||||||
|
|
||||||
|
Each session starts fresh. AGENTS.md defines your operational procedures. This file defines who you are. USER.md tells you about the humans you work with. Update these files as you learn.
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate WORKFLOW_YAML_TEMPLATE from the runtime objects (single source of truth).
|
||||||
|
*/
|
||||||
|
function buildWorkflowYaml(): string {
|
||||||
|
const roles: Record<string, { models: Record<string, string> }> = {};
|
||||||
|
for (const [id, config] of Object.entries(ROLE_REGISTRY)) {
|
||||||
|
roles[id] = { models: { ...config.models } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const header =
|
||||||
|
"# DevClaw workflow configuration\n" +
|
||||||
|
"# Modify values to customize. Copy to devclaw/projects/<project>/workflow.yaml for project-specific overrides.\n\n";
|
||||||
|
return header + YAML.stringify({ roles, workflow: DEFAULT_WORKFLOW });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WORKFLOW_YAML_TEMPLATE = buildWorkflowYaml();
|
||||||
|
|||||||
294
lib/testing/harness.ts
Normal file
294
lib/testing/harness.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Test harness — scaffolds a temporary workspace with projects.json,
|
||||||
|
* installs a mock runCommand, and provides helpers for E2E pipeline tests.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const h = await createTestHarness({ ... });
|
||||||
|
* try { ... } finally { await h.cleanup(); }
|
||||||
|
*/
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { initRunCommand } from "../run-command.js";
|
||||||
|
import { writeProjects, type ProjectsData, type Project, emptyWorkerState } from "../projects.js";
|
||||||
|
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||||
|
import { registerBootstrapHook } from "../bootstrap-hook.js";
|
||||||
|
import { TestProvider } from "./test-provider.js";
|
||||||
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bootstrap file type (mirrors OpenClaw's internal type)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type BootstrapFile = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
content?: string;
|
||||||
|
missing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command interceptor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CapturedCommand = {
|
||||||
|
argv: string[];
|
||||||
|
opts: { timeoutMs: number; cwd?: string };
|
||||||
|
/** Extracted from gateway `agent` call params, if applicable. */
|
||||||
|
taskMessage?: string;
|
||||||
|
/** Extracted from gateway `sessions.patch` params, if applicable. */
|
||||||
|
sessionPatch?: { key: string; model: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandInterceptor = {
|
||||||
|
/** All captured commands, in order. */
|
||||||
|
commands: CapturedCommand[];
|
||||||
|
/** Filter commands by first argv element. */
|
||||||
|
commandsFor(cmd: string): CapturedCommand[];
|
||||||
|
/** Get all task messages sent via `openclaw gateway call agent`. */
|
||||||
|
taskMessages(): string[];
|
||||||
|
/** Get all session patches. */
|
||||||
|
sessionPatches(): Array<{ key: string; model: string }>;
|
||||||
|
/** Reset captured commands. */
|
||||||
|
reset(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createCommandInterceptor(): {
|
||||||
|
interceptor: CommandInterceptor;
|
||||||
|
handler: (argv: string[], opts: number | { timeoutMs: number; cwd?: string }) => Promise<{ stdout: string; stderr: string; code: number | null; signal: null; killed: false }>;
|
||||||
|
} {
|
||||||
|
const commands: CapturedCommand[] = [];
|
||||||
|
|
||||||
|
const handler = async (
|
||||||
|
argv: string[],
|
||||||
|
optsOrTimeout: number | { timeoutMs: number; cwd?: string },
|
||||||
|
) => {
|
||||||
|
const opts = typeof optsOrTimeout === "number"
|
||||||
|
? { timeoutMs: optsOrTimeout }
|
||||||
|
: optsOrTimeout;
|
||||||
|
|
||||||
|
const captured: CapturedCommand = { argv, opts };
|
||||||
|
|
||||||
|
// Parse gateway agent calls to extract task message
|
||||||
|
if (argv[0] === "openclaw" && argv[1] === "gateway" && argv[2] === "call") {
|
||||||
|
const rpcMethod = argv[3];
|
||||||
|
const paramsIdx = argv.indexOf("--params");
|
||||||
|
if (paramsIdx !== -1 && argv[paramsIdx + 1]) {
|
||||||
|
try {
|
||||||
|
const params = JSON.parse(argv[paramsIdx + 1]);
|
||||||
|
if (rpcMethod === "agent" && params.message) {
|
||||||
|
captured.taskMessage = params.message;
|
||||||
|
}
|
||||||
|
if (rpcMethod === "sessions.patch") {
|
||||||
|
captured.sessionPatch = { key: params.key, model: params.model };
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commands.push(captured);
|
||||||
|
|
||||||
|
return { stdout: "{}", stderr: "", code: 0, signal: null as null, killed: false as const };
|
||||||
|
};
|
||||||
|
|
||||||
|
const interceptor: CommandInterceptor = {
|
||||||
|
commands,
|
||||||
|
commandsFor(cmd: string) {
|
||||||
|
return commands.filter((c) => c.argv[0] === cmd);
|
||||||
|
},
|
||||||
|
taskMessages() {
|
||||||
|
return commands
|
||||||
|
.filter((c) => c.taskMessage !== undefined)
|
||||||
|
.map((c) => c.taskMessage!);
|
||||||
|
},
|
||||||
|
sessionPatches() {
|
||||||
|
return commands
|
||||||
|
.filter((c) => c.sessionPatch !== undefined)
|
||||||
|
.map((c) => c.sessionPatch!);
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
commands.length = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { interceptor, handler };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test harness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type TestHarness = {
|
||||||
|
/** Temporary workspace directory. */
|
||||||
|
workspaceDir: string;
|
||||||
|
/** In-memory issue provider. */
|
||||||
|
provider: TestProvider;
|
||||||
|
/** Command interceptor — captures all runCommand calls. */
|
||||||
|
commands: CommandInterceptor;
|
||||||
|
/** The project group ID used for test data. */
|
||||||
|
groupId: string;
|
||||||
|
/** The project data. */
|
||||||
|
project: Project;
|
||||||
|
/** Workflow config. */
|
||||||
|
workflow: WorkflowConfig;
|
||||||
|
/** Write updated projects data to disk. */
|
||||||
|
writeProjects(data: ProjectsData): Promise<void>;
|
||||||
|
/** Read current projects data from disk. */
|
||||||
|
readProjects(): Promise<ProjectsData>;
|
||||||
|
/**
|
||||||
|
* Write a role prompt file to the workspace.
|
||||||
|
* @param role - Role name (e.g. "developer", "tester")
|
||||||
|
* @param content - Prompt file content
|
||||||
|
* @param projectName - If provided, writes project-specific prompt; otherwise writes default.
|
||||||
|
*/
|
||||||
|
writePrompt(role: string, content: string, projectName?: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Simulate the agent:bootstrap hook firing for a session key.
|
||||||
|
* Registers the real hook with a mock API, fires it, returns the injected bootstrap files.
|
||||||
|
* This tests the full hook chain: session key → parse → load instructions → inject.
|
||||||
|
*/
|
||||||
|
simulateBootstrap(sessionKey: string): Promise<BootstrapFile[]>;
|
||||||
|
/** Clean up temp directory. */
|
||||||
|
cleanup(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HarnessOptions = {
|
||||||
|
/** Project name (default: "test-project"). */
|
||||||
|
projectName?: string;
|
||||||
|
/** Group ID (default: "-1234567890"). */
|
||||||
|
groupId?: string;
|
||||||
|
/** Repo path (default: "/tmp/test-repo"). */
|
||||||
|
repo?: string;
|
||||||
|
/** Base branch (default: "main"). */
|
||||||
|
baseBranch?: string;
|
||||||
|
/** Workflow config (default: DEFAULT_WORKFLOW). */
|
||||||
|
workflow?: WorkflowConfig;
|
||||||
|
/** Initial worker state overrides. */
|
||||||
|
workers?: Record<string, Partial<import("../projects.js").WorkerState>>;
|
||||||
|
/** Additional projects to seed. */
|
||||||
|
extraProjects?: Record<string, Project>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createTestHarness(opts?: HarnessOptions): Promise<TestHarness> {
|
||||||
|
const {
|
||||||
|
projectName = "test-project",
|
||||||
|
groupId = "-1234567890",
|
||||||
|
repo = "/tmp/test-repo",
|
||||||
|
baseBranch = "main",
|
||||||
|
workflow = DEFAULT_WORKFLOW,
|
||||||
|
workers: workerOverrides,
|
||||||
|
extraProjects,
|
||||||
|
} = opts ?? {};
|
||||||
|
|
||||||
|
// Create temp workspace
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-e2e-"));
|
||||||
|
const dataDir = path.join(workspaceDir, "devclaw");
|
||||||
|
const logDir = path.join(dataDir, "log");
|
||||||
|
await fs.mkdir(logDir, { recursive: true });
|
||||||
|
|
||||||
|
// Build project
|
||||||
|
const defaultWorkers: Record<string, import("../projects.js").WorkerState> = {
|
||||||
|
developer: emptyWorkerState(["junior", "medior", "senior"]),
|
||||||
|
tester: emptyWorkerState(["junior", "medior", "senior"]),
|
||||||
|
architect: emptyWorkerState(["junior", "senior"]),
|
||||||
|
reviewer: emptyWorkerState(["junior", "senior"]),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply worker overrides
|
||||||
|
if (workerOverrides) {
|
||||||
|
for (const [role, overrides] of Object.entries(workerOverrides)) {
|
||||||
|
if (defaultWorkers[role]) {
|
||||||
|
defaultWorkers[role] = { ...defaultWorkers[role], ...overrides };
|
||||||
|
} else {
|
||||||
|
defaultWorkers[role] = { ...emptyWorkerState([]), ...overrides };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const project: Project = {
|
||||||
|
name: projectName,
|
||||||
|
repo,
|
||||||
|
groupName: "Test Group",
|
||||||
|
deployUrl: "",
|
||||||
|
baseBranch,
|
||||||
|
deployBranch: baseBranch,
|
||||||
|
provider: "github",
|
||||||
|
workers: defaultWorkers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectsData: ProjectsData = {
|
||||||
|
projects: {
|
||||||
|
[groupId]: project,
|
||||||
|
...extraProjects,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeProjects(workspaceDir, projectsData);
|
||||||
|
|
||||||
|
// Install mock runCommand
|
||||||
|
const { interceptor, handler } = createCommandInterceptor();
|
||||||
|
initRunCommand({
|
||||||
|
runtime: {
|
||||||
|
system: { runCommandWithTimeout: handler },
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawPluginApi);
|
||||||
|
|
||||||
|
// Create test provider
|
||||||
|
const provider = new TestProvider({ workflow });
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceDir,
|
||||||
|
provider,
|
||||||
|
commands: interceptor,
|
||||||
|
groupId,
|
||||||
|
project,
|
||||||
|
workflow,
|
||||||
|
async writeProjects(data: ProjectsData) {
|
||||||
|
await writeProjects(workspaceDir, data);
|
||||||
|
},
|
||||||
|
async readProjects() {
|
||||||
|
const { readProjects } = await import("../projects.js");
|
||||||
|
return readProjects(workspaceDir);
|
||||||
|
},
|
||||||
|
async writePrompt(role: string, content: string, forProject?: string) {
|
||||||
|
const dir = forProject
|
||||||
|
? path.join(dataDir, "projects", forProject, "prompts")
|
||||||
|
: path.join(dataDir, "prompts");
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dir, `${role}.md`), content, "utf-8");
|
||||||
|
},
|
||||||
|
async simulateBootstrap(sessionKey: string) {
|
||||||
|
// Capture the hook callback by mocking api.registerHook
|
||||||
|
let hookCallback: ((event: any) => Promise<void>) | null = null;
|
||||||
|
const mockApi = {
|
||||||
|
registerHook(_name: string, cb: (event: any) => Promise<void>) {
|
||||||
|
hookCallback = cb;
|
||||||
|
},
|
||||||
|
logger: {
|
||||||
|
debug() {},
|
||||||
|
info() {},
|
||||||
|
warn() {},
|
||||||
|
error() {},
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawPluginApi;
|
||||||
|
|
||||||
|
registerBootstrapHook(mockApi);
|
||||||
|
if (!hookCallback) throw new Error("registerBootstrapHook did not register a callback");
|
||||||
|
|
||||||
|
// Build a bootstrap event matching what OpenClaw sends
|
||||||
|
const bootstrapFiles: BootstrapFile[] = [];
|
||||||
|
await hookCallback({
|
||||||
|
sessionKey,
|
||||||
|
context: {
|
||||||
|
workspaceDir,
|
||||||
|
bootstrapFiles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return bootstrapFiles;
|
||||||
|
},
|
||||||
|
async cleanup() {
|
||||||
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
16
lib/testing/index.ts
Normal file
16
lib/testing/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* testing/ — Test infrastructure for DevClaw integration tests.
|
||||||
|
*
|
||||||
|
* Exports:
|
||||||
|
* - TestProvider: In-memory IssueProvider with call tracking
|
||||||
|
* - createTestHarness: Scaffolds temp workspace + mock runCommand
|
||||||
|
*/
|
||||||
|
export { TestProvider, type ProviderCall } from "./test-provider.js";
|
||||||
|
export {
|
||||||
|
createTestHarness,
|
||||||
|
type TestHarness,
|
||||||
|
type HarnessOptions,
|
||||||
|
type CommandInterceptor,
|
||||||
|
type CapturedCommand,
|
||||||
|
type BootstrapFile,
|
||||||
|
} from "./harness.js";
|
||||||
286
lib/testing/test-provider.ts
Normal file
286
lib/testing/test-provider.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* TestProvider — In-memory IssueProvider for integration tests.
|
||||||
|
*
|
||||||
|
* Tracks all method calls for assertion. Issues are stored in a simple map.
|
||||||
|
* No external dependencies — pure TypeScript.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
IssueProvider,
|
||||||
|
Issue,
|
||||||
|
StateLabel,
|
||||||
|
IssueComment,
|
||||||
|
PrStatus,
|
||||||
|
} from "../providers/provider.js";
|
||||||
|
import { getStateLabels } from "../workflow.js";
|
||||||
|
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Call tracking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ProviderCall =
|
||||||
|
| { method: "ensureLabel"; args: { name: string; color: string } }
|
||||||
|
| { method: "ensureAllStateLabels"; args: {} }
|
||||||
|
| {
|
||||||
|
method: "createIssue";
|
||||||
|
args: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
label: StateLabel;
|
||||||
|
assignees?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { method: "listIssuesByLabel"; args: { label: StateLabel } }
|
||||||
|
| { method: "getIssue"; args: { issueId: number } }
|
||||||
|
| { method: "listComments"; args: { issueId: number } }
|
||||||
|
| {
|
||||||
|
method: "transitionLabel";
|
||||||
|
args: { issueId: number; from: StateLabel; to: StateLabel };
|
||||||
|
}
|
||||||
|
| { method: "addLabel"; args: { issueId: number; label: string } }
|
||||||
|
| { method: "removeLabels"; args: { issueId: number; labels: string[] } }
|
||||||
|
| { method: "closeIssue"; args: { issueId: number } }
|
||||||
|
| { method: "reopenIssue"; args: { issueId: number } }
|
||||||
|
| { method: "hasMergedMR"; args: { issueId: number } }
|
||||||
|
| { method: "getMergedMRUrl"; args: { issueId: number } }
|
||||||
|
| { method: "getPrStatus"; args: { issueId: number } }
|
||||||
|
| { method: "mergePr"; args: { issueId: number } }
|
||||||
|
| { method: "getPrDiff"; args: { issueId: number } }
|
||||||
|
| { method: "addComment"; args: { issueId: number; body: string } }
|
||||||
|
| { method: "healthCheck"; args: {} };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TestProvider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class TestProvider implements IssueProvider {
|
||||||
|
/** All issues keyed by iid. */
|
||||||
|
issues = new Map<number, Issue>();
|
||||||
|
/** Comments per issue. */
|
||||||
|
comments = new Map<number, IssueComment[]>();
|
||||||
|
/** Labels that have been ensured. */
|
||||||
|
labels = new Map<string, string>();
|
||||||
|
/** PR status overrides per issue. Default: { state: "closed", url: null }. */
|
||||||
|
prStatuses = new Map<number, PrStatus>();
|
||||||
|
/** Merged MR URLs per issue. */
|
||||||
|
mergedMrUrls = new Map<number, string>();
|
||||||
|
/** Issue IDs where mergePr should fail (simulates merge conflicts). */
|
||||||
|
mergePrFailures = new Set<number>();
|
||||||
|
/** PR diffs per issue (for reviewer tests). */
|
||||||
|
prDiffs = new Map<number, string>();
|
||||||
|
/** All calls, in order. */
|
||||||
|
calls: ProviderCall[] = [];
|
||||||
|
|
||||||
|
private nextIssueId = 1;
|
||||||
|
private workflow: WorkflowConfig;
|
||||||
|
|
||||||
|
constructor(opts?: { workflow?: WorkflowConfig }) {
|
||||||
|
this.workflow = opts?.workflow ?? DEFAULT_WORKFLOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Create an issue directly in the store (bypasses createIssue tracking). */
|
||||||
|
seedIssue(overrides: Partial<Issue> & { iid: number }): Issue {
|
||||||
|
const issue: Issue = {
|
||||||
|
iid: overrides.iid,
|
||||||
|
title: overrides.title ?? `Issue #${overrides.iid}`,
|
||||||
|
description: overrides.description ?? "",
|
||||||
|
labels: overrides.labels ?? [],
|
||||||
|
state: overrides.state ?? "opened",
|
||||||
|
web_url:
|
||||||
|
overrides.web_url ?? `https://example.com/issues/${overrides.iid}`,
|
||||||
|
};
|
||||||
|
this.issues.set(issue.iid, issue);
|
||||||
|
if (issue.iid >= this.nextIssueId) this.nextIssueId = issue.iid + 1;
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set PR status for an issue (used by review pass tests). */
|
||||||
|
setPrStatus(issueId: number, status: PrStatus): void {
|
||||||
|
this.prStatuses.set(issueId, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get calls filtered by method name. */
|
||||||
|
callsTo<M extends ProviderCall["method"]>(
|
||||||
|
method: M,
|
||||||
|
): Extract<ProviderCall, { method: M }>[] {
|
||||||
|
return this.calls.filter((c) => c.method === method) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset call tracking (keeps issue state). */
|
||||||
|
resetCalls(): void {
|
||||||
|
this.calls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full reset — clear everything. */
|
||||||
|
reset(): void {
|
||||||
|
this.issues.clear();
|
||||||
|
this.comments.clear();
|
||||||
|
this.labels.clear();
|
||||||
|
this.prStatuses.clear();
|
||||||
|
this.mergedMrUrls.clear();
|
||||||
|
this.mergePrFailures.clear();
|
||||||
|
this.prDiffs.clear();
|
||||||
|
this.calls = [];
|
||||||
|
this.nextIssueId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// IssueProvider implementation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async ensureLabel(name: string, color: string): Promise<void> {
|
||||||
|
this.calls.push({ method: "ensureLabel", args: { name, color } });
|
||||||
|
this.labels.set(name, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureAllStateLabels(): Promise<void> {
|
||||||
|
this.calls.push({ method: "ensureAllStateLabels", args: {} });
|
||||||
|
const stateLabels = getStateLabels(this.workflow);
|
||||||
|
for (const label of stateLabels) {
|
||||||
|
this.labels.set(label, "#000000");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIssue(
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
label: StateLabel,
|
||||||
|
assignees?: string[],
|
||||||
|
): Promise<Issue> {
|
||||||
|
this.calls.push({
|
||||||
|
method: "createIssue",
|
||||||
|
args: { title, description, label, assignees },
|
||||||
|
});
|
||||||
|
const iid = this.nextIssueId++;
|
||||||
|
const issue: Issue = {
|
||||||
|
iid,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
labels: [label],
|
||||||
|
state: "opened",
|
||||||
|
web_url: `https://example.com/issues/${iid}`,
|
||||||
|
};
|
||||||
|
this.issues.set(iid, issue);
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
|
||||||
|
this.calls.push({ method: "listIssuesByLabel", args: { label } });
|
||||||
|
return [...this.issues.values()].filter((i) => i.labels.includes(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIssue(issueId: number): Promise<Issue> {
|
||||||
|
this.calls.push({ method: "getIssue", args: { issueId } });
|
||||||
|
const issue = this.issues.get(issueId);
|
||||||
|
if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`);
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listComments(issueId: number): Promise<IssueComment[]> {
|
||||||
|
this.calls.push({ method: "listComments", args: { issueId } });
|
||||||
|
return this.comments.get(issueId) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async transitionLabel(
|
||||||
|
issueId: number,
|
||||||
|
from: StateLabel,
|
||||||
|
to: StateLabel,
|
||||||
|
): Promise<void> {
|
||||||
|
this.calls.push({ method: "transitionLabel", args: { issueId, from, to } });
|
||||||
|
const issue = this.issues.get(issueId);
|
||||||
|
if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`);
|
||||||
|
// Remove all state labels, add the new one
|
||||||
|
const stateLabels = getStateLabels(this.workflow);
|
||||||
|
issue.labels = issue.labels.filter((l) => !stateLabels.includes(l));
|
||||||
|
issue.labels.push(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLabel(issueId: number, label: string): Promise<void> {
|
||||||
|
this.calls.push({ method: "addLabel", args: { issueId, label } });
|
||||||
|
const issue = this.issues.get(issueId);
|
||||||
|
if (issue && !issue.labels.includes(label)) {
|
||||||
|
issue.labels.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLabels(issueId: number, labels: string[]): Promise<void> {
|
||||||
|
this.calls.push({ method: "removeLabels", args: { issueId, labels } });
|
||||||
|
const issue = this.issues.get(issueId);
|
||||||
|
if (issue) {
|
||||||
|
issue.labels = issue.labels.filter((l) => !labels.includes(l));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeIssue(issueId: number): Promise<void> {
|
||||||
|
this.calls.push({ method: "closeIssue", args: { issueId } });
|
||||||
|
const issue = this.issues.get(issueId);
|
||||||
|
if (issue) issue.state = "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
async reopenIssue(issueId: number): Promise<void> {
|
||||||
|
this.calls.push({ method: "reopenIssue", args: { issueId } });
|
||||||
|
const issue = this.issues.get(issueId);
|
||||||
|
if (issue) issue.state = "opened";
|
||||||
|
}
|
||||||
|
|
||||||
|
hasStateLabel(issue: Issue, expected: StateLabel): boolean {
|
||||||
|
return issue.labels.includes(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentStateLabel(issue: Issue): StateLabel | null {
|
||||||
|
const stateLabels = getStateLabels(this.workflow);
|
||||||
|
return stateLabels.find((l) => issue.labels.includes(l)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasMergedMR(issueId: number): Promise<boolean> {
|
||||||
|
this.calls.push({ method: "hasMergedMR", args: { issueId } });
|
||||||
|
return this.mergedMrUrls.has(issueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMergedMRUrl(issueId: number): Promise<string | null> {
|
||||||
|
this.calls.push({ method: "getMergedMRUrl", args: { issueId } });
|
||||||
|
return this.mergedMrUrls.get(issueId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||||
|
this.calls.push({ method: "getPrStatus", args: { issueId } });
|
||||||
|
return this.prStatuses.get(issueId) ?? { state: "closed", url: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergePr(issueId: number): Promise<void> {
|
||||||
|
this.calls.push({ method: "mergePr", args: { issueId } });
|
||||||
|
if (this.mergePrFailures.has(issueId)) {
|
||||||
|
throw new Error(`Merge conflict: cannot merge PR for issue #${issueId}`);
|
||||||
|
}
|
||||||
|
// Simulate successful merge — update PR status to merged
|
||||||
|
const existing = this.prStatuses.get(issueId);
|
||||||
|
if (existing) {
|
||||||
|
this.prStatuses.set(issueId, { state: "merged", url: existing.url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrDiff(issueId: number): Promise<string | null> {
|
||||||
|
this.calls.push({ method: "getPrDiff", args: { issueId } });
|
||||||
|
return this.prDiffs.get(issueId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addComment(issueId: number, body: string): Promise<void> {
|
||||||
|
this.calls.push({ method: "addComment", args: { issueId, body } });
|
||||||
|
const existing = this.comments.get(issueId) ?? [];
|
||||||
|
existing.push({
|
||||||
|
author: "test",
|
||||||
|
body,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
this.comments.set(issueId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
this.calls.push({ method: "healthCheck", args: {} });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
lib/tiers.ts
90
lib/tiers.ts
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* tiers.ts — Developer level definitions and model resolution.
|
|
||||||
*
|
|
||||||
* This module now delegates to the centralized role registry (lib/roles/).
|
|
||||||
* Kept for backward compatibility — new code should import from lib/roles/ directly.
|
|
||||||
*
|
|
||||||
* Level names are plain: "junior", "senior", "reviewer", etc.
|
|
||||||
* Role context (dev/qa/architect) is always provided by the caller.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
type WorkerRole,
|
|
||||||
ROLE_REGISTRY,
|
|
||||||
getLevelsForRole,
|
|
||||||
getAllDefaultModels,
|
|
||||||
roleForLevel,
|
|
||||||
getDefaultModel,
|
|
||||||
getEmoji,
|
|
||||||
resolveModel as registryResolveModel,
|
|
||||||
} from "./roles/index.js";
|
|
||||||
|
|
||||||
// Re-export WorkerRole from the registry
|
|
||||||
export type { WorkerRole };
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Level constants — derived from registry
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** @deprecated Use roles/selectors.getAllDefaultModels() */
|
|
||||||
export const DEFAULT_MODELS = getAllDefaultModels();
|
|
||||||
|
|
||||||
/** @deprecated Use roles/selectors.getEmoji() */
|
|
||||||
export const LEVEL_EMOJI: Record<string, Record<string, string>> = Object.fromEntries(
|
|
||||||
Object.entries(ROLE_REGISTRY).map(([id, config]) => [id, { ...config.emoji }]),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const DEV_LEVELS = getLevelsForRole("dev") as readonly string[];
|
|
||||||
export const QA_LEVELS = getLevelsForRole("qa") as readonly string[];
|
|
||||||
export const ARCHITECT_LEVELS = getLevelsForRole("architect") as readonly string[];
|
|
||||||
|
|
||||||
export type DevLevel = string;
|
|
||||||
export type QaLevel = string;
|
|
||||||
export type ArchitectLevel = string;
|
|
||||||
export type Level = string;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Level checks — delegate to registry
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Check if a level belongs to the dev role. */
|
|
||||||
export function isDevLevel(value: string): boolean {
|
|
||||||
return DEV_LEVELS.includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if a level belongs to the qa role. */
|
|
||||||
export function isQaLevel(value: string): boolean {
|
|
||||||
return QA_LEVELS.includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if a level belongs to the architect role. */
|
|
||||||
export function isArchitectLevel(value: string): boolean {
|
|
||||||
return ARCHITECT_LEVELS.includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Determine the role a level belongs to. */
|
|
||||||
export function levelRole(level: string): WorkerRole | undefined {
|
|
||||||
return roleForLevel(level) as WorkerRole | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Model + emoji — delegate to registry
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** @deprecated Use roles/selectors.getDefaultModel() */
|
|
||||||
export function defaultModel(role: WorkerRole, level: string): string | undefined {
|
|
||||||
return getDefaultModel(role, level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Use roles/selectors.getEmoji() */
|
|
||||||
export function levelEmoji(role: WorkerRole, level: string): string | undefined {
|
|
||||||
return getEmoji(role, level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Use roles/selectors.resolveModel() */
|
|
||||||
export function resolveModel(
|
|
||||||
role: WorkerRole,
|
|
||||||
level: string,
|
|
||||||
pluginConfig?: Record<string, unknown>,
|
|
||||||
): string {
|
|
||||||
return registryResolveModel(role, level, pluginConfig);
|
|
||||||
}
|
|
||||||
@@ -102,7 +102,7 @@ export function createAutoConfigureModelsTool(api: OpenClawPluginApi) {
|
|||||||
if (modelCount === 1) {
|
if (modelCount === 1) {
|
||||||
message += "ℹ️ Only one authenticated model found — assigned to all roles.";
|
message += "ℹ️ Only one authenticated model found — assigned to all roles.";
|
||||||
} else {
|
} else {
|
||||||
message += "ℹ️ Models assigned by capability tier (Tier 1 → senior, Tier 2 → medior/reviewer, Tier 3 → junior/tester).";
|
message += "ℹ️ Models assigned by capability tier (Tier 1 → senior, Tier 2 → mid, Tier 3 → junior).";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preferProvider) {
|
if (preferProvider) {
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for architect role, design_task tool, and workflow integration.
|
|
||||||
* Run with: npx tsx --test lib/tools/design-task.test.ts
|
|
||||||
*/
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import assert from "node:assert";
|
|
||||||
import { parseDevClawSessionKey } from "../bootstrap-hook.js";
|
|
||||||
import { isArchitectLevel, levelRole, resolveModel, defaultModel, levelEmoji } from "../tiers.js";
|
|
||||||
import { selectLevel } from "../model-selector.js";
|
|
||||||
import {
|
|
||||||
DEFAULT_WORKFLOW, getQueueLabels, getActiveLabel, getCompletionRule,
|
|
||||||
getCompletionEmoji, detectRoleFromLabel, getStateLabels,
|
|
||||||
} from "../workflow.js";
|
|
||||||
|
|
||||||
describe("architect tiers", () => {
|
|
||||||
it("should recognize architect levels", () => {
|
|
||||||
assert.strictEqual(isArchitectLevel("opus"), true);
|
|
||||||
assert.strictEqual(isArchitectLevel("sonnet"), true);
|
|
||||||
assert.strictEqual(isArchitectLevel("medior"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should map architect levels to role", () => {
|
|
||||||
assert.strictEqual(levelRole("opus"), "architect");
|
|
||||||
assert.strictEqual(levelRole("sonnet"), "architect");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should resolve default architect models", () => {
|
|
||||||
assert.strictEqual(defaultModel("architect", "opus"), "anthropic/claude-opus-4-5");
|
|
||||||
assert.strictEqual(defaultModel("architect", "sonnet"), "anthropic/claude-sonnet-4-5");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should resolve architect model from config", () => {
|
|
||||||
const config = { models: { architect: { opus: "custom/model" } } };
|
|
||||||
assert.strictEqual(resolveModel("architect", "opus", config), "custom/model");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have architect emoji", () => {
|
|
||||||
assert.strictEqual(levelEmoji("architect", "opus"), "🏗️");
|
|
||||||
assert.strictEqual(levelEmoji("architect", "sonnet"), "📐");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("architect workflow states", () => {
|
|
||||||
it("should include To Design and Designing in state labels", () => {
|
|
||||||
const labels = getStateLabels(DEFAULT_WORKFLOW);
|
|
||||||
assert.ok(labels.includes("To Design"));
|
|
||||||
assert.ok(labels.includes("Designing"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have To Design as architect queue label", () => {
|
|
||||||
const queues = getQueueLabels(DEFAULT_WORKFLOW, "architect");
|
|
||||||
assert.deepStrictEqual(queues, ["To Design"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have Designing as architect active label", () => {
|
|
||||||
assert.strictEqual(getActiveLabel(DEFAULT_WORKFLOW, "architect"), "Designing");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should detect architect role from To Design label", () => {
|
|
||||||
assert.strictEqual(detectRoleFromLabel(DEFAULT_WORKFLOW, "To Design"), "architect");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have architect:done completion rule", () => {
|
|
||||||
const rule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "done");
|
|
||||||
assert.ok(rule);
|
|
||||||
assert.strictEqual(rule!.from, "Designing");
|
|
||||||
assert.strictEqual(rule!.to, "Planning");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have architect:blocked completion rule", () => {
|
|
||||||
const rule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "blocked");
|
|
||||||
assert.ok(rule);
|
|
||||||
assert.strictEqual(rule!.from, "Designing");
|
|
||||||
assert.strictEqual(rule!.to, "Refining");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have architect completion emoji", () => {
|
|
||||||
assert.strictEqual(getCompletionEmoji("architect", "done"), "🏗️");
|
|
||||||
assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("architect model selection", () => {
|
|
||||||
it("should select sonnet for standard design tasks", () => {
|
|
||||||
const result = selectLevel("Design: Add caching layer", "Simple caching strategy", "architect");
|
|
||||||
assert.strictEqual(result.level, "sonnet");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should select opus for complex design tasks", () => {
|
|
||||||
const result = selectLevel("Design: System-wide refactor", "Major migration and redesign of the architecture", "architect");
|
|
||||||
assert.strictEqual(result.level, "opus");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("architect session key parsing", () => {
|
|
||||||
it("should parse architect session key", () => {
|
|
||||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-architect-opus");
|
|
||||||
assert.deepStrictEqual(result, { projectName: "my-project", role: "architect" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse architect sonnet session key", () => {
|
|
||||||
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-sonnet");
|
|
||||||
assert.deepStrictEqual(result, { projectName: "webapp", role: "architect" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
/**
|
|
||||||
* design_task — Spawn an architect to investigate a design problem.
|
|
||||||
*
|
|
||||||
* Creates a "To Design" issue and optionally dispatches an architect worker.
|
|
||||||
* The architect investigates systematically, then produces structured findings
|
|
||||||
* as a GitHub issue in Planning state.
|
|
||||||
*/
|
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
||||||
import { jsonResult } from "openclaw/plugin-sdk";
|
|
||||||
import type { ToolContext } from "../types.js";
|
|
||||||
import type { StateLabel } from "../providers/provider.js";
|
|
||||||
import { getWorker } from "../projects.js";
|
|
||||||
import { dispatchTask } from "../dispatch.js";
|
|
||||||
import { log as auditLog } from "../audit.js";
|
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
|
||||||
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
|
|
||||||
|
|
||||||
export function createDesignTaskTool(api: OpenClawPluginApi) {
|
|
||||||
return (ctx: ToolContext) => ({
|
|
||||||
name: "design_task",
|
|
||||||
label: "Design Task",
|
|
||||||
description: `Spawn an architect to investigate a design/architecture problem. Creates a "To Design" issue and dispatches an architect worker with persistent session.
|
|
||||||
|
|
||||||
The architect will:
|
|
||||||
1. Investigate the problem systematically
|
|
||||||
2. Research alternatives (>= 3 options)
|
|
||||||
3. Produce structured findings with recommendation
|
|
||||||
4. Complete with work_finish, moving the issue to Planning
|
|
||||||
|
|
||||||
Example:
|
|
||||||
design_task({
|
|
||||||
projectGroupId: "-5176490302",
|
|
||||||
title: "Design: Session persistence strategy",
|
|
||||||
description: "How should sessions be persisted across restarts?",
|
|
||||||
complexity: "complex"
|
|
||||||
})`,
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["projectGroupId", "title"],
|
|
||||||
properties: {
|
|
||||||
projectGroupId: {
|
|
||||||
type: "string",
|
|
||||||
description: "Project group ID",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: "string",
|
|
||||||
description: "Design title (e.g., 'Design: Session persistence')",
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: "string",
|
|
||||||
description: "What are we designing & why? Include context and constraints.",
|
|
||||||
},
|
|
||||||
focusAreas: {
|
|
||||||
type: "array",
|
|
||||||
items: { type: "string" },
|
|
||||||
description: "Specific areas to investigate (e.g., ['performance', 'scalability', 'simplicity'])",
|
|
||||||
},
|
|
||||||
complexity: {
|
|
||||||
type: "string",
|
|
||||||
enum: ["simple", "medium", "complex"],
|
|
||||||
description: "Suggests architect level: simple/medium → sonnet, complex → opus. Defaults to medium.",
|
|
||||||
},
|
|
||||||
dryRun: {
|
|
||||||
type: "boolean",
|
|
||||||
description: "Preview without executing. 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 focusAreas = (params.focusAreas as string[]) ?? [];
|
|
||||||
const complexity = (params.complexity as "simple" | "medium" | "complex") ?? "medium";
|
|
||||||
const dryRun = (params.dryRun as boolean) ?? false;
|
|
||||||
const workspaceDir = requireWorkspaceDir(ctx);
|
|
||||||
|
|
||||||
if (!groupId) throw new Error("projectGroupId is required");
|
|
||||||
if (!title) throw new Error("title is required");
|
|
||||||
|
|
||||||
const { project } = await resolveProject(workspaceDir, groupId);
|
|
||||||
const { provider } = await resolveProvider(project);
|
|
||||||
|
|
||||||
// Build issue body with focus areas
|
|
||||||
const bodyParts = [description];
|
|
||||||
if (focusAreas.length > 0) {
|
|
||||||
bodyParts.push("", "## Focus Areas", ...focusAreas.map(a => `- ${a}`));
|
|
||||||
}
|
|
||||||
bodyParts.push(
|
|
||||||
"", "---",
|
|
||||||
"", "## Architect Output Template",
|
|
||||||
"",
|
|
||||||
"When complete, the architect will produce findings covering:",
|
|
||||||
"1. **Problem Statement** — Why is this design decision important?",
|
|
||||||
"2. **Current State** — What exists today? Limitations?",
|
|
||||||
"3. **Alternatives** (>= 3 options with pros/cons and effort estimates)",
|
|
||||||
"4. **Recommendation** — Which option and why?",
|
|
||||||
"5. **Implementation Outline** — What dev tasks are needed?",
|
|
||||||
"6. **References** — Code, docs, prior art",
|
|
||||||
);
|
|
||||||
const issueBody = bodyParts.join("\n");
|
|
||||||
|
|
||||||
// Create issue in To Design state
|
|
||||||
const issue = await provider.createIssue(title, issueBody, "To Design" as StateLabel);
|
|
||||||
|
|
||||||
await auditLog(workspaceDir, "design_task", {
|
|
||||||
project: project.name, groupId, issueId: issue.iid,
|
|
||||||
title, complexity, focusAreas, dryRun,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Select level based on complexity
|
|
||||||
const level = complexity === "complex" ? "opus" : "sonnet";
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
return jsonResult({
|
|
||||||
success: true,
|
|
||||||
dryRun: true,
|
|
||||||
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" },
|
|
||||||
design: {
|
|
||||||
level,
|
|
||||||
model: complexity === "complex" ? "anthropic/claude-opus-4-5" : "anthropic/claude-sonnet-4-5",
|
|
||||||
status: "dry_run",
|
|
||||||
},
|
|
||||||
announcement: `📐 [DRY RUN] Would spawn architect (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check architect availability
|
|
||||||
const worker = getWorker(project, "architect");
|
|
||||||
if (worker.active) {
|
|
||||||
// Issue created but can't dispatch yet — will be picked up by heartbeat
|
|
||||||
return jsonResult({
|
|
||||||
success: true,
|
|
||||||
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: "To Design" },
|
|
||||||
design: {
|
|
||||||
level,
|
|
||||||
status: "queued",
|
|
||||||
reason: `Architect already active on #${worker.issueId}. Issue queued for pickup.`,
|
|
||||||
},
|
|
||||||
announcement: `📐 Created design task #${issue.iid}: ${title} (queued — architect busy)\n🔗 ${issue.web_url}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch architect
|
|
||||||
const workflow = DEFAULT_WORKFLOW;
|
|
||||||
const targetLabel = getActiveLabel(workflow, "architect");
|
|
||||||
const pluginConfig = getPluginConfig(api);
|
|
||||||
|
|
||||||
const dr = await dispatchTask({
|
|
||||||
workspaceDir,
|
|
||||||
agentId: ctx.agentId,
|
|
||||||
groupId,
|
|
||||||
project,
|
|
||||||
issueId: issue.iid,
|
|
||||||
issueTitle: issue.title,
|
|
||||||
issueDescription: issueBody,
|
|
||||||
issueUrl: issue.web_url,
|
|
||||||
role: "architect",
|
|
||||||
level,
|
|
||||||
fromLabel: "To Design",
|
|
||||||
toLabel: targetLabel,
|
|
||||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
|
||||||
provider,
|
|
||||||
pluginConfig,
|
|
||||||
channel: project.channel,
|
|
||||||
sessionKey: ctx.sessionKey,
|
|
||||||
runtime: api.runtime,
|
|
||||||
});
|
|
||||||
|
|
||||||
return jsonResult({
|
|
||||||
success: true,
|
|
||||||
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: targetLabel },
|
|
||||||
design: {
|
|
||||||
sessionKey: dr.sessionKey,
|
|
||||||
level: dr.level,
|
|
||||||
model: dr.model,
|
|
||||||
sessionAction: dr.sessionAction,
|
|
||||||
status: "in_progress",
|
|
||||||
},
|
|
||||||
project: project.name,
|
|
||||||
announcement: dr.announcement,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ import { readProjects, getProject } from "../projects.js";
|
|||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type HealthFix } from "../services/health.js";
|
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type HealthFix } from "../services/health.js";
|
||||||
import { requireWorkspaceDir, resolveProvider } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProvider } from "../tool-helpers.js";
|
||||||
import { getAllRoleIds } from "../roles/index.js";
|
|
||||||
|
|
||||||
export function createHealthTool() {
|
export function createHealthTool() {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -52,13 +51,13 @@ export function createHealthTool() {
|
|||||||
if (!project) continue;
|
if (!project) continue;
|
||||||
const { provider } = await resolveProvider(project);
|
const { provider } = await resolveProvider(project);
|
||||||
|
|
||||||
for (const role of getAllRoleIds()) {
|
for (const role of Object.keys(project.workers)) {
|
||||||
// Worker health check (session liveness, label consistency, etc)
|
// Worker health check (session liveness, label consistency, etc)
|
||||||
const healthFixes = await checkWorkerHealth({
|
const healthFixes = await checkWorkerHealth({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
groupId: pid,
|
groupId: pid,
|
||||||
project,
|
project,
|
||||||
role: role as any,
|
role,
|
||||||
sessions,
|
sessions,
|
||||||
autoFix: fix,
|
autoFix: fix,
|
||||||
provider,
|
provider,
|
||||||
@@ -70,7 +69,7 @@ export function createHealthTool() {
|
|||||||
workspaceDir,
|
workspaceDir,
|
||||||
groupId: pid,
|
groupId: pid,
|
||||||
project,
|
project,
|
||||||
role: role as any,
|
role,
|
||||||
autoFix: fix,
|
autoFix: fix,
|
||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function createOnboardTool(api: OpenClawPluginApi) {
|
|||||||
const mode = params.mode ? (params.mode as "first-run" | "reconfigure")
|
const mode = params.mode ? (params.mode as "first-run" | "reconfigure")
|
||||||
: configured && hasWorkspace ? "reconfigure" : "first-run";
|
: configured && hasWorkspace ? "reconfigure" : "first-run";
|
||||||
|
|
||||||
const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext(api.pluginConfig as Record<string, unknown>);
|
const instructions = mode === "first-run" ? buildOnboardToolContext() : buildReconfigContext();
|
||||||
|
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
success: true, mode, configured, instructions,
|
success: true, mode, configured, instructions,
|
||||||
|
|||||||
@@ -15,40 +15,29 @@ import { resolveRepoPath } from "../projects.js";
|
|||||||
import { createProvider } from "../providers/index.js";
|
import { createProvider } from "../providers/index.js";
|
||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||||
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS, DEFAULT_ARCHITECT_INSTRUCTIONS } from "../templates.js";
|
import { ExecutionMode, getRoleLabels } from "../workflow.js";
|
||||||
|
import { loadConfig } from "../config/index.js";
|
||||||
|
import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js";
|
||||||
|
import { DATA_DIR } from "../setup/migrate-layout.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scaffold project-specific prompt files.
|
* Scaffold project-specific prompt files for all registered roles.
|
||||||
* Returns true if files were created, false if they already existed.
|
* Returns true if files were created, false if they already existed.
|
||||||
*/
|
*/
|
||||||
async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> {
|
async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> {
|
||||||
const projectDir = path.join(workspaceDir, "projects", "roles", projectName);
|
const promptsDir = path.join(workspaceDir, DATA_DIR, "projects", projectName, "prompts");
|
||||||
await fs.mkdir(projectDir, { recursive: true });
|
await fs.mkdir(promptsDir, { recursive: true });
|
||||||
|
|
||||||
const projectDev = path.join(projectDir, "dev.md");
|
|
||||||
const projectQa = path.join(projectDir, "qa.md");
|
|
||||||
let created = false;
|
let created = false;
|
||||||
|
for (const role of getAllRoleIds()) {
|
||||||
|
const filePath = path.join(promptsDir, `${role}.md`);
|
||||||
try {
|
try {
|
||||||
await fs.access(projectDev);
|
await fs.access(filePath);
|
||||||
} catch {
|
} catch {
|
||||||
await fs.writeFile(projectDev, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
|
const content = DEFAULT_ROLE_INSTRUCTIONS[role] ?? `# ${role.toUpperCase()} Worker Instructions\n\nAdd role-specific instructions here.\n`;
|
||||||
|
await fs.writeFile(filePath, content, "utf-8");
|
||||||
created = true;
|
created = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.access(projectQa);
|
|
||||||
} catch {
|
|
||||||
await fs.writeFile(projectQa, DEFAULT_QA_INSTRUCTIONS, "utf-8");
|
|
||||||
created = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectArchitect = path.join(projectDir, "architect.md");
|
|
||||||
try {
|
|
||||||
await fs.access(projectArchitect);
|
|
||||||
} catch {
|
|
||||||
await fs.writeFile(projectArchitect, DEFAULT_ARCHITECT_INSTRUCTIONS, "utf-8");
|
|
||||||
created = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
@@ -97,7 +86,7 @@ export function createProjectRegisterTool() {
|
|||||||
},
|
},
|
||||||
roleExecution: {
|
roleExecution: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["parallel", "sequential"],
|
enum: Object.values(ExecutionMode),
|
||||||
description: "Project-level role execution mode: parallel (DEV and QA can work simultaneously) or sequential (only one role active at a time). Defaults to parallel.",
|
description: "Project-level role execution mode: parallel (DEV and QA can work simultaneously) or sequential (only one role active at a time). Defaults to parallel.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -112,7 +101,7 @@ export function createProjectRegisterTool() {
|
|||||||
const baseBranch = params.baseBranch as string;
|
const baseBranch = params.baseBranch as string;
|
||||||
const deployBranch = (params.deployBranch as string) ?? baseBranch;
|
const deployBranch = (params.deployBranch as string) ?? baseBranch;
|
||||||
const deployUrl = (params.deployUrl as string) ?? "";
|
const deployUrl = (params.deployUrl as string) ?? "";
|
||||||
const roleExecution = (params.roleExecution as "parallel" | "sequential") ?? "parallel";
|
const roleExecution = (params.roleExecution as ExecutionMode) ?? ExecutionMode.PARALLEL;
|
||||||
const workspaceDir = ctx.workspaceDir;
|
const workspaceDir = ctx.workspaceDir;
|
||||||
|
|
||||||
if (!workspaceDir) {
|
if (!workspaceDir) {
|
||||||
@@ -122,7 +111,8 @@ export function createProjectRegisterTool() {
|
|||||||
// 1. Check project not already registered (allow re-register if incomplete)
|
// 1. Check project not already registered (allow re-register if incomplete)
|
||||||
const data = await readProjects(workspaceDir);
|
const data = await readProjects(workspaceDir);
|
||||||
const existing = data.projects[groupId];
|
const existing = data.projects[groupId];
|
||||||
if (existing && existing.dev?.sessions && Object.keys(existing.dev.sessions).length > 0) {
|
const existingWorkers = existing?.workers ?? {};
|
||||||
|
if (existing && Object.values(existingWorkers).some(w => w.sessions && Object.keys(w.sessions).length > 0)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Project already registered for this group: "${existing.name}". Remove the existing entry first or use a different group.`,
|
`Project already registered for this group: "${existing.name}". Remove the existing entry first or use a different group.`,
|
||||||
);
|
);
|
||||||
@@ -162,7 +152,20 @@ export function createProjectRegisterTool() {
|
|||||||
// 4. Create all state labels (idempotent)
|
// 4. Create all state labels (idempotent)
|
||||||
await provider.ensureAllStateLabels();
|
await provider.ensureAllStateLabels();
|
||||||
|
|
||||||
|
// 4b. Create role:level + step routing labels (e.g. developer:junior, review:human, test:skip)
|
||||||
|
const resolvedConfig = await loadConfig(workspaceDir, name);
|
||||||
|
const roleLabels = getRoleLabels(resolvedConfig.roles);
|
||||||
|
for (const { name: labelName, color } of roleLabels) {
|
||||||
|
await provider.ensureLabel(labelName, color);
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Add project to projects.json
|
// 5. Add project to projects.json
|
||||||
|
// Build workers map from all registered roles
|
||||||
|
const workers: Record<string, import("../projects.js").WorkerState> = {};
|
||||||
|
for (const role of getAllRoleIds()) {
|
||||||
|
workers[role] = emptyWorkerState([...getLevelsForRole(role)]);
|
||||||
|
}
|
||||||
|
|
||||||
data.projects[groupId] = {
|
data.projects[groupId] = {
|
||||||
name,
|
name,
|
||||||
repo,
|
repo,
|
||||||
@@ -173,9 +176,7 @@ export function createProjectRegisterTool() {
|
|||||||
channel,
|
channel,
|
||||||
provider: providerType,
|
provider: providerType,
|
||||||
roleExecution,
|
roleExecution,
|
||||||
dev: emptyWorkerState([...getLevelsForRole("dev")]),
|
workers,
|
||||||
qa: emptyWorkerState([...getLevelsForRole("qa")]),
|
|
||||||
architect: emptyWorkerState([...getLevelsForRole("architect")]),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await writeProjects(workspaceDir, data);
|
await writeProjects(workspaceDir, data);
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ describe("status execution-aware sequencing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("role assignment", () => {
|
describe("role assignment", () => {
|
||||||
it("should assign To Improve to dev", () => {
|
it("should assign To Improve to developer", () => {
|
||||||
// To Improve = dev work
|
// To Improve = developer work
|
||||||
assert.ok(true);
|
assert.ok(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should assign To Do to dev", () => {
|
it("should assign To Do to developer", () => {
|
||||||
// To Do = dev work
|
// To Do = developer work
|
||||||
assert.ok(true);
|
assert.ok(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should assign To Test to qa", () => {
|
it("should assign To Test to tester", () => {
|
||||||
// To Test = qa work
|
// To Test = tester work
|
||||||
assert.ok(true);
|
assert.ok(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -43,12 +43,12 @@ describe("status execution-aware sequencing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should support parallel role execution within project", () => {
|
it("should support parallel role execution within project", () => {
|
||||||
// DEV and QA can run simultaneously
|
// Developer and Tester can run simultaneously
|
||||||
assert.ok(true);
|
assert.ok(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should support sequential role execution within project", () => {
|
it("should support sequential role execution within project", () => {
|
||||||
// DEV and QA alternate
|
// Developer and Tester alternate
|
||||||
assert.ok(true);
|
assert.ok(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
104
lib/tools/research-task.test.ts
Normal file
104
lib/tools/research-task.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Tests for architect role, research_task tool, and workflow integration.
|
||||||
|
* Run with: npx tsx --test lib/tools/research-task.test.ts
|
||||||
|
*/
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { parseDevClawSessionKey } from "../bootstrap-hook.js";
|
||||||
|
import { isLevelForRole, roleForLevel, resolveModel, getDefaultModel, getEmoji } from "../roles/index.js";
|
||||||
|
import { selectLevel } from "../model-selector.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_WORKFLOW, getQueueLabels, getCompletionRule,
|
||||||
|
getCompletionEmoji, getStateLabels, hasWorkflowStates,
|
||||||
|
} from "../workflow.js";
|
||||||
|
|
||||||
|
describe("architect tiers", () => {
|
||||||
|
it("should recognize architect levels", () => {
|
||||||
|
assert.strictEqual(isLevelForRole("junior", "architect"), true);
|
||||||
|
assert.strictEqual(isLevelForRole("senior", "architect"), true);
|
||||||
|
assert.strictEqual(isLevelForRole("medior", "architect"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should map architect levels to role", () => {
|
||||||
|
// "junior" and "senior" appear in developer first (registry order), so roleForLevel returns "developer"
|
||||||
|
// This is expected — use isLevelForRole for role-specific checks
|
||||||
|
assert.strictEqual(roleForLevel("junior"), "developer");
|
||||||
|
assert.strictEqual(roleForLevel("senior"), "developer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve default architect models", () => {
|
||||||
|
assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6");
|
||||||
|
assert.strictEqual(getDefaultModel("architect", "junior"), "anthropic/claude-sonnet-4-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve architect model from resolved role config", () => {
|
||||||
|
const resolvedRole = { models: { senior: "custom/model" }, levels: ["junior", "senior"], defaultLevel: "junior", emoji: {}, completionResults: [] as string[], enabled: true };
|
||||||
|
assert.strictEqual(resolveModel("architect", "senior", resolvedRole), "custom/model");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have architect emoji", () => {
|
||||||
|
assert.strictEqual(getEmoji("architect", "senior"), "🏗️");
|
||||||
|
assert.strictEqual(getEmoji("architect", "junior"), "📐");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("architect workflow — no dedicated states", () => {
|
||||||
|
it("should NOT have To Design or Designing in state labels", () => {
|
||||||
|
const labels = getStateLabels(DEFAULT_WORKFLOW);
|
||||||
|
assert.ok(!labels.includes("To Design"), "To Design should not exist");
|
||||||
|
assert.ok(!labels.includes("Designing"), "Designing should not exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have no queue labels for architect", () => {
|
||||||
|
const queues = getQueueLabels(DEFAULT_WORKFLOW, "architect");
|
||||||
|
assert.deepStrictEqual(queues, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report architect has no workflow states", () => {
|
||||||
|
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "architect"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report developer has workflow states", () => {
|
||||||
|
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "developer"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report tester has workflow states", () => {
|
||||||
|
assert.strictEqual(hasWorkflowStates(DEFAULT_WORKFLOW, "tester"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have no completion rules for architect (no active state)", () => {
|
||||||
|
const doneRule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "done");
|
||||||
|
assert.strictEqual(doneRule, null);
|
||||||
|
const blockedRule = getCompletionRule(DEFAULT_WORKFLOW, "architect", "blocked");
|
||||||
|
assert.strictEqual(blockedRule, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should still have completion emoji for architect results", () => {
|
||||||
|
assert.strictEqual(getCompletionEmoji("architect", "done"), "✅");
|
||||||
|
assert.strictEqual(getCompletionEmoji("architect", "blocked"), "🚫");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("architect model selection", () => {
|
||||||
|
it("should select junior for standard design tasks", () => {
|
||||||
|
const result = selectLevel("Design: Add caching layer", "Simple caching strategy", "architect");
|
||||||
|
assert.strictEqual(result.level, "junior");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should select senior for complex design tasks", () => {
|
||||||
|
const result = selectLevel("Design: System-wide refactor", "Major migration and redesign of the architecture", "architect");
|
||||||
|
assert.strictEqual(result.level, "senior");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("architect session key parsing", () => {
|
||||||
|
it("should parse architect session key", () => {
|
||||||
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-architect-senior");
|
||||||
|
assert.deepStrictEqual(result, { projectName: "my-project", role: "architect" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse architect junior session key", () => {
|
||||||
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-architect-junior");
|
||||||
|
assert.deepStrictEqual(result, { projectName: "webapp", role: "architect" });
|
||||||
|
});
|
||||||
|
});
|
||||||
190
lib/tools/research-task.ts
Normal file
190
lib/tools/research-task.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* research_task — Spawn an architect to research a design/architecture problem.
|
||||||
|
*
|
||||||
|
* Creates a Planning issue with rich context and dispatches an architect worker.
|
||||||
|
* The architect researches the problem and produces detailed findings as issue comments.
|
||||||
|
* The issue stays in Planning — ready for human review when the architect completes.
|
||||||
|
*
|
||||||
|
* No queue states — tool-triggered only.
|
||||||
|
*/
|
||||||
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
|
import { jsonResult } from "openclaw/plugin-sdk";
|
||||||
|
import type { ToolContext } from "../types.js";
|
||||||
|
import type { StateLabel } from "../providers/provider.js";
|
||||||
|
import { getWorker } from "../projects.js";
|
||||||
|
import { dispatchTask } from "../dispatch.js";
|
||||||
|
import { log as auditLog } from "../audit.js";
|
||||||
|
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||||
|
import { loadConfig } from "../config/index.js";
|
||||||
|
import { selectLevel } from "../model-selector.js";
|
||||||
|
import { resolveModel } from "../roles/index.js";
|
||||||
|
|
||||||
|
/** Planning label — architect issues go directly here. */
|
||||||
|
const PLANNING_LABEL = "Planning";
|
||||||
|
|
||||||
|
export function createResearchTaskTool(api: OpenClawPluginApi) {
|
||||||
|
return (ctx: ToolContext) => ({
|
||||||
|
name: "research_task",
|
||||||
|
label: "Research Task",
|
||||||
|
description: `Spawn an architect to research a design/architecture problem. Creates a Planning issue and dispatches an architect worker.
|
||||||
|
|
||||||
|
IMPORTANT: Provide a detailed description with enough background context for the architect
|
||||||
|
to produce actionable, development-ready findings. Include: current state, constraints,
|
||||||
|
requirements, relevant code paths, and any prior decisions. The output should be detailed
|
||||||
|
enough for a developer to start implementation immediately.
|
||||||
|
|
||||||
|
The architect will:
|
||||||
|
1. Research the problem systematically (codebase, docs, web)
|
||||||
|
2. Investigate >= 3 alternatives with tradeoffs
|
||||||
|
3. Produce a recommendation with implementation outline
|
||||||
|
4. Post findings as issue comments, then complete with work_finish
|
||||||
|
|
||||||
|
Example:
|
||||||
|
research_task({
|
||||||
|
projectGroupId: "-5176490302",
|
||||||
|
title: "Research: Session persistence strategy",
|
||||||
|
description: "Sessions are lost on restart. Current impl uses in-memory Map in session-store.ts. Constraints: must work with SQLite (already a dep), max 50ms latency on read. Prior discussion in #42 ruled out Redis.",
|
||||||
|
focusAreas: ["SQLite vs file-based", "migration path", "cache invalidation"],
|
||||||
|
complexity: "complex"
|
||||||
|
})`,
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
required: ["projectGroupId", "title", "description"],
|
||||||
|
properties: {
|
||||||
|
projectGroupId: {
|
||||||
|
type: "string",
|
||||||
|
description: "Project group ID",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: "string",
|
||||||
|
description: "Research title (e.g., 'Research: Session persistence strategy')",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: "string",
|
||||||
|
description: "Detailed background context: what exists today, why this needs investigation, constraints, relevant code paths, prior decisions. Must be detailed enough for the architect to produce development-ready findings.",
|
||||||
|
},
|
||||||
|
focusAreas: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Specific areas to investigate (e.g., ['performance', 'scalability', 'simplicity'])",
|
||||||
|
},
|
||||||
|
complexity: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["simple", "medium", "complex"],
|
||||||
|
description: "Suggests architect level: simple/medium → junior, complex → senior. Defaults to medium.",
|
||||||
|
},
|
||||||
|
dryRun: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Preview without executing. 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 focusAreas = (params.focusAreas as string[]) ?? [];
|
||||||
|
const complexity = (params.complexity as "simple" | "medium" | "complex") ?? "medium";
|
||||||
|
const dryRun = (params.dryRun as boolean) ?? false;
|
||||||
|
const workspaceDir = requireWorkspaceDir(ctx);
|
||||||
|
|
||||||
|
if (!groupId) throw new Error("projectGroupId is required");
|
||||||
|
if (!title) throw new Error("title is required");
|
||||||
|
if (!description) throw new Error("description is required — provide detailed background context for the architect");
|
||||||
|
|
||||||
|
const { project } = await resolveProject(workspaceDir, groupId);
|
||||||
|
const { provider } = await resolveProvider(project);
|
||||||
|
const pluginConfig = getPluginConfig(api);
|
||||||
|
const role = "architect";
|
||||||
|
|
||||||
|
// Build issue body with rich context
|
||||||
|
const bodyParts = [
|
||||||
|
"## Background",
|
||||||
|
"",
|
||||||
|
description,
|
||||||
|
];
|
||||||
|
if (focusAreas.length > 0) {
|
||||||
|
bodyParts.push("", "## Focus Areas", ...focusAreas.map(a => `- ${a}`));
|
||||||
|
}
|
||||||
|
const issueBody = bodyParts.join("\n");
|
||||||
|
|
||||||
|
// Create issue directly in Planning state (no queue — tool-triggered only)
|
||||||
|
const issue = await provider.createIssue(title, issueBody, PLANNING_LABEL as StateLabel);
|
||||||
|
|
||||||
|
await auditLog(workspaceDir, "research_task", {
|
||||||
|
project: project.name, groupId, issueId: issue.iid,
|
||||||
|
title, complexity, focusAreas, dryRun,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select level: use complexity hint to guide the heuristic
|
||||||
|
const level = complexity === "complex"
|
||||||
|
? selectLevel(title, "system-wide " + description, role).level
|
||||||
|
: selectLevel(title, description, role).level;
|
||||||
|
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||||
|
const resolvedRole = resolvedConfig.roles[role];
|
||||||
|
const model = resolveModel(role, level, resolvedRole);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return jsonResult({
|
||||||
|
success: true,
|
||||||
|
dryRun: true,
|
||||||
|
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL },
|
||||||
|
design: { level, model, status: "dry_run" },
|
||||||
|
announcement: `📐 [DRY RUN] Would spawn ${role} (${level}) for #${issue.iid}: ${title}\n🔗 ${issue.web_url}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check worker availability
|
||||||
|
const worker = getWorker(project, role);
|
||||||
|
if (worker.active) {
|
||||||
|
return jsonResult({
|
||||||
|
success: true,
|
||||||
|
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL },
|
||||||
|
design: {
|
||||||
|
level,
|
||||||
|
status: "queued",
|
||||||
|
reason: `${role.toUpperCase()} already active on #${worker.issueId}. Issue created in Planning — dispatch manually when architect is free.`,
|
||||||
|
},
|
||||||
|
announcement: `📐 Created research task #${issue.iid}: ${title} (architect busy — issue in Planning)\n🔗 ${issue.web_url}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch architect directly — issue stays in Planning (no state transition)
|
||||||
|
const dr = await dispatchTask({
|
||||||
|
workspaceDir,
|
||||||
|
agentId: ctx.agentId,
|
||||||
|
groupId,
|
||||||
|
project,
|
||||||
|
issueId: issue.iid,
|
||||||
|
issueTitle: issue.title,
|
||||||
|
issueDescription: issueBody,
|
||||||
|
issueUrl: issue.web_url,
|
||||||
|
role,
|
||||||
|
level,
|
||||||
|
fromLabel: PLANNING_LABEL,
|
||||||
|
toLabel: PLANNING_LABEL,
|
||||||
|
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||||
|
provider,
|
||||||
|
pluginConfig,
|
||||||
|
channel: project.channel,
|
||||||
|
sessionKey: ctx.sessionKey,
|
||||||
|
runtime: api.runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResult({
|
||||||
|
success: true,
|
||||||
|
issue: { id: issue.iid, title: issue.title, url: issue.web_url, label: PLANNING_LABEL },
|
||||||
|
design: {
|
||||||
|
sessionKey: dr.sessionKey,
|
||||||
|
level: dr.level,
|
||||||
|
model: dr.model,
|
||||||
|
sessionAction: dr.sessionAction,
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
project: project.name,
|
||||||
|
announcement: dr.announcement,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,13 +8,14 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|||||||
import { jsonResult } from "openclaw/plugin-sdk";
|
import { jsonResult } from "openclaw/plugin-sdk";
|
||||||
import type { ToolContext } from "../types.js";
|
import type { ToolContext } from "../types.js";
|
||||||
import { runSetup, type SetupOpts } from "../setup/index.js";
|
import { runSetup, type SetupOpts } from "../setup/index.js";
|
||||||
import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "../tiers.js";
|
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||||
|
import { ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
export function createSetupTool(api: OpenClawPluginApi) {
|
export function createSetupTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
name: "setup",
|
name: "setup",
|
||||||
label: "Setup",
|
label: "Setup",
|
||||||
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, projects/projects.json, and model level config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`,
|
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, devclaw/projects.json, devclaw/prompts/, and model level config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`,
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -36,44 +37,22 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
|||||||
models: {
|
models: {
|
||||||
type: "object",
|
type: "object",
|
||||||
description: "Model overrides per role and level.",
|
description: "Model overrides per role and level.",
|
||||||
properties: {
|
properties: Object.fromEntries(
|
||||||
dev: {
|
getAllRoleIds().map((role) => [role, {
|
||||||
type: "object",
|
type: "object",
|
||||||
description: "Developer level models",
|
description: `${role.toUpperCase()} level models`,
|
||||||
properties: {
|
properties: Object.fromEntries(
|
||||||
junior: {
|
getLevelsForRole(role).map((level) => [level, {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: `Default: ${DEFAULT_MODELS.dev.junior}`,
|
description: `Default: ${getAllDefaultModels()[role]?.[level] ?? "auto"}`,
|
||||||
},
|
}]),
|
||||||
medior: {
|
),
|
||||||
type: "string",
|
}]),
|
||||||
description: `Default: ${DEFAULT_MODELS.dev.medior}`,
|
),
|
||||||
},
|
|
||||||
senior: {
|
|
||||||
type: "string",
|
|
||||||
description: `Default: ${DEFAULT_MODELS.dev.senior}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
qa: {
|
|
||||||
type: "object",
|
|
||||||
description: "QA level models",
|
|
||||||
properties: {
|
|
||||||
reviewer: {
|
|
||||||
type: "string",
|
|
||||||
description: `Default: ${DEFAULT_MODELS.qa.reviewer}`,
|
|
||||||
},
|
|
||||||
tester: {
|
|
||||||
type: "string",
|
|
||||||
description: `Default: ${DEFAULT_MODELS.qa.tester}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
projectExecution: {
|
projectExecution: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["parallel", "sequential"],
|
enum: Object.values(ExecutionMode),
|
||||||
description: "Project execution mode. Default: parallel.",
|
description: "Project execution mode. Default: parallel.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -90,8 +69,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
|||||||
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
|
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
|
||||||
models: params.models as SetupOpts["models"],
|
models: params.models as SetupOpts["models"],
|
||||||
projectExecution: params.projectExecution as
|
projectExecution: params.projectExecution as
|
||||||
| "parallel"
|
| ExecutionMode
|
||||||
| "sequential"
|
|
||||||
| undefined,
|
| undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,12 +85,13 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
|||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lines.push(
|
lines.push("Models:");
|
||||||
"Models:",
|
for (const [role, levels] of Object.entries(result.models)) {
|
||||||
...DEV_LEVELS.map((t) => ` dev.${t}: ${result.models.dev[t]}`),
|
for (const [level, model] of Object.entries(levels)) {
|
||||||
...QA_LEVELS.map((t) => ` qa.${t}: ${result.models.qa[t]}`),
|
lines.push(` ${role}.${level}: ${model}`);
|
||||||
"",
|
}
|
||||||
);
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
lines.push("Files:", ...result.filesWritten.map((f) => ` ${f}`));
|
lines.push("Files:", ...result.filesWritten.map((f) => ` ${f}`));
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { readProjects, getProject } from "../projects.js";
|
|||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js";
|
import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js";
|
||||||
import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js";
|
import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js";
|
||||||
import { DEFAULT_WORKFLOW } from "../workflow.js";
|
import { loadWorkflow, ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
export function createStatusTool(api: OpenClawPluginApi) {
|
export function createStatusTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -30,10 +30,10 @@ export function createStatusTool(api: OpenClawPluginApi) {
|
|||||||
const groupId = params.projectGroupId as string | undefined;
|
const groupId = params.projectGroupId as string | undefined;
|
||||||
|
|
||||||
const pluginConfig = getPluginConfig(api);
|
const pluginConfig = getPluginConfig(api);
|
||||||
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
|
const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL;
|
||||||
|
|
||||||
// TODO: Load per-project workflow when supported
|
// Load workspace-level workflow (per-project loaded inside map)
|
||||||
const workflow = DEFAULT_WORKFLOW;
|
const workflow = await loadWorkflow(workspaceDir);
|
||||||
|
|
||||||
const data = await readProjects(workspaceDir);
|
const data = await readProjects(workspaceDir);
|
||||||
const projectIds = groupId ? [groupId] : Object.keys(data.projects);
|
const projectIds = groupId ? [groupId] : Object.keys(data.projects);
|
||||||
@@ -52,28 +52,22 @@ export function createStatusTool(api: OpenClawPluginApi) {
|
|||||||
queueCounts[label] = issues.length;
|
queueCounts[label] = issues.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build dynamic workers summary
|
||||||
|
const workers: Record<string, { active: boolean; issueId: string | null; level: string | null; startTime: string | null }> = {};
|
||||||
|
for (const [role, worker] of Object.entries(project.workers)) {
|
||||||
|
workers[role] = {
|
||||||
|
active: worker.active,
|
||||||
|
issueId: worker.issueId,
|
||||||
|
level: worker.level,
|
||||||
|
startTime: worker.startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: project.name,
|
name: project.name,
|
||||||
groupId: pid,
|
groupId: pid,
|
||||||
roleExecution: project.roleExecution ?? "parallel",
|
roleExecution: project.roleExecution ?? ExecutionMode.PARALLEL,
|
||||||
dev: {
|
workers,
|
||||||
active: project.dev.active,
|
|
||||||
issueId: project.dev.issueId,
|
|
||||||
level: project.dev.level,
|
|
||||||
startTime: project.dev.startTime,
|
|
||||||
},
|
|
||||||
qa: {
|
|
||||||
active: project.qa.active,
|
|
||||||
issueId: project.qa.issueId,
|
|
||||||
level: project.qa.level,
|
|
||||||
startTime: project.qa.startTime,
|
|
||||||
},
|
|
||||||
architect: {
|
|
||||||
active: project.architect.active,
|
|
||||||
issueId: project.architect.issueId,
|
|
||||||
level: project.architect.level,
|
|
||||||
startTime: project.architect.startTime,
|
|
||||||
},
|
|
||||||
queue: queueCounts,
|
queue: queueCounts,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* task_comment — Add review comments or notes to an issue.
|
* task_comment — Add review comments or notes to an issue.
|
||||||
*
|
*
|
||||||
* Use cases:
|
* Use cases:
|
||||||
* - QA worker adds review feedback without blocking pass/fail
|
* - Tester worker adds review feedback without blocking pass/fail
|
||||||
* - DEV worker posts implementation notes
|
* - Developer worker posts implementation notes
|
||||||
* - Orchestrator adds summary comments
|
* - Orchestrator adds summary comments
|
||||||
*/
|
*/
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
@@ -11,10 +11,11 @@ import { jsonResult } from "openclaw/plugin-sdk";
|
|||||||
import type { ToolContext } from "../types.js";
|
import type { ToolContext } from "../types.js";
|
||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||||
|
import { getAllRoleIds, getFallbackEmoji } from "../roles/index.js";
|
||||||
|
|
||||||
/** Valid author roles for attribution */
|
/** Valid author roles for attribution — all registry roles + orchestrator */
|
||||||
const AUTHOR_ROLES = ["dev", "qa", "orchestrator"] as const;
|
const AUTHOR_ROLES = [...getAllRoleIds(), "orchestrator"];
|
||||||
type AuthorRole = (typeof AUTHOR_ROLES)[number];
|
type AuthorRole = string;
|
||||||
|
|
||||||
export function createTaskCommentTool(api: OpenClawPluginApi) {
|
export function createTaskCommentTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -23,15 +24,15 @@ export function createTaskCommentTool(api: OpenClawPluginApi) {
|
|||||||
description: `Add a comment to an issue. Use this for review feedback, implementation notes, or any discussion that doesn't require a state change.
|
description: `Add a comment to an issue. Use this for review feedback, implementation notes, or any discussion that doesn't require a state change.
|
||||||
|
|
||||||
Use cases:
|
Use cases:
|
||||||
- QA adds review feedback without blocking pass/fail
|
- Tester adds review feedback without blocking pass/fail
|
||||||
- DEV posts implementation notes or progress updates
|
- Developer posts implementation notes or progress updates
|
||||||
- Orchestrator adds summary comments
|
- Orchestrator adds summary comments
|
||||||
- Cross-referencing related issues or PRs
|
- Cross-referencing related issues or PRs
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- Simple: { projectGroupId: "-123456789", issueId: 42, body: "Found an edge case with null inputs" }
|
- Simple: { projectGroupId: "-123456789", issueId: 42, body: "Found an edge case with null inputs" }
|
||||||
- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "qa" }
|
- With role: { projectGroupId: "-123456789", issueId: 42, body: "LGTM!", authorRole: "tester" }
|
||||||
- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "dev" }`,
|
- Detailed: { projectGroupId: "-123456789", issueId: 42, body: "## Notes\\n\\n- Tested on staging\\n- All checks passing", authorRole: "developer" }`,
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["projectGroupId", "issueId", "body"],
|
required: ["projectGroupId", "issueId", "body"],
|
||||||
@@ -73,7 +74,7 @@ Examples:
|
|||||||
const issue = await provider.getIssue(issueId);
|
const issue = await provider.getIssue(issueId);
|
||||||
|
|
||||||
const commentBody = authorRole
|
const commentBody = authorRole
|
||||||
? `${ROLE_EMOJI[authorRole]} **${authorRole.toUpperCase()}**: ${body}`
|
? `${getRoleEmoji(authorRole)} **${authorRole.toUpperCase()}**: ${body}`
|
||||||
: body;
|
: body;
|
||||||
|
|
||||||
await provider.addComment(issueId, commentBody);
|
await provider.addComment(issueId, commentBody);
|
||||||
@@ -99,8 +100,7 @@ Examples:
|
|||||||
// Private helpers
|
// Private helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const ROLE_EMOJI: Record<AuthorRole, string> = {
|
function getRoleEmoji(role: string): string {
|
||||||
dev: "👨💻",
|
if (role === "orchestrator") return "🎛️";
|
||||||
qa: "🔍",
|
return getFallbackEmoji(role);
|
||||||
orchestrator: "🎛️",
|
}
|
||||||
};
|
|
||||||
|
|||||||
@@ -13,19 +13,23 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|||||||
import { jsonResult } from "openclaw/plugin-sdk";
|
import { jsonResult } from "openclaw/plugin-sdk";
|
||||||
import type { ToolContext } from "../types.js";
|
import type { ToolContext } from "../types.js";
|
||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { STATE_LABELS, type StateLabel } from "../providers/provider.js";
|
import type { StateLabel } from "../providers/provider.js";
|
||||||
|
import { DEFAULT_WORKFLOW, getStateLabels } from "../workflow.js";
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||||
|
|
||||||
|
/** Derive the initial state label from the workflow config. */
|
||||||
|
const INITIAL_LABEL = DEFAULT_WORKFLOW.states[DEFAULT_WORKFLOW.initial].label;
|
||||||
|
|
||||||
export function createTaskCreateTool(api: OpenClawPluginApi) {
|
export function createTaskCreateTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
name: "task_create",
|
name: "task_create",
|
||||||
label: "Task Create",
|
label: "Task Create",
|
||||||
description: `Create a new task (issue) in the project's issue tracker. Use this to file bugs, features, or tasks from chat.
|
description: `Create a new task (issue) in the project's issue tracker. Use this to file bugs, features, or tasks from chat.
|
||||||
|
|
||||||
**IMPORTANT:** Always creates in "Planning" unless the user explicitly asks to start work immediately. Never set label to "To Do" on your own — "Planning" issues require human review before entering the queue.
|
**IMPORTANT:** Always creates in "${INITIAL_LABEL}" unless the user explicitly asks to start work immediately. Never set label to "To Do" on your own — "${INITIAL_LABEL}" issues require human review before entering the queue.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- Default: { title: "Fix login bug" } → created in Planning
|
- Default: { title: "Fix login bug" } → created in ${INITIAL_LABEL}
|
||||||
- User says "create and start working": { title: "Implement auth", description: "...", label: "To Do" }`,
|
- User says "create and start working": { title: "Implement auth", description: "...", label: "To Do" }`,
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -45,8 +49,8 @@ Examples:
|
|||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: `State label. Defaults to "Planning" — only use "To Do" when the user explicitly asks to start work immediately.`,
|
description: `State label. Defaults to "${INITIAL_LABEL}" — only use "To Do" when the user explicitly asks to start work immediately.`,
|
||||||
enum: STATE_LABELS,
|
enum: getStateLabels(DEFAULT_WORKFLOW),
|
||||||
},
|
},
|
||||||
assignees: {
|
assignees: {
|
||||||
type: "array",
|
type: "array",
|
||||||
@@ -64,7 +68,7 @@ Examples:
|
|||||||
const groupId = params.projectGroupId as string;
|
const groupId = params.projectGroupId as string;
|
||||||
const title = params.title as string;
|
const title = params.title as string;
|
||||||
const description = (params.description as string) ?? "";
|
const description = (params.description as string) ?? "";
|
||||||
const label = (params.label as StateLabel) ?? "Planning";
|
const label = (params.label as StateLabel) ?? INITIAL_LABEL;
|
||||||
const assignees = (params.assignees as string[] | undefined) ?? [];
|
const assignees = (params.assignees as string[] | undefined) ?? [];
|
||||||
const pickup = (params.pickup as boolean) ?? false;
|
const pickup = (params.pickup as boolean) ?? false;
|
||||||
const workspaceDir = requireWorkspaceDir(ctx);
|
const workspaceDir = requireWorkspaceDir(ctx);
|
||||||
|
|||||||
@@ -1,63 +1,133 @@
|
|||||||
/**
|
/**
|
||||||
* Integration test for task_update tool.
|
* Tests for task_update tool — state transitions and level overrides.
|
||||||
*
|
*
|
||||||
* Run manually: node --loader ts-node/esm lib/tools/task-update.test.ts
|
* Run: npx tsx --test lib/tools/task-update.test.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
|
import { DEFAULT_WORKFLOW, getStateLabels, ReviewPolicy, resolveReviewRouting } from "../workflow.js";
|
||||||
|
import { detectLevelFromLabels, detectRoleLevelFromLabels, detectStepRouting } from "../services/queue-scan.js";
|
||||||
|
|
||||||
describe("task_update tool", () => {
|
describe("task_update tool", () => {
|
||||||
it("has correct schema", () => {
|
it("has correct schema", () => {
|
||||||
// Verify the tool signature matches requirements
|
// state is now optional — at least one of state or level required
|
||||||
const requiredParams = ["projectGroupId", "issueId", "state"];
|
const requiredParams = ["projectGroupId", "issueId"];
|
||||||
const optionalParams = ["reason"];
|
assert.strictEqual(requiredParams.length, 2);
|
||||||
|
|
||||||
// Schema validation would go here in a real test
|
|
||||||
assert.ok(true, "Schema structure is valid");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports all state labels", () => {
|
it("supports all state labels", () => {
|
||||||
const validStates = [
|
const labels = getStateLabels(DEFAULT_WORKFLOW);
|
||||||
"Planning",
|
assert.strictEqual(labels.length, 10);
|
||||||
"To Do",
|
assert.ok(labels.includes("Planning"));
|
||||||
"Doing",
|
assert.ok(labels.includes("Done"));
|
||||||
"To Test",
|
assert.ok(labels.includes("To Review"));
|
||||||
"Testing",
|
|
||||||
"Done",
|
|
||||||
"To Improve",
|
|
||||||
"Refining",
|
|
||||||
];
|
|
||||||
|
|
||||||
// In a real test, we'd verify these against the tool's enum
|
|
||||||
assert.strictEqual(validStates.length, 8);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates required parameters", () => {
|
it("validates required parameters", () => {
|
||||||
// Test cases:
|
// At least one of state or level required
|
||||||
// - Missing projectGroupId → Error
|
|
||||||
// - Missing issueId → Error
|
|
||||||
// - Missing state → Error
|
|
||||||
// - Invalid state → Error
|
|
||||||
// - Valid params → Success
|
|
||||||
assert.ok(true, "Parameter validation works");
|
assert.ok(true, "Parameter validation works");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles same-state transitions gracefully", () => {
|
it("handles same-state transitions gracefully", () => {
|
||||||
// When current state === new state, should return success without changes
|
|
||||||
assert.ok(true, "No-op transitions handled correctly");
|
assert.ok(true, "No-op transitions handled correctly");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs to audit trail", () => {
|
it("logs to audit trail", () => {
|
||||||
// Verify auditLog is called with correct parameters
|
|
||||||
assert.ok(true, "Audit logging works");
|
assert.ok(true, "Audit logging works");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test scenarios for manual verification:
|
describe("detectLevelFromLabels — colon format", () => {
|
||||||
// 1. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning" })
|
it("should detect level from colon-format labels", () => {
|
||||||
// → Should transition from "To Do" to "Planning"
|
assert.strictEqual(detectLevelFromLabels(["developer:senior", "Doing"]), "senior");
|
||||||
// 2. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "Planning", reason: "Needs more discussion" })
|
assert.strictEqual(detectLevelFromLabels(["tester:junior", "Testing"]), "junior");
|
||||||
// → Should log reason in audit trail
|
assert.strictEqual(detectLevelFromLabels(["reviewer:medior", "Reviewing"]), "medior");
|
||||||
// 3. task_update({ projectGroupId: "-5239235162", issueId: 28, state: "To Do" })
|
});
|
||||||
// → Should transition back from "Planning" to "To Do"
|
|
||||||
|
it("should prioritize colon format over dot format", () => {
|
||||||
|
// Colon format should win since it's checked first
|
||||||
|
assert.strictEqual(detectLevelFromLabels(["developer:senior", "dev.junior"]), "senior");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to dot format", () => {
|
||||||
|
assert.strictEqual(detectLevelFromLabels(["developer.senior", "Doing"]), "senior");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to plain level name", () => {
|
||||||
|
assert.strictEqual(detectLevelFromLabels(["senior", "Doing"]), "senior");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when no level found", () => {
|
||||||
|
assert.strictEqual(detectLevelFromLabels(["Doing", "bug"]), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectRoleLevelFromLabels", () => {
|
||||||
|
it("should detect role and level from colon-format labels", () => {
|
||||||
|
const result = detectRoleLevelFromLabels(["developer:senior", "Doing"]);
|
||||||
|
assert.deepStrictEqual(result, { role: "developer", level: "senior" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect tester role", () => {
|
||||||
|
const result = detectRoleLevelFromLabels(["tester:medior", "Testing"]);
|
||||||
|
assert.deepStrictEqual(result, { role: "tester", level: "medior" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for step routing labels", () => {
|
||||||
|
// review:human is a step routing label, not a role:level label
|
||||||
|
const result = detectRoleLevelFromLabels(["review:human", "Doing"]);
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when no colon labels present", () => {
|
||||||
|
assert.strictEqual(detectRoleLevelFromLabels(["Doing", "bug"]), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectStepRouting", () => {
|
||||||
|
it("should detect review:human", () => {
|
||||||
|
assert.strictEqual(detectStepRouting(["review:human", "Doing"], "review"), "human");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect review:agent", () => {
|
||||||
|
assert.strictEqual(detectStepRouting(["review:agent", "To Review"], "review"), "agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect review:skip", () => {
|
||||||
|
assert.strictEqual(detectStepRouting(["review:skip", "To Review"], "review"), "skip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect test:skip", () => {
|
||||||
|
assert.strictEqual(detectStepRouting(["test:skip", "To Test"], "test"), "skip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when no matching step label", () => {
|
||||||
|
assert.strictEqual(detectStepRouting(["developer:senior", "Doing"], "review"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be case-insensitive", () => {
|
||||||
|
assert.strictEqual(detectStepRouting(["Review:Human", "Doing"], "review"), "human");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveReviewRouting", () => {
|
||||||
|
it("should return review:human for HUMAN policy", () => {
|
||||||
|
assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "junior"), "review:human");
|
||||||
|
assert.strictEqual(resolveReviewRouting(ReviewPolicy.HUMAN, "senior"), "review:human");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return review:agent for AGENT policy", () => {
|
||||||
|
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "junior"), "review:agent");
|
||||||
|
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AGENT, "senior"), "review:agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return review:human for AUTO + senior", () => {
|
||||||
|
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "senior"), "review:human");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return review:agent for AUTO + non-senior", () => {
|
||||||
|
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "junior"), "review:agent");
|
||||||
|
assert.strictEqual(resolveReviewRouting(ReviewPolicy.AUTO, "medior"), "review:agent");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,27 +10,31 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|||||||
import { jsonResult } from "openclaw/plugin-sdk";
|
import { jsonResult } from "openclaw/plugin-sdk";
|
||||||
import type { ToolContext } from "../types.js";
|
import type { ToolContext } from "../types.js";
|
||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { STATE_LABELS, type StateLabel } from "../providers/provider.js";
|
import type { StateLabel } from "../providers/provider.js";
|
||||||
|
import { DEFAULT_WORKFLOW, getStateLabels, findStateByLabel } from "../workflow.js";
|
||||||
|
import { loadConfig } from "../config/index.js";
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider } from "../tool-helpers.js";
|
||||||
|
|
||||||
export function createTaskUpdateTool(api: OpenClawPluginApi) {
|
export function createTaskUpdateTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
name: "task_update",
|
name: "task_update",
|
||||||
label: "Task Update",
|
label: "Task Update",
|
||||||
description: `Change issue state programmatically. Use this when you need to update an issue's status without going through the full pickup/complete flow.
|
description: `Change issue state and/or role:level assignment. Use this when you need to update an issue's status or override the assigned level.
|
||||||
|
|
||||||
Use cases:
|
Use cases:
|
||||||
- Orchestrator or worker needs to change state manually
|
- Orchestrator or worker needs to change state manually
|
||||||
- Manual status adjustments (e.g., Planning → To Do after approval)
|
- Manual status adjustments (e.g., Planning → To Do after approval)
|
||||||
|
- Override the assigned level (e.g., escalate to senior for human review)
|
||||||
|
- Force human review via level change
|
||||||
- Failed auto-transitions that need correction
|
- Failed auto-transitions that need correction
|
||||||
- Bulk state changes
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- Simple: { projectGroupId: "-123456789", issueId: 42, state: "To Do" }
|
- State only: { projectGroupId: "-123456789", issueId: 42, state: "To Do" }
|
||||||
- With reason: { projectGroupId: "-123456789", issueId: 42, state: "To Do", reason: "Approved for development" }`,
|
- Level only: { projectGroupId: "-123456789", issueId: 42, level: "senior" }
|
||||||
|
- Both: { projectGroupId: "-123456789", issueId: 42, state: "To Do", level: "senior", reason: "Escalating to senior" }`,
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["projectGroupId", "issueId", "state"],
|
required: ["projectGroupId", "issueId"],
|
||||||
properties: {
|
properties: {
|
||||||
projectGroupId: {
|
projectGroupId: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -42,12 +46,16 @@ Examples:
|
|||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: STATE_LABELS,
|
enum: getStateLabels(DEFAULT_WORKFLOW),
|
||||||
description: `New state for the issue. One of: ${STATE_LABELS.join(", ")}`,
|
description: `New state for the issue. One of: ${getStateLabels(DEFAULT_WORKFLOW).join(", ")}`,
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: "string",
|
||||||
|
description: "Override the role:level assignment (e.g., 'senior', 'junior'). Detects role from current state label.",
|
||||||
},
|
},
|
||||||
reason: {
|
reason: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Optional audit log reason for the state change",
|
description: "Optional audit log reason for the change",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -55,41 +63,86 @@ Examples:
|
|||||||
async execute(_id: string, params: Record<string, unknown>) {
|
async execute(_id: string, params: Record<string, unknown>) {
|
||||||
const groupId = params.projectGroupId as string;
|
const groupId = params.projectGroupId as string;
|
||||||
const issueId = params.issueId as number;
|
const issueId = params.issueId as number;
|
||||||
const newState = params.state as StateLabel;
|
const newState = (params.state as StateLabel) ?? undefined;
|
||||||
|
const newLevel = (params.level as string) ?? undefined;
|
||||||
const reason = (params.reason as string) ?? undefined;
|
const reason = (params.reason as string) ?? undefined;
|
||||||
const workspaceDir = requireWorkspaceDir(ctx);
|
const workspaceDir = requireWorkspaceDir(ctx);
|
||||||
|
|
||||||
|
if (!newState && !newLevel) {
|
||||||
|
throw new Error("At least one of 'state' or 'level' must be provided.");
|
||||||
|
}
|
||||||
|
|
||||||
const { project } = await resolveProject(workspaceDir, groupId);
|
const { project } = await resolveProject(workspaceDir, groupId);
|
||||||
const { provider, type: providerType } = await resolveProvider(project);
|
const { provider, type: providerType } = await resolveProvider(project);
|
||||||
|
|
||||||
const issue = await provider.getIssue(issueId);
|
const issue = await provider.getIssue(issueId);
|
||||||
const currentState = provider.getCurrentStateLabel(issue);
|
const currentState = provider.getCurrentStateLabel(issue);
|
||||||
if (!currentState) {
|
if (!currentState) {
|
||||||
throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform transition.`);
|
throw new Error(`Issue #${issueId} has no recognized state label. Cannot perform update.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentState === newState) {
|
let stateChanged = false;
|
||||||
return jsonResult({
|
let levelChanged = false;
|
||||||
success: true, issueId, state: newState, changed: false,
|
let fromLevel: string | undefined;
|
||||||
message: `Issue #${issueId} is already in state "${newState}".`,
|
|
||||||
project: project.name, provider: providerType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Handle state transition
|
||||||
|
if (newState && currentState !== newState) {
|
||||||
await provider.transitionLabel(issueId, currentState, newState);
|
await provider.transitionLabel(issueId, currentState, newState);
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle level override
|
||||||
|
if (newLevel) {
|
||||||
|
// Detect role from current (or new) state label
|
||||||
|
const effectiveState = newState ?? currentState;
|
||||||
|
const workflow = (await loadConfig(workspaceDir, project.name)).workflow;
|
||||||
|
const stateConfig = findStateByLabel(workflow, effectiveState);
|
||||||
|
const role = stateConfig?.role;
|
||||||
|
if (!role) {
|
||||||
|
throw new Error(`Cannot determine role from state "${effectiveState}". Level can only be set on role-assigned states.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate level exists for role
|
||||||
|
const resolvedConfig = await loadConfig(workspaceDir, project.name);
|
||||||
|
const roleConfig = resolvedConfig.roles[role];
|
||||||
|
if (!roleConfig || !roleConfig.levels.includes(newLevel)) {
|
||||||
|
throw new Error(`Invalid level "${newLevel}" for role "${role}". Valid levels: ${roleConfig?.levels.join(", ") ?? "none"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old role:* labels, add new role:level
|
||||||
|
const oldRoleLabels = issue.labels.filter((l) => l.startsWith(`${role}:`));
|
||||||
|
fromLevel = oldRoleLabels[0]?.split(":")[1];
|
||||||
|
if (oldRoleLabels.length > 0) {
|
||||||
|
await provider.removeLabels(issueId, oldRoleLabels);
|
||||||
|
}
|
||||||
|
await provider.addLabel(issueId, `${role}:${newLevel}`);
|
||||||
|
levelChanged = fromLevel !== newLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit
|
||||||
await auditLog(workspaceDir, "task_update", {
|
await auditLog(workspaceDir, "task_update", {
|
||||||
project: project.name, groupId, issueId,
|
project: project.name, groupId, issueId,
|
||||||
fromState: currentState, toState: newState,
|
...(stateChanged ? { fromState: currentState, toState: newState } : {}),
|
||||||
|
...(levelChanged ? { fromLevel: fromLevel ?? null, toLevel: newLevel } : {}),
|
||||||
reason: reason ?? null, provider: providerType,
|
reason: reason ?? null, provider: providerType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build announcement
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (stateChanged) parts.push(`"${currentState}" → "${newState}"`);
|
||||||
|
if (levelChanged) parts.push(`level: ${fromLevel ?? "none"} → ${newLevel}`);
|
||||||
|
const changeDesc = parts.join(", ");
|
||||||
|
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
success: true, issueId, issueTitle: issue.title,
|
success: true, issueId, issueTitle: issue.title,
|
||||||
state: newState, changed: true,
|
...(newState ? { state: newState } : {}),
|
||||||
labelTransition: `${currentState} → ${newState}`,
|
...(newLevel ? { level: newLevel } : {}),
|
||||||
|
changed: stateChanged || levelChanged,
|
||||||
|
...(stateChanged ? { labelTransition: `${currentState} → ${newState}` } : {}),
|
||||||
project: project.name, provider: providerType,
|
project: project.name, provider: providerType,
|
||||||
announcement: `🔄 Updated #${issueId}: "${currentState}" → "${newState}"${reason ? ` (${reason})` : ""}`,
|
announcement: stateChanged || levelChanged
|
||||||
|
? `🔄 Updated #${issueId}: ${changeDesc}${reason ? ` (${reason})` : ""}`
|
||||||
|
: `Issue #${issueId} is already in the requested state.`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,27 +3,33 @@
|
|||||||
*
|
*
|
||||||
* Delegates side-effects to pipeline service: label transition, state update,
|
* Delegates side-effects to pipeline service: label transition, state update,
|
||||||
* issue close/reopen, notifications, and audit logging.
|
* issue close/reopen, notifications, and audit logging.
|
||||||
|
*
|
||||||
|
* Roles without workflow states (e.g. architect) are handled inline —
|
||||||
|
* deactivate worker, optionally transition label, and notify.
|
||||||
*/
|
*/
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { jsonResult } from "openclaw/plugin-sdk";
|
import { jsonResult } from "openclaw/plugin-sdk";
|
||||||
import type { ToolContext } from "../types.js";
|
import type { ToolContext } from "../types.js";
|
||||||
import { getWorker, resolveRepoPath } from "../projects.js";
|
import type { StateLabel } from "../providers/provider.js";
|
||||||
import { executeCompletion, getRule, NEXT_STATE } from "../services/pipeline.js";
|
import { deactivateWorker, getWorker, resolveRepoPath } from "../projects.js";
|
||||||
|
import { executeCompletion, getRule } from "../services/pipeline.js";
|
||||||
import { log as auditLog } from "../audit.js";
|
import { log as auditLog } from "../audit.js";
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||||
import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js";
|
import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js";
|
||||||
|
import { loadWorkflow, hasWorkflowStates, getCompletionEmoji } from "../workflow.js";
|
||||||
|
import { notify, getNotificationConfig } from "../notify.js";
|
||||||
|
|
||||||
export function createWorkFinishTool(api: OpenClawPluginApi) {
|
export function createWorkFinishTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
name: "work_finish",
|
name: "work_finish",
|
||||||
label: "Work Finish",
|
label: "Work Finish",
|
||||||
description: `Complete a task: DEV done/blocked, QA pass/fail/refine/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
|
description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Architect done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["role", "result", "projectGroupId"],
|
required: ["role", "result", "projectGroupId"],
|
||||||
properties: {
|
properties: {
|
||||||
role: { type: "string", enum: getAllRoleIds(), description: "Worker role" },
|
role: { type: "string", enum: getAllRoleIds(), description: "Worker role" },
|
||||||
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked"], description: "Completion result" },
|
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked", "approve", "reject"], description: "Completion result" },
|
||||||
projectGroupId: { type: "string", description: "Project group ID" },
|
projectGroupId: { type: "string", description: "Project group ID" },
|
||||||
summary: { type: "string", description: "Brief summary" },
|
summary: { type: "string", description: "Brief summary" },
|
||||||
prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" },
|
prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" },
|
||||||
@@ -31,7 +37,7 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
async execute(_id: string, params: Record<string, unknown>) {
|
||||||
const role = params.role as "dev" | "qa" | "architect";
|
const role = params.role as string;
|
||||||
const result = params.result as string;
|
const result = params.result as string;
|
||||||
const groupId = params.projectGroupId as string;
|
const groupId = params.projectGroupId as string;
|
||||||
const summary = params.summary as string | undefined;
|
const summary = params.summary as string | undefined;
|
||||||
@@ -43,8 +49,6 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
|||||||
const valid = getCompletionResults(role);
|
const valid = getCompletionResults(role);
|
||||||
throw new Error(`${role.toUpperCase()} cannot complete with "${result}". Valid results: ${valid.join(", ")}`);
|
throw new Error(`${role.toUpperCase()} cannot complete with "${result}". Valid results: ${valid.join(", ")}`);
|
||||||
}
|
}
|
||||||
if (!getRule(role, result))
|
|
||||||
throw new Error(`Invalid completion: ${role}:${result}`);
|
|
||||||
|
|
||||||
// Resolve project + worker
|
// Resolve project + worker
|
||||||
const { project } = await resolveProject(workspaceDir, groupId);
|
const { project } = await resolveProject(workspaceDir, groupId);
|
||||||
@@ -55,18 +59,31 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
|||||||
if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`);
|
if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`);
|
||||||
|
|
||||||
const { provider } = await resolveProvider(project);
|
const { provider } = await resolveProvider(project);
|
||||||
const repoPath = resolveRepoPath(project.repo);
|
const workflow = await loadWorkflow(workspaceDir, project.name);
|
||||||
const issue = await provider.getIssue(issueId);
|
|
||||||
|
|
||||||
|
// Roles without workflow states (e.g. architect) — handle inline
|
||||||
|
if (!hasWorkflowStates(workflow, role)) {
|
||||||
|
return handleStatelessCompletion({
|
||||||
|
workspaceDir, groupId, role, result, issueId, summary,
|
||||||
|
provider, projectName: project.name, channel: project.channel,
|
||||||
|
pluginConfig: getPluginConfig(api), runtime: api.runtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard pipeline completion for roles with workflow states
|
||||||
|
if (!getRule(role, result))
|
||||||
|
throw new Error(`Invalid completion: ${role}:${result}`);
|
||||||
|
|
||||||
|
const repoPath = resolveRepoPath(project.repo);
|
||||||
const pluginConfig = getPluginConfig(api);
|
const pluginConfig = getPluginConfig(api);
|
||||||
|
|
||||||
// Execute completion (pipeline service handles notification with runtime)
|
|
||||||
const completion = await executeCompletion({
|
const completion = await executeCompletion({
|
||||||
workspaceDir, groupId, role, result, issueId, summary, prUrl, provider, repoPath,
|
workspaceDir, groupId, role, result, issueId, summary, prUrl, provider, repoPath,
|
||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
channel: project.channel,
|
channel: project.channel,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
runtime: api.runtime,
|
runtime: api.runtime,
|
||||||
|
workflow,
|
||||||
});
|
});
|
||||||
|
|
||||||
const output: Record<string, unknown> = {
|
const output: Record<string, unknown> = {
|
||||||
@@ -74,7 +91,6 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
|||||||
...completion,
|
...completion,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Audit
|
|
||||||
await auditLog(workspaceDir, "work_finish", {
|
await auditLog(workspaceDir, "work_finish", {
|
||||||
project: project.name, groupId, issue: issueId, role, result,
|
project: project.name, groupId, issue: issueId, role, result,
|
||||||
summary: summary ?? null, labelTransition: completion.labelTransition,
|
summary: summary ?? null, labelTransition: completion.labelTransition,
|
||||||
@@ -84,3 +100,89 @@ export function createWorkFinishTool(api: OpenClawPluginApi) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle completion for roles without workflow states (e.g. architect).
|
||||||
|
*
|
||||||
|
* - done: deactivate worker, issue stays in current state (Planning)
|
||||||
|
* - blocked: deactivate worker, transition issue to Refining
|
||||||
|
*/
|
||||||
|
async function handleStatelessCompletion(opts: {
|
||||||
|
workspaceDir: string;
|
||||||
|
groupId: string;
|
||||||
|
role: string;
|
||||||
|
result: string;
|
||||||
|
issueId: number;
|
||||||
|
summary?: string;
|
||||||
|
provider: import("../providers/provider.js").IssueProvider;
|
||||||
|
projectName: string;
|
||||||
|
channel?: string;
|
||||||
|
pluginConfig?: Record<string, unknown>;
|
||||||
|
runtime?: import("openclaw/plugin-sdk").PluginRuntime;
|
||||||
|
}): Promise<ReturnType<typeof jsonResult>> {
|
||||||
|
const {
|
||||||
|
workspaceDir, groupId, role, result, issueId, summary,
|
||||||
|
provider, projectName, channel, pluginConfig, runtime,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const issue = await provider.getIssue(issueId);
|
||||||
|
|
||||||
|
// Deactivate worker
|
||||||
|
await deactivateWorker(workspaceDir, groupId, role);
|
||||||
|
|
||||||
|
// If blocked, transition to Refining
|
||||||
|
let labelTransition = "none";
|
||||||
|
if (result === "blocked") {
|
||||||
|
const currentLabel = provider.getCurrentStateLabel(issue) ?? "Planning";
|
||||||
|
await provider.transitionLabel(issueId, currentLabel as StateLabel, "Refining" as StateLabel);
|
||||||
|
labelTransition = `${currentLabel} → Refining`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification
|
||||||
|
const nextState = result === "blocked" ? "awaiting human decision" : "awaiting human decision";
|
||||||
|
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||||
|
notify(
|
||||||
|
{
|
||||||
|
type: "workerComplete",
|
||||||
|
project: projectName,
|
||||||
|
groupId,
|
||||||
|
issueId,
|
||||||
|
issueUrl: issue.web_url,
|
||||||
|
role,
|
||||||
|
result: result as "done" | "blocked",
|
||||||
|
summary,
|
||||||
|
nextState,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workspaceDir,
|
||||||
|
config: notifyConfig,
|
||||||
|
groupId,
|
||||||
|
channel: channel ?? "telegram",
|
||||||
|
runtime,
|
||||||
|
},
|
||||||
|
).catch((err) => {
|
||||||
|
auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build announcement
|
||||||
|
const emoji = getCompletionEmoji(role, result);
|
||||||
|
const label = `${role} ${result}`.toUpperCase();
|
||||||
|
let announcement = `${emoji} ${label} #${issueId}`;
|
||||||
|
if (summary) announcement += ` — ${summary}`;
|
||||||
|
announcement += `\n📋 Issue: ${issue.web_url}`;
|
||||||
|
if (result === "blocked") announcement += `\nawaiting human decision.`;
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
await auditLog(workspaceDir, "work_finish", {
|
||||||
|
project: projectName, groupId, issue: issueId, role, result,
|
||||||
|
summary: summary ?? null, labelTransition,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResult({
|
||||||
|
success: true, project: projectName, groupId, issueId, role, result,
|
||||||
|
labelTransition,
|
||||||
|
announcement,
|
||||||
|
nextState,
|
||||||
|
issueUrl: issue.web_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ import type { StateLabel } from "../providers/provider.js";
|
|||||||
import { selectLevel } from "../model-selector.js";
|
import { selectLevel } from "../model-selector.js";
|
||||||
import { getWorker } from "../projects.js";
|
import { getWorker } from "../projects.js";
|
||||||
import { dispatchTask } from "../dispatch.js";
|
import { dispatchTask } from "../dispatch.js";
|
||||||
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
|
import { findNextIssue, detectRoleFromLabel, detectRoleLevelFromLabels } from "../services/queue-scan.js";
|
||||||
import { isDevLevel } from "../tiers.js";
|
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
|
||||||
import { getAllRoleIds } from "../roles/index.js";
|
|
||||||
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
|
||||||
import { DEFAULT_WORKFLOW, getActiveLabel } from "../workflow.js";
|
import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js";
|
||||||
|
|
||||||
export function createWorkStartTool(api: OpenClawPluginApi) {
|
export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
@@ -30,13 +29,13 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
|||||||
projectGroupId: { type: "string", description: "Project group ID." },
|
projectGroupId: { type: "string", description: "Project group ID." },
|
||||||
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
|
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
|
||||||
role: { type: "string", enum: getAllRoleIds(), description: "Worker role. Auto-detected from label if omitted." },
|
role: { type: "string", enum: getAllRoleIds(), description: "Worker role. Auto-detected from label if omitted." },
|
||||||
level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." },
|
level: { type: "string", description: "Worker level (junior/mid/senior). Auto-detected if omitted." },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
async execute(_id: string, params: Record<string, unknown>) {
|
||||||
const issueIdParam = params.issueId as number | undefined;
|
const issueIdParam = params.issueId as number | undefined;
|
||||||
const roleParam = params.role as "dev" | "qa" | "architect" | undefined;
|
const roleParam = params.role as string | undefined;
|
||||||
const groupId = params.projectGroupId as string;
|
const groupId = params.projectGroupId as string;
|
||||||
const levelParam = (params.level ?? params.tier) as string | undefined;
|
const levelParam = (params.level ?? params.tier) as string | undefined;
|
||||||
const workspaceDir = requireWorkspaceDir(ctx);
|
const workspaceDir = requireWorkspaceDir(ctx);
|
||||||
@@ -45,8 +44,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
|||||||
const { project } = await resolveProject(workspaceDir, groupId);
|
const { project } = await resolveProject(workspaceDir, groupId);
|
||||||
const { provider } = await resolveProvider(project);
|
const { provider } = await resolveProvider(project);
|
||||||
|
|
||||||
// TODO: Load per-project workflow when supported
|
const workflow = await loadWorkflow(workspaceDir, project.name);
|
||||||
const workflow = DEFAULT_WORKFLOW;
|
|
||||||
|
|
||||||
// Find issue
|
// Find issue
|
||||||
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string };
|
let issue: { iid: number; title: string; description: string; labels: string[]; web_url: string; state: string };
|
||||||
@@ -72,24 +70,27 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
|||||||
// Check worker availability
|
// Check worker availability
|
||||||
const worker = getWorker(project, role);
|
const worker = getWorker(project, role);
|
||||||
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
|
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
|
||||||
if ((project.roleExecution ?? "parallel") === "sequential") {
|
if ((project.roleExecution ?? ExecutionMode.PARALLEL) === ExecutionMode.SEQUENTIAL) {
|
||||||
const other = role === "dev" ? "qa" : "dev";
|
for (const [otherRole, otherWorker] of Object.entries(project.workers)) {
|
||||||
if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`);
|
if (otherRole !== role && otherWorker.active) {
|
||||||
|
throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get target label from workflow
|
// Get target label from workflow
|
||||||
const targetLabel = getActiveLabel(workflow, role);
|
const targetLabel = getActiveLabel(workflow, role);
|
||||||
|
|
||||||
// Select level
|
// Select level: LLM param → own role label → inherit other role label → heuristic
|
||||||
let selectedLevel: string, levelReason: string, levelSource: string;
|
let selectedLevel: string, levelReason: string, levelSource: string;
|
||||||
if (levelParam) {
|
if (levelParam) {
|
||||||
selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm";
|
selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm";
|
||||||
} else {
|
} else {
|
||||||
const labelLevel = detectLevelFromLabels(issue.labels);
|
const roleLevel = detectRoleLevelFromLabels(issue.labels);
|
||||||
if (labelLevel) {
|
if (roleLevel?.role === role) {
|
||||||
if (role === "qa" && isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = `QA overrides dev level "${labelLevel}"`; levelSource = "role-override"; }
|
selectedLevel = roleLevel.level; levelReason = `Label: "${role}:${roleLevel.level}"`; levelSource = "label";
|
||||||
else if (role === "dev" && !isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; }
|
} else if (roleLevel && getLevelsForRole(role).includes(roleLevel.level)) {
|
||||||
else { selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; }
|
selectedLevel = roleLevel.level; levelReason = `Inherited from ${roleLevel.role}:${roleLevel.level}`; levelSource = "inherited";
|
||||||
} else {
|
} else {
|
||||||
const s = selectLevel(issue.title, issue.description ?? "", role);
|
const s = selectLevel(issue.title, issue.description ?? "", role);
|
||||||
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";
|
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";
|
||||||
|
|||||||
384
lib/workflow.ts
384
lib/workflow.ts
@@ -9,21 +9,74 @@
|
|||||||
*
|
*
|
||||||
* All workflow behavior is derived from this config — no hardcoded state names.
|
* All workflow behavior is derived from this config — no hardcoded state names.
|
||||||
*/
|
*/
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type StateType = "queue" | "active" | "hold" | "terminal";
|
/** Built-in state types. */
|
||||||
/** @deprecated Use WorkerRole from lib/roles/ */
|
export const StateType = {
|
||||||
export type Role = "dev" | "qa" | "architect";
|
QUEUE: "queue",
|
||||||
export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue";
|
ACTIVE: "active",
|
||||||
|
HOLD: "hold",
|
||||||
|
TERMINAL: "terminal",
|
||||||
|
} as const;
|
||||||
|
export type StateType = (typeof StateType)[keyof typeof StateType];
|
||||||
|
|
||||||
|
/** Built-in execution modes for role and project parallelism. */
|
||||||
|
export const ExecutionMode = {
|
||||||
|
PARALLEL: "parallel",
|
||||||
|
SEQUENTIAL: "sequential",
|
||||||
|
} as const;
|
||||||
|
export type ExecutionMode = (typeof ExecutionMode)[keyof typeof ExecutionMode];
|
||||||
|
|
||||||
|
/** Review policy for PR review after developer completion. */
|
||||||
|
export const ReviewPolicy = {
|
||||||
|
HUMAN: "human",
|
||||||
|
AGENT: "agent",
|
||||||
|
AUTO: "auto",
|
||||||
|
} as const;
|
||||||
|
export type ReviewPolicy = (typeof ReviewPolicy)[keyof typeof ReviewPolicy];
|
||||||
|
|
||||||
|
/** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */
|
||||||
|
export type Role = string;
|
||||||
|
/** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */
|
||||||
|
export type TransitionAction = string;
|
||||||
|
|
||||||
|
/** Built-in transition actions. Custom actions are also valid — these are just the ones with built-in handlers. */
|
||||||
|
export const Action = {
|
||||||
|
GIT_PULL: "gitPull",
|
||||||
|
DETECT_PR: "detectPr",
|
||||||
|
MERGE_PR: "mergePr",
|
||||||
|
CLOSE_ISSUE: "closeIssue",
|
||||||
|
REOPEN_ISSUE: "reopenIssue",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Built-in review check types for review states. */
|
||||||
|
export const ReviewCheck = {
|
||||||
|
PR_APPROVED: "prApproved",
|
||||||
|
PR_MERGED: "prMerged",
|
||||||
|
} as const;
|
||||||
|
export type ReviewCheckType = (typeof ReviewCheck)[keyof typeof ReviewCheck];
|
||||||
|
|
||||||
|
/** Built-in workflow events. */
|
||||||
|
export const WorkflowEvent = {
|
||||||
|
PICKUP: "PICKUP",
|
||||||
|
COMPLETE: "COMPLETE",
|
||||||
|
REVIEW: "REVIEW",
|
||||||
|
APPROVED: "APPROVED",
|
||||||
|
MERGE_FAILED: "MERGE_FAILED",
|
||||||
|
PASS: "PASS",
|
||||||
|
FAIL: "FAIL",
|
||||||
|
REFINE: "REFINE",
|
||||||
|
BLOCKED: "BLOCKED",
|
||||||
|
APPROVE: "APPROVE",
|
||||||
|
REJECT: "REJECT",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type TransitionTarget = string | {
|
export type TransitionTarget = string | {
|
||||||
target: string;
|
target: string;
|
||||||
actions?: TransitionAction[];
|
actions?: TransitionAction[];
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StateConfig = {
|
export type StateConfig = {
|
||||||
@@ -32,21 +85,21 @@ export type StateConfig = {
|
|||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
description?: string;
|
||||||
|
check?: ReviewCheckType;
|
||||||
on?: Record<string, TransitionTarget>;
|
on?: Record<string, TransitionTarget>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowConfig = {
|
export type WorkflowConfig = {
|
||||||
initial: string;
|
initial: string;
|
||||||
|
reviewPolicy?: ReviewPolicy;
|
||||||
states: Record<string, StateConfig>;
|
states: Record<string, StateConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CompletionRule = {
|
export type CompletionRule = {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
gitPull?: boolean;
|
actions: string[];
|
||||||
detectPr?: boolean;
|
|
||||||
closeIssue?: boolean;
|
|
||||||
reopenIssue?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -55,88 +108,99 @@ export type CompletionRule = {
|
|||||||
|
|
||||||
export const DEFAULT_WORKFLOW: WorkflowConfig = {
|
export const DEFAULT_WORKFLOW: WorkflowConfig = {
|
||||||
initial: "planning",
|
initial: "planning",
|
||||||
|
reviewPolicy: ReviewPolicy.AUTO,
|
||||||
states: {
|
states: {
|
||||||
|
// ── Main pipeline (happy path) ──────────────────────────────
|
||||||
planning: {
|
planning: {
|
||||||
type: "hold",
|
type: StateType.HOLD,
|
||||||
label: "Planning",
|
label: "Planning",
|
||||||
color: "#95a5a6",
|
color: "#95a5a6",
|
||||||
on: { APPROVE: "todo" },
|
on: { [WorkflowEvent.APPROVE]: "todo" },
|
||||||
},
|
},
|
||||||
todo: {
|
todo: {
|
||||||
type: "queue",
|
type: StateType.QUEUE,
|
||||||
role: "dev",
|
role: "developer",
|
||||||
label: "To Do",
|
label: "To Do",
|
||||||
color: "#428bca",
|
color: "#428bca",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
on: { PICKUP: "doing" },
|
on: { [WorkflowEvent.PICKUP]: "doing" },
|
||||||
},
|
},
|
||||||
doing: {
|
doing: {
|
||||||
type: "active",
|
type: StateType.ACTIVE,
|
||||||
role: "dev",
|
role: "developer",
|
||||||
label: "Doing",
|
label: "Doing",
|
||||||
color: "#f0ad4e",
|
color: "#f0ad4e",
|
||||||
on: {
|
on: {
|
||||||
COMPLETE: { target: "toTest", actions: ["gitPull", "detectPr"] },
|
[WorkflowEvent.COMPLETE]: { target: "toReview", actions: [Action.DETECT_PR] },
|
||||||
BLOCKED: "refining",
|
[WorkflowEvent.BLOCKED]: "refining",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toReview: {
|
||||||
|
type: StateType.QUEUE,
|
||||||
|
role: "reviewer",
|
||||||
|
label: "To Review",
|
||||||
|
color: "#7057ff",
|
||||||
|
priority: 2,
|
||||||
|
check: ReviewCheck.PR_APPROVED,
|
||||||
|
on: {
|
||||||
|
[WorkflowEvent.PICKUP]: "reviewing",
|
||||||
|
[WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] },
|
||||||
|
[WorkflowEvent.MERGE_FAILED]: "toImprove",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reviewing: {
|
||||||
|
type: StateType.ACTIVE,
|
||||||
|
role: "reviewer",
|
||||||
|
label: "Reviewing",
|
||||||
|
color: "#c5def5",
|
||||||
|
on: {
|
||||||
|
[WorkflowEvent.APPROVE]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] },
|
||||||
|
[WorkflowEvent.REJECT]: "toImprove",
|
||||||
|
[WorkflowEvent.BLOCKED]: "refining",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
toTest: {
|
toTest: {
|
||||||
type: "queue",
|
type: StateType.QUEUE,
|
||||||
role: "qa",
|
role: "tester",
|
||||||
label: "To Test",
|
label: "To Test",
|
||||||
color: "#5bc0de",
|
color: "#5bc0de",
|
||||||
priority: 2,
|
priority: 2,
|
||||||
on: { PICKUP: "testing" },
|
on: { [WorkflowEvent.PICKUP]: "testing" },
|
||||||
},
|
},
|
||||||
testing: {
|
testing: {
|
||||||
type: "active",
|
type: StateType.ACTIVE,
|
||||||
role: "qa",
|
role: "tester",
|
||||||
label: "Testing",
|
label: "Testing",
|
||||||
color: "#9b59b6",
|
color: "#9b59b6",
|
||||||
on: {
|
on: {
|
||||||
PASS: { target: "done", actions: ["closeIssue"] },
|
[WorkflowEvent.PASS]: { target: "done", actions: [Action.CLOSE_ISSUE] },
|
||||||
FAIL: { target: "toImprove", actions: ["reopenIssue"] },
|
[WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] },
|
||||||
REFINE: "refining",
|
[WorkflowEvent.REFINE]: "refining",
|
||||||
BLOCKED: "refining",
|
[WorkflowEvent.BLOCKED]: "refining",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
toImprove: {
|
|
||||||
type: "queue",
|
|
||||||
role: "dev",
|
|
||||||
label: "To Improve",
|
|
||||||
color: "#d9534f",
|
|
||||||
priority: 3,
|
|
||||||
on: { PICKUP: "doing" },
|
|
||||||
},
|
|
||||||
refining: {
|
|
||||||
type: "hold",
|
|
||||||
label: "Refining",
|
|
||||||
color: "#f39c12",
|
|
||||||
on: { APPROVE: "todo" },
|
|
||||||
},
|
|
||||||
done: {
|
done: {
|
||||||
type: "terminal",
|
type: StateType.TERMINAL,
|
||||||
label: "Done",
|
label: "Done",
|
||||||
color: "#5cb85c",
|
color: "#5cb85c",
|
||||||
},
|
},
|
||||||
toDesign: {
|
|
||||||
type: "queue",
|
// ── Side paths (loops back into main pipeline) ──────────────
|
||||||
role: "architect",
|
toImprove: {
|
||||||
label: "To Design",
|
type: StateType.QUEUE,
|
||||||
color: "#0075ca",
|
role: "developer",
|
||||||
priority: 1,
|
label: "To Improve",
|
||||||
on: { PICKUP: "designing" },
|
color: "#d9534f",
|
||||||
},
|
priority: 3,
|
||||||
designing: {
|
on: { [WorkflowEvent.PICKUP]: "doing" },
|
||||||
type: "active",
|
|
||||||
role: "architect",
|
|
||||||
label: "Designing",
|
|
||||||
color: "#d4c5f9",
|
|
||||||
on: {
|
|
||||||
COMPLETE: "planning",
|
|
||||||
BLOCKED: "refining",
|
|
||||||
},
|
},
|
||||||
|
refining: {
|
||||||
|
type: StateType.HOLD,
|
||||||
|
label: "Refining",
|
||||||
|
color: "#f39c12",
|
||||||
|
on: { [WorkflowEvent.APPROVE]: "todo" },
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,38 +210,15 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load workflow config for a project.
|
* Load workflow config for a project.
|
||||||
* Priority: project-specific → workspace default → built-in default
|
* Delegates to loadConfig() which handles the three-layer merge.
|
||||||
*/
|
*/
|
||||||
export async function loadWorkflow(
|
export async function loadWorkflow(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
_groupId?: string,
|
projectName?: string,
|
||||||
): Promise<WorkflowConfig> {
|
): Promise<WorkflowConfig> {
|
||||||
// TODO: Support per-project overrides from projects.json when needed
|
const { loadConfig } = await import("./config/loader.js");
|
||||||
// For now, try workspace-level config, fall back to default
|
const config = await loadConfig(workspaceDir, projectName);
|
||||||
|
return config.workflow;
|
||||||
const workflowPath = path.join(workspaceDir, "projects", "workflow.json");
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(workflowPath, "utf-8");
|
|
||||||
const parsed = JSON.parse(content) as { workflow?: WorkflowConfig };
|
|
||||||
if (parsed.workflow) {
|
|
||||||
return mergeWorkflow(DEFAULT_WORKFLOW, parsed.workflow);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No custom workflow, use default
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_WORKFLOW;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge custom workflow config over defaults.
|
|
||||||
* Custom states are merged, not replaced entirely.
|
|
||||||
*/
|
|
||||||
function mergeWorkflow(base: WorkflowConfig, custom: Partial<WorkflowConfig>): WorkflowConfig {
|
|
||||||
return {
|
|
||||||
initial: custom.initial ?? base.initial,
|
|
||||||
states: { ...base.states, ...custom.states },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -202,12 +243,89 @@ export function getLabelColors(workflow: WorkflowConfig): Record<string, string>
|
|||||||
return colors;
|
return colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Role:level labels — dynamic from config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Step routing label values — per-issue overrides for workflow steps. */
|
||||||
|
export const StepRouting = {
|
||||||
|
HUMAN: "human",
|
||||||
|
AGENT: "agent",
|
||||||
|
SKIP: "skip",
|
||||||
|
} as const;
|
||||||
|
export type StepRoutingValue = (typeof StepRouting)[keyof typeof StepRouting];
|
||||||
|
|
||||||
|
/** Known step routing labels (created on the provider during project registration). */
|
||||||
|
export const STEP_ROUTING_LABELS: readonly string[] = [
|
||||||
|
"review:human", "review:agent", "review:skip",
|
||||||
|
"test:skip",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Step routing label color. */
|
||||||
|
const STEP_ROUTING_COLOR = "#d93f0b";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine review routing label for an issue based on project policy and developer level.
|
||||||
|
* Called during developer dispatch to persist the routing decision as a label.
|
||||||
|
*/
|
||||||
|
export function resolveReviewRouting(
|
||||||
|
policy: ReviewPolicy, level: string,
|
||||||
|
): "review:human" | "review:agent" {
|
||||||
|
if (policy === ReviewPolicy.HUMAN) return "review:human";
|
||||||
|
if (policy === ReviewPolicy.AGENT) return "review:agent";
|
||||||
|
// AUTO: senior → human, else agent
|
||||||
|
return level === "senior" ? "review:human" : "review:agent";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default colors per role for role:level labels. */
|
||||||
|
const ROLE_LABEL_COLORS: Record<string, string> = {
|
||||||
|
developer: "#0e8a16",
|
||||||
|
tester: "#5319e7",
|
||||||
|
architect: "#0075ca",
|
||||||
|
reviewer: "#d93f0b",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all role:level label definitions from resolved config roles.
|
||||||
|
* Returns array of { name, color } for label creation (e.g. "developer:junior").
|
||||||
|
*/
|
||||||
|
export function getRoleLabels(
|
||||||
|
roles: Record<string, { levels: string[]; enabled?: boolean }>,
|
||||||
|
): Array<{ name: string; color: string }> {
|
||||||
|
const labels: Array<{ name: string; color: string }> = [];
|
||||||
|
for (const [roleId, role] of Object.entries(roles)) {
|
||||||
|
if (role.enabled === false) continue;
|
||||||
|
for (const level of role.levels) {
|
||||||
|
labels.push({
|
||||||
|
name: `${roleId}:${level}`,
|
||||||
|
color: getRoleLabelColor(roleId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Step routing labels (review:human, review:agent, test:skip, etc.)
|
||||||
|
for (const routingLabel of STEP_ROUTING_LABELS) {
|
||||||
|
labels.push({ name: routingLabel, color: STEP_ROUTING_COLOR });
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the label color for a role. Falls back to gray for unknown roles.
|
||||||
|
*/
|
||||||
|
export function getRoleLabelColor(role: string): string {
|
||||||
|
return ROLE_LABEL_COLORS[role] ?? "#cccccc";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Queue helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get queue labels for a role, ordered by priority (highest first).
|
* Get queue labels for a role, ordered by priority (highest first).
|
||||||
*/
|
*/
|
||||||
export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
|
export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
|
||||||
return Object.values(workflow.states)
|
return Object.values(workflow.states)
|
||||||
.filter((s) => s.type === "queue" && s.role === role)
|
.filter((s) => s.type === StateType.QUEUE && s.role === role)
|
||||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||||
.map((s) => s.label);
|
.map((s) => s.label);
|
||||||
}
|
}
|
||||||
@@ -217,7 +335,7 @@ export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
|
|||||||
*/
|
*/
|
||||||
export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
|
export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
|
||||||
return Object.values(workflow.states)
|
return Object.values(workflow.states)
|
||||||
.filter((s) => s.type === "queue")
|
.filter((s) => s.type === StateType.QUEUE)
|
||||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||||
.map((s) => s.label);
|
.map((s) => s.label);
|
||||||
}
|
}
|
||||||
@@ -227,7 +345,7 @@ export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
|
|||||||
*/
|
*/
|
||||||
export function getActiveLabel(workflow: WorkflowConfig, role: Role): string {
|
export function getActiveLabel(workflow: WorkflowConfig, role: Role): string {
|
||||||
const state = Object.values(workflow.states).find(
|
const state = Object.values(workflow.states).find(
|
||||||
(s) => s.type === "active" && s.role === role,
|
(s) => s.type === StateType.ACTIVE && s.role === role,
|
||||||
);
|
);
|
||||||
if (!state) throw new Error(`No active state for role "${role}"`);
|
if (!state) throw new Error(`No active state for role "${role}"`);
|
||||||
return state.label;
|
return state.label;
|
||||||
@@ -245,8 +363,8 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string {
|
|||||||
|
|
||||||
// Find queue states that transition to this active state
|
// Find queue states that transition to this active state
|
||||||
for (const [, state] of Object.entries(workflow.states)) {
|
for (const [, state] of Object.entries(workflow.states)) {
|
||||||
if (state.type !== "queue" || state.role !== role) continue;
|
if (state.type !== StateType.QUEUE || state.role !== role) continue;
|
||||||
const pickup = state.on?.PICKUP;
|
const pickup = state.on?.[WorkflowEvent.PICKUP];
|
||||||
if (pickup === activeStateKey) {
|
if (pickup === activeStateKey) {
|
||||||
return state.label;
|
return state.label;
|
||||||
}
|
}
|
||||||
@@ -261,7 +379,7 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string {
|
|||||||
*/
|
*/
|
||||||
export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Role | null {
|
export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Role | null {
|
||||||
for (const state of Object.values(workflow.states)) {
|
for (const state of Object.values(workflow.states)) {
|
||||||
if (state.label === label && state.type === "queue" && state.role) {
|
if (state.label === label && state.type === StateType.QUEUE && state.role) {
|
||||||
return state.role;
|
return state.role;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +391,7 @@ export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Ro
|
|||||||
*/
|
*/
|
||||||
export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
|
export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
|
||||||
return Object.values(workflow.states).some(
|
return Object.values(workflow.states).some(
|
||||||
(s) => s.label === label && s.type === "queue",
|
(s) => s.label === label && s.type === StateType.QUEUE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +400,7 @@ export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export function isActiveLabel(workflow: WorkflowConfig, label: string): boolean {
|
export function isActiveLabel(workflow: WorkflowConfig, label: string): boolean {
|
||||||
return Object.values(workflow.states).some(
|
return Object.values(workflow.states).some(
|
||||||
(s) => s.label === label && s.type === "active",
|
(s) => s.label === label && s.type === StateType.ACTIVE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,36 +418,43 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st
|
|||||||
return Object.entries(workflow.states).find(([, s]) => s.label === label)?.[0] ?? null;
|
return Object.entries(workflow.states).find(([, s]) => s.label === label)?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a role has any workflow states (queue, active, etc.).
|
||||||
|
* Roles without workflow states (e.g. architect) are dispatched by tool only.
|
||||||
|
*/
|
||||||
|
export function hasWorkflowStates(workflow: WorkflowConfig, role: Role): boolean {
|
||||||
|
return Object.values(workflow.states).some((s) => s.role === role);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Completion rules — derived from transitions
|
// Completion rules — derived from transitions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map role:result to completion event name.
|
* Map completion result to workflow transition event name.
|
||||||
|
* Convention: "done" → COMPLETE, others → uppercase.
|
||||||
*/
|
*/
|
||||||
const RESULT_TO_EVENT: Record<string, string> = {
|
function resultToEvent(result: string): string {
|
||||||
"dev:done": "COMPLETE",
|
if (result === "done") return WorkflowEvent.COMPLETE;
|
||||||
"dev:blocked": "BLOCKED",
|
return result.toUpperCase();
|
||||||
"qa:pass": "PASS",
|
}
|
||||||
"qa:fail": "FAIL",
|
|
||||||
"qa:refine": "REFINE",
|
|
||||||
"qa:blocked": "BLOCKED",
|
|
||||||
"architect:done": "COMPLETE",
|
|
||||||
"architect:blocked": "BLOCKED",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get completion rule for a role:result pair.
|
* Get completion rule for a role:result pair.
|
||||||
|
* Derives entirely from workflow transitions — no hardcoded role:result mapping.
|
||||||
*/
|
*/
|
||||||
export function getCompletionRule(
|
export function getCompletionRule(
|
||||||
workflow: WorkflowConfig,
|
workflow: WorkflowConfig,
|
||||||
role: Role,
|
role: Role,
|
||||||
result: string,
|
result: string,
|
||||||
): CompletionRule | null {
|
): CompletionRule | null {
|
||||||
const event = RESULT_TO_EVENT[`${role}:${result}`];
|
const event = resultToEvent(result);
|
||||||
if (!event) return null;
|
|
||||||
|
let activeLabel: string;
|
||||||
|
try {
|
||||||
|
activeLabel = getActiveLabel(workflow, role);
|
||||||
|
} catch { return null; }
|
||||||
|
|
||||||
const activeLabel = getActiveLabel(workflow, role);
|
|
||||||
const activeKey = findStateKeyByLabel(workflow, activeLabel);
|
const activeKey = findStateKeyByLabel(workflow, activeLabel);
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
|
|
||||||
@@ -347,15 +472,13 @@ export function getCompletionRule(
|
|||||||
return {
|
return {
|
||||||
from: activeLabel,
|
from: activeLabel,
|
||||||
to: targetState.label,
|
to: targetState.label,
|
||||||
gitPull: actions?.includes("gitPull"),
|
actions: actions ?? [],
|
||||||
detectPr: actions?.includes("detectPr"),
|
|
||||||
closeIssue: actions?.includes("closeIssue"),
|
|
||||||
reopenIssue: actions?.includes("reopenIssue"),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get human-readable next state description.
|
* Get human-readable next state description.
|
||||||
|
* Derives from target state type — no hardcoded role names.
|
||||||
*/
|
*/
|
||||||
export function getNextStateDescription(
|
export function getNextStateDescription(
|
||||||
workflow: WorkflowConfig,
|
workflow: WorkflowConfig,
|
||||||
@@ -365,15 +488,13 @@ export function getNextStateDescription(
|
|||||||
const rule = getCompletionRule(workflow, role, result);
|
const rule = getCompletionRule(workflow, role, result);
|
||||||
if (!rule) return "";
|
if (!rule) return "";
|
||||||
|
|
||||||
// Find the target state to determine the description
|
|
||||||
const targetState = findStateByLabel(workflow, rule.to);
|
const targetState = findStateByLabel(workflow, rule.to);
|
||||||
if (!targetState) return "";
|
if (!targetState) return "";
|
||||||
|
|
||||||
if (targetState.type === "terminal") return "Done!";
|
if (targetState.type === StateType.TERMINAL) return "Done!";
|
||||||
if (targetState.type === "hold") return "awaiting human decision";
|
if (targetState.type === StateType.HOLD) return "awaiting human decision";
|
||||||
if (targetState.type === "queue") {
|
if (targetState.type === StateType.QUEUE && targetState.role) {
|
||||||
if (targetState.role === "qa") return "QA queue";
|
return `${targetState.role.toUpperCase()} queue`;
|
||||||
if (targetState.role === "dev") return "back to DEV";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rule.to;
|
return rule.to;
|
||||||
@@ -381,19 +502,20 @@ export function getNextStateDescription(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get emoji for a completion result.
|
* Get emoji for a completion result.
|
||||||
|
* Keyed by result name — role-independent.
|
||||||
*/
|
*/
|
||||||
export function getCompletionEmoji(role: Role, result: string): string {
|
const RESULT_EMOJI: Record<string, string> = {
|
||||||
const map: Record<string, string> = {
|
done: "✅",
|
||||||
"dev:done": "✅",
|
pass: "🎉",
|
||||||
"qa:pass": "🎉",
|
fail: "❌",
|
||||||
"qa:fail": "❌",
|
refine: "🤔",
|
||||||
"qa:refine": "🤔",
|
blocked: "🚫",
|
||||||
"dev:blocked": "🚫",
|
approve: "✅",
|
||||||
"qa:blocked": "🚫",
|
reject: "❌",
|
||||||
"architect:done": "🏗️",
|
|
||||||
"architect:blocked": "🚫",
|
|
||||||
};
|
};
|
||||||
return map[`${role}:${result}`] ?? "📋";
|
|
||||||
|
export function getCompletionEmoji(_role: Role, result: string): string {
|
||||||
|
return RESULT_EMOJI[result] ?? "📋";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1,13 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "@laurentenhoor/devclaw",
|
"name": "@laurentenhoor/devclaw",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@laurentenhoor/devclaw",
|
"name": "@laurentenhoor/devclaw",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cockatiel": "^3.2.1",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
@@ -3766,6 +3771,15 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cockatiel": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -8729,7 +8743,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
@@ -8787,7 +8800,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,5 +53,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cockatiel": "^3.2.1",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user