@calltelemetry/openclaw-linear 0.6.0 → 0.7.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.
@@ -0,0 +1,759 @@
1
+ /**
2
+ * doctor.ts — Comprehensive health checks for the Linear plugin.
3
+ *
4
+ * Usage: openclaw openclaw-linear doctor [--fix] [--json]
5
+ */
6
+ import { existsSync, readFileSync, statSync, accessSync, unlinkSync, chmodSync, constants } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { execFileSync } from "node:child_process";
10
+
11
+ import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
12
+ import { readDispatchState, listActiveDispatches, listStaleDispatches, pruneCompleted, type DispatchState } from "../pipeline/dispatch-state.js";
13
+ import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
14
+ import { listWorktrees } from "./codex-worktree.js";
15
+ import { loadCodingConfig, type CodingBackend } from "../tools/code-tool.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export type CheckSeverity = "pass" | "warn" | "fail";
22
+
23
+ export interface CheckResult {
24
+ label: string;
25
+ severity: CheckSeverity;
26
+ detail?: string;
27
+ fixable?: boolean;
28
+ }
29
+
30
+ export interface CheckSection {
31
+ name: string;
32
+ checks: CheckResult[];
33
+ }
34
+
35
+ export interface DoctorReport {
36
+ sections: CheckSection[];
37
+ summary: { passed: number; warnings: number; errors: number };
38
+ }
39
+
40
+ export interface DoctorOptions {
41
+ fix: boolean;
42
+ json: boolean;
43
+ pluginConfig?: Record<string, unknown>;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Constants
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const AGENT_PROFILES_PATH = join(homedir(), ".openclaw", "agent-profiles.json");
51
+ const VALID_BACKENDS: readonly string[] = ["claude", "codex", "gemini"];
52
+ const CLI_BINS: [string, string][] = [
53
+ ["codex", "/home/claw/.npm-global/bin/codex"],
54
+ ["claude", "/home/claw/.npm-global/bin/claude"],
55
+ ["gemini", "/home/claw/.npm-global/bin/gemini"],
56
+ ];
57
+ const STALE_DISPATCH_MS = 2 * 60 * 60_000; // 2 hours
58
+ const OLD_COMPLETED_MS = 7 * 24 * 60 * 60_000; // 7 days
59
+ const LOCK_STALE_MS = 30_000; // 30 seconds
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function pass(label: string, detail?: string): CheckResult {
66
+ return { label, severity: "pass", detail };
67
+ }
68
+
69
+ function warn(label: string, detail?: string, fixable = false): CheckResult {
70
+ return { label, severity: "warn", detail, fixable: fixable || undefined };
71
+ }
72
+
73
+ function fail(label: string, detail?: string): CheckResult {
74
+ return { label, severity: "fail", detail };
75
+ }
76
+
77
+ function resolveDispatchStatePath(pluginConfig?: Record<string, unknown>): string {
78
+ const custom = pluginConfig?.dispatchStatePath as string | undefined;
79
+ if (!custom) return join(homedir(), ".openclaw", "linear-dispatch-state.json");
80
+ if (custom.startsWith("~/")) return custom.replace("~", homedir());
81
+ return custom;
82
+ }
83
+
84
+ function resolveWorktreeBaseDir(pluginConfig?: Record<string, unknown>): string {
85
+ const custom = pluginConfig?.worktreeBaseDir as string | undefined;
86
+ if (!custom) return join(homedir(), ".openclaw", "worktrees");
87
+ if (custom.startsWith("~/")) return custom.replace("~", homedir());
88
+ return custom;
89
+ }
90
+
91
+ function resolveBaseRepo(pluginConfig?: Record<string, unknown>): string {
92
+ return (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
93
+ }
94
+
95
+ interface AgentProfile {
96
+ label?: string;
97
+ mentionAliases?: string[];
98
+ isDefault?: boolean;
99
+ [key: string]: unknown;
100
+ }
101
+
102
+ function loadAgentProfiles(): Record<string, AgentProfile> {
103
+ try {
104
+ const raw = readFileSync(AGENT_PROFILES_PATH, "utf8");
105
+ return JSON.parse(raw).agents ?? {};
106
+ } catch {
107
+ return {};
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Section 1: Authentication & Tokens
113
+ // ---------------------------------------------------------------------------
114
+
115
+ interface AuthContext {
116
+ viewer?: { name: string };
117
+ organization?: { name: string; urlKey: string };
118
+ }
119
+
120
+ export async function checkAuth(pluginConfig?: Record<string, unknown>): Promise<{ checks: CheckResult[]; ctx: AuthContext }> {
121
+ const checks: CheckResult[] = [];
122
+ const ctx: AuthContext = {};
123
+
124
+ // Token existence
125
+ const tokenInfo = resolveLinearToken(pluginConfig);
126
+ if (tokenInfo.accessToken) {
127
+ checks.push(pass(`Access token found (source: ${tokenInfo.source})`));
128
+ } else {
129
+ checks.push(fail("No access token found", "Run: openclaw openclaw-linear auth"));
130
+ // Can't check further without token
131
+ return { checks, ctx };
132
+ }
133
+
134
+ // Token expiry
135
+ if (tokenInfo.expiresAt) {
136
+ const remaining = tokenInfo.expiresAt - Date.now();
137
+ if (remaining <= 0) {
138
+ checks.push(warn("Token expired", "Restart gateway to trigger auto-refresh"));
139
+ } else {
140
+ const hours = Math.floor(remaining / 3_600_000);
141
+ const mins = Math.floor((remaining % 3_600_000) / 60_000);
142
+ if (remaining < 3_600_000) {
143
+ checks.push(warn(`Token expires soon (${mins}m remaining)`));
144
+ } else {
145
+ checks.push(pass(`Token not expired (${hours}h ${mins}m remaining)`));
146
+ }
147
+ }
148
+ }
149
+
150
+ // API connectivity
151
+ try {
152
+ const authHeader = tokenInfo.refreshToken
153
+ ? `Bearer ${tokenInfo.accessToken}`
154
+ : tokenInfo.accessToken;
155
+
156
+ const res = await fetch(LINEAR_GRAPHQL_URL, {
157
+ method: "POST",
158
+ headers: {
159
+ "Content-Type": "application/json",
160
+ Authorization: authHeader,
161
+ },
162
+ body: JSON.stringify({
163
+ query: `{ viewer { id name } organization { name urlKey } }`,
164
+ }),
165
+ });
166
+
167
+ if (!res.ok) {
168
+ checks.push(fail(`API returned ${res.status} ${res.statusText}`));
169
+ } else {
170
+ const payload = await res.json() as any;
171
+ if (payload.errors?.length) {
172
+ checks.push(fail(`API error: ${payload.errors[0].message}`));
173
+ } else {
174
+ const { viewer, organization } = payload.data;
175
+ ctx.viewer = viewer;
176
+ ctx.organization = organization;
177
+ checks.push(pass(`API connectivity (user: ${viewer.name}, workspace: ${organization.name})`));
178
+ }
179
+ }
180
+ } catch (err) {
181
+ checks.push(fail(`API unreachable: ${err instanceof Error ? err.message : String(err)}`));
182
+ }
183
+
184
+ // auth-profiles.json permissions
185
+ try {
186
+ const stat = statSync(AUTH_PROFILES_PATH);
187
+ const mode = stat.mode & 0o777;
188
+ if (mode === 0o600) {
189
+ checks.push(pass("auth-profiles.json permissions (600)"));
190
+ } else {
191
+ checks.push(warn(
192
+ `auth-profiles.json permissions (${mode.toString(8)}, expected 600)`,
193
+ "Run: chmod 600 ~/.openclaw/auth-profiles.json",
194
+ true,
195
+ ));
196
+ }
197
+ } catch {
198
+ if (tokenInfo.source === "profile") {
199
+ checks.push(warn("auth-profiles.json not found (but token resolved from profile?)"));
200
+ }
201
+ // If token is from config/env, no auth-profiles.json is fine
202
+ }
203
+
204
+ // OAuth credentials
205
+ const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
206
+ const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
207
+ if (clientId && clientSecret) {
208
+ checks.push(pass("OAuth credentials configured"));
209
+ } else {
210
+ checks.push(warn(
211
+ "OAuth credentials not configured",
212
+ "Set LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET env vars or plugin config",
213
+ ));
214
+ }
215
+
216
+ return { checks, ctx };
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Section 2: Agent Configuration
221
+ // ---------------------------------------------------------------------------
222
+
223
+ export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckResult[] {
224
+ const checks: CheckResult[] = [];
225
+
226
+ // Load profiles
227
+ let profiles: Record<string, AgentProfile>;
228
+ try {
229
+ if (!existsSync(AGENT_PROFILES_PATH)) {
230
+ checks.push(fail(
231
+ "agent-profiles.json not found",
232
+ `Expected at: ${AGENT_PROFILES_PATH}`,
233
+ ));
234
+ return checks;
235
+ }
236
+ const raw = readFileSync(AGENT_PROFILES_PATH, "utf8");
237
+ const parsed = JSON.parse(raw);
238
+ profiles = parsed.agents ?? {};
239
+ } catch (err) {
240
+ checks.push(fail(
241
+ "agent-profiles.json invalid JSON",
242
+ err instanceof Error ? err.message : String(err),
243
+ ));
244
+ return checks;
245
+ }
246
+
247
+ const agentCount = Object.keys(profiles).length;
248
+ if (agentCount === 0) {
249
+ checks.push(fail("agent-profiles.json has no agents"));
250
+ return checks;
251
+ }
252
+ checks.push(pass(`agent-profiles.json loaded (${agentCount} agent${agentCount > 1 ? "s" : ""})`));
253
+
254
+ // Default agent
255
+ const defaultEntry = Object.entries(profiles).find(([, p]) => p.isDefault);
256
+ if (defaultEntry) {
257
+ checks.push(pass(`Default agent: ${defaultEntry[0]}`));
258
+ } else {
259
+ checks.push(warn("No agent has isDefault: true"));
260
+ }
261
+
262
+ // Required fields
263
+ const missing: string[] = [];
264
+ for (const [id, profile] of Object.entries(profiles)) {
265
+ if (!profile.label) missing.push(`${id}: missing label`);
266
+ if (!Array.isArray(profile.mentionAliases) || profile.mentionAliases.length === 0) {
267
+ missing.push(`${id}: missing or empty mentionAliases`);
268
+ }
269
+ }
270
+ if (missing.length === 0) {
271
+ checks.push(pass("All agents have required fields"));
272
+ } else {
273
+ checks.push(fail(`Agent field issues: ${missing.join("; ")}`));
274
+ }
275
+
276
+ // defaultAgentId match
277
+ const configAgentId = pluginConfig?.defaultAgentId as string | undefined;
278
+ if (configAgentId) {
279
+ if (profiles[configAgentId]) {
280
+ checks.push(pass(`defaultAgentId "${configAgentId}" matches a profile`));
281
+ } else {
282
+ checks.push(warn(
283
+ `defaultAgentId "${configAgentId}" not found in agent-profiles.json`,
284
+ `Available: ${Object.keys(profiles).join(", ")}`,
285
+ ));
286
+ }
287
+ }
288
+
289
+ // Duplicate aliases
290
+ const aliasMap = new Map<string, string>();
291
+ const dupes: string[] = [];
292
+ for (const [id, profile] of Object.entries(profiles)) {
293
+ for (const alias of profile.mentionAliases ?? []) {
294
+ const lower = alias.toLowerCase();
295
+ if (aliasMap.has(lower)) {
296
+ dupes.push(`"${alias}" (${aliasMap.get(lower)} and ${id})`);
297
+ } else {
298
+ aliasMap.set(lower, id);
299
+ }
300
+ }
301
+ }
302
+ if (dupes.length === 0) {
303
+ checks.push(pass("No duplicate mention aliases"));
304
+ } else {
305
+ checks.push(warn(`Duplicate aliases: ${dupes.join(", ")}`));
306
+ }
307
+
308
+ return checks;
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Section 3: Coding Tools
313
+ // ---------------------------------------------------------------------------
314
+
315
+ export function checkCodingTools(): CheckResult[] {
316
+ const checks: CheckResult[] = [];
317
+
318
+ // Load config
319
+ const config = loadCodingConfig();
320
+ const hasConfig = !!config.codingTool || !!config.backends;
321
+ if (hasConfig) {
322
+ checks.push(pass(`coding-tools.json loaded (default: ${config.codingTool ?? "claude"})`));
323
+ } else {
324
+ checks.push(warn("coding-tools.json not found or empty (using defaults)"));
325
+ }
326
+
327
+ // Validate default backend
328
+ const defaultBackend = config.codingTool ?? "claude";
329
+ if (VALID_BACKENDS.includes(defaultBackend)) {
330
+ // already reported in the line above
331
+ } else {
332
+ checks.push(fail(`Unknown default backend: "${defaultBackend}" (valid: ${VALID_BACKENDS.join(", ")})`));
333
+ }
334
+
335
+ // Validate per-agent overrides
336
+ if (config.agentCodingTools) {
337
+ for (const [agentId, backend] of Object.entries(config.agentCodingTools)) {
338
+ if (!VALID_BACKENDS.includes(backend)) {
339
+ checks.push(warn(`Agent "${agentId}" override "${backend}" is not a valid backend`));
340
+ }
341
+ }
342
+ }
343
+
344
+ // CLI availability
345
+ for (const [name, bin] of CLI_BINS) {
346
+ try {
347
+ const raw = execFileSync(bin, ["--version"], {
348
+ encoding: "utf8",
349
+ timeout: 15_000,
350
+ env: { ...process.env, CLAUDECODE: undefined } as any,
351
+ }).trim();
352
+ checks.push(pass(`${name}: ${raw || "installed"}`));
353
+ } catch {
354
+ try {
355
+ accessSync(bin, constants.X_OK);
356
+ checks.push(pass(`${name}: installed (version check skipped)`));
357
+ } catch {
358
+ checks.push(warn(`${name}: not found at ${bin}`));
359
+ }
360
+ }
361
+ }
362
+
363
+ return checks;
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Section 4: Files & Directories
368
+ // ---------------------------------------------------------------------------
369
+
370
+ export async function checkFilesAndDirs(pluginConfig?: Record<string, unknown>, fix = false): Promise<CheckResult[]> {
371
+ const checks: CheckResult[] = [];
372
+
373
+ // Dispatch state
374
+ const statePath = resolveDispatchStatePath(pluginConfig);
375
+ let dispatchState: DispatchState | null = null;
376
+ if (existsSync(statePath)) {
377
+ try {
378
+ dispatchState = await readDispatchState(pluginConfig?.dispatchStatePath as string | undefined);
379
+ const activeCount = Object.keys(dispatchState.dispatches.active).length;
380
+ const completedCount = Object.keys(dispatchState.dispatches.completed).length;
381
+ checks.push(pass(`Dispatch state: ${activeCount} active, ${completedCount} completed`));
382
+ } catch (err) {
383
+ checks.push(fail(
384
+ "Dispatch state corrupt",
385
+ err instanceof Error ? err.message : String(err),
386
+ ));
387
+ }
388
+ } else {
389
+ checks.push(pass("Dispatch state: no file yet (will be created on first dispatch)"));
390
+ }
391
+
392
+ // Stale lock files
393
+ const lockPath = statePath + ".lock";
394
+ if (existsSync(lockPath)) {
395
+ try {
396
+ const lockStat = statSync(lockPath);
397
+ const lockAge = Date.now() - lockStat.mtimeMs;
398
+ if (lockAge > LOCK_STALE_MS) {
399
+ if (fix) {
400
+ unlinkSync(lockPath);
401
+ checks.push(pass("Stale lock file removed (--fix)"));
402
+ } else {
403
+ checks.push(warn(
404
+ `Stale lock file (${Math.round(lockAge / 1000)}s old)`,
405
+ "Use --fix to remove",
406
+ true,
407
+ ));
408
+ }
409
+ } else {
410
+ checks.push(warn(`Lock file active (${Math.round(lockAge / 1000)}s old, may be in use)`));
411
+ }
412
+ } catch {
413
+ checks.push(pass("No stale lock files"));
414
+ }
415
+ } else {
416
+ checks.push(pass("No stale lock files"));
417
+ }
418
+
419
+ // Worktree base dir
420
+ const wtBaseDir = resolveWorktreeBaseDir(pluginConfig);
421
+ if (existsSync(wtBaseDir)) {
422
+ try {
423
+ accessSync(wtBaseDir, constants.W_OK);
424
+ checks.push(pass("Worktree base dir writable"));
425
+ } catch {
426
+ checks.push(fail(`Worktree base dir not writable: ${wtBaseDir}`));
427
+ }
428
+ } else {
429
+ checks.push(warn(`Worktree base dir does not exist: ${wtBaseDir}`, "Will be created on first dispatch"));
430
+ }
431
+
432
+ // Base git repo
433
+ const baseRepo = resolveBaseRepo(pluginConfig);
434
+ if (existsSync(baseRepo)) {
435
+ try {
436
+ execFileSync("git", ["rev-parse", "--git-dir"], {
437
+ cwd: baseRepo,
438
+ encoding: "utf8",
439
+ timeout: 5_000,
440
+ });
441
+ checks.push(pass("Base repo is valid git repo"));
442
+ } catch {
443
+ checks.push(fail(`Base repo is not a git repo: ${baseRepo}`));
444
+ }
445
+ } else {
446
+ checks.push(fail(`Base repo does not exist: ${baseRepo}`));
447
+ }
448
+
449
+ // Prompts
450
+ try {
451
+ clearPromptCache();
452
+ const loaded = loadPrompts(pluginConfig);
453
+ const errors: string[] = [];
454
+
455
+ const sections = [
456
+ ["worker.system", loaded.worker?.system],
457
+ ["worker.task", loaded.worker?.task],
458
+ ["audit.system", loaded.audit?.system],
459
+ ["audit.task", loaded.audit?.task],
460
+ ["rework.addendum", loaded.rework?.addendum],
461
+ ] as const;
462
+
463
+ let sectionCount = 0;
464
+ for (const [name, value] of sections) {
465
+ if (value) sectionCount++;
466
+ else errors.push(`Missing ${name}`);
467
+ }
468
+
469
+ const requiredVars = ["{{identifier}}", "{{title}}", "{{description}}", "{{worktreePath}}"];
470
+ let varCount = 0;
471
+ for (const v of requiredVars) {
472
+ const inWorker = loaded.worker?.task?.includes(v);
473
+ const inAudit = loaded.audit?.task?.includes(v);
474
+ if (inWorker && inAudit) {
475
+ varCount++;
476
+ } else {
477
+ if (!inWorker) errors.push(`worker.task missing ${v}`);
478
+ if (!inAudit) errors.push(`audit.task missing ${v}`);
479
+ }
480
+ }
481
+
482
+ if (errors.length === 0) {
483
+ checks.push(pass(`Prompts valid (${sectionCount}/5 sections, ${varCount}/4 variables)`));
484
+ } else {
485
+ checks.push(fail(`Prompt issues: ${errors.join("; ")}`));
486
+ }
487
+ } catch (err) {
488
+ checks.push(fail(
489
+ "Failed to load prompts",
490
+ err instanceof Error ? err.message : String(err),
491
+ ));
492
+ }
493
+
494
+ return checks;
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Section 5: Connectivity
499
+ // ---------------------------------------------------------------------------
500
+
501
+ export async function checkConnectivity(pluginConfig?: Record<string, unknown>, authCtx?: AuthContext): Promise<CheckResult[]> {
502
+ const checks: CheckResult[] = [];
503
+
504
+ // Linear API (share result from auth check if available)
505
+ if (authCtx?.viewer) {
506
+ checks.push(pass("Linear API: connected"));
507
+ } else {
508
+ // Re-check if auth context wasn't passed
509
+ const tokenInfo = resolveLinearToken(pluginConfig);
510
+ if (tokenInfo.accessToken) {
511
+ try {
512
+ const authHeader = tokenInfo.refreshToken
513
+ ? `Bearer ${tokenInfo.accessToken}`
514
+ : tokenInfo.accessToken;
515
+ const res = await fetch(LINEAR_GRAPHQL_URL, {
516
+ method: "POST",
517
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
518
+ body: JSON.stringify({ query: `{ viewer { id } }` }),
519
+ });
520
+ if (res.ok) {
521
+ checks.push(pass("Linear API: connected"));
522
+ } else {
523
+ checks.push(fail(`Linear API: ${res.status} ${res.statusText}`));
524
+ }
525
+ } catch (err) {
526
+ checks.push(fail(`Linear API: unreachable (${err instanceof Error ? err.message : String(err)})`));
527
+ }
528
+ } else {
529
+ checks.push(fail("Linear API: no token available"));
530
+ }
531
+ }
532
+
533
+ // Notification targets
534
+ const notifRaw = pluginConfig?.notifications as { targets?: { channel: string; target: string }[] } | undefined;
535
+ const notifTargets = notifRaw?.targets ?? [];
536
+ if (notifTargets.length === 0) {
537
+ checks.push(pass("Notifications: not configured (skipped)"));
538
+ } else {
539
+ for (const t of notifTargets) {
540
+ checks.push(pass(`Notifications: ${t.channel} → ${t.target}`));
541
+ }
542
+ }
543
+
544
+ // Webhook self-test
545
+ const gatewayPort = process.env.OPENCLAW_GATEWAY_PORT ?? "18789";
546
+ try {
547
+ const res = await fetch(`http://localhost:${gatewayPort}/linear/webhook`, {
548
+ method: "POST",
549
+ headers: { "Content-Type": "application/json" },
550
+ body: JSON.stringify({ type: "test", action: "ping" }),
551
+ });
552
+ const body = await res.text();
553
+ if (res.ok && body === "ok") {
554
+ checks.push(pass("Webhook self-test: responds OK"));
555
+ } else {
556
+ checks.push(warn(`Webhook self-test: ${res.status} — ${body.slice(0, 100)}`));
557
+ }
558
+ } catch {
559
+ checks.push(warn(`Webhook self-test: skipped (gateway not detected on :${gatewayPort})`));
560
+ }
561
+
562
+ return checks;
563
+ }
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // Section 6: Dispatch Health
567
+ // ---------------------------------------------------------------------------
568
+
569
+ export async function checkDispatchHealth(pluginConfig?: Record<string, unknown>, fix = false): Promise<CheckResult[]> {
570
+ const checks: CheckResult[] = [];
571
+
572
+ const statePath = resolveDispatchStatePath(pluginConfig);
573
+ let state: DispatchState;
574
+ try {
575
+ state = await readDispatchState(pluginConfig?.dispatchStatePath as string | undefined);
576
+ } catch {
577
+ checks.push(pass("Dispatch health: no state file (nothing to check)"));
578
+ return checks;
579
+ }
580
+
581
+ // Active dispatches by status
582
+ const active = listActiveDispatches(state);
583
+ if (active.length === 0) {
584
+ checks.push(pass("No active dispatches"));
585
+ } else {
586
+ const byStatus = new Map<string, number>();
587
+ for (const d of active) {
588
+ byStatus.set(d.status, (byStatus.get(d.status) ?? 0) + 1);
589
+ }
590
+ const parts = Array.from(byStatus.entries()).map(([s, n]) => `${n} ${s}`);
591
+ const hasStuck = byStatus.has("stuck");
592
+ if (hasStuck) {
593
+ checks.push(warn(`Active dispatches: ${parts.join(", ")}`));
594
+ } else {
595
+ checks.push(pass(`Active dispatches: ${parts.join(", ")}`));
596
+ }
597
+ }
598
+
599
+ // Stale dispatches
600
+ const stale = listStaleDispatches(state, STALE_DISPATCH_MS);
601
+ if (stale.length === 0) {
602
+ checks.push(pass("No stale dispatches"));
603
+ } else {
604
+ const ids = stale.map((d) => d.issueIdentifier).join(", ");
605
+ checks.push(warn(`${stale.length} stale dispatch${stale.length > 1 ? "es" : ""}: ${ids}`));
606
+ }
607
+
608
+ // Orphaned worktrees
609
+ try {
610
+ const worktrees = listWorktrees({ baseDir: resolveWorktreeBaseDir(pluginConfig) });
611
+ const activeIds = new Set(Object.keys(state.dispatches.active));
612
+ const orphaned = worktrees.filter((wt) => !activeIds.has(wt.issueIdentifier));
613
+ if (orphaned.length === 0) {
614
+ checks.push(pass("No orphaned worktrees"));
615
+ } else {
616
+ checks.push(warn(
617
+ `${orphaned.length} orphaned worktree${orphaned.length > 1 ? "s" : ""} (not in active dispatches)`,
618
+ orphaned.map((w) => w.path).join(", "),
619
+ ));
620
+ }
621
+ } catch {
622
+ // Worktree listing may fail if dir doesn't exist — that's fine
623
+ }
624
+
625
+ // Old completed dispatches
626
+ const completed = Object.values(state.dispatches.completed);
627
+ const now = Date.now();
628
+ const old = completed.filter((c) => {
629
+ const age = now - new Date(c.completedAt).getTime();
630
+ return age > OLD_COMPLETED_MS;
631
+ });
632
+
633
+ if (old.length === 0) {
634
+ checks.push(pass("No old completed dispatches"));
635
+ } else {
636
+ if (fix) {
637
+ const pruned = await pruneCompleted(OLD_COMPLETED_MS, pluginConfig?.dispatchStatePath as string | undefined);
638
+ checks.push(pass(`Pruned ${pruned} old completed dispatch${pruned > 1 ? "es" : ""} (--fix)`));
639
+ } else {
640
+ checks.push(warn(
641
+ `${old.length} completed dispatch${old.length > 1 ? "es" : ""} older than 7 days`,
642
+ "Use --fix to prune",
643
+ true,
644
+ ));
645
+ }
646
+ }
647
+
648
+ return checks;
649
+ }
650
+
651
+ // ---------------------------------------------------------------------------
652
+ // Main entry point
653
+ // ---------------------------------------------------------------------------
654
+
655
+ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
656
+ const sections: CheckSection[] = [];
657
+
658
+ // 1. Auth (also captures context for connectivity)
659
+ const auth = await checkAuth(opts.pluginConfig);
660
+ sections.push({ name: "Authentication & Tokens", checks: auth.checks });
661
+
662
+ // 2. Agent config
663
+ sections.push({ name: "Agent Configuration", checks: checkAgentConfig(opts.pluginConfig) });
664
+
665
+ // 3. Coding tools
666
+ sections.push({ name: "Coding Tools", checks: checkCodingTools() });
667
+
668
+ // 4. Files & dirs
669
+ sections.push({
670
+ name: "Files & Directories",
671
+ checks: await checkFilesAndDirs(opts.pluginConfig, opts.fix),
672
+ });
673
+
674
+ // 5. Connectivity (pass auth context to avoid double API call)
675
+ sections.push({
676
+ name: "Connectivity",
677
+ checks: await checkConnectivity(opts.pluginConfig, auth.ctx),
678
+ });
679
+
680
+ // 6. Dispatch health
681
+ sections.push({
682
+ name: "Dispatch Health",
683
+ checks: await checkDispatchHealth(opts.pluginConfig, opts.fix),
684
+ });
685
+
686
+ // Fix: chmod auth-profiles.json if needed
687
+ if (opts.fix) {
688
+ const permCheck = auth.checks.find((c) => c.fixable && c.label.includes("permissions"));
689
+ if (permCheck) {
690
+ try {
691
+ chmodSync(AUTH_PROFILES_PATH, 0o600);
692
+ permCheck.severity = "pass";
693
+ permCheck.label = "auth-profiles.json permissions fixed to 600 (--fix)";
694
+ permCheck.fixable = undefined;
695
+ } catch { /* best effort */ }
696
+ }
697
+ }
698
+
699
+ // Build summary
700
+ let passed = 0, warnings = 0, errors = 0;
701
+ for (const section of sections) {
702
+ for (const check of section.checks) {
703
+ switch (check.severity) {
704
+ case "pass": passed++; break;
705
+ case "warn": warnings++; break;
706
+ case "fail": errors++; break;
707
+ }
708
+ }
709
+ }
710
+
711
+ return { sections, summary: { passed, warnings, errors } };
712
+ }
713
+
714
+ // ---------------------------------------------------------------------------
715
+ // Formatters
716
+ // ---------------------------------------------------------------------------
717
+
718
+ function icon(severity: CheckSeverity): string {
719
+ const isTTY = process.stdout?.isTTY;
720
+ switch (severity) {
721
+ case "pass": return isTTY ? "\x1b[32m✓\x1b[0m" : "✓";
722
+ case "warn": return isTTY ? "\x1b[33m⚠\x1b[0m" : "⚠";
723
+ case "fail": return isTTY ? "\x1b[31m✗\x1b[0m" : "✗";
724
+ }
725
+ }
726
+
727
+ export function formatReport(report: DoctorReport): string {
728
+ const lines: string[] = [];
729
+ const bar = "═".repeat(40);
730
+
731
+ lines.push("");
732
+ lines.push("Linear Plugin Doctor");
733
+ lines.push(bar);
734
+
735
+ for (const section of report.sections) {
736
+ lines.push("");
737
+ lines.push(section.name);
738
+ for (const check of section.checks) {
739
+ lines.push(` ${icon(check.severity)} ${check.label}`);
740
+ }
741
+ }
742
+
743
+ lines.push("");
744
+ lines.push(bar);
745
+
746
+ const { passed, warnings, errors } = report.summary;
747
+ const parts: string[] = [];
748
+ parts.push(`${passed} passed`);
749
+ if (warnings > 0) parts.push(`${warnings} warning${warnings > 1 ? "s" : ""}`);
750
+ if (errors > 0) parts.push(`${errors} error${errors > 1 ? "s" : ""}`);
751
+ lines.push(`Results: ${parts.join(", ")}`);
752
+ lines.push("");
753
+
754
+ return lines.join("\n");
755
+ }
756
+
757
+ export function formatReportJson(report: DoctorReport): string {
758
+ return JSON.stringify(report, null, 2);
759
+ }