@clipboard-health/groundcrew 2.3.6 → 3.0.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.
@@ -1,10 +1,120 @@
1
+ // src/commands/ticketDoctor.ts
2
+ //
3
+ // `crew doctor --ticket <TICKET>` — single per-ticket diagnostic that covers
4
+ // both the pre-dispatch question ("will this dispatch on the next tick?") and
5
+ // the post-dispatch question ("what's already happened and what's left to
6
+ // do?"). Verdict precedence runs strictly from post-dispatch states down:
7
+ //
8
+ // pr-open > pr-merged > in-flight > recoverable
9
+ // > unresolvable > ineligible > would-dispatch
10
+ // > lost
11
+ //
12
+ // When a post-dispatch verdict fires, the Resolution and Eligibility sections
13
+ // are skipped — they describe pre-dispatch state that no longer matters.
1
14
  import { existsSync } from "node:fs";
2
15
  import { join } from "node:path";
3
16
  import { fetchBlockersForTicket, fetchInProgressIssueCount, fetchRawLinearIssue, resolveModelFor, resolveRepositoryFor, } from "../lib/boardSource.js";
17
+ import { runCommandAsync } from "../lib/commandRunner.js";
4
18
  import { AGENT_ANY_MODEL, loadConfig } from "../lib/config.js";
19
+ import { which } from "../lib/host.js";
5
20
  import { getUsageByModel } from "../lib/usage.js";
6
- import { getLinearClient, writeOutput } from "../lib/util.js";
21
+ import { getLinearClient, lazyLinearClient, writeOutput } from "../lib/util.js";
22
+ import { workspaces } from "../lib/workspaces.js";
23
+ import { worktrees } from "../lib/worktrees.js";
7
24
  import { classifyBlockers, classifyUsageExhaustion, pickBestModel, } from "./eligibility.js";
25
+ import { renderTicketCheckResult } from "./ticketCheck.js";
26
+ /**
27
+ * Placeholder Linear state name used when `--no-linear` suppresses the Linear
28
+ * probe. The verdict decision reads `linear.kind` only — this name is never
29
+ * surfaced to the user.
30
+ */
31
+ const LINEAR_SKIPPED_STATE_NAME = "(linear skipped)";
32
+ // ───────── post-dispatch verdict (PR / in-flight / recoverable) ─────────
33
+ function verdictInFlight(input) {
34
+ if (input.linear.kind !== "non-terminal") {
35
+ return undefined;
36
+ }
37
+ const worktreePresent = input.worktree.kind === "present-clean" ||
38
+ input.worktree.kind === "present-dirty" ||
39
+ input.worktree.kind === "present-unknown-dirtiness";
40
+ if (!worktreePresent) {
41
+ return undefined;
42
+ }
43
+ /* v8 ignore next 3 @preserve -- callers always pass workspaceName or worktreeDir; defensive guard for the rare both-missing path */
44
+ const where = input.workspaceName === undefined
45
+ ? `worktree at ${input.worktreeDir ?? "<unknown>"}`
46
+ : `workspace "${input.workspaceName}"`;
47
+ return { kind: "in-flight", reason: `ticket is mid-flight in ${where}` };
48
+ }
49
+ function verdictRecoverable(input) {
50
+ if (input.worktree.kind === "present-dirty") {
51
+ /* v8 ignore next @preserve -- a present worktree always has a worktreeDir; nullish guard is defensive */
52
+ const where = input.worktreeDir ?? "<worktree>";
53
+ return {
54
+ kind: "recoverable",
55
+ reason: `dirty worktree (${input.worktree.modified} modified, ${input.worktree.untracked} untracked)`,
56
+ nextStep: `commit or stash in ${where}, then re-run \`crew doctor --ticket ${input.branch}\``,
57
+ };
58
+ }
59
+ const worktreeIsClean = input.worktree.kind === "present-clean" || input.worktree.kind === "present-unknown-dirtiness";
60
+ if (worktreeIsClean &&
61
+ input.localBranch.kind === "present" &&
62
+ input.remoteBranch.kind === "absent") {
63
+ /* v8 ignore next @preserve -- a present worktree always has a worktreeDir; nullish guard is defensive */
64
+ const where = input.worktreeDir ?? "<worktree>";
65
+ return {
66
+ kind: "recoverable",
67
+ reason: `clean worktree with un-pushed local branch`,
68
+ nextStep: `cd ${where}; git push -u origin ${input.branch}; gh pr create`,
69
+ };
70
+ }
71
+ if (input.worktree.kind === "absent" &&
72
+ input.remoteBranch.kind === "present" &&
73
+ input.pullRequest.kind === "absent") {
74
+ return {
75
+ kind: "recoverable",
76
+ reason: `remote branch exists without a PR`,
77
+ nextStep: `gh pr create --head ${input.branch}`,
78
+ };
79
+ }
80
+ if (input.worktree.kind === "absent" && input.localBranch.kind === "present") {
81
+ return {
82
+ kind: "recoverable",
83
+ reason: `stranded local branch (no worktree)`,
84
+ nextStep: `push the branch or delete it: \`git branch -D ${input.branch}\``,
85
+ };
86
+ }
87
+ return undefined;
88
+ }
89
+ /**
90
+ * Returns a post-dispatch verdict if the probe bundle matches one of the
91
+ * "ticket has moved past dispatch" cases. Returns `undefined` otherwise,
92
+ * signalling that the caller should fall through to the pre-dispatch path.
93
+ *
94
+ * Precedence: PR > in-flight > recoverable. Inside `recoverable`, dirty
95
+ * worktree beats clean-with-un-pushed-local beats remote-only beats stranded
96
+ * local.
97
+ */
98
+ export function decidePostDispatchVerdict(input) {
99
+ if (input.pullRequest.kind === "open") {
100
+ return { kind: "pr-open", number: input.pullRequest.number, url: input.pullRequest.url };
101
+ }
102
+ if (input.pullRequest.kind === "merged") {
103
+ return { kind: "pr-merged", number: input.pullRequest.number, url: input.pullRequest.url };
104
+ }
105
+ return verdictInFlight(input) ?? verdictRecoverable(input);
106
+ }
107
+ function emptySkipReasons() {
108
+ return {
109
+ resolution: "",
110
+ eligibility: "",
111
+ worktree: "",
112
+ workspace: "",
113
+ localBranch: "",
114
+ remoteBranch: "",
115
+ pullRequest: "",
116
+ };
117
+ }
8
118
  function buildModelChecks(raw, config) {
9
119
  const modelResolution = resolveModelFor({ labels: raw.labels, config });
10
120
  const checks = [];
@@ -117,8 +227,7 @@ function buildRepoChecks(raw, config, ticket) {
117
227
  status: "skipped",
118
228
  });
119
229
  }
120
- // repositoryResolution.kind is "ok" only when the first check passed.
121
- /* v8 ignore else @preserve */
230
+ /* v8 ignore else @preserve -- resolved repository is empty only on the fail branch above; the field is only consumed on the ok path */
122
231
  const resolvedRepository = repositoryResolution.kind === "ok" ? repositoryResolution.repository : "";
123
232
  return { resolvedRepository, checks };
124
233
  }
@@ -151,8 +260,7 @@ async function runEligibilityChecks(arguments_) {
151
260
  });
152
261
  return false;
153
262
  }
154
- // firstSkip.blockers is always set for "blocked" and "blockers_paginated" skip reasons.
155
- /* v8 ignore next @preserve */
263
+ /* v8 ignore next @preserve -- firstSkip.blockers is always set for "blocked" and "blockers_paginated" skip reasons */
156
264
  const blockerIds = firstSkip.blockers ?? [];
157
265
  eligibility.push({
158
266
  name: "No active blockers",
@@ -225,113 +333,253 @@ function usageExhaustionCheck(exhaustion) {
225
333
  failureSummary: `${exhaustion.model} weekly usage ${exhaustion.usedPercentage.toFixed(1)}% over ${exhaustion.allowedPercentage.toFixed(1)}% paced budget`,
226
334
  };
227
335
  }
228
- const STATUS_TAG = {
229
- ok: "[ok]",
230
- fail: "[--]",
231
- skipped: "[? ]",
232
- };
233
- function formatCheck(check) {
234
- const tag = STATUS_TAG[check.status];
235
- const detail = check.detail === undefined ? "" : ` (${check.detail})`;
236
- return ` ${tag} ${check.name}${detail}`;
237
- }
238
- function formatVerdict(verdict) {
239
- switch (verdict.kind) {
240
- case "would-dispatch": {
241
- return "→ would be dispatched on next tick";
242
- }
243
- case "unresolvable": {
244
- return `→ unresolvable: ${verdict.reason}`;
245
- }
246
- case "ineligible": {
247
- return `→ ineligible: ${verdict.reason}`;
248
- }
249
- /* v8 ignore next 3 @preserve -- exhaustive over TicketDoctorVerdict.kind */
250
- default: {
251
- return `→ ${verdict.kind}`;
252
- }
336
+ /**
337
+ * Fetches Linear and adds the "Ticket exists in Linear" + status checks to
338
+ * the resolution section. Returns the raw issue on success so the orchestrator
339
+ * can continue with label/repo/eligibility. On failure, returns enough
340
+ * context to render an `unresolvable` verdict.
341
+ */
342
+ async function probeLinear(deps, upperTicket) {
343
+ if (deps.fetchRawIssue === undefined) {
344
+ return { linearStatus: { kind: "skipped" }, resolution: [] };
253
345
  }
254
- }
255
- function eligibilityLines(result) {
256
- if (result.eligibility.length === 0) {
257
- const skipMessage = result.verdict.kind === "unresolvable"
258
- ? " (skipped — ticket unresolved)"
259
- : " (skipped — resolution checks failed)";
260
- return [skipMessage];
261
- }
262
- return result.eligibility.map(formatCheck);
263
- }
264
- export function renderTicketDoctorResult(result) {
265
- const titlePart = result.title === undefined ? "" : ` (${result.title})`;
266
- const header = `groundcrew doctor --ticket ${result.ticket}${titlePart}`;
267
- const bar = "─".repeat(header.length);
268
- const verdictLine = formatVerdict(result.verdict);
269
- return [
270
- header,
271
- bar,
272
- "",
273
- "Resolution",
274
- ...result.resolution.map(formatCheck),
275
- "",
276
- "Eligibility",
277
- ...eligibilityLines(result),
278
- "",
279
- verdictLine,
280
- ];
281
- }
282
- export async function ticketDoctor(dependencies) {
283
- const ticket = dependencies.ticket.toUpperCase();
284
- const resolution = [];
285
- const eligibility = [];
286
- let raw;
287
346
  try {
288
- raw = await dependencies.fetchRawIssue({ ticket });
347
+ const raw = await deps.fetchRawIssue({ ticket: upperTicket });
348
+ const isTerminal = deps.config.linear.statuses.terminal.includes(raw.stateName);
349
+ const todoState = deps.config.linear.statuses.todo;
350
+ const resolution = [
351
+ { name: "Ticket exists in Linear", status: "ok", detail: `"${raw.title}"` },
352
+ ];
353
+ if (raw.stateName === todoState) {
354
+ resolution.push({ name: "Status is Todo", status: "ok" });
355
+ }
356
+ else {
357
+ resolution.push({
358
+ name: "Status is Todo",
359
+ status: "fail",
360
+ detail: `current: ${raw.stateName}`,
361
+ failureSummary: `status is ${raw.stateName} (need ${todoState})`,
362
+ });
363
+ }
364
+ return {
365
+ linearStatus: { kind: isTerminal ? "terminal" : "non-terminal", stateName: raw.stateName },
366
+ resolution,
367
+ title: raw.title,
368
+ raw,
369
+ };
289
370
  }
290
371
  catch (error) {
291
372
  const message = error instanceof Error ? error.message : String(error);
292
- resolution.push({ name: "Ticket exists in Linear", status: "fail", detail: message });
293
373
  return {
294
- ticket,
295
- resolution,
296
- eligibility,
297
- verdict: { kind: "unresolvable", reason: message },
374
+ linearStatus: { kind: "unresolvable", reason: message },
375
+ resolution: [{ name: "Ticket exists in Linear", status: "fail", detail: message }],
298
376
  };
299
377
  }
300
- const { config } = dependencies;
301
- resolution.push({ name: "Ticket exists in Linear", status: "ok", detail: `"${raw.title}"` });
302
- // Status check
303
- const todoState = config.linear.statuses.todo;
304
- if (raw.stateName === todoState) {
305
- resolution.push({ name: "Status is Todo", status: "ok" });
378
+ }
379
+ async function probeWorktreeSection(deps, ticket) {
380
+ const entry = deps.findWorktree(ticket);
381
+ if (entry === undefined) {
382
+ return {
383
+ checks: [
384
+ {
385
+ name: "Host worktree exists",
386
+ status: "fail",
387
+ detail: "no worktree found for this ticket",
388
+ },
389
+ ],
390
+ status: { kind: "absent" },
391
+ entry: undefined,
392
+ };
306
393
  }
307
- else {
308
- resolution.push({
309
- name: "Status is Todo",
394
+ const dirtiness = await deps.probeWorkingTree({ worktreeDir: entry.dir });
395
+ const checks = [{ name: "Host worktree exists", status: "ok", detail: entry.dir }];
396
+ let status;
397
+ if (dirtiness.kind === "clean") {
398
+ checks.push({ name: "Working tree clean", status: "ok" });
399
+ status = { kind: "present-clean" };
400
+ }
401
+ else if (dirtiness.kind === "dirty") {
402
+ checks.push({
403
+ name: "Working tree clean",
310
404
  status: "fail",
311
- detail: `current: ${raw.stateName}`,
312
- failureSummary: `status is ${raw.stateName} (need ${todoState})`,
405
+ detail: `${dirtiness.modified} modified, ${dirtiness.untracked} untracked`,
313
406
  });
407
+ status = {
408
+ kind: "present-dirty",
409
+ modified: dirtiness.modified,
410
+ untracked: dirtiness.untracked,
411
+ };
314
412
  }
315
- // Label + model checks
316
- const { resolvedModel, checks: modelChecks } = buildModelChecks(raw, config);
317
- resolution.push(...modelChecks);
318
- // Repo checks
319
- const { resolvedRepository, checks: repoChecks } = buildRepoChecks(raw, config, ticket);
320
- resolution.push(...repoChecks);
321
- const firstResolutionFail = resolution.find((check) => check.status === "fail");
322
- if (firstResolutionFail !== undefined) {
323
- // failureSummary is always set for all resolution fail paths; .name fallback is defensive.
324
- /* v8 ignore next @preserve */
325
- const resolutionReason = firstResolutionFail.failureSummary ?? firstResolutionFail.name;
413
+ else {
414
+ // dirtiness.kind === "unknown"
415
+ checks.push({ name: "Working tree clean", status: "skipped", detail: "could not inspect" });
416
+ status = { kind: "present-unknown-dirtiness", reason: "git status failed" };
417
+ }
418
+ checks.push({ name: "Branch checked out", status: "ok", detail: entry.branchName });
419
+ return { checks, status, entry };
420
+ }
421
+ async function probeWorkspaceSection(deps, ticket) {
422
+ const probe = await deps.probeWorkspaces();
423
+ if (probe.kind === "unavailable") {
326
424
  return {
327
- ticket,
328
- title: raw.title,
329
- resolution,
330
- eligibility,
331
- verdict: { kind: "ineligible", reason: resolutionReason },
425
+ checks: [
426
+ { name: "Workspace pane open", status: "skipped", detail: "workspace probe unavailable" },
427
+ ],
428
+ workspaceName: undefined,
429
+ };
430
+ }
431
+ if (probe.names.has(ticket)) {
432
+ const hint = await deps.workspaceAccessHint(ticket);
433
+ const detail = hint === undefined ? ticket : `${ticket} — attach: \`${hint.command}\``;
434
+ return {
435
+ checks: [{ name: "Workspace pane open", status: "ok", detail }],
436
+ workspaceName: ticket,
437
+ };
438
+ }
439
+ return {
440
+ checks: [
441
+ { name: "Workspace pane open", status: "fail", detail: "no pane found for this ticket" },
442
+ ],
443
+ workspaceName: undefined,
444
+ };
445
+ }
446
+ function repoDirFromEntry(entry, deps) {
447
+ return `${deps.config.workspace.projectDir}/${entry.repository}`;
448
+ }
449
+ async function probeLocalBranchSection(deps, entry) {
450
+ if (entry === undefined) {
451
+ return {
452
+ checks: [],
453
+ skipReason: "repo dir unresolved",
454
+ probe: { kind: "absent" },
455
+ branch: undefined,
456
+ };
457
+ }
458
+ const repoDir = repoDirFromEntry(entry, deps);
459
+ const probe = await deps.probeLocalBranch({
460
+ repoDir,
461
+ branch: entry.branchName,
462
+ defaultBranch: deps.config.git.defaultBranch,
463
+ });
464
+ if (probe.kind === "present") {
465
+ const defaultBranchName = probe.defaultBranch ?? deps.config.git.defaultBranch;
466
+ return {
467
+ checks: [
468
+ {
469
+ name: "Local branch exists",
470
+ status: "ok",
471
+ detail: `${entry.branchName}, ${probe.ahead} ahead / ${probe.behind} behind origin/${defaultBranchName}`,
472
+ },
473
+ ],
474
+ skipReason: "",
475
+ probe,
476
+ branch: entry.branchName,
477
+ };
478
+ }
479
+ if (probe.kind === "absent") {
480
+ return {
481
+ checks: [{ name: "Local branch exists", status: "fail", detail: "branch not in git" }],
482
+ skipReason: "",
483
+ probe,
484
+ branch: entry.branchName,
485
+ };
486
+ }
487
+ // probe.kind === "unknown"
488
+ return {
489
+ checks: [{ name: "Local branch exists", status: "skipped", detail: probe.reason }],
490
+ skipReason: "",
491
+ probe,
492
+ branch: entry.branchName,
493
+ };
494
+ }
495
+ async function probeRemoteBranchSection(deps, entry) {
496
+ if (entry === undefined) {
497
+ return { checks: [], skipReason: "repo dir unresolved", probe: { kind: "absent" } };
498
+ }
499
+ const repoDir = repoDirFromEntry(entry, deps);
500
+ const probe = await deps.probeRemoteBranch({
501
+ repoDir,
502
+ branch: entry.branchName,
503
+ doFetch: deps.doFetch,
504
+ });
505
+ if (probe.kind === "present") {
506
+ return { checks: [{ name: "Branch present on origin", status: "ok" }], skipReason: "", probe };
507
+ }
508
+ if (probe.kind === "absent") {
509
+ return {
510
+ checks: [{ name: "Branch present on origin", status: "fail", detail: "not pushed" }],
511
+ skipReason: "",
512
+ probe,
513
+ };
514
+ }
515
+ // probe.kind === "unknown"
516
+ return {
517
+ checks: [{ name: "Branch present on origin", status: "skipped", detail: probe.reason }],
518
+ skipReason: "",
519
+ probe,
520
+ };
521
+ }
522
+ async function probePullRequestSection(deps, entry) {
523
+ if (entry === undefined) {
524
+ return { checks: [], skipReason: "repo dir unresolved", probe: { kind: "absent" } };
525
+ }
526
+ const repoDir = repoDirFromEntry(entry, deps);
527
+ const probe = await deps.probePullRequest({ repoDir, branch: entry.branchName });
528
+ if (probe.kind === "open" || probe.kind === "merged") {
529
+ return {
530
+ checks: [
531
+ {
532
+ name: "Open PR for this branch",
533
+ status: "ok",
534
+ detail: `#${probe.number} ${probe.url}`,
535
+ },
536
+ ],
537
+ skipReason: "",
538
+ probe,
539
+ };
540
+ }
541
+ if (probe.kind === "absent") {
542
+ return {
543
+ checks: [{ name: "Open PR for this branch", status: "fail", detail: "none found" }],
544
+ skipReason: "",
545
+ probe,
546
+ };
547
+ }
548
+ if (probe.kind === "gh-missing") {
549
+ return {
550
+ checks: [
551
+ { name: "Open PR for this branch", status: "skipped", detail: "gh CLI not on PATH" },
552
+ ],
553
+ skipReason: "",
554
+ probe,
332
555
  };
333
556
  }
334
- // All resolution checks passed (or were skipped). Run eligibility checks.
557
+ // probe.kind === "unknown"
558
+ return {
559
+ checks: [{ name: "Open PR for this branch", status: "skipped", detail: probe.reason }],
560
+ skipReason: "",
561
+ probe,
562
+ };
563
+ }
564
+ async function runPreDispatch(input) {
565
+ const { ticket, raw, config, dependencies } = input;
566
+ const resolutionExtra = [];
567
+ const eligibility = [];
568
+ const { resolvedModel, checks: modelChecks } = buildModelChecks(raw, config);
569
+ resolutionExtra.push(...modelChecks);
570
+ const { resolvedRepository, checks: repoChecks } = buildRepoChecks(raw, config, ticket);
571
+ resolutionExtra.push(...repoChecks);
572
+ if (input.statusCheckFailed) {
573
+ /* v8 ignore next @preserve -- statusFailureSummary is always set when statusCheckFailed is true; nullish fallback is defensive */
574
+ const reason = input.statusFailureSummary ?? "status not Todo";
575
+ return { resolutionExtra, eligibility, verdict: { kind: "ineligible", reason } };
576
+ }
577
+ const resolutionFail = resolutionExtra.find((check) => check.status === "fail");
578
+ if (resolutionFail !== undefined) {
579
+ /* v8 ignore next @preserve -- failureSummary is always set on resolution fail paths; .name fallback is defensive */
580
+ const reason = resolutionFail.failureSummary ?? resolutionFail.name;
581
+ return { resolutionExtra, eligibility, verdict: { kind: "ineligible", reason } };
582
+ }
335
583
  const allEligibilityOk = await runEligibilityChecks({
336
584
  ticket,
337
585
  raw,
@@ -343,60 +591,445 @@ export async function ticketDoctor(dependencies) {
343
591
  });
344
592
  if (!allEligibilityOk) {
345
593
  const firstEligibilityFail = eligibility.find((check) => check.status === "fail");
346
- // firstEligibilityFail is always defined when allEligibilityOk is false; fallback is defensive.
347
- /* v8 ignore next @preserve */
594
+ /* v8 ignore next 4 @preserve -- firstEligibilityFail is always defined when allEligibilityOk is false; fallback is defensive */
348
595
  const reason = firstEligibilityFail?.failureSummary ??
349
596
  firstEligibilityFail?.name ??
350
597
  "eligibility check failed";
351
- return {
352
- ticket,
353
- title: raw.title,
598
+ return { resolutionExtra, eligibility, verdict: { kind: "ineligible", reason } };
599
+ }
600
+ return { resolutionExtra, eligibility, verdict: { kind: "would-dispatch" } };
601
+ }
602
+ /**
603
+ * Pure-with-async orchestrator that gathers all sections plus the verdict.
604
+ * All I/O happens via injected probes — the function itself does no
605
+ * filesystem, network, or stdout work.
606
+ */
607
+ export async function ticketDoctor(dependencies) {
608
+ // Linear's GraphQL API treats ticket ids as uppercase. Local-state probes —
609
+ // worktree dirs, workspace pane names, branch-name fallbacks — are derived
610
+ // from the lowercase convention set by `setupWorkspaceCli`. Mixing cases is
611
+ // the root cause of "no worktree found" when the user types `--ticket
612
+ // HRD-442`.
613
+ const upperTicket = dependencies.ticket.toUpperCase();
614
+ const lowerTicket = dependencies.ticket.toLowerCase();
615
+ const skipReasons = emptySkipReasons();
616
+ const linearResult = await probeLinear(dependencies, upperTicket);
617
+ const resolution = [...linearResult.resolution];
618
+ const worktreeResult = await probeWorktreeSection(dependencies, lowerTicket);
619
+ const workspaceResult = await probeWorkspaceSection(dependencies, lowerTicket);
620
+ const localResult = await probeLocalBranchSection(dependencies, worktreeResult.entry);
621
+ const remoteResult = await probeRemoteBranchSection(dependencies, worktreeResult.entry);
622
+ const prResult = await probePullRequestSection(dependencies, worktreeResult.entry);
623
+ skipReasons.localBranch = localResult.skipReason;
624
+ skipReasons.remoteBranch = remoteResult.skipReason;
625
+ skipReasons.pullRequest = prResult.skipReason;
626
+ // `--no-linear` synthesizes a non-terminal kind so the in-flight check can
627
+ // still fire from local state alone. The placeholder name is read only by
628
+ // `decidePostDispatchVerdict` and never surfaced to the user.
629
+ const linearForVerdict = linearResult.linearStatus.kind === "skipped"
630
+ ? { kind: "non-terminal", stateName: LINEAR_SKIPPED_STATE_NAME }
631
+ : linearResult.linearStatus;
632
+ const postVerdict = decidePostDispatchVerdict({
633
+ linear: linearForVerdict,
634
+ worktree: worktreeResult.status,
635
+ localBranch: localResult.probe,
636
+ remoteBranch: remoteResult.probe,
637
+ pullRequest: prResult.probe,
638
+ branch: worktreeResult.entry?.branchName ?? lowerTicket,
639
+ worktreeDir: worktreeResult.entry?.dir,
640
+ workspaceName: workspaceResult.workspaceName,
641
+ });
642
+ if (postVerdict !== undefined) {
643
+ skipReasons.resolution = "post-dispatch — pre-dispatch checks are irrelevant";
644
+ skipReasons.eligibility = "post-dispatch — pre-dispatch checks are irrelevant";
645
+ return buildResult({
646
+ upperTicket,
647
+ title: linearResult.title,
354
648
  resolution,
355
- eligibility,
356
- verdict: { kind: "ineligible", reason },
357
- };
649
+ eligibility: [],
650
+ worktreeResult,
651
+ workspaceResult,
652
+ localResult,
653
+ remoteResult,
654
+ prResult,
655
+ skipReasons,
656
+ verdict: postVerdict,
657
+ });
358
658
  }
359
- return {
360
- ticket,
361
- title: raw.title,
659
+ // No post-dispatch verdict. Decide pre-dispatch path.
660
+ if (linearResult.linearStatus.kind === "skipped") {
661
+ // --no-linear with nothing locally actionable → lost. We cannot reach
662
+ // Linear, so the pre-dispatch resolution/eligibility checks are unavailable.
663
+ skipReasons.resolution = "--no-linear";
664
+ skipReasons.eligibility = "--no-linear";
665
+ return buildResult({
666
+ upperTicket,
667
+ title: linearResult.title,
668
+ resolution,
669
+ eligibility: [],
670
+ worktreeResult,
671
+ workspaceResult,
672
+ localResult,
673
+ remoteResult,
674
+ prResult,
675
+ skipReasons,
676
+ verdict: {
677
+ kind: "lost",
678
+ reason: `no local state and no PR — re-dispatch via \`crew run --ticket ${lowerTicket}\` or move the ticket back to Todo in Linear`,
679
+ },
680
+ });
681
+ }
682
+ if (linearResult.linearStatus.kind === "unresolvable") {
683
+ const { reason } = linearResult.linearStatus;
684
+ skipReasons.eligibility = "ticket unresolved";
685
+ return buildResult({
686
+ upperTicket,
687
+ title: linearResult.title,
688
+ resolution,
689
+ eligibility: [],
690
+ worktreeResult,
691
+ workspaceResult,
692
+ localResult,
693
+ remoteResult,
694
+ prResult,
695
+ skipReasons,
696
+ verdict: { kind: "unresolvable", reason },
697
+ });
698
+ }
699
+ // After the skipped/unresolvable branches above, linearStatus.kind is either
700
+ // "terminal" or "non-terminal", both of which carry the raw issue. TS can't
701
+ // see the invariant through the union, so we narrow with an explicit guard.
702
+ const { raw } = linearResult;
703
+ /* v8 ignore next 3 @preserve -- raw is defined whenever linearStatus.kind is terminal/non-terminal; the guard is for type narrowing only */
704
+ if (raw === undefined) {
705
+ throw new Error("ticketDoctor: invariant violated — raw Linear issue missing after status check");
706
+ }
707
+ // The "Status is Todo" check was already pushed by `probeLinear`; surface it
708
+ // as the verdict reason if it failed.
709
+ const statusCheck = resolution.find((check) => check.name === "Status is Todo");
710
+ const statusCheckFailed = statusCheck?.status === "fail";
711
+ const preDispatch = await runPreDispatch({
712
+ ticket: upperTicket,
713
+ raw,
714
+ config: dependencies.config,
715
+ dependencies,
716
+ statusCheckFailed,
717
+ statusFailureSummary: statusCheck?.failureSummary,
718
+ });
719
+ resolution.push(...preDispatch.resolutionExtra);
720
+ if (preDispatch.eligibility.length === 0 && preDispatch.verdict.kind === "ineligible") {
721
+ skipReasons.eligibility = "resolution checks failed";
722
+ }
723
+ return buildResult({
724
+ upperTicket,
725
+ title: linearResult.title,
362
726
  resolution,
363
- eligibility,
364
- verdict: { kind: "would-dispatch" },
727
+ eligibility: preDispatch.eligibility,
728
+ worktreeResult,
729
+ workspaceResult,
730
+ localResult,
731
+ remoteResult,
732
+ prResult,
733
+ skipReasons,
734
+ verdict: preDispatch.verdict,
735
+ });
736
+ }
737
+ function buildResult(input) {
738
+ return {
739
+ ticket: input.upperTicket,
740
+ ...(input.title === undefined ? {} : { title: input.title }),
741
+ resolution: input.resolution,
742
+ eligibility: input.eligibility,
743
+ worktree: input.worktreeResult.checks,
744
+ workspace: input.workspaceResult.checks,
745
+ localBranch: input.localResult.checks,
746
+ remoteBranch: input.remoteResult.checks,
747
+ pullRequest: input.prResult.checks,
748
+ skipReasons: input.skipReasons,
749
+ verdict: input.verdict,
365
750
  };
366
751
  }
367
- export async function ticketDoctorCli(argv) {
368
- const [ticket, ...extraArgs] = argv;
369
- if (ticket === undefined || ticket.length === 0 || ticket.startsWith("-")) {
370
- throw new Error("Usage: crew doctor --ticket <ticket>");
371
- }
372
- /* v8 ignore else @preserve */
373
- if (extraArgs.length > 0) {
374
- throw new Error(`crew doctor --ticket: unexpected arguments: ${extraArgs.join(" ")}`);
752
+ /**
753
+ * Parses optional `--no-linear` and `--no-fetch` flags that follow
754
+ * `crew doctor --ticket <id>`. The ticket id is consumed by `cli.ts` before
755
+ * this point.
756
+ */
757
+ export function parseTicketDoctorFlags(argv) {
758
+ let doLinear = true;
759
+ let doFetch = true;
760
+ for (const argument of argv) {
761
+ if (argument === "--no-linear") {
762
+ doLinear = false;
763
+ continue;
764
+ }
765
+ if (argument === "--no-fetch") {
766
+ doFetch = false;
767
+ continue;
768
+ }
769
+ throw new Error(`crew doctor --ticket: unknown argument: ${argument}`);
375
770
  }
376
- /* v8 ignore start @preserve */
377
- const ok = await runTicketDoctor(ticket);
378
- if (!ok) {
379
- process.exitCode = 1;
771
+ return { doLinear, doFetch };
772
+ }
773
+ function formatVerdict(verdict) {
774
+ switch (verdict.kind) {
775
+ case "pr-open": {
776
+ return `→ pr-open: ${verdict.url} (#${verdict.number})`;
777
+ }
778
+ case "pr-merged": {
779
+ return `→ pr-merged: ${verdict.url} (#${verdict.number})`;
780
+ }
781
+ case "in-flight": {
782
+ return `→ in-flight: ${verdict.reason}`;
783
+ }
784
+ case "recoverable": {
785
+ return `→ recoverable: ${verdict.reason}; ${verdict.nextStep}`;
786
+ }
787
+ case "would-dispatch": {
788
+ return "→ would be dispatched on next tick";
789
+ }
790
+ case "ineligible": {
791
+ return `→ ineligible: ${verdict.reason}`;
792
+ }
793
+ case "unresolvable": {
794
+ return `→ unresolvable: ${verdict.reason}`;
795
+ }
796
+ case "lost": {
797
+ return `→ lost: ${verdict.reason}`;
798
+ }
799
+ /* v8 ignore next 3 @preserve -- exhaustive over TicketDoctorVerdict.kind */
800
+ default: {
801
+ return `→ ${verdict.kind}`;
802
+ }
380
803
  }
381
- /* v8 ignore stop @preserve */
382
804
  }
383
- export async function runTicketDoctor(ticket) {
805
+ export function renderTicketDoctorResult(result) {
806
+ const sections = [
807
+ {
808
+ name: "Resolution",
809
+ checks: result.resolution,
810
+ ...(result.skipReasons.resolution === ""
811
+ ? {}
812
+ : { skipReason: result.skipReasons.resolution }),
813
+ },
814
+ {
815
+ name: "Eligibility",
816
+ checks: result.eligibility,
817
+ ...(result.skipReasons.eligibility === ""
818
+ ? {}
819
+ : { skipReason: result.skipReasons.eligibility }),
820
+ },
821
+ {
822
+ name: "Worktree",
823
+ checks: result.worktree,
824
+ ...(result.skipReasons.worktree === "" ? {} : { skipReason: result.skipReasons.worktree }),
825
+ },
826
+ {
827
+ name: "Workspace",
828
+ checks: result.workspace,
829
+ ...(result.skipReasons.workspace === "" ? {} : { skipReason: result.skipReasons.workspace }),
830
+ },
831
+ {
832
+ name: "Local branch",
833
+ checks: result.localBranch,
834
+ ...(result.skipReasons.localBranch === ""
835
+ ? {}
836
+ : { skipReason: result.skipReasons.localBranch }),
837
+ },
838
+ {
839
+ name: "Remote branch",
840
+ checks: result.remoteBranch,
841
+ ...(result.skipReasons.remoteBranch === ""
842
+ ? {}
843
+ : { skipReason: result.skipReasons.remoteBranch }),
844
+ },
845
+ {
846
+ name: "Pull request",
847
+ checks: result.pullRequest,
848
+ ...(result.skipReasons.pullRequest === ""
849
+ ? {}
850
+ : { skipReason: result.skipReasons.pullRequest }),
851
+ },
852
+ ];
853
+ return renderTicketCheckResult({
854
+ command: "doctor --ticket",
855
+ argument: result.ticket,
856
+ ...(result.title === undefined ? {} : { title: result.title }),
857
+ sections,
858
+ verdict: formatVerdict(result.verdict),
859
+ });
860
+ }
861
+ /* v8 ignore start @preserve -- production wiring; covered by integration smoke tests */
862
+ export async function runTicketDoctor(parsed) {
384
863
  const config = await loadConfig();
385
- let client;
386
- const linearClient = () => {
387
- client ??= getLinearClient();
388
- return client;
389
- };
864
+ const linearClient = lazyLinearClient(getLinearClient);
865
+ const fetchRawIssue = parsed.doLinear
866
+ ? async ({ ticket }) => await fetchRawLinearIssue({ client: linearClient(), ticket })
867
+ : undefined;
390
868
  const result = await ticketDoctor({
391
869
  config,
392
- ticket,
393
- fetchRawIssue: async ({ ticket: t }) => await fetchRawLinearIssue({ client: linearClient(), ticket: t }),
394
- fetchBlockersFor: async ({ ticket: t, uuid }) => await fetchBlockersForTicket({ client: linearClient(), ticket: t, uuid }),
870
+ ticket: parsed.ticket,
871
+ fetchRawIssue,
872
+ fetchBlockersFor: async ({ ticket, uuid }) => await fetchBlockersForTicket({ client: linearClient(), ticket, uuid }),
395
873
  fetchUsage: async () => await getUsageByModel(config),
396
874
  countInProgress: async () => await fetchInProgressIssueCount({ client: linearClient(), config }),
875
+ findWorktree: (ticket) => worktrees.findByTicket(config, ticket)[0],
876
+ probeWorkspaces: async () => await workspaces.probe(config),
877
+ workspaceAccessHint: async (name) => await workspaces.accessHint(config, name),
878
+ probeWorkingTree: async ({ worktreeDir }) => await worktrees.probeWorkingTree({ worktreeDir }),
879
+ probeLocalBranch: probeLocalBranchImpl,
880
+ probeRemoteBranch: probeRemoteBranchImpl,
881
+ probePullRequest: probePullRequestImpl,
882
+ doFetch: parsed.doFetch,
397
883
  });
398
884
  for (const line of renderTicketDoctorResult(result)) {
399
885
  writeOutput(line);
400
886
  }
401
- return result.verdict.kind === "would-dispatch";
887
+ return (result.verdict.kind === "would-dispatch" ||
888
+ result.verdict.kind === "pr-open" ||
889
+ result.verdict.kind === "pr-merged");
890
+ }
891
+ // ───── production probes ─────
892
+ /**
893
+ * Reads the numeric exit status that `normalizeCommandError` in
894
+ * `commandRunner.ts` includes in failed-command error messages as
895
+ * `Exit status: <N>`. Returns undefined when no such line is present.
896
+ */
897
+ function parseExitStatus(error) {
898
+ if (!(error instanceof Error)) {
899
+ return undefined;
900
+ }
901
+ const match = /Exit status: (\d+)/.exec(error.message);
902
+ if (match === null || match[1] === undefined) {
903
+ return undefined;
904
+ }
905
+ return Number.parseInt(match[1], 10);
906
+ }
907
+ async function probeLocalBranchImpl(input) {
908
+ if (!existsSync(input.repoDir)) {
909
+ return { kind: "unknown", reason: `repo dir not found: ${input.repoDir}` };
910
+ }
911
+ try {
912
+ await runCommandAsync("git", [
913
+ "-C",
914
+ input.repoDir,
915
+ "rev-parse",
916
+ "--verify",
917
+ "-q",
918
+ input.branch,
919
+ ]);
920
+ }
921
+ catch (error) {
922
+ if (parseExitStatus(error) === 1) {
923
+ return { kind: "absent" };
924
+ }
925
+ return {
926
+ kind: "unknown",
927
+ reason: error instanceof Error ? error.message : String(error),
928
+ };
929
+ }
930
+ try {
931
+ const output = await runCommandAsync("git", [
932
+ "-C",
933
+ input.repoDir,
934
+ "rev-list",
935
+ "--left-right",
936
+ "--count",
937
+ `${input.branch}...origin/${input.defaultBranch}`,
938
+ ]);
939
+ const [aheadString, behindString] = output.trim().split(/\s+/);
940
+ const ahead = Number.parseInt(aheadString ?? "0", 10);
941
+ const behind = Number.parseInt(behindString ?? "0", 10);
942
+ return { kind: "present", ahead, behind, defaultBranch: input.defaultBranch };
943
+ }
944
+ catch {
945
+ return { kind: "present", ahead: 0, behind: 0, defaultBranch: input.defaultBranch };
946
+ }
947
+ }
948
+ async function probeRemoteBranchImpl(input) {
949
+ if (!existsSync(input.repoDir)) {
950
+ return { kind: "unknown", reason: `repo dir not found: ${input.repoDir}` };
951
+ }
952
+ if (input.doFetch) {
953
+ try {
954
+ await runCommandAsync("git", [
955
+ "-C",
956
+ input.repoDir,
957
+ "fetch",
958
+ "--quiet",
959
+ "origin",
960
+ input.branch,
961
+ ]);
962
+ }
963
+ catch {
964
+ // Best-effort fetch; ls-remote below is the authoritative check.
965
+ }
966
+ }
967
+ try {
968
+ await runCommandAsync("git", [
969
+ "-C",
970
+ input.repoDir,
971
+ "ls-remote",
972
+ "--exit-code",
973
+ "origin",
974
+ `refs/heads/${input.branch}`,
975
+ ]);
976
+ return { kind: "present" };
977
+ }
978
+ catch (error) {
979
+ if (parseExitStatus(error) === 2) {
980
+ return { kind: "absent" };
981
+ }
982
+ return {
983
+ kind: "unknown",
984
+ reason: error instanceof Error ? error.message : String(error),
985
+ };
986
+ }
987
+ }
988
+ async function probePullRequestImpl(input) {
989
+ const ghPath = await which("gh");
990
+ if (ghPath === undefined) {
991
+ return { kind: "gh-missing" };
992
+ }
993
+ let output;
994
+ try {
995
+ output = await runCommandAsync("gh", [
996
+ "pr",
997
+ "list",
998
+ "--head",
999
+ input.branch,
1000
+ "--state",
1001
+ "all",
1002
+ "--json",
1003
+ "number,url,state,mergedAt",
1004
+ ], { cwd: input.repoDir });
1005
+ }
1006
+ catch (error) {
1007
+ return {
1008
+ kind: "unknown",
1009
+ reason: error instanceof Error ? error.message : String(error),
1010
+ };
1011
+ }
1012
+ try {
1013
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- gh's --json schema is fixed by our request fields (number,url,state,mergedAt)
1014
+ const parsed = JSON.parse(output);
1015
+ if (parsed.length === 0) {
1016
+ return { kind: "absent" };
1017
+ }
1018
+ const open = parsed.find((pullRequest) => pullRequest.state === "OPEN");
1019
+ if (open !== undefined) {
1020
+ return { kind: "open", number: open.number, url: open.url };
1021
+ }
1022
+ const merged = parsed.find((pullRequest) => pullRequest.mergedAt !== null && pullRequest.mergedAt !== undefined);
1023
+ if (merged !== undefined) {
1024
+ return { kind: "merged", number: merged.number, url: merged.url };
1025
+ }
1026
+ return { kind: "absent" };
1027
+ }
1028
+ catch (error) {
1029
+ return {
1030
+ kind: "unknown",
1031
+ reason: error instanceof Error ? error.message : String(error),
1032
+ };
1033
+ }
402
1034
  }
1035
+ /* v8 ignore stop @preserve */