@calltelemetry/openclaw-linear 0.8.8 → 0.9.1

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.
Files changed (38) hide show
  1. package/README.md +280 -91
  2. package/index.ts +36 -4
  3. package/package.json +1 -1
  4. package/src/__test__/webhook-scenarios.test.ts +1 -1
  5. package/src/gateway/dispatch-methods.test.ts +9 -9
  6. package/src/infra/commands.test.ts +5 -5
  7. package/src/infra/config-paths.test.ts +246 -0
  8. package/src/infra/doctor.test.ts +1 -1
  9. package/src/infra/doctor.ts +45 -36
  10. package/src/infra/notify.test.ts +49 -0
  11. package/src/infra/notify.ts +7 -2
  12. package/src/infra/observability.ts +1 -0
  13. package/src/infra/shared-profiles.test.ts +262 -0
  14. package/src/infra/shared-profiles.ts +116 -0
  15. package/src/infra/template.test.ts +86 -0
  16. package/src/infra/template.ts +18 -0
  17. package/src/infra/validation.test.ts +175 -0
  18. package/src/infra/validation.ts +52 -0
  19. package/src/pipeline/active-session.test.ts +2 -2
  20. package/src/pipeline/agent-end-hook.test.ts +305 -0
  21. package/src/pipeline/artifacts.test.ts +3 -3
  22. package/src/pipeline/dispatch-state.test.ts +111 -8
  23. package/src/pipeline/dispatch-state.ts +48 -13
  24. package/src/pipeline/e2e-dispatch.test.ts +2 -2
  25. package/src/pipeline/intent-classify.test.ts +20 -2
  26. package/src/pipeline/intent-classify.ts +14 -24
  27. package/src/pipeline/pipeline.ts +28 -11
  28. package/src/pipeline/planner.ts +1 -8
  29. package/src/pipeline/planning-state.ts +9 -0
  30. package/src/pipeline/tier-assess.test.ts +39 -39
  31. package/src/pipeline/tier-assess.ts +15 -33
  32. package/src/pipeline/webhook-dedup.test.ts +1 -1
  33. package/src/pipeline/webhook.test.ts +149 -1
  34. package/src/pipeline/webhook.ts +90 -62
  35. package/src/tools/dispatch-history-tool.test.ts +21 -20
  36. package/src/tools/dispatch-history-tool.ts +1 -1
  37. package/src/tools/linear-issues-tool.test.ts +115 -0
  38. package/src/tools/linear-issues-tool.ts +25 -0
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # @calltelemetry/openclaw-linear
2
2
 
3
+ [![CI](https://github.com/calltelemetry/openclaw-linear-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/calltelemetry/openclaw-linear-plugin/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/calltelemetry/openclaw-linear-plugin/graph/badge.svg)](https://codecov.io/gh/calltelemetry/openclaw-linear-plugin)
5
+ [![npm](https://img.shields.io/npm/v/@calltelemetry/openclaw-linear)](https://www.npmjs.com/package/@calltelemetry/openclaw-linear)
3
6
  [![OpenClaw](https://img.shields.io/badge/OpenClaw-v2026.2+-blue)](https://github.com/calltelemetry/openclaw)
4
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
5
8
 
@@ -7,6 +10,55 @@ Connect Linear to AI agents. Issues get triaged, implemented, and audited — au
7
10
 
8
11
  ---
9
12
 
13
+ ## Why This Exists
14
+
15
+ Linear is a great project tracker. But it doesn't orchestrate AI agents — it just gives you issues, comments, and sessions. Without something bridging that gap, every stage of an AI-driven workflow requires a human in the loop: copy the issue context, start an agent, wait, read the output, decide what's next, start another agent, paste in the feedback, repeat. That's not autonomous — that's babysitting.
16
+
17
+ This plugin makes the full lifecycle hands-off:
18
+
19
+ ```mermaid
20
+ sequenceDiagram
21
+ actor You
22
+ participant Linear
23
+ participant Plugin
24
+ participant Worker as Worker Agent
25
+ participant Auditor as Auditor Agent
26
+
27
+ You->>Linear: Create issue
28
+ Note over Plugin: auto-triage
29
+ Linear-->>You: Estimate, labels, priority
30
+
31
+ You->>Linear: Assign to agent
32
+ Plugin->>Worker: dispatch (isolated worktree)
33
+ Worker-->>Plugin: implementation done
34
+ Plugin->>Auditor: audit (automatic, hard-enforced)
35
+ alt Pass
36
+ Auditor-->>Plugin: ✅ verdict
37
+ Plugin-->>Linear: Done
38
+ else Fail (retries left)
39
+ Auditor-->>Plugin: ❌ gaps
40
+ Plugin->>Worker: rework (gaps injected)
41
+ else Fail (no retries)
42
+ Auditor-->>Plugin: ❌ stuck
43
+ Plugin-->>You: 🚨 needs your help
44
+ end
45
+ ```
46
+
47
+ **What Linear can't do on its own — and what this plugin handles:**
48
+
49
+ | Problem | What the plugin does |
50
+ |---|---|
51
+ | **No agent orchestration** | Assigns complexity tiers, picks the right model, creates isolated worktrees, runs workers, triggers audits, processes verdicts — all from a single issue assignment |
52
+ | **No independent verification** | Hard-enforces a worker → auditor boundary in plugin code. The worker cannot mark its own work done. The audit is not optional and not LLM-mediated. |
53
+ | **No failure recovery** | Watchdog kills hung agents after configurable silence. Retries once automatically. Feeds audit failures back as context for rework. Escalates when retries are exhausted. |
54
+ | **No multi-agent routing** | Routes `@mentions` and natural language ("hey kaylee look at this") to specific agents. Intent classifier handles plan requests, questions, close commands, and work requests. |
55
+ | **No webhook deduplication** | Linear sends events from two separate webhook systems that can overlap. The plugin deduplicates across session IDs, comment IDs, and assignment events with a 60s sliding window. |
56
+ | **No project-scale planning** | Planner interviews you, creates issues with user stories and acceptance criteria, runs a cross-model review, then dispatches the full dependency graph — up to 3 issues in parallel. |
57
+
58
+ The end result: you work in Linear. You create issues, assign them, comment in plain English. The agents do the rest — or tell you when they can't.
59
+
60
+ ---
61
+
10
62
  ## What It Does
11
63
 
12
64
  - **New issue?** Agent estimates story points, adds labels, sets priority.
@@ -29,7 +81,127 @@ Connect Linear to AI agents. Issues get triaged, implemented, and audited — au
29
81
  openclaw plugins install @calltelemetry/openclaw-linear
30
82
  ```
31
83
 
32
- ### 2. Create a Linear OAuth app
84
+ ### 2. Expose the gateway (Cloudflare Tunnel)
85
+
86
+ 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.
87
+
88
+ ```mermaid
89
+ flowchart TB
90
+ subgraph Internet
91
+ LW["Linear Webhooks<br/><i>Comment, Issue, AgentSession</i>"]
92
+ LO["Linear OAuth<br/><i>callback redirect</i>"]
93
+ You["You<br/><i>browser, curl</i>"]
94
+ end
95
+
96
+ subgraph CF["Cloudflare Edge"]
97
+ TLS["TLS termination<br/>DDoS protection"]
98
+ end
99
+
100
+ subgraph Server["Your Server"]
101
+ CD["cloudflared<br/><i>outbound-only tunnel</i>"]
102
+ GW["openclaw-gateway<br/><i>localhost:18789</i>"]
103
+ end
104
+
105
+ LW -- "POST /linear/webhook" --> TLS
106
+ LO -- "GET /linear/oauth/callback" --> TLS
107
+ You -- "HTTPS" --> TLS
108
+ TLS -- "tunnel" --> CD
109
+ CD -- "HTTP" --> GW
110
+ ```
111
+
112
+ **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.
113
+
114
+ #### Install cloudflared
115
+
116
+ ```bash
117
+ # RHEL / Rocky / Alma
118
+ sudo dnf install -y cloudflared
119
+
120
+ # Debian / Ubuntu
121
+ curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
122
+ echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \
123
+ | sudo tee /etc/apt/sources.list.d/cloudflared.list
124
+ sudo apt update && sudo apt install -y cloudflared
125
+
126
+ # macOS
127
+ brew install cloudflare/cloudflare/cloudflared
128
+ ```
129
+
130
+ #### Authenticate with Cloudflare
131
+
132
+ ```bash
133
+ cloudflared tunnel login
134
+ ```
135
+
136
+ This opens your browser. You must:
137
+ 1. Log in to your Cloudflare account
138
+ 2. **Select the domain** (zone) for the tunnel (e.g., `yourdomain.com`)
139
+ 3. Click **Authorize**
140
+
141
+ Cloudflare writes an origin certificate to `~/.cloudflared/cert.pem`. This cert grants `cloudflared` permission to create tunnels and DNS records under that domain.
142
+
143
+ > **Prerequisite:** Your domain must already be on Cloudflare (nameservers pointed to Cloudflare). If it's not, add it in the Cloudflare dashboard first.
144
+
145
+ #### Create a tunnel
146
+
147
+ ```bash
148
+ cloudflared tunnel create openclaw-linear
149
+ ```
150
+
151
+ This outputs a **Tunnel ID** (UUID like `da1f21bf-856e-...`) and writes credentials to `~/.cloudflared/<TUNNEL_ID>.json`.
152
+
153
+ #### DNS — point your hostname to the tunnel
154
+
155
+ ```bash
156
+ cloudflared tunnel route dns openclaw-linear linear.yourdomain.com
157
+ ```
158
+
159
+ This creates a CNAME record in Cloudflare DNS: `linear.yourdomain.com → <TUNNEL_ID>.cfargotunnel.com`. You can verify it in the Cloudflare dashboard under **DNS > Records**. You can also create this record manually.
160
+
161
+ The hostname you choose here is what you'll use for **both** webhook URLs and the OAuth redirect URI in Linear. Make sure they all match.
162
+
163
+ #### Configure the tunnel
164
+
165
+ Create `/etc/cloudflared/config.yml` (system-wide) or `~/.cloudflared/config.yml` (user):
166
+
167
+ ```yaml
168
+ tunnel: <TUNNEL_ID>
169
+ credentials-file: /home/<user>/.cloudflared/<TUNNEL_ID>.json
170
+
171
+ ingress:
172
+ - hostname: linear.yourdomain.com
173
+ service: http://localhost:18789
174
+ - service: http_status:404 # catch-all, reject unmatched requests
175
+ ```
176
+
177
+ The `ingress` rule routes all traffic for your hostname to the gateway on localhost. The catch-all `http_status:404` rejects requests for any other hostname.
178
+
179
+ #### Run as a service
180
+
181
+ ```bash
182
+ # Install as system service (recommended for production)
183
+ sudo cloudflared service install
184
+ sudo systemctl enable --now cloudflared
185
+ ```
186
+
187
+ To test without installing as a service:
188
+
189
+ ```bash
190
+ cloudflared tunnel run openclaw-linear
191
+ ```
192
+
193
+ #### Verify end-to-end
194
+
195
+ ```bash
196
+ curl -s https://linear.yourdomain.com/linear/webhook \
197
+ -X POST -H "Content-Type: application/json" \
198
+ -d '{"type":"test","action":"ping"}'
199
+ # Should return: "ok"
200
+ ```
201
+
202
+ > **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.
203
+
204
+ ### 3. Create a Linear OAuth app
33
205
 
34
206
  Go to **Linear Settings > API > Applications** and create an app:
35
207
 
@@ -40,7 +212,7 @@ Go to **Linear Settings > API > Applications** and create an app:
40
212
 
41
213
  > 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
214
 
43
- ### 3. Set credentials
215
+ ### 4. Set credentials
44
216
 
45
217
  ```bash
46
218
  export LINEAR_CLIENT_ID="your_client_id"
@@ -57,7 +229,7 @@ Environment=LINEAR_CLIENT_SECRET=your_client_secret
57
229
 
58
230
  Then reload: `systemctl --user daemon-reload && systemctl --user restart openclaw-gateway`
59
231
 
60
- ### 4. Authorize
232
+ ### 5. Authorize
61
233
 
62
234
  ```bash
63
235
  openclaw openclaw-linear auth
@@ -69,7 +241,7 @@ This opens your browser. Approve the authorization, then restart:
69
241
  systemctl --user restart openclaw-gateway
70
242
  ```
71
243
 
72
- ### 5. Verify
244
+ ### 6. Verify
73
245
 
74
246
  ```bash
75
247
  openclaw openclaw-linear status
@@ -96,24 +268,48 @@ That's it. Create an issue in Linear and watch the agent respond.
96
268
 
97
269
  ## How It Works — Step by Step
98
270
 
99
- Every issue moves through a clear pipeline. Here's exactly what happens at each stage and what you'll see in Linear.
100
-
271
+ Every issue moves through a clear pipeline. Here's the full interaction flow between you, Linear, the plugin, and the agents:
272
+
273
+ ```mermaid
274
+ sequenceDiagram
275
+ participant You
276
+ participant Linear
277
+ participant Plugin
278
+ participant Agents
279
+
280
+ You->>Linear: Create issue
281
+ Linear->>Plugin: Webhook (Issue.create)
282
+ Plugin->>Agents: Triage agent
283
+ Agents-->>Plugin: Estimate + labels
284
+ Plugin-->>Linear: Update issue
285
+ Plugin-->>Linear: Post assessment
286
+
287
+ You->>Linear: Assign to agent
288
+ Linear->>Plugin: Webhook (Issue.update)
289
+ Plugin->>Agents: Worker agent
290
+ Agents-->>Linear: Streaming status
291
+ Plugin->>Agents: Audit agent (automatic)
292
+ Agents-->>Plugin: JSON verdict
293
+ Plugin-->>Linear: Result comment
294
+
295
+ You->>Linear: Comment "@kaylee review"
296
+ Linear->>Plugin: Webhook (Comment)
297
+ Plugin->>Agents: Kaylee agent
298
+ Agents-->>Plugin: Response
299
+ Plugin-->>Linear: Branded comment
101
300
  ```
102
- ┌─────────┐ ┌──────────┐ ┌────────┐ ┌───────┐ ┌──────────┐
103
- Triage │───▶│ Dispatch │───▶│ Worker │───▶│ Audit │───▶│ Done ✔ │
104
- │(auto) │ │(you │ │(auto) │ │(auto) │ │ │
105
- │ │ │ assign) │ │ │ │ │ └──────────┘
106
- └─────────┘ └──────────┘ └────────┘ └───┬───┘
107
-
108
- ┌─────────────┤
109
- ▼ ▼
110
- ┌──────────┐ ┌───────────────┐
111
- Rework │ │ Needs Your │
112
- │ (auto │ │ Help ⚠ │
113
- │ retry) │ │ (escalated) │
114
- └────┬─────┘ └───────────────┘
115
-
116
- └──▶ back to Worker
301
+
302
+ Here's what each stage does, and what you'll see in Linear:
303
+
304
+ ```mermaid
305
+ flowchart LR
306
+ A["Triage<br/><i>(auto)</i>"] --> B["Dispatch<br/><i>(you assign)</i>"]
307
+ B --> C["Worker<br/><i>(auto)</i>"]
308
+ C --> D["Audit<br/><i>(auto)</i>"]
309
+ D --> E["Done ✔"]
310
+ D --> F["Rework<br/><i>(auto retry)</i>"]
311
+ D --> G["Needs Your<br/>Help ⚠<br/><i>(escalated)</i>"]
312
+ F --> C
117
313
  ```
118
314
 
119
315
  ### Stage 1: Triage (automatic)
@@ -136,7 +332,7 @@ The agent assesses complexity, picks an appropriate model, creates an isolated g
136
332
 
137
333
  **What you'll see in Linear:**
138
334
 
139
- > **Dispatched** as **senior** (anthropic/claude-opus-4-6)
335
+ > **Dispatched** as **high** (anthropic/claude-opus-4-6)
140
336
  > > Complex multi-service refactor with migration concerns
141
337
  >
142
338
  > Worktree: `/home/claw/worktrees/ENG-100` (fresh)
@@ -153,9 +349,9 @@ The agent assesses complexity, picks an appropriate model, creates an isolated g
153
349
 
154
350
  | Tier | Model | When |
155
351
  |---|---|---|
156
- | Junior | claude-haiku-4-5 | Simple config changes, typos, one-file fixes |
157
- | Medior | claude-sonnet-4-6 | Standard features, multi-file changes |
158
- | Senior | claude-opus-4-6 | Complex refactors, architecture changes |
352
+ | Small | claude-haiku-4-5 | Simple config changes, typos, one-file fixes |
353
+ | Medium | claude-sonnet-4-6 | Standard features, multi-file changes |
354
+ | High | claude-opus-4-6 | Complex refactors, architecture changes |
159
355
 
160
356
  ### Stage 3: Implementation (automatic)
161
357
 
@@ -291,10 +487,12 @@ If something went wrong, start with `log.jsonl` — it shows every phase, how lo
291
487
 
292
488
  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
489
 
294
- ```
295
- User comment → Intent Classifier (small model, ~2s) → Route to handler
296
- (on failure)
297
- Regex fallback Route to handler
490
+ ```mermaid
491
+ flowchart LR
492
+ A["User comment"] --> B["Intent Classifier<br/><i>(small model, ~2s)</i>"]
493
+ B --> C["Route to handler"]
494
+ B -. "on failure" .-> D["Regex fallback"]
495
+ D --> C
298
496
  ```
299
497
 
300
498
  **What the bot understands:**
@@ -753,7 +951,7 @@ rework:
753
951
  | `{{title}}` | Issue title |
754
952
  | `{{description}}` | Full issue body |
755
953
  | `{{worktreePath}}` | Path to the git worktree |
756
- | `{{tier}}` | Complexity tier (junior/medior/senior) |
954
+ | `{{tier}}` | Complexity tier (small/medium/high) |
757
955
  | `{{attempt}}` | Current attempt number |
758
956
  | `{{gaps}}` | Audit gaps from previous attempt |
759
957
  | `{{projectName}}` | Project name (planner prompts) |
@@ -853,7 +1051,7 @@ If you don't tag the issue at all, the plugin uses your `codexBaseRepo` setting
853
1051
 
854
1052
  When the agent picks up a multi-repo issue, the dispatch comment tells you:
855
1053
 
856
- > **Dispatched** as **senior** (anthropic/claude-opus-4-6)
1054
+ > **Dispatched** as **high** (anthropic/claude-opus-4-6)
857
1055
  >
858
1056
  > Worktrees:
859
1057
  > - `api` → `/home/claw/worktrees/ENG-100/api`
@@ -1025,30 +1223,15 @@ Both must point to the same URL. `AgentSessionEvent` payloads carry workspace/te
1025
1223
 
1026
1224
  The handler dispatches by `type + action`:
1027
1225
 
1028
- ```
1029
- Incoming POST /linear/webhook
1030
-
1031
- ├─ type=AgentSessionEvent, action=created
1032
- └─ New agent session → dedup → scan message for @mentions →
1033
- │ route to mentioned agent (or default)run agent
1034
-
1035
- ├─ type=AgentSessionEvent, action=prompted
1036
- └─ Follow-up message dedup scan message for @mentions →
1037
- │ route to mentioned agent (one-time detour, or default) → resume agent
1038
-
1039
- ├─ type=Comment, action=create
1040
- │ └─ Comment on issue → filter self-comments (viewerId) → dedup →
1041
- │ intent classify → route to handler (see Intent Classification below)
1042
-
1043
- ├─ type=Issue, action=update
1044
- │ └─ Issue field changed → check assignment → if assigned to app user →
1045
- │ dispatch (triage or full implementation)
1046
-
1047
- ├─ type=Issue, action=create
1048
- │ └─ New issue created → triage (estimate, labels, priority)
1049
-
1050
- └─ type=AppUserNotification
1051
- └─ Immediately discarded (duplicates workspace webhook events)
1226
+ ```mermaid
1227
+ flowchart TD
1228
+ A["POST /linear/webhook"] --> B{"Event Type"}
1229
+ B --> C["AgentSessionEvent.created<br/>→ dedup → scan @mentions → run agent"]
1230
+ B --> D["AgentSessionEvent.prompted<br/>→ dedup → scan @mentions → resume agent"]
1231
+ B --> E["Comment.create<br/>→ filter self dedup intent classify → route"]
1232
+ B --> F["Issue.update<br/>→ check assignment → dispatch"]
1233
+ B --> G["Issue.create<br/>→ triage (estimate, labels, priority)"]
1234
+ B --> H["AppUserNotification<br/>→ discarded (duplicates workspace events)"]
1052
1235
  ```
1053
1236
 
1054
1237
  ### Intent Classification
@@ -1100,16 +1283,15 @@ Agent responses follow an **emitActivity-first** pattern:
1100
1283
 
1101
1284
  When intent classification returns `close_issue`:
1102
1285
 
1103
- ```
1104
- close_issue intent
1105
-
1106
- ├─ Fetch full issue details (getIssueDetails)
1107
- ├─ Find team's "completed" state (getTeamStates type=completed)
1108
- ├─ Create agent session on issue (createSessionOnIssue)
1109
- ├─ Emit "preparing closure report" thought (emitActivity)
1110
- ├─ Run agent in read-only mode to generate closure report (runAgent)
1111
- ├─ Transition issue state to completed (updateIssuestateId)
1112
- └─ Post closure report (emitActivity → createComment fallback)
1286
+ ```mermaid
1287
+ flowchart TD
1288
+ A["close_issue intent"] --> B["Fetch issue details"]
1289
+ B --> C["Find team's completed state"]
1290
+ C --> D["Create agent session on issue"]
1291
+ D --> E["Emit 'preparing closure report' thought"]
1292
+ E --> F["Run agent in read-only mode<br/><i>(generates closure report)</i>"]
1293
+ F --> G["Transition issue completed"]
1294
+ G --> H["Post closure report<br/><i>(emitActivitycreateComment fallback)</i>"]
1113
1295
  ```
1114
1296
 
1115
1297
  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.
@@ -1118,37 +1300,43 @@ This is a **static action** — the intent triggers direct API calls orchestrate
1118
1300
 
1119
1301
  The full dispatch flow for implementing an issue:
1120
1302
 
1121
- ```
1122
- Issue assigned to app user
1123
-
1124
- ├─ 1. Assess complexity tier (runAgent → junior/medior/senior)
1125
- ├─ 2. Create isolated git worktree (createWorktree)
1126
- ├─ 3. Register dispatch in state file (registerDispatch)
1127
- ├─ 4. Write .claw/manifest.json with issue metadata
1128
- ├─ 5. Notify: "dispatched as {tier}"
1129
-
1130
- ├─ 6. Worker phase (spawnWorker)
1131
- │ ├─ Build prompt from prompts.yaml (worker.system + worker.task)
1132
- ├─ If retry: append rework.addendum with prior audit gaps
1133
- ├─ Tool access: code_run YES, linear_issues NO
1134
- └─ Output captured as text saved to .claw/worker-{attempt}.md
1135
-
1136
- ├─ 7. Audit phase (triggerAudit)
1137
- │ ├─ Build prompt from prompts.yaml (audit.system + audit.task)
1138
- │ ├─ Tool access: code_run YES, linear_issues READ+WRITE
1139
- │ ├─ Auditor verifies acceptance criteria, runs tests, reviews diff
1140
- │ └─ Must return JSON verdict: {pass, criteria, gaps, testResults}
1141
-
1142
- └─ 8. Verdict (processVerdict)
1143
- ├─ PASS → updateIssue(stateId=Done), post summary, notify ✅
1144
- ├─ FAIL + retries left → back to step 6 with audit gaps as context
1145
- └─ FAIL + no retries → escalate, notify 🚨, status="stuck"
1303
+ ```mermaid
1304
+ flowchart TD
1305
+ A["Issue assigned to app user"] --> B["1. Assess complexity tier<br/><i>junior / medior / senior</i>"]
1306
+ B --> C["2. Create isolated git worktree"]
1307
+ C --> D["3. Register dispatch in state file"]
1308
+ D --> E["4. Write .claw/manifest.json"]
1309
+ E --> F["5. Notify: dispatched as tier"]
1310
+
1311
+ F --> W["6. Worker phase<br/><i>code_run: YES, linear_issues: NO</i><br/>Build prompt → implement → save to .claw/"]
1312
+ 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"]
1313
+
1314
+ AU --> V{"8. Verdict"}
1315
+ V -->|PASS| DONE["Done ✔<br/>updateIssue notify"]
1316
+ V -->|"FAIL max"| RW["Rework<br/><i>attempt++, inject audit gaps</i>"]
1317
+ RW --> W
1318
+ V -->|"FAIL > max"| STUCK["Stuck 🚨<br/>escalate + notify"]
1146
1319
  ```
1147
1320
 
1148
1321
  **State persistence:** Dispatch state is written to `~/.openclaw/linear-dispatch-state.json` with active dispatches, completed history, session mappings, and processed event IDs.
1149
1322
 
1150
1323
  **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.
1151
1324
 
1325
+ ### Dispatch State Machine
1326
+
1327
+ All transitions use compare-and-swap (CAS) to prevent races. `dispatch-state.json` is the canonical source of truth.
1328
+
1329
+ ```mermaid
1330
+ stateDiagram-v2
1331
+ [*] --> DISPATCHED
1332
+ DISPATCHED --> WORKING
1333
+ WORKING --> AUDITING
1334
+ AUDITING --> DONE
1335
+ AUDITING --> WORKING : FAIL (attempt++)
1336
+ WORKING --> STUCK : watchdog kill 2x
1337
+ AUDITING --> STUCK : attempt > max
1338
+ ```
1339
+
1152
1340
  ### `linear_issues` Tool → API Mapping
1153
1341
 
1154
1342
  The `linear_issues` registered tool translates agent requests into `LinearAgentApi` method calls:
@@ -1293,7 +1481,7 @@ npx tsx scripts/uat-linear.ts --test intent
1293
1481
  ```
1294
1482
  [dispatch] Created issue ENG-200: "UAT: simple config tweak"
1295
1483
  [dispatch] Assigned to agent — waiting for dispatch comment...
1296
- [dispatch] ✔ Dispatch confirmed (12s) — assessed as junior
1484
+ [dispatch] ✔ Dispatch confirmed (12s) — assessed as small
1297
1485
  [dispatch] Waiting for audit result...
1298
1486
  [dispatch] ✔ Audit passed (94s) — issue marked done
1299
1487
  [dispatch] Total: 106s
@@ -1444,6 +1632,7 @@ journalctl --user -u openclaw-gateway -f # Watch live logs
1444
1632
  | `code_run` uses wrong backend | Check `coding-tools.json` — explicit backend > per-agent > global default. Run `code-run doctor` to see routing. |
1445
1633
  | `code_run` fails at runtime | Run `openclaw openclaw-linear code-run doctor` — checks binary, API key, and live callability for each backend. |
1446
1634
  | Webhook events not arriving | Run `openclaw openclaw-linear webhooks setup` to auto-provision. Both webhooks must point to `/linear/webhook`. Check tunnel is running. |
1635
+ | 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"`. |
1447
1636
  | OAuth token expired | Auto-refreshes. If stuck, re-run `openclaw openclaw-linear auth` and restart. |
1448
1637
  | Audit always fails | Run `openclaw openclaw-linear prompts validate` to check prompt syntax. |
1449
1638
  | 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", "/home/claw/.npm-global/bin/codex", "npm install -g @openai/codex"],
227
- ["claude", "/home/claw/.npm-global/bin/claude", "npm install -g @anthropic-ai/claude-code"],
228
- ["gemini", "/home/claw/.npm-global/bin/gemini", "npm install -g @anthropic-ai/gemini-cli"],
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.8.8",
3
+ "version": "0.9.1",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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: "senior",
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: "junior" });
133
- const d2 = makeDispatch({ issueIdentifier: "CT-2", tier: "senior" });
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: "senior" }, respond });
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: "junior" };
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: "senior" }),
376
- makeDispatch({ status: "working", tier: "junior" }),
377
- makeDispatch({ status: "stuck", tier: "senior" }),
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({ senior: 2, junior: 1 });
390
+ expect(result.byTier).toEqual({ high: 2, small: 1 });
391
391
  });
392
392
 
393
393
  it("returns zeros when no dispatches", async () => {