@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.
Files changed (36) hide show
  1. package/README.md +230 -89
  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.ts +45 -36
  9. package/src/infra/notify.test.ts +49 -0
  10. package/src/infra/notify.ts +7 -2
  11. package/src/infra/observability.ts +1 -0
  12. package/src/infra/shared-profiles.test.ts +262 -0
  13. package/src/infra/shared-profiles.ts +116 -0
  14. package/src/infra/template.test.ts +86 -0
  15. package/src/infra/template.ts +18 -0
  16. package/src/infra/validation.test.ts +175 -0
  17. package/src/infra/validation.ts +52 -0
  18. package/src/pipeline/active-session.test.ts +2 -2
  19. package/src/pipeline/agent-end-hook.test.ts +305 -0
  20. package/src/pipeline/artifacts.test.ts +3 -3
  21. package/src/pipeline/dispatch-state.test.ts +111 -8
  22. package/src/pipeline/dispatch-state.ts +48 -13
  23. package/src/pipeline/e2e-dispatch.test.ts +2 -2
  24. package/src/pipeline/intent-classify.test.ts +20 -2
  25. package/src/pipeline/intent-classify.ts +14 -24
  26. package/src/pipeline/pipeline.ts +28 -11
  27. package/src/pipeline/planner.ts +1 -8
  28. package/src/pipeline/planning-state.ts +9 -0
  29. package/src/pipeline/tier-assess.test.ts +39 -39
  30. package/src/pipeline/tier-assess.ts +15 -33
  31. package/src/pipeline/webhook.test.ts +149 -1
  32. package/src/pipeline/webhook.ts +90 -62
  33. package/src/tools/dispatch-history-tool.test.ts +21 -20
  34. package/src/tools/dispatch-history-tool.ts +1 -1
  35. package/src/tools/linear-issues-tool.test.ts +115 -0
  36. 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. Create a Linear OAuth app
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
- ### 3. Set credentials
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
- ### 4. Authorize
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
- ### 5. Verify
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 exactly what happens at each stage and what you'll see in Linear.
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
- 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
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 **senior** (anthropic/claude-opus-4-6)
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
- | 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 |
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
- User comment → Intent Classifier (small model, ~2s) → Route to handler
296
- (on failure)
297
- Regex fallback Route to handler
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 (junior/medior/senior) |
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 **senior** (anthropic/claude-opus-4-6)
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
- Incoming POST /linear/webhook
994
-
995
- ├─ type=AgentSessionEvent, action=created
996
- └─ New agent session on issue → dedup → create LinearAgentApirun agent
997
-
998
- ├─ type=AgentSessionEvent, action=prompted
999
- └─ Follow-up message in existing session → dedup → resume agent with context
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 @mentionsresume 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
- close_issue intent
1067
-
1068
- ├─ Fetch full issue details (getIssueDetails)
1069
- ├─ Find team's "completed" state (getTeamStates type=completed)
1070
- ├─ Create agent session on issue (createSessionOnIssue)
1071
- ├─ Emit "preparing closure report" thought (emitActivity)
1072
- ├─ Run agent in read-only mode to generate closure report (runAgent)
1073
- ├─ Transition issue state to completed (updateIssuestateId)
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>(emitActivitycreateComment 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
- Issue assigned to app user
1085
-
1086
- ├─ 1. Assess complexity tier (runAgent → junior/medior/senior)
1087
- ├─ 2. Create isolated git worktree (createWorktree)
1088
- ├─ 3. Register dispatch in state file (registerDispatch)
1089
- ├─ 4. Write .claw/manifest.json with issue metadata
1090
- ├─ 5. Notify: "dispatched as {tier}"
1091
-
1092
- ├─ 6. Worker phase (spawnWorker)
1093
- │ ├─ Build prompt from prompts.yaml (worker.system + worker.task)
1094
- ├─ If retry: append rework.addendum with prior audit gaps
1095
- ├─ Tool access: code_run YES, linear_issues NO
1096
- └─ Output captured as text saved to .claw/worker-{attempt}.md
1097
-
1098
- ├─ 7. Audit phase (triggerAudit)
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 junior
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", "/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.7",
3
+ "version": "0.9.0",
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 () => {
@@ -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: "junior",
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: "senior",
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("senior");
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: "medior",
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("medior");
161
+ expect(result.text).toContain("medium");
162
162
  expect(result.text).toContain("Attempt: 2");
163
163
  });
164
164