@calltelemetry/openclaw-linear 0.8.7 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -89
- package/index.ts +36 -4
- package/package.json +1 -1
- package/src/__test__/webhook-scenarios.test.ts +1 -1
- package/src/gateway/dispatch-methods.test.ts +9 -9
- package/src/infra/commands.test.ts +5 -5
- package/src/infra/config-paths.test.ts +246 -0
- package/src/infra/doctor.ts +45 -36
- package/src/infra/notify.test.ts +49 -0
- package/src/infra/notify.ts +7 -2
- package/src/infra/observability.ts +1 -0
- package/src/infra/shared-profiles.test.ts +262 -0
- package/src/infra/shared-profiles.ts +116 -0
- package/src/infra/template.test.ts +86 -0
- package/src/infra/template.ts +18 -0
- package/src/infra/validation.test.ts +175 -0
- package/src/infra/validation.ts +52 -0
- package/src/pipeline/active-session.test.ts +2 -2
- package/src/pipeline/agent-end-hook.test.ts +305 -0
- package/src/pipeline/artifacts.test.ts +3 -3
- package/src/pipeline/dispatch-state.test.ts +111 -8
- package/src/pipeline/dispatch-state.ts +48 -13
- package/src/pipeline/e2e-dispatch.test.ts +2 -2
- package/src/pipeline/intent-classify.test.ts +20 -2
- package/src/pipeline/intent-classify.ts +14 -24
- package/src/pipeline/pipeline.ts +28 -11
- package/src/pipeline/planner.ts +1 -8
- package/src/pipeline/planning-state.ts +9 -0
- package/src/pipeline/tier-assess.test.ts +39 -39
- package/src/pipeline/tier-assess.ts +15 -33
- package/src/pipeline/webhook.test.ts +149 -1
- package/src/pipeline/webhook.ts +90 -62
- package/src/tools/dispatch-history-tool.test.ts +21 -20
- package/src/tools/dispatch-history-tool.ts +1 -1
- package/src/tools/linear-issues-tool.test.ts +115 -0
- package/src/tools/linear-issues-tool.ts +25 -0
package/README.md
CHANGED
|
@@ -29,7 +29,93 @@ Connect Linear to AI agents. Issues get triaged, implemented, and audited — au
|
|
|
29
29
|
openclaw plugins install @calltelemetry/openclaw-linear
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
### 2.
|
|
32
|
+
### 2. Expose the gateway (Cloudflare Tunnel)
|
|
33
|
+
|
|
34
|
+
Linear sends webhook events over the public internet, so the gateway must be reachable via HTTPS. A [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is the recommended approach — no open ports, no TLS cert management, no static IP required.
|
|
35
|
+
|
|
36
|
+
```mermaid
|
|
37
|
+
flowchart TB
|
|
38
|
+
subgraph Internet
|
|
39
|
+
LW["Linear Webhooks<br/><i>Comment, Issue, AgentSession</i>"]
|
|
40
|
+
LO["Linear OAuth<br/><i>callback redirect</i>"]
|
|
41
|
+
You["You<br/><i>browser, curl</i>"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
subgraph CF["Cloudflare Edge"]
|
|
45
|
+
TLS["TLS termination<br/>DDoS protection"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
subgraph Server["Your Server"]
|
|
49
|
+
CD["cloudflared<br/><i>outbound-only tunnel</i>"]
|
|
50
|
+
GW["openclaw-gateway<br/><i>localhost:18789</i>"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
LW -- "POST /linear/webhook" --> TLS
|
|
54
|
+
LO -- "GET /linear/oauth/callback" --> TLS
|
|
55
|
+
You -- "HTTPS" --> TLS
|
|
56
|
+
TLS -- "tunnel" --> CD
|
|
57
|
+
CD -- "HTTP" --> GW
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**How it works:** `cloudflared` opens an outbound connection to Cloudflare's edge and keeps it alive. Cloudflare routes incoming HTTPS requests for your hostname back through the tunnel to `localhost:18789`. No inbound firewall rules needed.
|
|
61
|
+
|
|
62
|
+
#### Setup
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Install cloudflared
|
|
66
|
+
# RHEL/AlmaLinux:
|
|
67
|
+
sudo dnf install cloudflared
|
|
68
|
+
# Or download: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
|
|
69
|
+
|
|
70
|
+
# Authenticate (opens browser, saves cert to ~/.cloudflared/)
|
|
71
|
+
cloudflared tunnel login
|
|
72
|
+
|
|
73
|
+
# Create a named tunnel
|
|
74
|
+
cloudflared tunnel create openclaw-linear
|
|
75
|
+
# Note the tunnel UUID from the output (e.g., da1f21bf-856e-49ea-83c2-d210092d96be)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
#### Configure the tunnel
|
|
79
|
+
|
|
80
|
+
Create `/etc/cloudflared/config.yml` (system-wide) or `~/.cloudflared/config.yml` (user):
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
tunnel: <your-tunnel-uuid>
|
|
84
|
+
credentials-file: /home/<user>/.cloudflared/<your-tunnel-uuid>.json
|
|
85
|
+
|
|
86
|
+
ingress:
|
|
87
|
+
- hostname: your-domain.com
|
|
88
|
+
service: http://localhost:18789
|
|
89
|
+
- service: http_status:404 # catch-all, reject unmatched requests
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### DNS
|
|
93
|
+
|
|
94
|
+
Point your hostname to the tunnel:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
cloudflared tunnel route dns <your-tunnel-uuid> your-domain.com
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This creates a CNAME record in Cloudflare DNS. You can also do this manually in the Cloudflare dashboard.
|
|
101
|
+
|
|
102
|
+
#### Run as a service
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Install as system service (recommended for production)
|
|
106
|
+
sudo cloudflared service install
|
|
107
|
+
sudo systemctl enable --now cloudflared
|
|
108
|
+
|
|
109
|
+
# Verify
|
|
110
|
+
curl -s https://your-domain.com/linear/webhook \
|
|
111
|
+
-X POST -H "Content-Type: application/json" \
|
|
112
|
+
-d '{"type":"test","action":"ping"}'
|
|
113
|
+
# Should return: "ok"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
> **Tip:** Keep the tunnel running at all times. If `cloudflared` stops, Linear webhook deliveries will fail silently — the gateway won't know about new issues, comments, or agent sessions until the tunnel is restored.
|
|
117
|
+
|
|
118
|
+
### 3. Create a Linear OAuth app
|
|
33
119
|
|
|
34
120
|
Go to **Linear Settings > API > Applications** and create an app:
|
|
35
121
|
|
|
@@ -40,7 +126,7 @@ Go to **Linear Settings > API > Applications** and create an app:
|
|
|
40
126
|
|
|
41
127
|
> You also need a **workspace webhook** — run `openclaw openclaw-linear webhooks setup` to auto-provision it, or manually create one in Settings > API > Webhooks pointing to the same URL with **Comment + Issue** events enabled. Both webhooks are required.
|
|
42
128
|
|
|
43
|
-
###
|
|
129
|
+
### 4. Set credentials
|
|
44
130
|
|
|
45
131
|
```bash
|
|
46
132
|
export LINEAR_CLIENT_ID="your_client_id"
|
|
@@ -57,7 +143,7 @@ Environment=LINEAR_CLIENT_SECRET=your_client_secret
|
|
|
57
143
|
|
|
58
144
|
Then reload: `systemctl --user daemon-reload && systemctl --user restart openclaw-gateway`
|
|
59
145
|
|
|
60
|
-
###
|
|
146
|
+
### 5. Authorize
|
|
61
147
|
|
|
62
148
|
```bash
|
|
63
149
|
openclaw openclaw-linear auth
|
|
@@ -69,7 +155,7 @@ This opens your browser. Approve the authorization, then restart:
|
|
|
69
155
|
systemctl --user restart openclaw-gateway
|
|
70
156
|
```
|
|
71
157
|
|
|
72
|
-
###
|
|
158
|
+
### 6. Verify
|
|
73
159
|
|
|
74
160
|
```bash
|
|
75
161
|
openclaw openclaw-linear status
|
|
@@ -96,24 +182,48 @@ That's it. Create an issue in Linear and watch the agent respond.
|
|
|
96
182
|
|
|
97
183
|
## How It Works — Step by Step
|
|
98
184
|
|
|
99
|
-
Every issue moves through a clear pipeline. Here's
|
|
100
|
-
|
|
185
|
+
Every issue moves through a clear pipeline. Here's the full interaction flow between you, Linear, the plugin, and the agents:
|
|
186
|
+
|
|
187
|
+
```mermaid
|
|
188
|
+
sequenceDiagram
|
|
189
|
+
participant You
|
|
190
|
+
participant Linear
|
|
191
|
+
participant Plugin
|
|
192
|
+
participant Agents
|
|
193
|
+
|
|
194
|
+
You->>Linear: Create issue
|
|
195
|
+
Linear->>Plugin: Webhook (Issue.create)
|
|
196
|
+
Plugin->>Agents: Triage agent
|
|
197
|
+
Agents-->>Plugin: Estimate + labels
|
|
198
|
+
Plugin-->>Linear: Update issue
|
|
199
|
+
Plugin-->>Linear: Post assessment
|
|
200
|
+
|
|
201
|
+
You->>Linear: Assign to agent
|
|
202
|
+
Linear->>Plugin: Webhook (Issue.update)
|
|
203
|
+
Plugin->>Agents: Worker agent
|
|
204
|
+
Agents-->>Linear: Streaming status
|
|
205
|
+
Plugin->>Agents: Audit agent (automatic)
|
|
206
|
+
Agents-->>Plugin: JSON verdict
|
|
207
|
+
Plugin-->>Linear: Result comment
|
|
208
|
+
|
|
209
|
+
You->>Linear: Comment "@kaylee review"
|
|
210
|
+
Linear->>Plugin: Webhook (Comment)
|
|
211
|
+
Plugin->>Agents: Kaylee agent
|
|
212
|
+
Agents-->>Plugin: Response
|
|
213
|
+
Plugin-->>Linear: Branded comment
|
|
101
214
|
```
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
└────┬─────┘ └───────────────┘
|
|
115
|
-
│
|
|
116
|
-
└──▶ back to Worker
|
|
215
|
+
|
|
216
|
+
Here's what each stage does, and what you'll see in Linear:
|
|
217
|
+
|
|
218
|
+
```mermaid
|
|
219
|
+
flowchart LR
|
|
220
|
+
A["Triage<br/><i>(auto)</i>"] --> B["Dispatch<br/><i>(you assign)</i>"]
|
|
221
|
+
B --> C["Worker<br/><i>(auto)</i>"]
|
|
222
|
+
C --> D["Audit<br/><i>(auto)</i>"]
|
|
223
|
+
D --> E["Done ✔"]
|
|
224
|
+
D --> F["Rework<br/><i>(auto retry)</i>"]
|
|
225
|
+
D --> G["Needs Your<br/>Help ⚠<br/><i>(escalated)</i>"]
|
|
226
|
+
F --> C
|
|
117
227
|
```
|
|
118
228
|
|
|
119
229
|
### Stage 1: Triage (automatic)
|
|
@@ -136,7 +246,7 @@ The agent assesses complexity, picks an appropriate model, creates an isolated g
|
|
|
136
246
|
|
|
137
247
|
**What you'll see in Linear:**
|
|
138
248
|
|
|
139
|
-
> **Dispatched** as **
|
|
249
|
+
> **Dispatched** as **high** (anthropic/claude-opus-4-6)
|
|
140
250
|
> > Complex multi-service refactor with migration concerns
|
|
141
251
|
>
|
|
142
252
|
> Worktree: `/home/claw/worktrees/ENG-100` (fresh)
|
|
@@ -153,9 +263,9 @@ The agent assesses complexity, picks an appropriate model, creates an isolated g
|
|
|
153
263
|
|
|
154
264
|
| Tier | Model | When |
|
|
155
265
|
|---|---|---|
|
|
156
|
-
|
|
|
157
|
-
|
|
|
158
|
-
|
|
|
266
|
+
| Small | claude-haiku-4-5 | Simple config changes, typos, one-file fixes |
|
|
267
|
+
| Medium | claude-sonnet-4-6 | Standard features, multi-file changes |
|
|
268
|
+
| High | claude-opus-4-6 | Complex refactors, architecture changes |
|
|
159
269
|
|
|
160
270
|
### Stage 3: Implementation (automatic)
|
|
161
271
|
|
|
@@ -291,10 +401,12 @@ If something went wrong, start with `log.jsonl` — it shows every phase, how lo
|
|
|
291
401
|
|
|
292
402
|
You don't need to memorize magic commands. The bot uses an LLM-based intent classifier to understand what you want from any comment.
|
|
293
403
|
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
404
|
+
```mermaid
|
|
405
|
+
flowchart LR
|
|
406
|
+
A["User comment"] --> B["Intent Classifier<br/><i>(small model, ~2s)</i>"]
|
|
407
|
+
B --> C["Route to handler"]
|
|
408
|
+
B -. "on failure" .-> D["Regex fallback"]
|
|
409
|
+
D --> C
|
|
298
410
|
```
|
|
299
411
|
|
|
300
412
|
**What the bot understands:**
|
|
@@ -305,6 +417,7 @@ User comment → Intent Classifier (small model, ~2s) → Route to handler
|
|
|
305
417
|
| "looks good, ship it" (during planning) | Runs plan audit + cross-model review |
|
|
306
418
|
| "nevermind, cancel this" (during planning) | Exits planning mode |
|
|
307
419
|
| "hey kaylee can you look at this?" | Routes to Kaylee (no `@` needed) |
|
|
420
|
+
| "@mal close this issue" | Routes to Mal (one-time detour) and closes the issue |
|
|
308
421
|
| "what can I do here?" | Default agent responds (not silently dropped) |
|
|
309
422
|
| "fix the search bug" | Default agent dispatches work |
|
|
310
423
|
| "close this" / "mark as done" / "this is resolved" | Generates closure report, transitions issue to completed |
|
|
@@ -313,6 +426,41 @@ User comment → Intent Classifier (small model, ~2s) → Route to handler
|
|
|
313
426
|
|
|
314
427
|
> **Tip:** Configure `classifierAgentId` to point to a small/fast model agent (like Haiku) for low-latency, low-cost intent classification. The classifier only needs ~300 tokens per call.
|
|
315
428
|
|
|
429
|
+
### Agent Routing
|
|
430
|
+
|
|
431
|
+
The plugin supports a multi-agent team where one agent is the default (`isDefault: true` in agent profiles) and others are routed to on demand. Routing works across all webhook paths:
|
|
432
|
+
|
|
433
|
+
| Webhook Path | How agent is selected |
|
|
434
|
+
|---|---|
|
|
435
|
+
| `Comment.create` | `@mention` in comment text → specific agent. No mention → intent classifier may detect agent name ("hey kaylee") → `ask_agent` intent. Otherwise → default agent. |
|
|
436
|
+
| `AgentSessionEvent.created` | Scans user's message for `@mention` aliases → routes to mentioned agent for that interaction. No mention → default agent. |
|
|
437
|
+
| `AgentSessionEvent.prompted` | Same as `created` — scans follow-up message for `@mention` → one-time detour to mentioned agent. No mention → default agent. |
|
|
438
|
+
| `Issue.update` (assignment) | Always dispatches to default agent. |
|
|
439
|
+
| `Issue.create` (triage) | Always dispatches to default agent. |
|
|
440
|
+
|
|
441
|
+
**One-time detour:** When you `@mention` an agent in a session that belongs to a different default agent, the mentioned agent handles that single interaction. The session itself stays owned by whoever created it — subsequent messages without `@mentions` go back to the default. This lets you ask a specific agent for help without permanently switching context.
|
|
442
|
+
|
|
443
|
+
**Agent profiles** are configured in `~/.openclaw/agent-profiles.json`:
|
|
444
|
+
|
|
445
|
+
```json
|
|
446
|
+
{
|
|
447
|
+
"agents": {
|
|
448
|
+
"mal": {
|
|
449
|
+
"label": "Mal",
|
|
450
|
+
"mentionAliases": ["mal"],
|
|
451
|
+
"isDefault": false
|
|
452
|
+
},
|
|
453
|
+
"zoe": {
|
|
454
|
+
"label": "Zoe",
|
|
455
|
+
"mentionAliases": ["zoe"],
|
|
456
|
+
"isDefault": true
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Each agent needs a unique set of `mentionAliases`. The `appAliases` field (e.g. `["ctclaw"]`) is separate — those trigger `AgentSessionEvent` from Linear's own `@app` mention system, not the plugin's routing.
|
|
463
|
+
|
|
316
464
|
### Deduplication
|
|
317
465
|
|
|
318
466
|
The webhook handler prevents double-processing through a two-tier guard system:
|
|
@@ -717,7 +865,7 @@ rework:
|
|
|
717
865
|
| `{{title}}` | Issue title |
|
|
718
866
|
| `{{description}}` | Full issue body |
|
|
719
867
|
| `{{worktreePath}}` | Path to the git worktree |
|
|
720
|
-
| `{{tier}}` | Complexity tier (
|
|
868
|
+
| `{{tier}}` | Complexity tier (small/medium/high) |
|
|
721
869
|
| `{{attempt}}` | Current attempt number |
|
|
722
870
|
| `{{gaps}}` | Audit gaps from previous attempt |
|
|
723
871
|
| `{{projectName}}` | Project name (planner prompts) |
|
|
@@ -817,7 +965,7 @@ If you don't tag the issue at all, the plugin uses your `codexBaseRepo` setting
|
|
|
817
965
|
|
|
818
966
|
When the agent picks up a multi-repo issue, the dispatch comment tells you:
|
|
819
967
|
|
|
820
|
-
> **Dispatched** as **
|
|
968
|
+
> **Dispatched** as **high** (anthropic/claude-opus-4-6)
|
|
821
969
|
>
|
|
822
970
|
> Worktrees:
|
|
823
971
|
> - `api` → `/home/claw/worktrees/ENG-100/api`
|
|
@@ -989,28 +1137,15 @@ Both must point to the same URL. `AgentSessionEvent` payloads carry workspace/te
|
|
|
989
1137
|
|
|
990
1138
|
The handler dispatches by `type + action`:
|
|
991
1139
|
|
|
992
|
-
```
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
├─ type=Comment, action=create
|
|
1002
|
-
│ └─ Comment on issue → filter self-comments (viewerId) → dedup →
|
|
1003
|
-
│ intent classify → route to handler (see Intent Classification below)
|
|
1004
|
-
│
|
|
1005
|
-
├─ type=Issue, action=update
|
|
1006
|
-
│ └─ Issue field changed → check assignment → if assigned to app user →
|
|
1007
|
-
│ dispatch (triage or full implementation)
|
|
1008
|
-
│
|
|
1009
|
-
├─ type=Issue, action=create
|
|
1010
|
-
│ └─ New issue created → triage (estimate, labels, priority)
|
|
1011
|
-
│
|
|
1012
|
-
└─ type=AppUserNotification
|
|
1013
|
-
└─ Immediately discarded (duplicates workspace webhook events)
|
|
1140
|
+
```mermaid
|
|
1141
|
+
flowchart TD
|
|
1142
|
+
A["POST /linear/webhook"] --> B{"Event Type"}
|
|
1143
|
+
B --> C["AgentSessionEvent.created<br/>→ dedup → scan @mentions → run agent"]
|
|
1144
|
+
B --> D["AgentSessionEvent.prompted<br/>→ dedup → scan @mentions → resume agent"]
|
|
1145
|
+
B --> E["Comment.create<br/>→ filter self → dedup → intent classify → route"]
|
|
1146
|
+
B --> F["Issue.update<br/>→ check assignment → dispatch"]
|
|
1147
|
+
B --> G["Issue.create<br/>→ triage (estimate, labels, priority)"]
|
|
1148
|
+
B --> H["AppUserNotification<br/>→ discarded (duplicates workspace events)"]
|
|
1014
1149
|
```
|
|
1015
1150
|
|
|
1016
1151
|
### Intent Classification
|
|
@@ -1062,16 +1197,15 @@ Agent responses follow an **emitActivity-first** pattern:
|
|
|
1062
1197
|
|
|
1063
1198
|
When intent classification returns `close_issue`:
|
|
1064
1199
|
|
|
1065
|
-
```
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
└─ Post closure report (emitActivity → createComment fallback)
|
|
1200
|
+
```mermaid
|
|
1201
|
+
flowchart TD
|
|
1202
|
+
A["close_issue intent"] --> B["Fetch issue details"]
|
|
1203
|
+
B --> C["Find team's completed state"]
|
|
1204
|
+
C --> D["Create agent session on issue"]
|
|
1205
|
+
D --> E["Emit 'preparing closure report' thought"]
|
|
1206
|
+
E --> F["Run agent in read-only mode<br/><i>(generates closure report)</i>"]
|
|
1207
|
+
F --> G["Transition issue → completed"]
|
|
1208
|
+
G --> H["Post closure report<br/><i>(emitActivity → createComment fallback)</i>"]
|
|
1075
1209
|
```
|
|
1076
1210
|
|
|
1077
1211
|
This is a **static action** — the intent triggers direct API calls orchestrated by the plugin, not by giving the agent write tools. The agent only generates the closure report text; all state transitions are handled by the plugin.
|
|
@@ -1080,37 +1214,43 @@ This is a **static action** — the intent triggers direct API calls orchestrate
|
|
|
1080
1214
|
|
|
1081
1215
|
The full dispatch flow for implementing an issue:
|
|
1082
1216
|
|
|
1083
|
-
```
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
│ ├─ Build prompt from prompts.yaml (audit.system + audit.task)
|
|
1100
|
-
│ ├─ Tool access: code_run YES, linear_issues READ+WRITE
|
|
1101
|
-
│ ├─ Auditor verifies acceptance criteria, runs tests, reviews diff
|
|
1102
|
-
│ └─ Must return JSON verdict: {pass, criteria, gaps, testResults}
|
|
1103
|
-
│
|
|
1104
|
-
└─ 8. Verdict (processVerdict)
|
|
1105
|
-
├─ PASS → updateIssue(stateId=Done), post summary, notify ✅
|
|
1106
|
-
├─ FAIL + retries left → back to step 6 with audit gaps as context
|
|
1107
|
-
└─ FAIL + no retries → escalate, notify 🚨, status="stuck"
|
|
1217
|
+
```mermaid
|
|
1218
|
+
flowchart TD
|
|
1219
|
+
A["Issue assigned to app user"] --> B["1. Assess complexity tier<br/><i>junior / medior / senior</i>"]
|
|
1220
|
+
B --> C["2. Create isolated git worktree"]
|
|
1221
|
+
C --> D["3. Register dispatch in state file"]
|
|
1222
|
+
D --> E["4. Write .claw/manifest.json"]
|
|
1223
|
+
E --> F["5. Notify: dispatched as tier"]
|
|
1224
|
+
|
|
1225
|
+
F --> W["6. Worker phase<br/><i>code_run: YES, linear_issues: NO</i><br/>Build prompt → implement → save to .claw/"]
|
|
1226
|
+
W -->|"plugin code — automatic"| AU["7. Audit phase<br/><i>code_run: YES, linear_issues: READ+WRITE</i><br/>Verify criteria → run tests → JSON verdict"]
|
|
1227
|
+
|
|
1228
|
+
AU --> V{"8. Verdict"}
|
|
1229
|
+
V -->|PASS| DONE["Done ✔<br/>updateIssue → notify"]
|
|
1230
|
+
V -->|"FAIL ≤ max"| RW["Rework<br/><i>attempt++, inject audit gaps</i>"]
|
|
1231
|
+
RW --> W
|
|
1232
|
+
V -->|"FAIL > max"| STUCK["Stuck 🚨<br/>escalate + notify"]
|
|
1108
1233
|
```
|
|
1109
1234
|
|
|
1110
1235
|
**State persistence:** Dispatch state is written to `~/.openclaw/linear-dispatch-state.json` with active dispatches, completed history, session mappings, and processed event IDs.
|
|
1111
1236
|
|
|
1112
1237
|
**Watchdog:** A configurable inactivity timer (`inactivitySec`, default 120s) monitors agent output. If no tool calls or text output for the configured period, the agent process is killed and retried once. If the retry also times out, the dispatch is escalated.
|
|
1113
1238
|
|
|
1239
|
+
### Dispatch State Machine
|
|
1240
|
+
|
|
1241
|
+
All transitions use compare-and-swap (CAS) to prevent races. `dispatch-state.json` is the canonical source of truth.
|
|
1242
|
+
|
|
1243
|
+
```mermaid
|
|
1244
|
+
stateDiagram-v2
|
|
1245
|
+
[*] --> DISPATCHED
|
|
1246
|
+
DISPATCHED --> WORKING
|
|
1247
|
+
WORKING --> AUDITING
|
|
1248
|
+
AUDITING --> DONE
|
|
1249
|
+
AUDITING --> WORKING : FAIL (attempt++)
|
|
1250
|
+
WORKING --> STUCK : watchdog kill 2x
|
|
1251
|
+
AUDITING --> STUCK : attempt > max
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1114
1254
|
### `linear_issues` Tool → API Mapping
|
|
1115
1255
|
|
|
1116
1256
|
The `linear_issues` registered tool translates agent requests into `LinearAgentApi` method calls:
|
|
@@ -1255,7 +1395,7 @@ npx tsx scripts/uat-linear.ts --test intent
|
|
|
1255
1395
|
```
|
|
1256
1396
|
[dispatch] Created issue ENG-200: "UAT: simple config tweak"
|
|
1257
1397
|
[dispatch] Assigned to agent — waiting for dispatch comment...
|
|
1258
|
-
[dispatch] ✔ Dispatch confirmed (12s) — assessed as
|
|
1398
|
+
[dispatch] ✔ Dispatch confirmed (12s) — assessed as small
|
|
1259
1399
|
[dispatch] Waiting for audit result...
|
|
1260
1400
|
[dispatch] ✔ Audit passed (94s) — issue marked done
|
|
1261
1401
|
[dispatch] Total: 106s
|
|
@@ -1406,6 +1546,7 @@ journalctl --user -u openclaw-gateway -f # Watch live logs
|
|
|
1406
1546
|
| `code_run` uses wrong backend | Check `coding-tools.json` — explicit backend > per-agent > global default. Run `code-run doctor` to see routing. |
|
|
1407
1547
|
| `code_run` fails at runtime | Run `openclaw openclaw-linear code-run doctor` — checks binary, API key, and live callability for each backend. |
|
|
1408
1548
|
| Webhook events not arriving | Run `openclaw openclaw-linear webhooks setup` to auto-provision. Both webhooks must point to `/linear/webhook`. Check tunnel is running. |
|
|
1549
|
+
| Tunnel down / webhooks silently failing | `systemctl status cloudflared` (or `systemctl --user status cloudflared`). Restart with `systemctl restart cloudflared`. Test: `curl -s -X POST https://your-domain.com/linear/webhook -H 'Content-Type: application/json' -d '{"type":"test"}'` — should return `"ok"`. |
|
|
1409
1550
|
| OAuth token expired | Auto-refreshes. If stuck, re-run `openclaw openclaw-linear auth` and restart. |
|
|
1410
1551
|
| Audit always fails | Run `openclaw openclaw-linear prompts validate` to check prompt syntax. |
|
|
1411
1552
|
| Multi-repo not detected | Markers must be `<!-- repos: name1, name2 -->`. Names must match `repos` config keys. |
|
package/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
5
|
import { registerLinearProvider } from "./src/api/auth.js";
|
|
4
6
|
import { registerCli } from "./src/infra/cli.js";
|
|
@@ -8,7 +10,7 @@ import { handleOAuthCallback } from "./src/api/oauth-callback.js";
|
|
|
8
10
|
import { LinearAgentApi, resolveLinearToken } from "./src/api/linear-api.js";
|
|
9
11
|
import { createDispatchService } from "./src/pipeline/dispatch-service.js";
|
|
10
12
|
import { registerDispatchMethods } from "./src/gateway/dispatch-methods.js";
|
|
11
|
-
import { readDispatchState, lookupSessionMapping, getActiveDispatch } from "./src/pipeline/dispatch-state.js";
|
|
13
|
+
import { readDispatchState, lookupSessionMapping, getActiveDispatch, transitionDispatch, type DispatchStatus } from "./src/pipeline/dispatch-state.js";
|
|
12
14
|
import { triggerAudit, processVerdict, type HookContext } from "./src/pipeline/pipeline.js";
|
|
13
15
|
import { createNotifierFromConfig, type NotifyFn } from "./src/infra/notify.js";
|
|
14
16
|
import { readPlanningState, setPlanningCache } from "./src/pipeline/planning-state.js";
|
|
@@ -169,6 +171,35 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
169
171
|
}
|
|
170
172
|
} catch (err) {
|
|
171
173
|
api.logger.error(`agent_end hook error: ${err}`);
|
|
174
|
+
// Escalate: mark dispatch as stuck so it's visible
|
|
175
|
+
try {
|
|
176
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
177
|
+
const state = await readDispatchState(statePath);
|
|
178
|
+
const sessionKey = ctx?.sessionKey ?? "";
|
|
179
|
+
const mapping = sessionKey ? lookupSessionMapping(state, sessionKey) : null;
|
|
180
|
+
if (mapping) {
|
|
181
|
+
const dispatch = getActiveDispatch(state, mapping.dispatchId);
|
|
182
|
+
if (dispatch && dispatch.status !== "done" && dispatch.status !== "stuck" && dispatch.status !== "failed") {
|
|
183
|
+
const stuckReason = `Hook error: ${err instanceof Error ? err.message : String(err)}`.slice(0, 500);
|
|
184
|
+
await transitionDispatch(
|
|
185
|
+
mapping.dispatchId,
|
|
186
|
+
dispatch.status as DispatchStatus,
|
|
187
|
+
"stuck",
|
|
188
|
+
{ stuckReason },
|
|
189
|
+
statePath,
|
|
190
|
+
);
|
|
191
|
+
// Notify if possible
|
|
192
|
+
await notify("escalation", {
|
|
193
|
+
identifier: dispatch.issueIdentifier,
|
|
194
|
+
title: dispatch.issueTitle ?? "Unknown",
|
|
195
|
+
status: "stuck",
|
|
196
|
+
reason: `Dispatch failed in ${mapping.phase} phase: ${stuckReason}`,
|
|
197
|
+
}).catch(() => {}); // Don't fail on notification failure
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch (escalateErr) {
|
|
201
|
+
api.logger.error(`agent_end escalation also failed: ${escalateErr}`);
|
|
202
|
+
}
|
|
172
203
|
}
|
|
173
204
|
});
|
|
174
205
|
|
|
@@ -222,10 +253,11 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
222
253
|
|
|
223
254
|
// Check CLI availability (Codex, Claude, Gemini)
|
|
224
255
|
const cliChecks: Record<string, string> = {};
|
|
256
|
+
const defaultBinDir = join(process.env.HOME ?? homedir(), ".npm-global", "bin");
|
|
225
257
|
const cliBins: [string, string, string][] = [
|
|
226
|
-
["codex", "
|
|
227
|
-
["claude", "
|
|
228
|
-
["gemini", "
|
|
258
|
+
["codex", pluginConfig?.codexBin as string ?? join(defaultBinDir, "codex"), "npm install -g @openai/codex"],
|
|
259
|
+
["claude", pluginConfig?.claudeBin as string ?? join(defaultBinDir, "claude"), "npm install -g @anthropic-ai/claude-code"],
|
|
260
|
+
["gemini", pluginConfig?.geminiBin as string ?? join(defaultBinDir, "gemini"), "npm install -g @anthropic-ai/gemini-cli"],
|
|
229
261
|
];
|
|
230
262
|
for (const [name, bin, installCmd] of cliBins) {
|
|
231
263
|
try {
|
package/package.json
CHANGED
|
@@ -98,7 +98,7 @@ vi.mock("../pipeline/intent-classify.js", () => ({
|
|
|
98
98
|
}));
|
|
99
99
|
|
|
100
100
|
vi.mock("../pipeline/dispatch-state.js", () => ({
|
|
101
|
-
readDispatchState: vi.fn().mockResolvedValue({ dispatches: { active: {}, completed: {} }, sessionMap: {} }),
|
|
101
|
+
readDispatchState: vi.fn().mockResolvedValue({ version: 2, dispatches: { active: {}, completed: {} }, sessionMap: {}, processedEvents: [] }),
|
|
102
102
|
getActiveDispatch: vi.fn().mockReturnValue(null),
|
|
103
103
|
registerDispatch: vi.fn().mockResolvedValue(undefined),
|
|
104
104
|
updateDispatchStatus: vi.fn().mockResolvedValue(undefined),
|
|
@@ -46,7 +46,7 @@ function makeDispatch(overrides?: Record<string, any>) {
|
|
|
46
46
|
issueIdentifier: "CT-100",
|
|
47
47
|
issueId: "issue-id",
|
|
48
48
|
status: "working",
|
|
49
|
-
tier: "
|
|
49
|
+
tier: "high",
|
|
50
50
|
attempt: 0,
|
|
51
51
|
worktreePath: "/wt/ct-100",
|
|
52
52
|
model: "opus",
|
|
@@ -129,13 +129,13 @@ describe("dispatch.list", () => {
|
|
|
129
129
|
const { api, methods } = createApi();
|
|
130
130
|
registerDispatchMethods(api);
|
|
131
131
|
|
|
132
|
-
const d1 = makeDispatch({ issueIdentifier: "CT-1", tier: "
|
|
133
|
-
const d2 = makeDispatch({ issueIdentifier: "CT-2", tier: "
|
|
132
|
+
const d1 = makeDispatch({ issueIdentifier: "CT-1", tier: "small" });
|
|
133
|
+
const d2 = makeDispatch({ issueIdentifier: "CT-2", tier: "high" });
|
|
134
134
|
mockReadDispatchState.mockResolvedValue(makeState());
|
|
135
135
|
mockListActiveDispatches.mockReturnValue([d1, d2]);
|
|
136
136
|
|
|
137
137
|
const respond = vi.fn();
|
|
138
|
-
await methods["dispatch.list"]({ params: { tier: "
|
|
138
|
+
await methods["dispatch.list"]({ params: { tier: "high" }, respond });
|
|
139
139
|
|
|
140
140
|
const result = respond.mock.calls[0][1];
|
|
141
141
|
expect(result.active).toEqual([d2]);
|
|
@@ -167,7 +167,7 @@ describe("dispatch.get", () => {
|
|
|
167
167
|
const { api, methods } = createApi();
|
|
168
168
|
registerDispatchMethods(api);
|
|
169
169
|
|
|
170
|
-
const completed = { status: "done", tier: "
|
|
170
|
+
const completed = { status: "done", tier: "small" };
|
|
171
171
|
mockReadDispatchState.mockResolvedValue(makeState({}, { "CT-99": completed }));
|
|
172
172
|
mockGetActiveDispatch.mockReturnValue(undefined);
|
|
173
173
|
|
|
@@ -372,9 +372,9 @@ describe("dispatch.stats", () => {
|
|
|
372
372
|
registerDispatchMethods(api);
|
|
373
373
|
|
|
374
374
|
const active = [
|
|
375
|
-
makeDispatch({ status: "working", tier: "
|
|
376
|
-
makeDispatch({ status: "working", tier: "
|
|
377
|
-
makeDispatch({ status: "stuck", tier: "
|
|
375
|
+
makeDispatch({ status: "working", tier: "high" }),
|
|
376
|
+
makeDispatch({ status: "working", tier: "small" }),
|
|
377
|
+
makeDispatch({ status: "stuck", tier: "high" }),
|
|
378
378
|
];
|
|
379
379
|
mockReadDispatchState.mockResolvedValue(makeState({}, { "CT-99": {} }));
|
|
380
380
|
mockListActiveDispatches.mockReturnValue(active);
|
|
@@ -387,7 +387,7 @@ describe("dispatch.stats", () => {
|
|
|
387
387
|
expect(result.activeCount).toBe(3);
|
|
388
388
|
expect(result.completedCount).toBe(1);
|
|
389
389
|
expect(result.byStatus).toEqual({ working: 2, stuck: 1 });
|
|
390
|
-
expect(result.byTier).toEqual({
|
|
390
|
+
expect(result.byTier).toEqual({ high: 2, small: 1 });
|
|
391
391
|
});
|
|
392
392
|
|
|
393
393
|
it("returns zeros when no dispatches", async () => {
|
|
@@ -63,7 +63,7 @@ function makeActive(overrides?: Partial<ActiveDispatch>): ActiveDispatch {
|
|
|
63
63
|
issueIdentifier: "CT-100",
|
|
64
64
|
worktreePath: "/tmp/wt/CT-100",
|
|
65
65
|
branch: "codex/CT-100",
|
|
66
|
-
tier: "
|
|
66
|
+
tier: "small",
|
|
67
67
|
model: "test-model",
|
|
68
68
|
status: "dispatched",
|
|
69
69
|
dispatchedAt: new Date("2026-02-18T10:00:00Z").toISOString(),
|
|
@@ -106,7 +106,7 @@ describe("registerDispatchCommands", () => {
|
|
|
106
106
|
it("dispatch list shows active dispatches with age/tier/status", async () => {
|
|
107
107
|
const d = makeActive({
|
|
108
108
|
issueIdentifier: "CT-100",
|
|
109
|
-
tier: "
|
|
109
|
+
tier: "high",
|
|
110
110
|
status: "working",
|
|
111
111
|
attempt: 1,
|
|
112
112
|
});
|
|
@@ -122,7 +122,7 @@ describe("registerDispatchCommands", () => {
|
|
|
122
122
|
expect(result.text).toContain("Active Dispatches (1)");
|
|
123
123
|
expect(result.text).toContain("CT-100");
|
|
124
124
|
expect(result.text).toContain("working");
|
|
125
|
-
expect(result.text).toContain("
|
|
125
|
+
expect(result.text).toContain("high");
|
|
126
126
|
expect(result.text).toContain("attempt 1");
|
|
127
127
|
// Should contain age in minutes (a number followed by 'm')
|
|
128
128
|
expect(result.text).toMatch(/\d+m/);
|
|
@@ -142,7 +142,7 @@ describe("registerDispatchCommands", () => {
|
|
|
142
142
|
const d = makeActive({
|
|
143
143
|
issueIdentifier: "CT-100",
|
|
144
144
|
issueTitle: "Fix the login bug",
|
|
145
|
-
tier: "
|
|
145
|
+
tier: "medium",
|
|
146
146
|
status: "auditing",
|
|
147
147
|
attempt: 2,
|
|
148
148
|
});
|
|
@@ -158,7 +158,7 @@ describe("registerDispatchCommands", () => {
|
|
|
158
158
|
expect(result.text).toContain("CT-100");
|
|
159
159
|
expect(result.text).toContain("Fix the login bug");
|
|
160
160
|
expect(result.text).toContain("auditing");
|
|
161
|
-
expect(result.text).toContain("
|
|
161
|
+
expect(result.text).toContain("medium");
|
|
162
162
|
expect(result.text).toContain("Attempt: 2");
|
|
163
163
|
});
|
|
164
164
|
|