@calltelemetry/openclaw-linear 0.7.0 → 0.8.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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +719 -539
  3. package/index.ts +40 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +19 -5
  7. package/src/__test__/fixtures/linear-responses.ts +75 -0
  8. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  9. package/src/__test__/helpers.ts +133 -0
  10. package/src/agent/agent.test.ts +143 -0
  11. package/src/api/linear-api.test.ts +586 -0
  12. package/src/api/linear-api.ts +50 -11
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/gateway/dispatch-methods.ts +243 -0
  15. package/src/infra/cli.ts +273 -30
  16. package/src/infra/codex-worktree.ts +83 -0
  17. package/src/infra/commands.test.ts +276 -0
  18. package/src/infra/commands.ts +156 -0
  19. package/src/infra/doctor.test.ts +19 -0
  20. package/src/infra/doctor.ts +28 -23
  21. package/src/infra/file-lock.test.ts +61 -0
  22. package/src/infra/file-lock.ts +49 -0
  23. package/src/infra/multi-repo.test.ts +163 -0
  24. package/src/infra/multi-repo.ts +114 -0
  25. package/src/infra/notify.test.ts +155 -16
  26. package/src/infra/notify.ts +137 -26
  27. package/src/infra/observability.test.ts +85 -0
  28. package/src/infra/observability.ts +48 -0
  29. package/src/infra/resilience.test.ts +94 -0
  30. package/src/infra/resilience.ts +101 -0
  31. package/src/pipeline/artifacts.test.ts +26 -3
  32. package/src/pipeline/artifacts.ts +38 -2
  33. package/src/pipeline/dag-dispatch.test.ts +553 -0
  34. package/src/pipeline/dag-dispatch.ts +390 -0
  35. package/src/pipeline/dispatch-service.ts +48 -1
  36. package/src/pipeline/dispatch-state.ts +3 -42
  37. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  38. package/src/pipeline/e2e-planning.test.ts +455 -0
  39. package/src/pipeline/pipeline.test.ts +69 -0
  40. package/src/pipeline/pipeline.ts +132 -29
  41. package/src/pipeline/planner.test.ts +1 -1
  42. package/src/pipeline/planner.ts +18 -31
  43. package/src/pipeline/planning-state.ts +2 -40
  44. package/src/pipeline/tier-assess.test.ts +264 -0
  45. package/src/pipeline/webhook.ts +134 -36
  46. package/src/tools/cli-shared.test.ts +155 -0
  47. package/src/tools/code-tool.test.ts +210 -0
  48. package/src/tools/dispatch-history-tool.test.ts +315 -0
  49. package/src/tools/dispatch-history-tool.ts +201 -0
  50. package/src/tools/orchestration-tools.test.ts +158 -0
@@ -0,0 +1,276 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks
5
+ // ---------------------------------------------------------------------------
6
+
7
+ vi.mock("../pipeline/dispatch-state.js", () => ({
8
+ readDispatchState: vi.fn(),
9
+ getActiveDispatch: vi.fn(),
10
+ listActiveDispatches: vi.fn(),
11
+ removeActiveDispatch: vi.fn(),
12
+ transitionDispatch: vi.fn(),
13
+ TransitionError: class TransitionError extends Error {
14
+ constructor(
15
+ public dispatchId: string,
16
+ public fromStatus: string,
17
+ public toStatus: string,
18
+ public actualStatus: string,
19
+ ) {
20
+ super(
21
+ `CAS transition failed for ${dispatchId}: ` +
22
+ `expected ${fromStatus} → ${toStatus}, but current status is ${actualStatus}`,
23
+ );
24
+ this.name = "TransitionError";
25
+ }
26
+ },
27
+ registerDispatch: vi.fn(),
28
+ }));
29
+
30
+ import { registerDispatchCommands } from "./commands.js";
31
+ import {
32
+ readDispatchState,
33
+ getActiveDispatch,
34
+ listActiveDispatches,
35
+ removeActiveDispatch,
36
+ transitionDispatch,
37
+ registerDispatch,
38
+ } from "../pipeline/dispatch-state.js";
39
+ import type { DispatchState, ActiveDispatch } from "../pipeline/dispatch-state.js";
40
+
41
+ const mockReadDispatchState = readDispatchState as ReturnType<typeof vi.fn>;
42
+ const mockGetActiveDispatch = getActiveDispatch as ReturnType<typeof vi.fn>;
43
+ const mockListActiveDispatches = listActiveDispatches as ReturnType<typeof vi.fn>;
44
+ const mockRemoveActiveDispatch = removeActiveDispatch as ReturnType<typeof vi.fn>;
45
+ const mockTransitionDispatch = transitionDispatch as ReturnType<typeof vi.fn>;
46
+ const mockRegisterDispatch = registerDispatch as ReturnType<typeof vi.fn>;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Helpers
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function emptyState(): DispatchState {
53
+ return {
54
+ dispatches: { active: {}, completed: {} },
55
+ sessionMap: {},
56
+ processedEvents: [],
57
+ };
58
+ }
59
+
60
+ function makeActive(overrides?: Partial<ActiveDispatch>): ActiveDispatch {
61
+ return {
62
+ issueId: "uuid-1",
63
+ issueIdentifier: "CT-100",
64
+ worktreePath: "/tmp/wt/CT-100",
65
+ branch: "codex/CT-100",
66
+ tier: "junior",
67
+ model: "test-model",
68
+ status: "dispatched",
69
+ dispatchedAt: new Date("2026-02-18T10:00:00Z").toISOString(),
70
+ attempt: 0,
71
+ ...overrides,
72
+ };
73
+ }
74
+
75
+ /** Capture the registered command handler from the mock api */
76
+ function captureHandler() {
77
+ let handler: (ctx: any) => Promise<any>;
78
+ const api = {
79
+ pluginConfig: undefined,
80
+ registerCommand: vi.fn((cmd: any) => {
81
+ handler = cmd.handler;
82
+ }),
83
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
84
+ };
85
+ registerDispatchCommands(api as any);
86
+ return { api, handler: handler! };
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Tests
91
+ // ---------------------------------------------------------------------------
92
+
93
+ describe("registerDispatchCommands", () => {
94
+ beforeEach(() => {
95
+ vi.clearAllMocks();
96
+ });
97
+
98
+ it("registers the /dispatch command", () => {
99
+ const { api } = captureHandler();
100
+ expect(api.registerCommand).toHaveBeenCalledOnce();
101
+ const cmd = api.registerCommand.mock.calls[0][0];
102
+ expect(cmd.name).toBe("dispatch");
103
+ expect(cmd.acceptsArgs).toBe(true);
104
+ });
105
+
106
+ it("dispatch list shows active dispatches with age/tier/status", async () => {
107
+ const d = makeActive({
108
+ issueIdentifier: "CT-100",
109
+ tier: "senior",
110
+ status: "working",
111
+ attempt: 1,
112
+ });
113
+ const state = emptyState();
114
+ state.dispatches.active["CT-100"] = d;
115
+
116
+ mockReadDispatchState.mockResolvedValue(state);
117
+ mockListActiveDispatches.mockReturnValue([d]);
118
+
119
+ const { handler } = captureHandler();
120
+ const result = await handler({ args: "list" });
121
+
122
+ expect(result.text).toContain("Active Dispatches (1)");
123
+ expect(result.text).toContain("CT-100");
124
+ expect(result.text).toContain("working");
125
+ expect(result.text).toContain("senior");
126
+ expect(result.text).toContain("attempt 1");
127
+ // Should contain age in minutes (a number followed by 'm')
128
+ expect(result.text).toMatch(/\d+m/);
129
+ });
130
+
131
+ it("dispatch list with no active dispatches shows 'No active dispatches'", async () => {
132
+ mockReadDispatchState.mockResolvedValue(emptyState());
133
+ mockListActiveDispatches.mockReturnValue([]);
134
+
135
+ const { handler } = captureHandler();
136
+ const result = await handler({ args: "list" });
137
+
138
+ expect(result.text).toBe("No active dispatches.");
139
+ });
140
+
141
+ it("dispatch status shows details for active dispatch", async () => {
142
+ const d = makeActive({
143
+ issueIdentifier: "CT-100",
144
+ issueTitle: "Fix the login bug",
145
+ tier: "medior",
146
+ status: "auditing",
147
+ attempt: 2,
148
+ });
149
+ const state = emptyState();
150
+ state.dispatches.active["CT-100"] = d;
151
+
152
+ mockReadDispatchState.mockResolvedValue(state);
153
+ mockGetActiveDispatch.mockReturnValue(d);
154
+
155
+ const { handler } = captureHandler();
156
+ const result = await handler({ args: "status CT-100" });
157
+
158
+ expect(result.text).toContain("CT-100");
159
+ expect(result.text).toContain("Fix the login bug");
160
+ expect(result.text).toContain("auditing");
161
+ expect(result.text).toContain("medior");
162
+ expect(result.text).toContain("Attempt: 2");
163
+ });
164
+
165
+ it("dispatch status shows 'not found' for missing identifier", async () => {
166
+ const state = emptyState();
167
+ mockReadDispatchState.mockResolvedValue(state);
168
+ mockGetActiveDispatch.mockReturnValue(null);
169
+
170
+ const { handler } = captureHandler();
171
+ const result = await handler({ args: "status CT-999" });
172
+
173
+ expect(result.text).toContain("No dispatch found for CT-999");
174
+ });
175
+
176
+ it("dispatch retry on stuck dispatch transitions to dispatched", async () => {
177
+ const d = makeActive({
178
+ issueIdentifier: "CT-100",
179
+ status: "stuck",
180
+ stuckReason: "timed out",
181
+ });
182
+ const state = emptyState();
183
+ state.dispatches.active["CT-100"] = d;
184
+
185
+ mockReadDispatchState.mockResolvedValue(state);
186
+ mockGetActiveDispatch.mockReturnValue(d);
187
+ mockRemoveActiveDispatch.mockResolvedValue(undefined);
188
+ mockRegisterDispatch.mockResolvedValue(undefined);
189
+
190
+ const { handler } = captureHandler();
191
+ const result = await handler({ args: "retry CT-100" });
192
+
193
+ expect(mockRemoveActiveDispatch).toHaveBeenCalledWith("CT-100", undefined);
194
+ expect(mockRegisterDispatch).toHaveBeenCalledWith(
195
+ "CT-100",
196
+ expect.objectContaining({ status: "dispatched", stuckReason: undefined }),
197
+ undefined,
198
+ );
199
+ expect(result.text).toContain("CT-100");
200
+ expect(result.text).toContain("reset to dispatched");
201
+ });
202
+
203
+ it("dispatch retry on working dispatch returns error", async () => {
204
+ const d = makeActive({ issueIdentifier: "CT-100", status: "working" });
205
+ const state = emptyState();
206
+ state.dispatches.active["CT-100"] = d;
207
+
208
+ mockReadDispatchState.mockResolvedValue(state);
209
+ mockGetActiveDispatch.mockReturnValue(d);
210
+
211
+ const { handler } = captureHandler();
212
+ const result = await handler({ args: "retry CT-100" });
213
+
214
+ expect(result.text).toContain("Cannot retry CT-100");
215
+ expect(result.text).toContain("working");
216
+ expect(result.text).toContain("must be stuck");
217
+ });
218
+
219
+ it("dispatch escalate sets dispatch to stuck with reason", async () => {
220
+ const d = makeActive({ issueIdentifier: "CT-100", status: "working" });
221
+ const state = emptyState();
222
+ state.dispatches.active["CT-100"] = d;
223
+
224
+ mockReadDispatchState.mockResolvedValue(state);
225
+ mockGetActiveDispatch.mockReturnValue(d);
226
+ mockTransitionDispatch.mockResolvedValue({ ...d, status: "stuck", stuckReason: "agent looping" });
227
+
228
+ const { handler } = captureHandler();
229
+ const result = await handler({ args: "escalate CT-100 agent looping" });
230
+
231
+ expect(mockTransitionDispatch).toHaveBeenCalledWith(
232
+ "CT-100",
233
+ "working",
234
+ "stuck",
235
+ { stuckReason: "agent looping" },
236
+ undefined,
237
+ );
238
+ expect(result.text).toContain("CT-100");
239
+ expect(result.text).toContain("escalated to stuck");
240
+ expect(result.text).toContain("agent looping");
241
+ });
242
+
243
+ it("dispatch escalate without reason uses default", async () => {
244
+ const d = makeActive({ issueIdentifier: "CT-100", status: "dispatched" });
245
+ const state = emptyState();
246
+ state.dispatches.active["CT-100"] = d;
247
+
248
+ mockReadDispatchState.mockResolvedValue(state);
249
+ mockGetActiveDispatch.mockReturnValue(d);
250
+ mockTransitionDispatch.mockResolvedValue({ ...d, status: "stuck", stuckReason: "manual escalation" });
251
+
252
+ const { handler } = captureHandler();
253
+ const result = await handler({ args: "escalate CT-100" });
254
+
255
+ expect(mockTransitionDispatch).toHaveBeenCalledWith(
256
+ "CT-100",
257
+ "dispatched",
258
+ "stuck",
259
+ { stuckReason: "manual escalation" },
260
+ undefined,
261
+ );
262
+ expect(result.text).toContain("escalated to stuck");
263
+ expect(result.text).toContain("manual escalation");
264
+ });
265
+
266
+ it("help/unknown subcommand shows available commands", async () => {
267
+ const { handler } = captureHandler();
268
+ const result = await handler({ args: "help" });
269
+
270
+ expect(result.text).toContain("Dispatch Commands");
271
+ expect(result.text).toContain("/dispatch list");
272
+ expect(result.text).toContain("/dispatch status");
273
+ expect(result.text).toContain("/dispatch retry");
274
+ expect(result.text).toContain("/dispatch escalate");
275
+ });
276
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * commands.ts — Zero-LLM slash commands for dispatch operations.
3
+ *
4
+ * Registered via api.registerCommand(). These commands bypass the AI agent
5
+ * entirely — they read/write dispatch state directly and return formatted text.
6
+ */
7
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
8
+ import {
9
+ readDispatchState,
10
+ getActiveDispatch,
11
+ listActiveDispatches,
12
+ removeActiveDispatch,
13
+ transitionDispatch,
14
+ TransitionError,
15
+ registerDispatch,
16
+ type ActiveDispatch,
17
+ } from "../pipeline/dispatch-state.js";
18
+
19
+ export function registerDispatchCommands(api: OpenClawPluginApi): void {
20
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
21
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
22
+
23
+ api.registerCommand({
24
+ name: "dispatch",
25
+ description: "Manage dispatches: list, status <id>, retry <id>, escalate <id>",
26
+ acceptsArgs: true,
27
+ handler: async (ctx) => {
28
+ const args = (ctx.args ?? "").trim().split(/\s+/);
29
+ const sub = args[0]?.toLowerCase();
30
+ const id = args[1];
31
+
32
+ if (!sub || sub === "list") {
33
+ return await handleList(statePath);
34
+ }
35
+ if (sub === "status" && id) {
36
+ return await handleStatus(id, statePath);
37
+ }
38
+ if (sub === "retry" && id) {
39
+ return await handleRetry(id, statePath, api);
40
+ }
41
+ if (sub === "escalate" && id) {
42
+ const reason = args.slice(2).join(" ") || "manual escalation";
43
+ return await handleEscalate(id, reason, statePath, api);
44
+ }
45
+
46
+ return {
47
+ text: [
48
+ "**Dispatch Commands:**",
49
+ "`/dispatch list` — show active dispatches",
50
+ "`/dispatch status <id>` — phase/attempt details",
51
+ "`/dispatch retry <id>` — reset stuck → dispatched",
52
+ "`/dispatch escalate <id> [reason]` — force to stuck",
53
+ ].join("\n"),
54
+ };
55
+ },
56
+ });
57
+ }
58
+
59
+ async function handleList(statePath?: string) {
60
+ const state = await readDispatchState(statePath);
61
+ const active = listActiveDispatches(state);
62
+
63
+ if (active.length === 0) {
64
+ return { text: "No active dispatches." };
65
+ }
66
+
67
+ const lines = active.map((d) => {
68
+ const age = Math.round((Date.now() - new Date(d.dispatchedAt).getTime()) / 60_000);
69
+ return `**${d.issueIdentifier}** — ${d.status} (${d.tier}, attempt ${d.attempt}, ${age}m)`;
70
+ });
71
+
72
+ return { text: `**Active Dispatches (${active.length})**\n${lines.join("\n")}` };
73
+ }
74
+
75
+ async function handleStatus(id: string, statePath?: string) {
76
+ const state = await readDispatchState(statePath);
77
+ const d = getActiveDispatch(state, id);
78
+
79
+ if (!d) {
80
+ const completed = state.dispatches.completed[id];
81
+ if (completed) {
82
+ return {
83
+ text: `**${id}** — completed (${completed.status}, ${completed.tier}, ${completed.totalAttempts ?? 0} attempts)`,
84
+ };
85
+ }
86
+ return { text: `No dispatch found for ${id}.` };
87
+ }
88
+
89
+ const age = Math.round((Date.now() - new Date(d.dispatchedAt).getTime()) / 60_000);
90
+ const lines = [
91
+ `**${d.issueIdentifier}** — ${d.issueTitle ?? d.issueIdentifier}`,
92
+ `Status: ${d.status} | Tier: ${d.tier} | Attempt: ${d.attempt}`,
93
+ `Age: ${age}m | Worktree: \`${d.worktreePath}\``,
94
+ d.stuckReason ? `Stuck reason: ${d.stuckReason}` : "",
95
+ d.agentSessionId ? `Session: ${d.agentSessionId}` : "",
96
+ ].filter(Boolean);
97
+
98
+ return { text: lines.join("\n") };
99
+ }
100
+
101
+ async function handleRetry(id: string, statePath: string | undefined, api: OpenClawPluginApi) {
102
+ const state = await readDispatchState(statePath);
103
+ const d = getActiveDispatch(state, id);
104
+
105
+ if (!d) {
106
+ return { text: `No active dispatch found for ${id}.` };
107
+ }
108
+
109
+ if (d.status !== "stuck") {
110
+ return { text: `Cannot retry ${id} — status is ${d.status} (must be stuck).` };
111
+ }
112
+
113
+ // Remove and re-register with reset status
114
+ await removeActiveDispatch(id, statePath);
115
+ const retryDispatch: ActiveDispatch = {
116
+ ...d,
117
+ status: "dispatched",
118
+ stuckReason: undefined,
119
+ workerSessionKey: undefined,
120
+ auditSessionKey: undefined,
121
+ dispatchedAt: new Date().toISOString(),
122
+ };
123
+ await registerDispatch(id, retryDispatch, statePath);
124
+
125
+ api.logger.info(`/dispatch retry: ${id} reset from stuck → dispatched`);
126
+ return { text: `**${id}** reset to dispatched. Will be picked up by next dispatch cycle.` };
127
+ }
128
+
129
+ async function handleEscalate(
130
+ id: string,
131
+ reason: string,
132
+ statePath: string | undefined,
133
+ api: OpenClawPluginApi,
134
+ ) {
135
+ const state = await readDispatchState(statePath);
136
+ const d = getActiveDispatch(state, id);
137
+
138
+ if (!d) {
139
+ return { text: `No active dispatch found for ${id}.` };
140
+ }
141
+
142
+ if (d.status === "stuck" || d.status === "done" || d.status === "failed") {
143
+ return { text: `Cannot escalate ${id} — already in terminal state: ${d.status}.` };
144
+ }
145
+
146
+ try {
147
+ await transitionDispatch(id, d.status, "stuck", { stuckReason: reason }, statePath);
148
+ api.logger.info(`/dispatch escalate: ${id} → stuck (${reason})`);
149
+ return { text: `**${id}** escalated to stuck: ${reason}` };
150
+ } catch (err) {
151
+ if (err instanceof TransitionError) {
152
+ return { text: `CAS conflict: ${err.message}` };
153
+ }
154
+ return { text: `Error: ${String(err)}` };
155
+ }
156
+ }
@@ -382,6 +382,25 @@ describe("formatReport", () => {
382
382
  expect(output).toContain("1 passed");
383
383
  expect(output).toContain("1 warning");
384
384
  });
385
+
386
+ it("shows fix guidance for warnings and errors", () => {
387
+ const report = {
388
+ sections: [
389
+ {
390
+ name: "Auth",
391
+ checks: [
392
+ { label: "Token expired", severity: "warn" as const, fix: "Run: openclaw openclaw-linear auth" },
393
+ { label: "All good", severity: "pass" as const, fix: "Should not appear" },
394
+ ],
395
+ },
396
+ ],
397
+ summary: { passed: 1, warnings: 1, errors: 0 },
398
+ };
399
+
400
+ const output = formatReport(report);
401
+ expect(output).toContain("→ Run: openclaw openclaw-linear auth");
402
+ expect(output).not.toContain("Should not appear");
403
+ });
385
404
  });
386
405
 
387
406
  describe("formatReportJson", () => {
@@ -25,6 +25,8 @@ export interface CheckResult {
25
25
  severity: CheckSeverity;
26
26
  detail?: string;
27
27
  fixable?: boolean;
28
+ /** User-facing guidance on how to resolve the issue */
29
+ fix?: string;
28
30
  }
29
31
 
30
32
  export interface CheckSection {
@@ -66,12 +68,12 @@ function pass(label: string, detail?: string): CheckResult {
66
68
  return { label, severity: "pass", detail };
67
69
  }
68
70
 
69
- function warn(label: string, detail?: string, fixable = false): CheckResult {
70
- return { label, severity: "warn", detail, fixable: fixable || undefined };
71
+ function warn(label: string, detail?: string, opts?: { fixable?: boolean; fix?: string }): CheckResult {
72
+ return { label, severity: "warn", detail, fixable: opts?.fixable || undefined, fix: opts?.fix };
71
73
  }
72
74
 
73
- function fail(label: string, detail?: string): CheckResult {
74
- return { label, severity: "fail", detail };
75
+ function fail(label: string, detail?: string, fix?: string): CheckResult {
76
+ return { label, severity: "fail", detail, fix };
75
77
  }
76
78
 
77
79
  function resolveDispatchStatePath(pluginConfig?: Record<string, unknown>): string {
@@ -126,7 +128,7 @@ export async function checkAuth(pluginConfig?: Record<string, unknown>): Promise
126
128
  if (tokenInfo.accessToken) {
127
129
  checks.push(pass(`Access token found (source: ${tokenInfo.source})`));
128
130
  } else {
129
- checks.push(fail("No access token found", "Run: openclaw openclaw-linear auth"));
131
+ checks.push(fail("No access token found", undefined, "Run: openclaw openclaw-linear auth"));
130
132
  // Can't check further without token
131
133
  return { checks, ctx };
132
134
  }
@@ -135,12 +137,12 @@ export async function checkAuth(pluginConfig?: Record<string, unknown>): Promise
135
137
  if (tokenInfo.expiresAt) {
136
138
  const remaining = tokenInfo.expiresAt - Date.now();
137
139
  if (remaining <= 0) {
138
- checks.push(warn("Token expired", "Restart gateway to trigger auto-refresh"));
140
+ checks.push(warn("Token expired", undefined, { fix: "Run: openclaw openclaw-linear auth" }));
139
141
  } else {
140
142
  const hours = Math.floor(remaining / 3_600_000);
141
143
  const mins = Math.floor((remaining % 3_600_000) / 60_000);
142
144
  if (remaining < 3_600_000) {
143
- checks.push(warn(`Token expires soon (${mins}m remaining)`));
145
+ checks.push(warn(`Token expires soon (${mins}m remaining)`, undefined, { fix: "Restart the gateway to trigger auto-refresh, or run: openclaw openclaw-linear auth" }));
144
146
  } else {
145
147
  checks.push(pass(`Token not expired (${hours}h ${mins}m remaining)`));
146
148
  }
@@ -190,8 +192,8 @@ export async function checkAuth(pluginConfig?: Record<string, unknown>): Promise
190
192
  } else {
191
193
  checks.push(warn(
192
194
  `auth-profiles.json permissions (${mode.toString(8)}, expected 600)`,
193
- "Run: chmod 600 ~/.openclaw/auth-profiles.json",
194
- true,
195
+ undefined,
196
+ { fixable: true, fix: "Run: chmod 600 ~/.openclaw/auth-profiles.json" },
195
197
  ));
196
198
  }
197
199
  } catch {
@@ -246,7 +248,7 @@ export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckR
246
248
 
247
249
  const agentCount = Object.keys(profiles).length;
248
250
  if (agentCount === 0) {
249
- checks.push(fail("agent-profiles.json has no agents"));
251
+ checks.push(fail("agent-profiles.json has no agents", undefined, "Add at least one agent to ~/.openclaw/agent-profiles.json"));
250
252
  return checks;
251
253
  }
252
254
  checks.push(pass(`agent-profiles.json loaded (${agentCount} agent${agentCount > 1 ? "s" : ""})`));
@@ -256,7 +258,7 @@ export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckR
256
258
  if (defaultEntry) {
257
259
  checks.push(pass(`Default agent: ${defaultEntry[0]}`));
258
260
  } else {
259
- checks.push(warn("No agent has isDefault: true"));
261
+ checks.push(warn("No agent has isDefault: true", undefined, { fix: "Add \"isDefault\": true to one agent in ~/.openclaw/agent-profiles.json" }));
260
262
  }
261
263
 
262
264
  // Required fields
@@ -270,7 +272,7 @@ export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckR
270
272
  if (missing.length === 0) {
271
273
  checks.push(pass("All agents have required fields"));
272
274
  } else {
273
- checks.push(fail(`Agent field issues: ${missing.join("; ")}`));
275
+ checks.push(fail(`Agent field issues: ${missing.join("; ")}`, undefined, "Each agent needs at least a \"label\" and \"mentionAliases\" array in agent-profiles.json"));
274
276
  }
275
277
 
276
278
  // defaultAgentId match
@@ -321,7 +323,7 @@ export function checkCodingTools(): CheckResult[] {
321
323
  if (hasConfig) {
322
324
  checks.push(pass(`coding-tools.json loaded (default: ${config.codingTool ?? "claude"})`));
323
325
  } else {
324
- checks.push(warn("coding-tools.json not found or empty (using defaults)"));
326
+ checks.push(warn("coding-tools.json not found or empty (using defaults)", undefined, { fix: "Create coding-tools.json in the plugin root — see README for format" }));
325
327
  }
326
328
 
327
329
  // Validate default backend
@@ -355,7 +357,7 @@ export function checkCodingTools(): CheckResult[] {
355
357
  accessSync(bin, constants.X_OK);
356
358
  checks.push(pass(`${name}: installed (version check skipped)`));
357
359
  } catch {
358
- checks.push(warn(`${name}: not found at ${bin}`));
360
+ checks.push(warn(`${name}: not found at ${bin}`, undefined, { fix: `Install ${name} or check that it's in your PATH` }));
359
361
  }
360
362
  }
361
363
  }
@@ -402,8 +404,8 @@ export async function checkFilesAndDirs(pluginConfig?: Record<string, unknown>,
402
404
  } else {
403
405
  checks.push(warn(
404
406
  `Stale lock file (${Math.round(lockAge / 1000)}s old)`,
405
- "Use --fix to remove",
406
- true,
407
+ undefined,
408
+ { fixable: true, fix: "Run: openclaw openclaw-linear doctor --fix to remove" },
407
409
  ));
408
410
  }
409
411
  } else {
@@ -440,10 +442,10 @@ export async function checkFilesAndDirs(pluginConfig?: Record<string, unknown>,
440
442
  });
441
443
  checks.push(pass("Base repo is valid git repo"));
442
444
  } catch {
443
- checks.push(fail(`Base repo is not a git repo: ${baseRepo}`));
445
+ checks.push(fail(`Base repo is not a git repo: ${baseRepo}`, undefined, "Run: git init in the base repo directory, or set codexBaseRepo to a different path"));
444
446
  }
445
447
  } else {
446
- checks.push(fail(`Base repo does not exist: ${baseRepo}`));
448
+ checks.push(fail(`Base repo does not exist: ${baseRepo}`, undefined, "Set codexBaseRepo in plugin config to your git repository path"));
447
449
  }
448
450
 
449
451
  // Prompts
@@ -482,7 +484,7 @@ export async function checkFilesAndDirs(pluginConfig?: Record<string, unknown>,
482
484
  if (errors.length === 0) {
483
485
  checks.push(pass(`Prompts valid (${sectionCount}/5 sections, ${varCount}/4 variables)`));
484
486
  } else {
485
- checks.push(fail(`Prompt issues: ${errors.join("; ")}`));
487
+ checks.push(fail(`Prompt issues: ${errors.join("; ")}`, undefined, "Run: openclaw openclaw-linear prompts validate for details"));
486
488
  }
487
489
  } catch (err) {
488
490
  checks.push(fail(
@@ -526,7 +528,7 @@ export async function checkConnectivity(pluginConfig?: Record<string, unknown>,
526
528
  checks.push(fail(`Linear API: unreachable (${err instanceof Error ? err.message : String(err)})`));
527
529
  }
528
530
  } else {
529
- checks.push(fail("Linear API: no token available"));
531
+ checks.push(fail("Linear API: no token available", undefined, "Run: openclaw openclaw-linear auth"));
530
532
  }
531
533
  }
532
534
 
@@ -602,7 +604,7 @@ export async function checkDispatchHealth(pluginConfig?: Record<string, unknown>
602
604
  checks.push(pass("No stale dispatches"));
603
605
  } else {
604
606
  const ids = stale.map((d) => d.issueIdentifier).join(", ");
605
- checks.push(warn(`${stale.length} stale dispatch${stale.length > 1 ? "es" : ""}: ${ids}`));
607
+ checks.push(warn(`${stale.length} stale dispatch${stale.length > 1 ? "es" : ""}: ${ids}`, undefined, { fix: "Re-assign the issue to retry, or run: openclaw openclaw-linear doctor --fix to clean up" }));
606
608
  }
607
609
 
608
610
  // Orphaned worktrees
@@ -639,8 +641,8 @@ export async function checkDispatchHealth(pluginConfig?: Record<string, unknown>
639
641
  } else {
640
642
  checks.push(warn(
641
643
  `${old.length} completed dispatch${old.length > 1 ? "es" : ""} older than 7 days`,
642
- "Use --fix to prune",
643
- true,
644
+ undefined,
645
+ { fixable: true, fix: "Run: openclaw openclaw-linear doctor --fix to clean up old entries" },
644
646
  ));
645
647
  }
646
648
  }
@@ -737,6 +739,9 @@ export function formatReport(report: DoctorReport): string {
737
739
  lines.push(section.name);
738
740
  for (const check of section.checks) {
739
741
  lines.push(` ${icon(check.severity)} ${check.label}`);
742
+ if (check.fix && check.severity !== "pass") {
743
+ lines.push(` → ${check.fix}`);
744
+ }
740
745
  }
741
746
  }
742
747
 
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { acquireLock, releaseLock } from "./file-lock.js";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+
7
+ const tmpDir = os.tmpdir();
8
+ const testState = path.join(tmpDir, `file-lock-test-${process.pid}.json`);
9
+ const lockFile = testState + ".lock";
10
+
11
+ afterEach(async () => {
12
+ try { await fs.unlink(lockFile); } catch {}
13
+ try { await fs.unlink(testState); } catch {}
14
+ });
15
+
16
+ describe("acquireLock / releaseLock", () => {
17
+ it("creates and removes a lock file", async () => {
18
+ await acquireLock(testState);
19
+ const stat = await fs.stat(lockFile);
20
+ expect(stat.isFile()).toBe(true);
21
+
22
+ await releaseLock(testState);
23
+ await expect(fs.stat(lockFile)).rejects.toThrow();
24
+ });
25
+
26
+ it("blocks concurrent acquires until released", async () => {
27
+ await acquireLock(testState);
28
+
29
+ let secondAcquired = false;
30
+ const secondLock = acquireLock(testState).then(() => {
31
+ secondAcquired = true;
32
+ });
33
+
34
+ // Give the second acquire a moment to spin
35
+ await new Promise((r) => setTimeout(r, 120));
36
+ expect(secondAcquired).toBe(false);
37
+
38
+ await releaseLock(testState);
39
+ await secondLock;
40
+ expect(secondAcquired).toBe(true);
41
+
42
+ await releaseLock(testState);
43
+ });
44
+
45
+ it("releaseLock is safe to call when no lock exists", async () => {
46
+ await expect(releaseLock(testState)).resolves.toBeUndefined();
47
+ });
48
+
49
+ it("recovers from stale lock", async () => {
50
+ // Write a lock file with an old timestamp (> 30s ago)
51
+ await fs.writeFile(lockFile, String(Date.now() - 60_000), { flag: "w" });
52
+
53
+ // Should succeed by detecting stale lock
54
+ await acquireLock(testState);
55
+ const content = await fs.readFile(lockFile, "utf-8");
56
+ const lockTime = Number(content);
57
+ expect(Date.now() - lockTime).toBeLessThan(5000);
58
+
59
+ await releaseLock(testState);
60
+ });
61
+ });