@clipboard-health/groundcrew 2.3.0 → 2.3.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.
- package/README.md +57 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +22 -10
- package/dist/commands/dispatcher.d.ts.map +1 -1
- package/dist/commands/dispatcher.js +10 -35
- package/dist/commands/doctor.d.ts +4 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +21 -1
- package/dist/commands/eligibility.d.ts +14 -0
- package/dist/commands/eligibility.d.ts.map +1 -1
- package/dist/commands/eligibility.js +44 -0
- package/dist/commands/ticketDoctor.d.ts +48 -0
- package/dist/commands/ticketDoctor.d.ts.map +1 -0
- package/dist/commands/ticketDoctor.js +402 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/lib/boardSource.d.ts +55 -0
- package/dist/lib/boardSource.d.ts.map +1 -1
- package/dist/lib/boardSource.js +171 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,6 +92,7 @@ This installs the `crew` binary. `@clipboard-health/clearance` is pulled in tran
|
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
94
|
crew doctor
|
|
95
|
+
crew doctor --ticket TEAM-123
|
|
95
96
|
crew run --dry-run
|
|
96
97
|
crew run # one-shot
|
|
97
98
|
crew run --watch # poll forever
|
|
@@ -162,15 +163,71 @@ Rules:
|
|
|
162
163
|
## Manual commands
|
|
163
164
|
|
|
164
165
|
```bash
|
|
166
|
+
crew doctor --ticket <TICKET>
|
|
165
167
|
crew run --ticket <TICKET>
|
|
166
168
|
crew setup repos [--dry-run] [<repo>...]
|
|
167
169
|
crew cleanup <TICKET>
|
|
168
170
|
```
|
|
169
171
|
|
|
172
|
+
`crew doctor --ticket <TICKET>` diagnoses one Linear ticket without provisioning it. It checks the same resolution inputs that decide whether the orchestrator can see the ticket — Todo status, `agent-*` label, model resolution, repository mention, local clone — then checks blockers, model session usage, and available in-progress capacity.
|
|
173
|
+
|
|
170
174
|
`crew run --ticket <TICKET>` provisions a single ticket the same way the orchestrator would: the repo is parsed from the ticket's Linear description and the model comes from the ticket's `agent-*` label (manual setup falls back to `models.default` for unlabeled tickets). If the description does not mention a repo from `workspace.knownRepositories`, setup fails before provisioning. `--watch` and `--ticket` are mutually exclusive — `--watch` drives the orchestrator loop; `--ticket` provisions one ticket and exits. `crew cleanup <TICKET>` resolves to every tracked worktree carrying that ticket id (across repos) and tears them all down. To inspect codexbar session windows directly, run `codexbar usage`; the orchestrator already gates on this internally via `orchestrator.sessionLimitPercentage`.
|
|
171
175
|
|
|
176
|
+
### `crew doctor --ticket <ticket>`
|
|
177
|
+
|
|
178
|
+
Diagnose why a ticket would or wouldn't be dispatched on the next orchestrator tick. Runs the same resolution and eligibility chain as the dispatcher, but for a single ticket, and prints a tree of pass/fail checks.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
crew doctor --ticket HRD-446
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Exits 0 if the ticket would dispatch, 1 otherwise. Useful when you've labelled a ticket with `agent-claude` and it doesn't show up on the board.
|
|
185
|
+
|
|
186
|
+
Example output for a ticket that would dispatch:
|
|
187
|
+
|
|
188
|
+
```text
|
|
189
|
+
groundcrew doctor --ticket HRD-446 (Add retry logic to the sync job)
|
|
190
|
+
────────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
Resolution
|
|
193
|
+
[ok] Ticket exists in Linear ("Add retry logic to the sync job")
|
|
194
|
+
[ok] Status is Todo
|
|
195
|
+
[ok] Has agent-* label (agent-claude)
|
|
196
|
+
[ok] Model resolves from agent-* label (model "claude")
|
|
197
|
+
[ok] Description mentions known repo (owner/repo)
|
|
198
|
+
[ok] Resolved repo is cloned locally (/dev/workspaces/owner/repo)
|
|
199
|
+
|
|
200
|
+
Eligibility
|
|
201
|
+
[ok] No active blockers
|
|
202
|
+
[ok] Model "claude" usage under sessionLimitPercentage (12% (limit 85%))
|
|
203
|
+
[ok] In-progress cap not hit (2/4 used)
|
|
204
|
+
|
|
205
|
+
→ would be dispatched on next tick
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Example output for a ticket that's not in the Todo status:
|
|
209
|
+
|
|
210
|
+
```text
|
|
211
|
+
groundcrew doctor --ticket HRD-447 (Refactor auth middleware)
|
|
212
|
+
─────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
Resolution
|
|
215
|
+
[ok] Ticket exists in Linear ("Refactor auth middleware")
|
|
216
|
+
[--] Status is Todo (current: In Progress)
|
|
217
|
+
[ok] Has agent-* label (agent-claude)
|
|
218
|
+
[ok] Model resolves from agent-* label (model "claude")
|
|
219
|
+
[ok] Description mentions known repo (owner/repo)
|
|
220
|
+
[ok] Resolved repo is cloned locally (/dev/workspaces/owner/repo)
|
|
221
|
+
|
|
222
|
+
Eligibility
|
|
223
|
+
(skipped — resolution checks failed)
|
|
224
|
+
|
|
225
|
+
→ ineligible: status is In Progress (need Todo)
|
|
226
|
+
```
|
|
227
|
+
|
|
172
228
|
## Gotchas
|
|
173
229
|
|
|
230
|
+
- **Ticket labeled but not on the board?** Run `crew doctor --ticket <ticket>` — it lists every check the dispatcher runs and flags the failing one.
|
|
174
231
|
- **Local execution picks one of safehouse/sdx/none.** `local.runner: "auto"` resolves to `safehouse` on macOS and `sdx` (Docker Sandboxes) on Linux/WSL. Override with `local.runner: "safehouse" | "sdx" | "none"`. There is no per-model `isolation` knob anymore — the runner is global. `sdx` requires a per-model `sandbox: { agent }` block so groundcrew can map the model to an sbx agent.
|
|
175
232
|
- **Safehouse-already-wrapped commands are not re-wrapped.** If a `models.definitions.<name>.cmd` already starts with `safehouse`, groundcrew assumes that command owns its Safehouse flags and does not add the `safehouse-clearance` wrapper a second time. Changing the proxy's allowlist after it's running requires killing the PID in `${XDG_CACHE_HOME:-$HOME/.cache}/clearance/clearance.pid` so the next launch picks up the new env.
|
|
176
233
|
- **Sandbox lifecycle is create-only.** Groundcrew auto-creates the sandbox for a `<repository, model>` pair when missing, but never deletes one — sandboxes persist across tickets and across `crew cleanup`. Auth state lives inside the sandbox, so deleting it forces a re-login. Inspect or remove them manually with `sbx ls` / `sbx rm`.
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAyIA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvD"}
|
package/dist/cli.js
CHANGED
|
@@ -51,6 +51,24 @@ async function runCli(argv) {
|
|
|
51
51
|
}
|
|
52
52
|
await setupWorkspaceCli(ticket, { dryRun });
|
|
53
53
|
}
|
|
54
|
+
async function doctorCli(argv) {
|
|
55
|
+
let ticket;
|
|
56
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
57
|
+
const argument = argv[index];
|
|
58
|
+
if (argument === "--ticket") {
|
|
59
|
+
const value = argv[index + 1];
|
|
60
|
+
if (value === undefined || value.length === 0 || value.startsWith("-")) {
|
|
61
|
+
throw new Error("crew doctor --ticket: ticket id is required");
|
|
62
|
+
}
|
|
63
|
+
ticket = value;
|
|
64
|
+
index += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`crew doctor: unknown argument: ${argument}`);
|
|
68
|
+
}
|
|
69
|
+
const ok = ticket === undefined ? await doctor() : await doctor({ ticket });
|
|
70
|
+
process.exitCode = ok ? process.exitCode : 1;
|
|
71
|
+
}
|
|
54
72
|
const SUBCOMMANDS = {
|
|
55
73
|
run: {
|
|
56
74
|
summary: "Run the orchestrator (one-shot by default), or provision one ticket with --ticket",
|
|
@@ -58,13 +76,9 @@ const SUBCOMMANDS = {
|
|
|
58
76
|
invoke: runCli,
|
|
59
77
|
},
|
|
60
78
|
doctor: {
|
|
61
|
-
summary: "Verify prereqs
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!ok) {
|
|
65
|
-
process.exitCode = 1;
|
|
66
|
-
}
|
|
67
|
-
},
|
|
79
|
+
summary: "Verify prereqs, or diagnose one ticket with --ticket",
|
|
80
|
+
usage: "[--ticket <ticket>]",
|
|
81
|
+
invoke: doctorCli,
|
|
68
82
|
},
|
|
69
83
|
cleanup: {
|
|
70
84
|
summary: "Tear down a worktree",
|
|
@@ -87,9 +101,7 @@ function printHelp() {
|
|
|
87
101
|
writeOutput("Commands:");
|
|
88
102
|
for (const [name, command] of Object.entries(SUBCOMMANDS)) {
|
|
89
103
|
writeOutput(` ${name.padEnd(width)} ${command.summary}`);
|
|
90
|
-
|
|
91
|
-
writeOutput(` ${" ".repeat(width)} → crew ${name} ${command.usage}`);
|
|
92
|
-
}
|
|
104
|
+
writeOutput(` ${" ".repeat(width)} → crew ${name} ${command.usage}`);
|
|
93
105
|
}
|
|
94
106
|
writeOutput("\nSee README.md for full configuration and behavior.");
|
|
95
107
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/commands/dispatcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,EAAE,KAAK,UAAU,EAA2C,MAAM,uBAAuB,CAAC;AACjG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAGpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/commands/dispatcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,EAAE,KAAK,UAAU,EAA2C,MAAM,uBAAuB,CAAC;AACjG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAGpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAWzD,UAAU,cAAc;IACtB,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,UAAU,EAAE;QAClB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,+FAA+F;QAC/F,KAAK,EAAE,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;QACvD,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;KACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,UAAU,CAuLjE"}
|
|
@@ -10,42 +10,16 @@ import { isGroundcrewIssue } from "../lib/boardSource.js";
|
|
|
10
10
|
import { createLinearIssueStatusUpdater } from "../lib/linearIssueStatus.js";
|
|
11
11
|
import { errorMessage, log, logEvent } from "../lib/util.js";
|
|
12
12
|
import { workspaces } from "../lib/workspaces.js";
|
|
13
|
-
import { classifyBlockers, classifyEligibility, } from "./eligibility.js";
|
|
13
|
+
import { classifyBlockers, classifyEligibility, classifyUsageExhaustion, } from "./eligibility.js";
|
|
14
14
|
import { setupWorkspace } from "./setupWorkspace.js";
|
|
15
|
-
const PERCENT_FRACTION_DIVISOR = 100;
|
|
16
|
-
const DAYS_PER_WEEK = 7;
|
|
17
|
-
const MINUTES_PER_DAY = 24 * 60;
|
|
18
|
-
const MINUTES_PER_WEEK = DAYS_PER_WEEK * MINUTES_PER_DAY;
|
|
19
15
|
export function createDispatcher(deps) {
|
|
20
16
|
const { config, client } = deps;
|
|
21
17
|
const issueStatusUpdater = createLinearIssueStatusUpdater({ config, client });
|
|
22
18
|
function buildExhaustedSet(usage) {
|
|
23
19
|
const exhausted = new Set();
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
exhausted.add(model);
|
|
28
|
-
const pct = (snapshot.session * PERCENT_FRACTION_DIVISOR).toFixed(0);
|
|
29
|
-
const mins = snapshot.sessionEndDuration ?? "?";
|
|
30
|
-
log(`${model} session at ${pct}% (> ${sessionLimit}%), resets in ${mins}m — skipping its tickets`);
|
|
31
|
-
}
|
|
32
|
-
// Weekly gate paces total weekly usage against day buckets from the
|
|
33
|
-
// weekly reset. Day 1's budget is available immediately after rollover,
|
|
34
|
-
// then each later day opens another 1/7 of the weekly budget. Skipped when:
|
|
35
|
-
// - weekly is null (no codexbar secondary window this tick)
|
|
36
|
-
// - weekly is non-finite (EXHAUSTED_USAGE — session gate above
|
|
37
|
-
// already pins it to Infinity)
|
|
38
|
-
// - weekEndDuration is null (can't compute where we are in week)
|
|
39
|
-
if (snapshot.weekly !== null &&
|
|
40
|
-
Number.isFinite(snapshot.weekly) &&
|
|
41
|
-
snapshot.weekEndDuration !== null) {
|
|
42
|
-
const usedPct = snapshot.weekly * PERCENT_FRACTION_DIVISOR;
|
|
43
|
-
const allowedPct = weeklyPacedBudgetPercentage(snapshot.weekEndDuration);
|
|
44
|
-
if (usedPct > allowedPct) {
|
|
45
|
-
exhausted.add(model);
|
|
46
|
-
log(`${model} weekly at ${usedPct.toFixed(1)}% (> ${allowedPct.toFixed(1)}% paced budget), resets in ${snapshot.weekEndDuration}m — skipping its tickets`);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
20
|
+
for (const exhaustion of classifyUsageExhaustion(config, usage)) {
|
|
21
|
+
exhausted.add(exhaustion.model);
|
|
22
|
+
log(formatUsageExhaustion(exhaustion));
|
|
49
23
|
}
|
|
50
24
|
return exhausted;
|
|
51
25
|
}
|
|
@@ -187,9 +161,10 @@ export function createDispatcher(deps) {
|
|
|
187
161
|
}
|
|
188
162
|
return { runOnce };
|
|
189
163
|
}
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
164
|
+
function formatUsageExhaustion(exhaustion) {
|
|
165
|
+
if (exhaustion.kind === "session") {
|
|
166
|
+
const mins = exhaustion.resetMinutes ?? "?";
|
|
167
|
+
return `${exhaustion.model} session at ${exhaustion.usedPercentage.toFixed(0)}% (> ${exhaustion.limitPercentage}%), resets in ${mins}m — skipping its tickets`;
|
|
168
|
+
}
|
|
169
|
+
return `${exhaustion.model} weekly at ${exhaustion.usedPercentage.toFixed(1)}% (> ${exhaustion.allowedPercentage.toFixed(1)}% paced budget), resets in ${exhaustion.resetMinutes}m — skipping its tickets`;
|
|
195
170
|
}
|
|
@@ -2,5 +2,8 @@
|
|
|
2
2
|
* doctor — verify groundcrew prerequisites against the resolved config.
|
|
3
3
|
* Returns true if every required check passes; false otherwise.
|
|
4
4
|
*/
|
|
5
|
-
export
|
|
5
|
+
export interface DoctorOptions {
|
|
6
|
+
ticket?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function doctor(options?: DoctorOptions): Promise<boolean>;
|
|
6
9
|
//# sourceMappingURL=doctor.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"AAAA;;;GAGG;
|
|
1
|
+
{"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"AAAA;;;GAGG;AA2BH,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAsHD,wBAAsB,MAAM,CAAC,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,OAAO,CAAC,CAK1E"}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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;
|
|
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_) {
|
|
@@ -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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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";
|
|
@@ -69,6 +69,61 @@ interface ResolvedIssue {
|
|
|
69
69
|
model: string;
|
|
70
70
|
teamId: string;
|
|
71
71
|
}
|
|
72
|
+
export interface RawLinearIssue {
|
|
73
|
+
uuid: string;
|
|
74
|
+
title: string;
|
|
75
|
+
description: string;
|
|
76
|
+
teamId: string;
|
|
77
|
+
labels: {
|
|
78
|
+
name: string;
|
|
79
|
+
}[];
|
|
80
|
+
/** Linear workflow state name, e.g. "Todo", "In Review". May be "" if state was null. */
|
|
81
|
+
stateName: string;
|
|
82
|
+
blockers: Blocker[];
|
|
83
|
+
hasMoreBlockers: boolean;
|
|
84
|
+
}
|
|
85
|
+
export declare function fetchBlockersForTicket(arguments_: {
|
|
86
|
+
client: LinearClient;
|
|
87
|
+
ticket: string;
|
|
88
|
+
uuid: string;
|
|
89
|
+
}): Promise<readonly Blocker[]>;
|
|
90
|
+
export declare function fetchRawLinearIssue(arguments_: {
|
|
91
|
+
client: LinearClient;
|
|
92
|
+
ticket: string;
|
|
93
|
+
}): Promise<RawLinearIssue>;
|
|
94
|
+
export declare function fetchInProgressIssueCount(arguments_: {
|
|
95
|
+
client: LinearClient;
|
|
96
|
+
config: ResolvedConfig;
|
|
97
|
+
}): Promise<number>;
|
|
98
|
+
export type RepositoryResolution = {
|
|
99
|
+
kind: "ok";
|
|
100
|
+
repository: string;
|
|
101
|
+
} | {
|
|
102
|
+
kind: "missing";
|
|
103
|
+
};
|
|
104
|
+
export declare function resolveRepositoryFor(arguments_: {
|
|
105
|
+
description: string | undefined;
|
|
106
|
+
config: ResolvedConfig;
|
|
107
|
+
ticket: string;
|
|
108
|
+
}): RepositoryResolution;
|
|
109
|
+
export type ModelResolution = {
|
|
110
|
+
kind: "matched";
|
|
111
|
+
model: string;
|
|
112
|
+
} | {
|
|
113
|
+
kind: "no-label";
|
|
114
|
+
} | {
|
|
115
|
+
kind: "agent-any";
|
|
116
|
+
} | {
|
|
117
|
+
kind: "disabled-fallback";
|
|
118
|
+
requestedModel: string;
|
|
119
|
+
fallbackModel: string;
|
|
120
|
+
};
|
|
121
|
+
export declare function resolveModelFor(arguments_: {
|
|
122
|
+
labels: {
|
|
123
|
+
name: string;
|
|
124
|
+
}[];
|
|
125
|
+
config: ResolvedConfig;
|
|
126
|
+
}): ModelResolution;
|
|
72
127
|
/**
|
|
73
128
|
* `agent-any` collapses to `models.default` here — manual setup doesn't run
|
|
74
129
|
* the usage-gated `any` resolver, so the caller gets a concrete model name
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"boardSource.d.ts","sourceRoot":"","sources":["../../src/lib/boardSource.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAM7F,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAED,MAAM,WAAW,KAAK;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,0FAA0F;IAC1F,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,0FAA0F;IAC1F,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,KAAK,GAAG,KAAK,IAAI,eAAe,CAExE;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,KAAK,EAAE,CAAC;CACjB;AAED,qBAAa,yBAA0B,SAAQ,KAAK;IAClD,YAAmB,UAAU,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,EAMjF;CACF;AAED,MAAM,WAAW,WAAW;IAC1B;;;OAGG;IACH,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,8DAA8D;IAC9D,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;CAC9B;AAED,UAAU,eAAe;IACvB,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,eAAe,GAAG,WAAW,CAUpE;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAEhF;
|
|
1
|
+
{"version":3,"file":"boardSource.d.ts","sourceRoot":"","sources":["../../src/lib/boardSource.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/boardSource.js
CHANGED
|
@@ -129,9 +129,9 @@ async function fetchBoard(client, config) {
|
|
|
129
129
|
const issues = nodes
|
|
130
130
|
.filter((node) => node.children.nodes.length === 0)
|
|
131
131
|
.map((node) => {
|
|
132
|
-
const
|
|
133
|
-
warnIfDisabledFallback(node.identifier,
|
|
134
|
-
const repository =
|
|
132
|
+
const modelResolution = resolveModelFor({ labels: node.labels.nodes, config });
|
|
133
|
+
warnIfDisabledFallback(node.identifier, modelResolution, config);
|
|
134
|
+
const repository = modelResolution.kind === "no-label"
|
|
135
135
|
? undefined
|
|
136
136
|
: parseRepository({
|
|
137
137
|
description: node.description ?? undefined,
|
|
@@ -139,6 +139,16 @@ async function fetchBoard(client, config) {
|
|
|
139
139
|
repositoryRegex,
|
|
140
140
|
ticket: node.identifier,
|
|
141
141
|
});
|
|
142
|
+
let model;
|
|
143
|
+
if (modelResolution.kind === "matched") {
|
|
144
|
+
({ model } = modelResolution);
|
|
145
|
+
}
|
|
146
|
+
else if (modelResolution.kind === "disabled-fallback") {
|
|
147
|
+
model = modelResolution.fallbackModel;
|
|
148
|
+
}
|
|
149
|
+
else if (modelResolution.kind === "agent-any") {
|
|
150
|
+
model = AGENT_ANY_MODEL;
|
|
151
|
+
}
|
|
142
152
|
return {
|
|
143
153
|
id: node.identifier.toLowerCase(),
|
|
144
154
|
uuid: node.id,
|
|
@@ -148,7 +158,7 @@ async function fetchBoard(client, config) {
|
|
|
148
158
|
assignee: node.assignee?.name ?? "Unassigned",
|
|
149
159
|
updatedAt: node.updatedAt,
|
|
150
160
|
repository,
|
|
151
|
-
model
|
|
161
|
+
model,
|
|
152
162
|
teamId: node.team?.id ?? "",
|
|
153
163
|
blockers: blockersFromRelations(node.inverseRelations?.nodes ?? []),
|
|
154
164
|
hasMoreBlockers: node.inverseRelations?.pageInfo.hasNextPage ?? false,
|
|
@@ -174,22 +184,64 @@ function buildRepositoryRegex(config) {
|
|
|
174
184
|
return new RegExp(String.raw `\b(${alternation})\b`);
|
|
175
185
|
}
|
|
176
186
|
const ISSUE_LABEL_PAGE_SIZE = 50;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
187
|
+
const ISSUE_RELATION_PAGE_SIZE = 50;
|
|
188
|
+
export async function fetchBlockersForTicket(arguments_) {
|
|
189
|
+
const { client, uuid } = arguments_;
|
|
190
|
+
const relations = [];
|
|
191
|
+
let after = null;
|
|
192
|
+
for (;;) {
|
|
193
|
+
// oxlint-disable-next-line no-await-in-loop -- pagination cursor depends on the previous response
|
|
194
|
+
const response = await client.client.rawRequest(`query IssueBlockers($id: String!, $after: String) {
|
|
195
|
+
issue(id: $id) {
|
|
196
|
+
inverseRelations(first: ${ISSUE_RELATION_PAGE_SIZE}, after: $after, includeArchived: false) {
|
|
197
|
+
nodes {
|
|
198
|
+
type
|
|
199
|
+
issue {
|
|
200
|
+
identifier
|
|
201
|
+
title
|
|
202
|
+
state { name }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
pageInfo { hasNextPage endCursor }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}`, { id: uuid, after });
|
|
209
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- shape is fixed by our GraphQL query above
|
|
210
|
+
const { issue } = response.data;
|
|
211
|
+
if (issue === null) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
relations.push(...issue.inverseRelations.nodes);
|
|
215
|
+
if (!issue.inverseRelations.pageInfo.hasNextPage) {
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
after = issue.inverseRelations.pageInfo.endCursor;
|
|
219
|
+
}
|
|
220
|
+
return blockersFromRelations(relations);
|
|
221
|
+
}
|
|
222
|
+
export async function fetchRawLinearIssue(arguments_) {
|
|
223
|
+
const { client, ticket } = arguments_;
|
|
184
224
|
const response = await client.client.rawRequest(`query ResolveIssue($id: String!) {
|
|
185
225
|
issue(id: $id) {
|
|
186
226
|
id
|
|
187
227
|
title
|
|
188
228
|
description
|
|
189
229
|
team { id }
|
|
230
|
+
state { name }
|
|
190
231
|
labels(first: ${ISSUE_LABEL_PAGE_SIZE}) {
|
|
191
232
|
nodes { name }
|
|
192
233
|
}
|
|
234
|
+
inverseRelations(first: 50, includeArchived: false) {
|
|
235
|
+
nodes {
|
|
236
|
+
type
|
|
237
|
+
issue {
|
|
238
|
+
identifier
|
|
239
|
+
title
|
|
240
|
+
state { name }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
pageInfo { hasNextPage }
|
|
244
|
+
}
|
|
193
245
|
}
|
|
194
246
|
}`, { id: ticket.toUpperCase() });
|
|
195
247
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- shape is fixed by our GraphQL query above
|
|
@@ -197,26 +249,119 @@ export async function fetchResolvedIssue(arguments_) {
|
|
|
197
249
|
if (issue === null) {
|
|
198
250
|
throw new Error(`Ticket ${ticket.toUpperCase()} not found in Linear`);
|
|
199
251
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
252
|
+
return {
|
|
253
|
+
uuid: issue.id,
|
|
254
|
+
title: issue.title,
|
|
255
|
+
description: issue.description ?? "",
|
|
256
|
+
teamId: issue.team?.id ?? "",
|
|
257
|
+
labels: issue.labels.nodes,
|
|
258
|
+
stateName: issue.state?.name ?? "",
|
|
259
|
+
blockers: blockersFromRelations(issue.inverseRelations?.nodes ?? []),
|
|
260
|
+
hasMoreBlockers: issue.inverseRelations?.pageInfo.hasNextPage ?? false,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
export async function fetchInProgressIssueCount(arguments_) {
|
|
264
|
+
const { client, config } = arguments_;
|
|
265
|
+
let after = null;
|
|
266
|
+
let count = 0;
|
|
267
|
+
for (;;) {
|
|
268
|
+
// oxlint-disable-next-line no-await-in-loop -- pagination cursor depends on the previous response
|
|
269
|
+
const response = await client.client.rawRequest(`query InProgressIssues($slugId: String!, $stateName: String!, $agentLabelPrefix: String!, $after: String) {
|
|
270
|
+
issues(
|
|
271
|
+
filter: {
|
|
272
|
+
project: { slugId: { eq: $slugId } }
|
|
273
|
+
state: { name: { eq: $stateName } }
|
|
274
|
+
labels: { some: { name: { startsWith: $agentLabelPrefix } } }
|
|
275
|
+
}
|
|
276
|
+
first: ${ISSUES_PAGE_SIZE}
|
|
277
|
+
after: $after
|
|
278
|
+
includeArchived: false
|
|
279
|
+
) {
|
|
280
|
+
nodes { id }
|
|
281
|
+
pageInfo { hasNextPage endCursor }
|
|
282
|
+
}
|
|
283
|
+
}`, {
|
|
284
|
+
slugId: config.linear.slugId,
|
|
285
|
+
stateName: config.linear.statuses.inProgress,
|
|
286
|
+
agentLabelPrefix: AGENT_LABEL_PREFIX,
|
|
287
|
+
after,
|
|
288
|
+
});
|
|
289
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- shape is fixed by our GraphQL query above
|
|
290
|
+
const { issues: page } = response.data;
|
|
291
|
+
count += page.nodes.length;
|
|
292
|
+
if (!page.pageInfo.hasNextPage) {
|
|
293
|
+
return count;
|
|
294
|
+
}
|
|
295
|
+
after = page.pageInfo.endCursor;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
export function resolveRepositoryFor(arguments_) {
|
|
299
|
+
const { description, config } = arguments_;
|
|
300
|
+
if (description === undefined || description.length === 0) {
|
|
301
|
+
return { kind: "missing" };
|
|
302
|
+
}
|
|
303
|
+
const repository = buildRepositoryRegex(config).exec(description)?.[1];
|
|
304
|
+
if (repository === undefined) {
|
|
305
|
+
return { kind: "missing" };
|
|
306
|
+
}
|
|
307
|
+
return { kind: "ok", repository };
|
|
308
|
+
}
|
|
309
|
+
export function resolveModelFor(arguments_) {
|
|
310
|
+
const { labels, config } = arguments_;
|
|
311
|
+
const parsed = parseAgentLabels(labels, config);
|
|
312
|
+
if (parsed === undefined) {
|
|
313
|
+
return { kind: "no-label" };
|
|
314
|
+
}
|
|
315
|
+
if (parsed.model === AGENT_ANY_MODEL) {
|
|
316
|
+
return { kind: "agent-any" };
|
|
317
|
+
}
|
|
318
|
+
if (parsed.disabledFallback !== undefined) {
|
|
319
|
+
return {
|
|
320
|
+
kind: "disabled-fallback",
|
|
321
|
+
requestedModel: parsed.disabledFallback,
|
|
322
|
+
fallbackModel: parsed.model,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return { kind: "matched", model: parsed.model };
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* `agent-any` collapses to `models.default` here — manual setup doesn't run
|
|
329
|
+
* the usage-gated `any` resolver, so the caller gets a concrete model name
|
|
330
|
+
* instead of a sentinel that downstream code can't interpret.
|
|
331
|
+
*/
|
|
332
|
+
export async function fetchResolvedIssue(arguments_) {
|
|
333
|
+
const { client, config, ticket } = arguments_;
|
|
334
|
+
const raw = await fetchRawLinearIssue({ client, ticket });
|
|
335
|
+
const repositoryResolution = resolveRepositoryFor({
|
|
336
|
+
description: raw.description,
|
|
203
337
|
config,
|
|
204
|
-
repositoryRegex: buildRepositoryRegex(config),
|
|
205
338
|
ticket: ticket.toUpperCase(),
|
|
206
339
|
});
|
|
340
|
+
if (repositoryResolution.kind === "missing") {
|
|
341
|
+
throw new RepositoryResolutionError({
|
|
342
|
+
ticket: ticket.toUpperCase(),
|
|
343
|
+
repositories: config.workspace.knownRepositories,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
207
346
|
// Manual setup is an explicit per-ticket opt-in by the user, so an
|
|
208
347
|
// unlabeled ticket still resolves to `models.default` — different from
|
|
209
348
|
// the auto-pickup path, where unlabeled tickets are ignored.
|
|
210
|
-
const
|
|
211
|
-
warnIfDisabledFallback(ticket,
|
|
212
|
-
|
|
349
|
+
const modelResolution = resolveModelFor({ labels: raw.labels, config });
|
|
350
|
+
warnIfDisabledFallback(ticket, modelResolution, config);
|
|
351
|
+
let model = config.models.default;
|
|
352
|
+
if (modelResolution.kind === "matched") {
|
|
353
|
+
({ model } = modelResolution);
|
|
354
|
+
}
|
|
355
|
+
else if (modelResolution.kind === "disabled-fallback") {
|
|
356
|
+
model = modelResolution.fallbackModel;
|
|
357
|
+
}
|
|
213
358
|
return {
|
|
214
|
-
uuid:
|
|
215
|
-
title:
|
|
216
|
-
description,
|
|
217
|
-
repository,
|
|
359
|
+
uuid: raw.uuid,
|
|
360
|
+
title: raw.title,
|
|
361
|
+
description: raw.description,
|
|
362
|
+
repository: repositoryResolution.repository,
|
|
218
363
|
model,
|
|
219
|
-
teamId:
|
|
364
|
+
teamId: raw.teamId,
|
|
220
365
|
};
|
|
221
366
|
}
|
|
222
367
|
function parseRepository(arguments_) {
|
|
@@ -278,11 +423,11 @@ function parseAgentLabels(labels, config) {
|
|
|
278
423
|
}
|
|
279
424
|
return fallback;
|
|
280
425
|
}
|
|
281
|
-
function warnIfDisabledFallback(ticket,
|
|
282
|
-
if (
|
|
426
|
+
function warnIfDisabledFallback(ticket, modelResolution, config) {
|
|
427
|
+
if (modelResolution.kind !== "disabled-fallback") {
|
|
283
428
|
return;
|
|
284
429
|
}
|
|
285
|
-
log(`${ticket.toLowerCase()}: agent-${
|
|
430
|
+
log(`${ticket.toLowerCase()}: agent-${modelResolution.requestedModel} label refers to a disabled model; falling back to models.default (${config.models.default})`);
|
|
286
431
|
}
|
|
287
432
|
function blockersFromRelations(relations) {
|
|
288
433
|
return relations
|
package/package.json
CHANGED