@clipboard-health/groundcrew 2.3.0 → 2.3.4

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.
@@ -8,6 +8,7 @@ import { detectHostCapabilities, which } from "../lib/host.js";
8
8
  import { resolveLocalRunner } from "../lib/localRunner.js";
9
9
  import { errorMessage, resolveLinearApiKey, writeOutput } from "../lib/util.js";
10
10
  import { resolveWorkspaceKind } from "../lib/workspaces.js";
11
+ import { runTicketDoctor } from "./ticketDoctor.js";
11
12
  // Tokenization stops after this many non-flag tokens. Two is enough to
12
13
  // catch wrapper + wrapped CLI commands like `safehouse claude --foo`.
13
14
  const MAX_TOKENS_PER_CMD = 2;
@@ -121,7 +122,26 @@ function format(check) {
121
122
  const hint = check.hint !== undefined && check.hint.length > 0 ? ` — ${check.hint}` : "";
122
123
  return `${tag}${check.name}${hint}`;
123
124
  }
124
- export async function doctor() {
125
+ export async function doctor(options = {}) {
126
+ if (options.ticket !== undefined) {
127
+ return await doctorTicket(options.ticket);
128
+ }
129
+ return await doctorHost();
130
+ }
131
+ async function doctorTicket(ticket) {
132
+ try {
133
+ return await runTicketDoctor(ticket);
134
+ }
135
+ catch (error) {
136
+ const displayTicket = ticket.toUpperCase();
137
+ const header = `groundcrew doctor --ticket ${displayTicket}`;
138
+ writeOutput(header);
139
+ writeOutput("=".repeat(header.length));
140
+ writeOutput(`[--] config: ${errorMessage(error)}`);
141
+ return false;
142
+ }
143
+ }
144
+ async function doctorHost() {
125
145
  writeOutput("groundcrew doctor");
126
146
  writeOutput("=================");
127
147
  let config;
@@ -37,6 +37,19 @@ export interface SkipVerdict {
37
37
  model?: string;
38
38
  }
39
39
  type Verdict = StartVerdict | SkipVerdict;
40
+ export type ModelUsageExhaustion = {
41
+ kind: "session";
42
+ model: string;
43
+ usedPercentage: number;
44
+ limitPercentage: number;
45
+ resetMinutes: number | null;
46
+ } | {
47
+ kind: "weekly";
48
+ model: string;
49
+ usedPercentage: number;
50
+ allowedPercentage: number;
51
+ resetMinutes: number;
52
+ };
40
53
  export interface ClassifyArguments {
41
54
  config: ResolvedConfig;
42
55
  /**
@@ -68,6 +81,7 @@ interface BlockerClassification {
68
81
  * falls back to the default predictably.
69
82
  */
70
83
  export declare function pickBestModel(config: ResolvedConfig, usage: UsageByModel, exhausted: Set<string>): string | undefined;
84
+ export declare function classifyUsageExhaustion(config: ResolvedConfig, usage: UsageByModel): ModelUsageExhaustion[];
71
85
  /**
72
86
  * Cheap pre-pass — partitions Todo into unblocked issues and blocker
73
87
  * skip verdicts. Runs before the dispatcher fetches usage or probes the
@@ -1 +1 @@
1
- {"version":3,"file":"eligibility.d.ts","sourceRoot":"","sources":["../../src/commands/eligibility.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAgB,KAAK,eAAe,EAAoB,MAAM,uBAAuB,CAAC;AAC7F,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,KAAK,UAAU,GACX,SAAS,GACT,oBAAoB,GACpB,oBAAoB,GACpB,iBAAiB,GACjB,4BAA4B,GAC5B,mBAAmB,CAAC;AAExB,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,eAAe,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,8EAA8E;IAC9E,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,eAAe,CAAC;IACvB,sBAAsB;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,WAAW,EAAE,UAAU,CAAC;IACxB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,OAAO,GAAG,YAAY,GAAG,WAAW,CAAC;AAE1C,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,cAAc,CAAC;IACvB;;;;;OAKG;IACH,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;IACtC,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,cAAc,EAAE,cAAc,CAAC;IAC/B,KAAK,EAAE,YAAY,CAAC;IACpB,oDAAoD;IACpD,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACvB,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,UAAU,qBAAqB;IAC7B,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,KAAK,EAAE,WAAW,EAAE,CAAC;CACtB;AAqCD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,YAAY,EACnB,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,GACrB,MAAM,GAAG,SAAS,CAepB;AA4CD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,SAAS,eAAe,EAAE,GAC/B,qBAAqB,CAYvB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,iBAAiB,GAAG,OAAO,EAAE,CAgE5E"}
1
+ {"version":3,"file":"eligibility.d.ts","sourceRoot":"","sources":["../../src/commands/eligibility.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAgB,KAAK,eAAe,EAAoB,MAAM,uBAAuB,CAAC;AAC7F,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAOzD,KAAK,UAAU,GACX,SAAS,GACT,oBAAoB,GACpB,oBAAoB,GACpB,iBAAiB,GACjB,4BAA4B,GAC5B,mBAAmB,CAAC;AAExB,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,eAAe,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,8EAA8E;IAC9E,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,eAAe,CAAC;IACvB,sBAAsB;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,WAAW,EAAE,UAAU,CAAC;IACxB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,OAAO,GAAG,YAAY,GAAG,WAAW,CAAC;AAE1C,MAAM,MAAM,oBAAoB,GAC5B;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B,GACD;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEN,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,cAAc,CAAC;IACvB;;;;;OAKG;IACH,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;IACtC,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,cAAc,EAAE,cAAc,CAAC;IAC/B,KAAK,EAAE,YAAY,CAAC;IACpB,oDAAoD;IACpD,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACvB,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,UAAU,qBAAqB;IAC7B,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,KAAK,EAAE,WAAW,EAAE,CAAC;CACtB;AAqCD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,YAAY,EACnB,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,GACrB,MAAM,GAAG,SAAS,CAepB;AAaD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,YAAY,GAClB,oBAAoB,EAAE,CAmCxB;AA4CD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,SAAS,eAAe,EAAE,GAC/B,qBAAqB,CAYvB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,iBAAiB,GAAG,OAAO,EAAE,CAgE5E"}
@@ -8,6 +8,10 @@
8
8
  */
9
9
  import { isTerminalStatus } from "../lib/boardSource.js";
10
10
  import { AGENT_ANY_MODEL } from "../lib/config.js";
11
+ const PERCENT_FRACTION_DIVISOR = 100;
12
+ const DAYS_PER_WEEK = 7;
13
+ const MINUTES_PER_DAY = 24 * 60;
14
+ const MINUTES_PER_WEEK = DAYS_PER_WEEK * MINUTES_PER_DAY;
11
15
  function blockerSummary(blocker) {
12
16
  return `${blocker.id}:${blocker.status ?? "missing"}`;
13
17
  }
@@ -59,6 +63,46 @@ export function pickBestModel(config, usage, exhausted) {
59
63
  return best;
60
64
  }).name;
61
65
  }
66
+ function weeklyPacedBudgetPercentage(weekEndDuration) {
67
+ const elapsedMinutes = Math.min(MINUTES_PER_WEEK, Math.max(0, MINUTES_PER_WEEK - weekEndDuration));
68
+ const elapsedDayCount = Math.ceil(elapsedMinutes / MINUTES_PER_DAY);
69
+ const budgetDayCount = Math.min(DAYS_PER_WEEK, Math.max(1, elapsedDayCount));
70
+ return (budgetDayCount / DAYS_PER_WEEK) * PERCENT_FRACTION_DIVISOR;
71
+ }
72
+ export function classifyUsageExhaustion(config, usage) {
73
+ const exhausted = [];
74
+ const sessionLimit = config.orchestrator.sessionLimitPercentage;
75
+ for (const [model, snapshot] of Object.entries(usage)) {
76
+ if (snapshot.session !== null && snapshot.session * PERCENT_FRACTION_DIVISOR > sessionLimit) {
77
+ exhausted.push({
78
+ kind: "session",
79
+ model,
80
+ usedPercentage: snapshot.session * PERCENT_FRACTION_DIVISOR,
81
+ limitPercentage: sessionLimit,
82
+ resetMinutes: snapshot.sessionEndDuration,
83
+ });
84
+ }
85
+ // Weekly gate paces total weekly usage against day buckets from the
86
+ // weekly reset. Day 1's budget is available immediately after rollover,
87
+ // then each later day opens another 1/7 of the weekly budget.
88
+ if (snapshot.weekly !== null &&
89
+ Number.isFinite(snapshot.weekly) &&
90
+ snapshot.weekEndDuration !== null) {
91
+ const usedPercentage = snapshot.weekly * PERCENT_FRACTION_DIVISOR;
92
+ const allowedPercentage = weeklyPacedBudgetPercentage(snapshot.weekEndDuration);
93
+ if (usedPercentage > allowedPercentage) {
94
+ exhausted.push({
95
+ kind: "weekly",
96
+ model,
97
+ usedPercentage,
98
+ allowedPercentage,
99
+ resetMinutes: snapshot.weekEndDuration,
100
+ });
101
+ }
102
+ }
103
+ }
104
+ return exhausted;
105
+ }
62
106
  // Stale worktrees with no matching live workspace are filtered out here so
63
107
  // they don't permanently block later tickets in the Todo queue.
64
108
  function classifyRecovery(arguments_) {
@@ -1 +1 @@
1
- {"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAOA,OAAO,EAAkC,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAUvF,UAAU,aAAa;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAgBD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAmED,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CAoGf;AA0FD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAoBf"}
1
+ {"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAOA,OAAO,EAAkC,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAUvF,UAAU,aAAa;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAgBD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAmED,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CAuGf;AA0FD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAoBf"}
@@ -122,7 +122,9 @@ export async function setupWorkspace(config, options, runOptions = {}) {
122
122
  const stagedPrompt = stagePrompt({ config, ticket, ticketDetails, worktreeName });
123
123
  promptDir = stagedPrompt.directory;
124
124
  const secretsFile = stageBuildSecrets(promptDir);
125
- const sandboxName = runner === "sdx" ? sandboxNameFor({ repository, model }) : undefined;
125
+ const sandboxName = runner === "sdx" && definition.sandbox !== undefined
126
+ ? sandboxNameFor({ agent: definition.sandbox.agent })
127
+ : undefined;
126
128
  if (runner === "sdx" && sandboxName !== undefined && definition.sandbox !== undefined) {
127
129
  await ensureSandbox({
128
130
  sandboxName,
@@ -0,0 +1,48 @@
1
+ import { type Blocker, type RawLinearIssue } from "../lib/boardSource.ts";
2
+ import { type ResolvedConfig } from "../lib/config.ts";
3
+ import { type UsageByModel } from "../lib/usage.ts";
4
+ export type TicketDoctorVerdict = {
5
+ kind: "would-dispatch";
6
+ } | {
7
+ kind: "ineligible";
8
+ reason: string;
9
+ } | {
10
+ kind: "unresolvable";
11
+ reason: string;
12
+ };
13
+ export interface TicketCheck {
14
+ name: string;
15
+ status: "ok" | "fail" | "skipped";
16
+ detail?: string;
17
+ failureSummary?: string;
18
+ }
19
+ export interface TicketDoctorResult {
20
+ ticket: string;
21
+ title?: string;
22
+ resolution: TicketCheck[];
23
+ eligibility: TicketCheck[];
24
+ verdict: TicketDoctorVerdict;
25
+ }
26
+ export interface TicketDoctorDependencies {
27
+ config: ResolvedConfig;
28
+ ticket: string;
29
+ /**
30
+ * Injected to keep `ticketDoctor` pure and easy to unit-test. Production
31
+ * callers pass a closure that delegates to `fetchRawLinearIssue` with a
32
+ * real `LinearClient`; tests pass a `vi.fn()` returning a fixture.
33
+ */
34
+ fetchRawIssue: (input: {
35
+ ticket: string;
36
+ }) => Promise<RawLinearIssue>;
37
+ fetchBlockersFor: (input: {
38
+ ticket: string;
39
+ uuid: string;
40
+ }) => Promise<readonly Blocker[]>;
41
+ fetchUsage: () => Promise<UsageByModel>;
42
+ countInProgress: () => Promise<number>;
43
+ }
44
+ export declare function renderTicketDoctorResult(result: TicketDoctorResult): string[];
45
+ export declare function ticketDoctor(dependencies: TicketDoctorDependencies): Promise<TicketDoctorResult>;
46
+ export declare function ticketDoctorCli(argv: string[]): Promise<void>;
47
+ export declare function runTicketDoctor(ticket: string): Promise<boolean>;
48
+ //# sourceMappingURL=ticketDoctor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ticketDoctor.d.ts","sourceRoot":"","sources":["../../src/commands/ticketDoctor.ts"],"names":[],"mappings":"AAEA,OAAO,EAML,KAAK,OAAO,EAEZ,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAA+B,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACpF,OAAO,EAAmB,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AASrE,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,gBAAgB,CAAA;CAAE,GAC1B;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,IAAI,GAAG,MAAM,GAAG,SAAS,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,WAAW,EAAE,CAAC;IAC1B,WAAW,EAAE,WAAW,EAAE,CAAC;IAC3B,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,aAAa,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IACtE,gBAAgB,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,SAAS,OAAO,EAAE,CAAC,CAAC;IAC3F,UAAU,EAAE,MAAM,OAAO,CAAC,YAAY,CAAC,CAAC;IACxC,eAAe,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;CACxC;AA+SD,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,EAAE,CAmB7E;AAED,wBAAsB,YAAY,CAChC,YAAY,EAAE,wBAAwB,GACrC,OAAO,CAAC,kBAAkB,CAAC,CA0F7B;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAenE;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAwBtE"}
@@ -0,0 +1,402 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fetchBlockersForTicket, fetchInProgressIssueCount, fetchRawLinearIssue, resolveModelFor, resolveRepositoryFor, } from "../lib/boardSource.js";
4
+ import { AGENT_ANY_MODEL, loadConfig } from "../lib/config.js";
5
+ import { getUsageByModel } from "../lib/usage.js";
6
+ import { getLinearClient, writeOutput } from "../lib/util.js";
7
+ import { classifyBlockers, classifyUsageExhaustion, pickBestModel, } from "./eligibility.js";
8
+ function buildModelChecks(raw, config) {
9
+ const modelResolution = resolveModelFor({ labels: raw.labels, config });
10
+ const checks = [];
11
+ switch (modelResolution.kind) {
12
+ case "no-label": {
13
+ checks.push({
14
+ name: "Has agent-* label",
15
+ status: "fail",
16
+ detail: "no agent-* label on this ticket",
17
+ failureSummary: "ticket has no agent-* label",
18
+ });
19
+ checks.push({ name: "Model resolves from agent-* label", status: "skipped" });
20
+ break;
21
+ }
22
+ case "agent-any": {
23
+ checks.push({
24
+ name: "Has agent-* label",
25
+ status: "ok",
26
+ detail: "agent-any",
27
+ });
28
+ checks.push({
29
+ name: "Model resolves from agent-* label",
30
+ status: "ok",
31
+ detail: `model picked at dispatch time; defaults to "${config.models.default}" when usage ties`,
32
+ });
33
+ break;
34
+ }
35
+ case "matched": {
36
+ checks.push({
37
+ name: "Has agent-* label",
38
+ status: "ok",
39
+ detail: `agent-${modelResolution.model}`,
40
+ });
41
+ checks.push({
42
+ name: "Model resolves from agent-* label",
43
+ status: "ok",
44
+ detail: `model "${modelResolution.model}"`,
45
+ });
46
+ break;
47
+ }
48
+ case "disabled-fallback": {
49
+ checks.push({
50
+ name: "Has agent-* label",
51
+ status: "ok",
52
+ detail: `agent-${modelResolution.requestedModel}`,
53
+ });
54
+ checks.push({
55
+ name: "Model resolves from agent-* label",
56
+ status: "ok",
57
+ detail: `agent-${modelResolution.requestedModel} disabled; falling back to model "${modelResolution.fallbackModel}"`,
58
+ });
59
+ break;
60
+ }
61
+ /* v8 ignore next @preserve */
62
+ default: {
63
+ break;
64
+ }
65
+ }
66
+ let resolvedModel = config.models.default;
67
+ if (modelResolution.kind === "matched") {
68
+ resolvedModel = modelResolution.model;
69
+ }
70
+ else if (modelResolution.kind === "agent-any") {
71
+ resolvedModel = AGENT_ANY_MODEL;
72
+ }
73
+ else if (modelResolution.kind === "disabled-fallback") {
74
+ resolvedModel = modelResolution.fallbackModel;
75
+ }
76
+ return { resolvedModel, checks };
77
+ }
78
+ function buildRepoChecks(raw, config, ticket) {
79
+ const repositoryResolution = resolveRepositoryFor({
80
+ description: raw.description,
81
+ config,
82
+ ticket,
83
+ });
84
+ const checks = [];
85
+ if (repositoryResolution.kind === "ok") {
86
+ checks.push({
87
+ name: "Description mentions known repo",
88
+ status: "ok",
89
+ detail: repositoryResolution.repository,
90
+ });
91
+ const repoDir = join(config.workspace.projectDir, repositoryResolution.repository);
92
+ if (existsSync(repoDir)) {
93
+ checks.push({
94
+ name: "Resolved repo is cloned locally",
95
+ status: "ok",
96
+ detail: repoDir,
97
+ });
98
+ }
99
+ else {
100
+ checks.push({
101
+ name: "Resolved repo is cloned locally",
102
+ status: "fail",
103
+ detail: `${repositoryResolution.repository} not found at ${repoDir} — run \`crew setup repos ${repositoryResolution.repository}\``,
104
+ failureSummary: `resolved repo ${repositoryResolution.repository} is not cloned locally`,
105
+ });
106
+ }
107
+ }
108
+ else {
109
+ checks.push({
110
+ name: "Description mentions known repo",
111
+ status: "fail",
112
+ detail: `no entry from workspace.knownRepositories (${config.workspace.knownRepositories.join(", ")}) appears in description`,
113
+ failureSummary: "description does not mention a known repo",
114
+ });
115
+ checks.push({
116
+ name: "Resolved repo is cloned locally",
117
+ status: "skipped",
118
+ });
119
+ }
120
+ // repositoryResolution.kind is "ok" only when the first check passed.
121
+ /* v8 ignore else @preserve */
122
+ const resolvedRepository = repositoryResolution.kind === "ok" ? repositoryResolution.repository : "";
123
+ return { resolvedRepository, checks };
124
+ }
125
+ async function runEligibilityChecks(arguments_) {
126
+ const { ticket, raw, config, resolvedRepository, resolvedModel, dependencies, eligibility } = arguments_;
127
+ const blockers = await dependencies.fetchBlockersFor({ ticket, uuid: raw.uuid });
128
+ const groundcrewIssue = {
129
+ id: ticket,
130
+ uuid: raw.uuid,
131
+ title: raw.title,
132
+ status: raw.stateName,
133
+ statusId: "",
134
+ assignee: "",
135
+ updatedAt: "",
136
+ teamId: raw.teamId,
137
+ repository: resolvedRepository,
138
+ model: resolvedModel,
139
+ blockers: [...blockers],
140
+ hasMoreBlockers: raw.hasMoreBlockers,
141
+ };
142
+ const blockerClassification = classifyBlockers(config, [groundcrewIssue]);
143
+ const [firstSkip] = blockerClassification.skips;
144
+ if (firstSkip !== undefined) {
145
+ if (firstSkip.eventReason === "blockers_paginated") {
146
+ eligibility.push({
147
+ name: "No active blockers",
148
+ status: "fail",
149
+ detail: "blockers exceeded the v1 relation page size",
150
+ failureSummary: "blockers exceeded the v1 relation page size",
151
+ });
152
+ return false;
153
+ }
154
+ // firstSkip.blockers is always set for "blocked" and "blockers_paginated" skip reasons.
155
+ /* v8 ignore next @preserve */
156
+ const blockerIds = firstSkip.blockers ?? [];
157
+ eligibility.push({
158
+ name: "No active blockers",
159
+ status: "fail",
160
+ detail: blockerIds.join(", "),
161
+ failureSummary: `blocked by ${blockerIds.join(", ")}`,
162
+ });
163
+ return false;
164
+ }
165
+ eligibility.push({ name: "No active blockers", status: "ok" });
166
+ const usage = await dependencies.fetchUsage();
167
+ const usageExhaustion = classifyUsageExhaustion(config, usage);
168
+ const exhausted = new Set(usageExhaustion.map((exhaustion) => exhaustion.model));
169
+ let model = resolvedModel;
170
+ let resolvedFromAny = "";
171
+ if (model === AGENT_ANY_MODEL) {
172
+ const picked = pickBestModel(config, usage, exhausted);
173
+ if (picked === undefined) {
174
+ eligibility.push({
175
+ name: "Model usage under sessionLimitPercentage",
176
+ status: "fail",
177
+ detail: "agent-any but no model has available capacity",
178
+ failureSummary: "agent-any has no model with available capacity",
179
+ });
180
+ return false;
181
+ }
182
+ model = picked;
183
+ resolvedFromAny = `; agent-any resolved to model "${picked}"`;
184
+ }
185
+ const exhaustedUsage = usageExhaustion.find((exhaustion) => exhaustion.model === model);
186
+ eligibility.push(exhaustedUsage === undefined
187
+ ? modelUsageOkCheck({ config, model, usage, resolvedFromAny })
188
+ : usageExhaustionCheck(exhaustedUsage));
189
+ const inProgress = await dependencies.countInProgress();
190
+ const cap = config.orchestrator.maximumInProgress;
191
+ const capOk = inProgress < cap;
192
+ const capCheck = {
193
+ name: "In-progress cap not hit",
194
+ status: capOk ? "ok" : "fail",
195
+ detail: `${inProgress}/${cap} used`,
196
+ };
197
+ if (!capOk) {
198
+ capCheck.failureSummary = `in-progress cap is full (${inProgress}/${cap} used)`;
199
+ }
200
+ eligibility.push(capCheck);
201
+ return eligibility.every((check) => check.status === "ok");
202
+ }
203
+ function modelUsageOkCheck(arguments_) {
204
+ const { config, model, usage, resolvedFromAny } = arguments_;
205
+ const sessionPercent = ((usage[model]?.session ?? 0) * 100).toFixed(0);
206
+ return {
207
+ name: `Model "${model}" usage under sessionLimitPercentage`,
208
+ status: "ok",
209
+ detail: `${sessionPercent}% (limit ${config.orchestrator.sessionLimitPercentage}%)${resolvedFromAny}`,
210
+ };
211
+ }
212
+ function usageExhaustionCheck(exhaustion) {
213
+ if (exhaustion.kind === "session") {
214
+ return {
215
+ name: `Model "${exhaustion.model}" usage under sessionLimitPercentage`,
216
+ status: "fail",
217
+ detail: `${exhaustion.usedPercentage.toFixed(0)}% (limit ${exhaustion.limitPercentage}%)`,
218
+ failureSummary: `${exhaustion.model} session usage ${exhaustion.usedPercentage.toFixed(0)}% over ${exhaustion.limitPercentage}% limit`,
219
+ };
220
+ }
221
+ return {
222
+ name: `Model "${exhaustion.model}" weekly usage within paced budget`,
223
+ status: "fail",
224
+ detail: `${exhaustion.usedPercentage.toFixed(1)}% (paced budget ${exhaustion.allowedPercentage.toFixed(1)}%, resets in ${exhaustion.resetMinutes}m)`,
225
+ failureSummary: `${exhaustion.model} weekly usage ${exhaustion.usedPercentage.toFixed(1)}% over ${exhaustion.allowedPercentage.toFixed(1)}% paced budget`,
226
+ };
227
+ }
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
+ }
253
+ }
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
+ try {
288
+ raw = await dependencies.fetchRawIssue({ ticket });
289
+ }
290
+ catch (error) {
291
+ const message = error instanceof Error ? error.message : String(error);
292
+ resolution.push({ name: "Ticket exists in Linear", status: "fail", detail: message });
293
+ return {
294
+ ticket,
295
+ resolution,
296
+ eligibility,
297
+ verdict: { kind: "unresolvable", reason: message },
298
+ };
299
+ }
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" });
306
+ }
307
+ else {
308
+ resolution.push({
309
+ name: "Status is Todo",
310
+ status: "fail",
311
+ detail: `current: ${raw.stateName}`,
312
+ failureSummary: `status is ${raw.stateName} (need ${todoState})`,
313
+ });
314
+ }
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;
326
+ return {
327
+ ticket,
328
+ title: raw.title,
329
+ resolution,
330
+ eligibility,
331
+ verdict: { kind: "ineligible", reason: resolutionReason },
332
+ };
333
+ }
334
+ // All resolution checks passed (or were skipped). Run eligibility checks.
335
+ const allEligibilityOk = await runEligibilityChecks({
336
+ ticket,
337
+ raw,
338
+ config,
339
+ resolvedRepository,
340
+ resolvedModel,
341
+ dependencies,
342
+ eligibility,
343
+ });
344
+ if (!allEligibilityOk) {
345
+ 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 */
348
+ const reason = firstEligibilityFail?.failureSummary ??
349
+ firstEligibilityFail?.name ??
350
+ "eligibility check failed";
351
+ return {
352
+ ticket,
353
+ title: raw.title,
354
+ resolution,
355
+ eligibility,
356
+ verdict: { kind: "ineligible", reason },
357
+ };
358
+ }
359
+ return {
360
+ ticket,
361
+ title: raw.title,
362
+ resolution,
363
+ eligibility,
364
+ verdict: { kind: "would-dispatch" },
365
+ };
366
+ }
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(" ")}`);
375
+ }
376
+ /* v8 ignore start @preserve */
377
+ const ok = await runTicketDoctor(ticket);
378
+ if (!ok) {
379
+ process.exitCode = 1;
380
+ }
381
+ /* v8 ignore stop @preserve */
382
+ }
383
+ export async function runTicketDoctor(ticket) {
384
+ const config = await loadConfig();
385
+ let client;
386
+ const linearClient = () => {
387
+ client ??= getLinearClient();
388
+ return client;
389
+ };
390
+ const result = await ticketDoctor({
391
+ 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 }),
395
+ fetchUsage: async () => await getUsageByModel(config),
396
+ countInProgress: async () => await fetchInProgressIssueCount({ client: linearClient(), config }),
397
+ });
398
+ for (const line of renderTicketDoctorResult(result)) {
399
+ writeOutput(line);
400
+ }
401
+ return result.verdict.kind === "would-dispatch";
402
+ }
package/dist/index.d.ts CHANGED
@@ -5,5 +5,7 @@ export { orchestrate, type OrchestratorOptions } from "./commands/orchestrator.t
5
5
  export { setupWorkspace, type SetupWorkspaceOptions } from "./commands/setupWorkspace.ts";
6
6
  export type { Config, ModelDefinition, ResolvedConfig } from "./lib/config.ts";
7
7
  export { loadConfig } from "./lib/config.ts";
8
+ export { fetchBlockersForTicket, fetchInProgressIssueCount, fetchRawLinearIssue, fetchResolvedIssue, resolveModelFor, resolveRepositoryFor, type ModelResolution, type RawLinearIssue, type RepositoryResolution, } from "./lib/boardSource.ts";
8
9
  export { getUsageByModel, type UsageByModel } from "./lib/usage.ts";
10
+ export { ticketDoctor, type TicketCheck, type TicketDoctorDependencies, type TicketDoctorResult, type TicketDoctorVerdict, } from "./commands/ticketDoctor.ts";
9
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAC/B,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAChG,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,KAAK,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACnF,OAAO,EAAE,cAAc,EAAE,KAAK,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAC1F,YAAY,EAAE,MAAM,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,eAAe,EAAE,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAC/B,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAChG,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,KAAK,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACnF,OAAO,EAAE,cAAc,EAAE,KAAK,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAC1F,YAAY,EAAE,MAAM,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EACL,sBAAsB,EACtB,yBAAyB,EACzB,mBAAmB,EACnB,kBAAkB,EAClB,eAAe,EACf,oBAAoB,EACpB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,oBAAoB,GAC1B,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAE,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,EACL,YAAY,EACZ,KAAK,WAAW,EAChB,KAAK,wBAAwB,EAC7B,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,GACzB,MAAM,4BAA4B,CAAC"}
package/dist/index.js CHANGED
@@ -4,4 +4,6 @@ export { doctor } from "./commands/doctor.js";
4
4
  export { orchestrate } from "./commands/orchestrator.js";
5
5
  export { setupWorkspace } from "./commands/setupWorkspace.js";
6
6
  export { loadConfig } from "./lib/config.js";
7
+ export { fetchBlockersForTicket, fetchInProgressIssueCount, fetchRawLinearIssue, fetchResolvedIssue, resolveModelFor, resolveRepositoryFor, } from "./lib/boardSource.js";
7
8
  export { getUsageByModel } from "./lib/usage.js";
9
+ export { ticketDoctor, } from "./commands/ticketDoctor.js";