@clipboard-health/groundcrew 4.2.4 → 4.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 +21 -36
- package/dist/commands/dispatcher.d.ts.map +1 -1
- package/dist/commands/dispatcher.js +5 -1
- package/dist/commands/setupWorkspace.d.ts +2 -0
- package/dist/commands/setupWorkspace.d.ts.map +1 -1
- package/dist/commands/setupWorkspace.js +11 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +332 -52
- package/dist/commands/upgrade.d.ts +1 -0
- package/dist/commands/upgrade.d.ts.map +1 -1
- package/dist/commands/upgrade.js +57 -8
- package/dist/lib/adapters/linear/factory.d.ts.map +1 -1
- package/dist/lib/adapters/linear/factory.js +2 -0
- package/dist/lib/adapters/linear/fetch.d.ts +5 -0
- package/dist/lib/adapters/linear/fetch.d.ts.map +1 -1
- package/dist/lib/adapters/linear/fetch.js +6 -0
- package/dist/lib/adapters/shell/factory.d.ts.map +1 -1
- package/dist/lib/adapters/shell/factory.js +1 -0
- package/dist/lib/adapters/shell/schema.d.ts +2 -0
- package/dist/lib/adapters/shell/schema.d.ts.map +1 -1
- package/dist/lib/adapters/shell/schema.js +5 -0
- package/dist/lib/npmGlobal.d.ts +3 -2
- package/dist/lib/npmGlobal.d.ts.map +1 -1
- package/dist/lib/npmGlobal.js +9 -8
- package/dist/lib/pullRequests.d.ts +23 -0
- package/dist/lib/pullRequests.d.ts.map +1 -0
- package/dist/lib/pullRequests.js +74 -0
- package/dist/lib/runState.d.ts +15 -0
- package/dist/lib/runState.d.ts.map +1 -1
- package/dist/lib/runState.js +11 -0
- package/dist/lib/ticketSource.d.ts +7 -0
- package/dist/lib/ticketSource.d.ts.map +1 -1
- package/dist/lib/workspaces.d.ts +2 -1
- package/dist/lib/workspaces.d.ts.map +1 -1
- package/dist/lib/workspaces.js +5 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,28 +20,17 @@
|
|
|
20
20
|
$ crew status HRD-446
|
|
21
21
|
groundcrew status HRD-446
|
|
22
22
|
========================
|
|
23
|
-
ticket: hrd-446
|
|
23
|
+
ticket: hrd-446 in-progress https://linear.app/example/issue/HRD-446
|
|
24
|
+
title: Add retry logic to the sync job
|
|
25
|
+
run: running; model=claude; updated=2026-05-26T00:01:00.000Z; resumes=0
|
|
26
|
+
workspace: live
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
projectDir: /dev/workspaces
|
|
28
|
-
repositories: owner/repo
|
|
29
|
-
git: remote=origin; defaultBranch=main
|
|
30
|
-
workspaceKind: auto
|
|
31
|
-
|
|
32
|
-
Worktree state
|
|
33
|
-
--------------
|
|
28
|
+
Worktrees
|
|
29
|
+
---------
|
|
34
30
|
- owner/repo host
|
|
35
31
|
branch: rocky-hrd-446
|
|
32
|
+
dir: /dev/workspaces/owner/repo-hrd-446
|
|
36
33
|
git: dirty (2 modified, 1 untracked)
|
|
37
|
-
|
|
38
|
-
Workspace probe
|
|
39
|
-
---------------
|
|
40
|
-
live: yes
|
|
41
|
-
|
|
42
|
-
Last Linear status
|
|
43
|
-
------------------
|
|
44
|
-
In Progress (state.type=started) — Add retry logic to the sync job
|
|
45
34
|
```
|
|
46
35
|
|
|
47
36
|
## Why
|
|
@@ -217,9 +206,9 @@ Replace `claude` with the sbx agent for the model and `<projectDir>` with `works
|
|
|
217
206
|
|
|
218
207
|
## Inspecting status
|
|
219
208
|
|
|
220
|
-
`crew status <TICKET>` prints a read-only snapshot for one ticket:
|
|
209
|
+
`crew status <TICKET>` prints a read-only snapshot for one ticket: cached title/URL when present, recorded run state, live workspace presence, matching worktrees, git dirtiness, PR links for matching branches, recent log lines when present, and the ticket status from the configured ticket source. It does not recover, tear down, resume, or mutate any local/remote state.
|
|
221
210
|
|
|
222
|
-
`crew status` with no ticket prints the current inventory: known worktrees with workspace/run-state
|
|
211
|
+
`crew status` with no ticket prints the current inventory: known worktrees with cached ticket metadata, workspace/run-state agreement, attach hints, worktree paths, PR links, and stray sessions reported by the configured backend. Local worktree/session diagnostics are printed before ticket-source fetches complete; when the source fetch succeeds, status also prints slot usage plus Queue/Blocked sections for eligible Todo tickets. If the source fetch fails, Queue shows `unavailable: <reason>` and the slots line is omitted.
|
|
223
212
|
|
|
224
213
|
Use `crew cleanup <TICKET>` to tear down stale worktrees and `crew resume <TICKET>` to reopen preserved work. Status is intentionally informational only.
|
|
225
214
|
|
|
@@ -233,26 +222,22 @@ Use `crew cleanup <TICKET>` to tear down stale worktrees and `crew resume <TICKE
|
|
|
233
222
|
```text
|
|
234
223
|
groundcrew status HRD-442
|
|
235
224
|
=========================
|
|
236
|
-
ticket: hrd-442
|
|
225
|
+
ticket: hrd-442 in-progress https://linear.app/example/issue/HRD-442
|
|
226
|
+
title: Multi-event extractor: year inference can produce date_start > date_end
|
|
227
|
+
run: running; model=claude; updated=2026-05-26T00:01:00.000Z; resumes=0
|
|
228
|
+
workspace: live
|
|
237
229
|
|
|
238
|
-
|
|
230
|
+
Worktrees
|
|
239
231
|
---------
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
Worktree
|
|
243
|
-
--------
|
|
244
|
-
- herds-social host
|
|
232
|
+
- herds-social/herds host
|
|
245
233
|
branch: paul-hrd-442
|
|
246
|
-
dir: /
|
|
234
|
+
dir: /dev/workspaces/herds-social/herds-hrd-442
|
|
247
235
|
git: dirty (0 modified, 1 untracked)
|
|
236
|
+
pr: https://github.com/herds-social/herds/pull/224 (open)
|
|
248
237
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
Last Linear status
|
|
254
|
-
------------------
|
|
255
|
-
In Progress (state.type=started) — Multi-event extractor: year inference can produce date_start > date_end
|
|
238
|
+
Recent logs
|
|
239
|
+
-----------
|
|
240
|
+
[10:15:30] Workspace "hrd-442" launched
|
|
256
241
|
```
|
|
257
242
|
|
|
258
243
|
</details>
|
|
@@ -463,7 +448,7 @@ op run --env-file .env.1password -- crew doctor
|
|
|
463
448
|
|
|
464
449
|
## Troubleshooting
|
|
465
450
|
|
|
466
|
-
First stop for "what exists locally right now": `crew status <ticket>` shows the ticket's worktrees, workspace presence, run state, logs, and
|
|
451
|
+
First stop for "what exists locally right now": `crew status <ticket>` shows the ticket's worktrees, workspace presence, run state, logs, and ticket-source status. Use `crew doctor` when you need to verify host setup.
|
|
467
452
|
|
|
468
453
|
<details>
|
|
469
454
|
<summary>Safehouse-already-wrapped commands are not re-wrapped</summary>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/commands/dispatcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,EACL,KAAK,UAAU,EAGf,KAAK,KAAK,EAEX,MAAM,wBAAwB,CAAC;AAChC,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,KAAK,EAAE,KAAK,CAAC;CACd;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;QACrB;;;;WAIG;QACH,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAaD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,UAAU,
|
|
1
|
+
{"version":3,"file":"dispatcher.d.ts","sourceRoot":"","sources":["../../src/commands/dispatcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,EACL,KAAK,UAAU,EAGf,KAAK,KAAK,EAEX,MAAM,wBAAwB,CAAC;AAChC,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,KAAK,EAAE,KAAK,CAAC;CACd;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;QACrB;;;;WAIG;QACH,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAaD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,UAAU,CAqNjE;AAUD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,KAAK,EAAE,GAAG,MAAM,CAQrE"}
|
|
@@ -60,7 +60,11 @@ export function createDispatcher(deps) {
|
|
|
60
60
|
repository: issue.repository,
|
|
61
61
|
ticket: ticketId,
|
|
62
62
|
model: issue.model,
|
|
63
|
-
details: {
|
|
63
|
+
details: {
|
|
64
|
+
title: issue.title,
|
|
65
|
+
description: issue.description,
|
|
66
|
+
...(issue.url === undefined ? {} : { url: issue.url }),
|
|
67
|
+
},
|
|
64
68
|
};
|
|
65
69
|
await (signal === undefined
|
|
66
70
|
? setupWorkspace(config, setupOptions)
|
|
@@ -2,6 +2,8 @@ import { type ResolvedConfig } from "../lib/config.ts";
|
|
|
2
2
|
export interface TicketDetails {
|
|
3
3
|
title: string;
|
|
4
4
|
description: string;
|
|
5
|
+
/** Direct web URL for the ticket; cached into RunState when present. */
|
|
6
|
+
url?: string;
|
|
5
7
|
}
|
|
6
8
|
export interface SetupWorkspaceOptions {
|
|
7
9
|
ticket: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAiBnE,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAiBnE,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAuBD,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CA6Gf;AAqHD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CA4Cf"}
|
|
@@ -102,6 +102,8 @@ export async function setupWorkspace(config, options, runOptions = {}) {
|
|
|
102
102
|
branchName,
|
|
103
103
|
workspaceName: ticket,
|
|
104
104
|
state: "running",
|
|
105
|
+
title: ticketDetails.title,
|
|
106
|
+
...(ticketDetails.url === undefined ? {} : { url: ticketDetails.url }),
|
|
105
107
|
});
|
|
106
108
|
log(`Workspace "${ticket}" launched (${model})`);
|
|
107
109
|
log(` Worktree: ${launchDir}`);
|
|
@@ -122,6 +124,8 @@ export async function setupWorkspace(config, options, runOptions = {}) {
|
|
|
122
124
|
workspaceName: ticket,
|
|
123
125
|
state: "failed-to-launch",
|
|
124
126
|
detail: errorMessage(error),
|
|
127
|
+
title: options.details.title,
|
|
128
|
+
...(options.details.url === undefined ? {} : { url: options.details.url }),
|
|
125
129
|
});
|
|
126
130
|
throw error;
|
|
127
131
|
}
|
|
@@ -167,7 +171,9 @@ function recordRunStateBestEffort(arguments_) {
|
|
|
167
171
|
branchName: arguments_.branchName,
|
|
168
172
|
workspaceName: arguments_.workspaceName,
|
|
169
173
|
state: arguments_.state,
|
|
174
|
+
title: arguments_.title,
|
|
170
175
|
...(arguments_.detail === undefined ? {} : { detail: arguments_.detail }),
|
|
176
|
+
...(arguments_.url === undefined ? {} : { url: arguments_.url }),
|
|
171
177
|
},
|
|
172
178
|
});
|
|
173
179
|
}
|
|
@@ -242,7 +248,11 @@ export async function setupWorkspaceCli(ticket, options = {}) {
|
|
|
242
248
|
ticket: naturalId,
|
|
243
249
|
repository: resolved.repository,
|
|
244
250
|
model: resolved.model,
|
|
245
|
-
details: {
|
|
251
|
+
details: {
|
|
252
|
+
title: resolved.title,
|
|
253
|
+
description: resolved.description,
|
|
254
|
+
...(resolved.url === undefined ? {} : { url: resolved.url }),
|
|
255
|
+
},
|
|
246
256
|
});
|
|
247
257
|
await board.markInProgress(resolved);
|
|
248
258
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AAIA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AAIA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAanE,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA6gBD,wBAAsB,MAAM,CAAC,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/F;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAI7D"}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createBoard } from "../lib/board.js";
|
|
3
|
+
import { buildSources, sourcesFromConfig } from "../lib/buildSources.js";
|
|
4
4
|
import { loadConfig } from "../lib/config.js";
|
|
5
|
+
import { findPullRequestsForBranch } from "../lib/pullRequests.js";
|
|
5
6
|
import { readRunState } from "../lib/runState.js";
|
|
7
|
+
import { isGroundcrewIssue, naturalIdFromCanonical, } from "../lib/ticketSource.js";
|
|
6
8
|
import { errorMessage, withLogOutputSuppressed, writeOutput } from "../lib/util.js";
|
|
7
9
|
import { workspaces } from "../lib/workspaces.js";
|
|
8
10
|
import { worktrees } from "../lib/worktrees.js";
|
|
@@ -25,16 +27,6 @@ function writeSection(title) {
|
|
|
25
27
|
writeOutput(title);
|
|
26
28
|
writeOutput("-".repeat(title.length));
|
|
27
29
|
}
|
|
28
|
-
function writeConfigSnapshot(config) {
|
|
29
|
-
writeSection("Config snapshot");
|
|
30
|
-
writeOutput(`projectDir: ${config.workspace.projectDir}`);
|
|
31
|
-
writeOutput(`repositories: ${config.workspace.knownRepositories.join(", ")}`);
|
|
32
|
-
writeOutput(`git: remote=${config.git.remote}; defaultBranch=${config.git.defaultBranch}`);
|
|
33
|
-
writeOutput(`workspaceKind: ${config.workspaceKind}`);
|
|
34
|
-
writeOutput(`local.runner: ${config.local.runner}`);
|
|
35
|
-
writeOutput(`models: default=${config.models.default}; enabled=${Object.keys(config.models.definitions).join(", ")}`);
|
|
36
|
-
writeOutput(`logFile: ${config.logging.file}`);
|
|
37
|
-
}
|
|
38
30
|
function formatDirtiness(dirtiness) {
|
|
39
31
|
if (dirtiness.kind === "dirty") {
|
|
40
32
|
return `dirty (${dirtiness.modified} modified, ${dirtiness.untracked} untracked)`;
|
|
@@ -42,7 +34,7 @@ function formatDirtiness(dirtiness) {
|
|
|
42
34
|
return dirtiness.kind;
|
|
43
35
|
}
|
|
44
36
|
async function writeTicketWorktrees(config, ticket) {
|
|
45
|
-
writeSection("
|
|
37
|
+
writeSection("Worktrees");
|
|
46
38
|
const entries = worktrees.findByTicket(config, ticket);
|
|
47
39
|
if (entries.length === 0) {
|
|
48
40
|
writeOutput("(none)");
|
|
@@ -50,12 +42,21 @@ async function writeTicketWorktrees(config, ticket) {
|
|
|
50
42
|
}
|
|
51
43
|
for (const entry of entries) {
|
|
52
44
|
// oxlint-disable-next-line no-await-in-loop -- status output is easier to read in worktree order.
|
|
53
|
-
const dirtiness = await worktrees.probeWorkingTree({
|
|
45
|
+
const dirtiness = await worktrees.probeWorkingTree({
|
|
46
|
+
worktreeDir: entry.dir,
|
|
47
|
+
});
|
|
48
|
+
// oxlint-disable-next-line no-await-in-loop -- one gh lookup per worktree is acceptable; multi-worktree-per-ticket is rare.
|
|
49
|
+
const prs = await findPullRequestsForBranch({
|
|
50
|
+
repository: entry.repository,
|
|
51
|
+
branchName: entry.branchName,
|
|
52
|
+
});
|
|
54
53
|
writeOutput(`- ${entry.repository} ${entry.kind}`);
|
|
55
|
-
writeOutput(` ticket: ${entry.ticket}`);
|
|
56
54
|
writeOutput(` branch: ${entry.branchName}`);
|
|
57
55
|
writeOutput(` dir: ${entry.dir}`);
|
|
58
56
|
writeOutput(` git: ${formatDirtiness(dirtiness)}`);
|
|
57
|
+
if (prs.length > 0) {
|
|
58
|
+
writeOutput(` pr: ${formatPullRequests(prs)}`);
|
|
59
|
+
}
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
function workspaceProbeUnavailableLine(probe) {
|
|
@@ -63,13 +64,11 @@ function workspaceProbeUnavailableLine(probe) {
|
|
|
63
64
|
? "Workspace probe unavailable"
|
|
64
65
|
: `Workspace probe unavailable: ${errorMessage(probe.error)}`;
|
|
65
66
|
}
|
|
66
|
-
function
|
|
67
|
-
writeSection("Workspace probe");
|
|
67
|
+
function ticketWorkspaceText(probe, ticket) {
|
|
68
68
|
if (probe.kind === "unavailable") {
|
|
69
|
-
|
|
70
|
-
return;
|
|
69
|
+
return workspaceProbeUnavailableLine(probe);
|
|
71
70
|
}
|
|
72
|
-
|
|
71
|
+
return probe.names.has(ticket) ? "live" : "not live";
|
|
73
72
|
}
|
|
74
73
|
function formatRunState(state) {
|
|
75
74
|
if (state === undefined) {
|
|
@@ -93,15 +92,56 @@ function recentTicketLogLines(config, ticket) {
|
|
|
93
92
|
.filter((line) => pattern.test(line))
|
|
94
93
|
.slice(-RECENT_LOG_LINE_COUNT);
|
|
95
94
|
}
|
|
96
|
-
async function
|
|
95
|
+
async function resolveTicketSource(config, ticket) {
|
|
96
|
+
const board = await buildBoardForStatus(config);
|
|
97
|
+
return await withLogOutputSuppressed(async () => await board.resolveOne(ticket));
|
|
98
|
+
}
|
|
99
|
+
async function readTicketSourceStatus(config, ticket) {
|
|
97
100
|
try {
|
|
98
|
-
const issue = await
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
const issue = await resolveTicketSource(config, ticket);
|
|
102
|
+
if (issue === undefined) {
|
|
103
|
+
return { kind: "not-found" };
|
|
104
|
+
}
|
|
105
|
+
return { kind: "found", issue };
|
|
102
106
|
}
|
|
103
107
|
catch (error) {
|
|
104
|
-
return
|
|
108
|
+
return { kind: "unavailable", reason: errorMessage(error) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function writeRecentLogs(config, ticket) {
|
|
112
|
+
const logLines = recentTicketLogLines(config, ticket);
|
|
113
|
+
if (logLines.length === 0) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
writeSection("Recent logs");
|
|
117
|
+
writeOutput(logLines.join("\n"));
|
|
118
|
+
}
|
|
119
|
+
function formatTicketLine(ticket, runState, sourceStatus) {
|
|
120
|
+
const parts = [`ticket: ${ticket}`];
|
|
121
|
+
if (sourceStatus.kind === "found") {
|
|
122
|
+
parts.push(sourceStatus.issue.status);
|
|
123
|
+
}
|
|
124
|
+
const url = sourceStatus.kind === "found" ? (sourceStatus.issue.url ?? runState?.url) : runState?.url;
|
|
125
|
+
if (url !== undefined) {
|
|
126
|
+
parts.push(url);
|
|
127
|
+
}
|
|
128
|
+
if (sourceStatus.kind === "not-found") {
|
|
129
|
+
parts.push("source not found");
|
|
130
|
+
}
|
|
131
|
+
if (sourceStatus.kind === "unavailable") {
|
|
132
|
+
parts.push(`source unavailable: ${sourceStatus.reason}`);
|
|
133
|
+
}
|
|
134
|
+
return parts.join(" ");
|
|
135
|
+
}
|
|
136
|
+
function writeTicketTitle(runState, sourceStatus) {
|
|
137
|
+
const cachedTitle = runState?.title;
|
|
138
|
+
const sourceTitle = sourceStatus.kind === "found" ? sourceStatus.issue.title : undefined;
|
|
139
|
+
const title = cachedTitle ?? sourceTitle;
|
|
140
|
+
if (title !== undefined) {
|
|
141
|
+
writeOutput(`title: ${title}`);
|
|
142
|
+
}
|
|
143
|
+
if (cachedTitle !== undefined && sourceTitle !== undefined && cachedTitle !== sourceTitle) {
|
|
144
|
+
writeOutput(`source title: ${sourceTitle}`);
|
|
105
145
|
}
|
|
106
146
|
}
|
|
107
147
|
async function writeTicketStatus(config, rawTicket) {
|
|
@@ -109,26 +149,118 @@ async function writeTicketStatus(config, rawTicket) {
|
|
|
109
149
|
const displayTicket = ticket.toUpperCase();
|
|
110
150
|
writeOutput(`groundcrew status ${displayTicket}`);
|
|
111
151
|
writeOutput("=".repeat(`groundcrew status ${displayTicket}`.length));
|
|
112
|
-
|
|
113
|
-
|
|
152
|
+
const runState = readRunState(config, ticket);
|
|
153
|
+
const [workspaceProbe, sourceStatus] = await Promise.all([
|
|
154
|
+
withLogOutputSuppressed(async () => await workspaces.probe(config)),
|
|
155
|
+
readTicketSourceStatus(config, ticket),
|
|
156
|
+
]);
|
|
157
|
+
writeOutput(formatTicketLine(ticket, runState, sourceStatus));
|
|
158
|
+
writeTicketTitle(runState, sourceStatus);
|
|
159
|
+
writeOutput(`run: ${formatRunState(runState)}`);
|
|
160
|
+
writeOutput(`workspace: ${ticketWorkspaceText(workspaceProbe, ticket)}`);
|
|
114
161
|
await writeTicketWorktrees(config, ticket);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
162
|
+
writeRecentLogs(config, ticket);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Wall-clock elapsed time since the run was first recorded (RunState.createdAt
|
|
166
|
+
* is preserved across resume/interrupt). Returns undefined when the row isn't
|
|
167
|
+
* actively running, when no run state exists, or when the timestamp cannot
|
|
168
|
+
* be parsed.
|
|
169
|
+
*/
|
|
170
|
+
function runStateDurationMs(runState, now) {
|
|
171
|
+
if (runState === undefined) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
if (runState.state !== "running" && runState.state !== "resumed") {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
const created = Date.parse(runState.createdAt);
|
|
178
|
+
if (Number.isNaN(created)) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
return now.getTime() - created;
|
|
182
|
+
}
|
|
183
|
+
const MS_PER_MINUTE = 60_000;
|
|
184
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
185
|
+
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
186
|
+
function formatDuration(ms) {
|
|
187
|
+
if (ms < MS_PER_MINUTE) {
|
|
188
|
+
return "<1m";
|
|
189
|
+
}
|
|
190
|
+
if (ms < MS_PER_HOUR) {
|
|
191
|
+
return `${Math.floor(ms / MS_PER_MINUTE)}m`;
|
|
192
|
+
}
|
|
193
|
+
if (ms < MS_PER_DAY) {
|
|
194
|
+
const hours = Math.floor(ms / MS_PER_HOUR);
|
|
195
|
+
const minutes = Math.floor((ms - hours * MS_PER_HOUR) / MS_PER_MINUTE);
|
|
196
|
+
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
|
|
197
|
+
}
|
|
198
|
+
const days = Math.floor(ms / MS_PER_DAY);
|
|
199
|
+
const hours = Math.floor((ms - days * MS_PER_DAY) / MS_PER_HOUR);
|
|
200
|
+
return hours === 0 ? `${days}d` : `${days}d ${hours}h`;
|
|
124
201
|
}
|
|
125
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Combined human-readable state for the inventory row. Surfaces RunState
|
|
204
|
+
* lifecycle and flags the two interesting disagreements with the workspace
|
|
205
|
+
* probe — `(session dead)` when we recorded a running dispatch but no
|
|
206
|
+
* session is alive, and `(stray session)` when a session is alive without
|
|
207
|
+
* any recorded dispatch. `probe.kind === "unavailable"` is treated as
|
|
208
|
+
* "we don't know" and never produces a suffix. When the row is actively
|
|
209
|
+
* running, appends the elapsed wall-clock time since dispatch.
|
|
210
|
+
*/
|
|
211
|
+
function inventoryStateText(runState, probe, ticket, now) {
|
|
212
|
+
const lifecycle = runState?.state ?? "idle";
|
|
213
|
+
const duration = runStateDurationMs(runState, now);
|
|
214
|
+
const flags = [];
|
|
215
|
+
if (probe.kind === "ok") {
|
|
216
|
+
const sessionLive = probe.names.has(ticket);
|
|
217
|
+
if (lifecycle === "idle" && sessionLive) {
|
|
218
|
+
flags.push("stray session");
|
|
219
|
+
}
|
|
220
|
+
if ((lifecycle === "running" || lifecycle === "resumed") && !sessionLive) {
|
|
221
|
+
flags.push("session dead");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (duration !== undefined) {
|
|
225
|
+
flags.push(formatDuration(duration));
|
|
226
|
+
}
|
|
227
|
+
return flags.length === 0 ? lifecycle : `${lifecycle} (${flags.join(", ")})`;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Hint command for inventory rows where the run-state and the workspace
|
|
231
|
+
* probe disagree. Returned commands are safe defaults; the user is free to
|
|
232
|
+
* ignore them and use `attach:` + `pr:` to investigate first.
|
|
233
|
+
*
|
|
234
|
+
* - Stray session (live session, no run-state record) → `crew cleanup` to
|
|
235
|
+
* tear down the orphaned worktree + close the session.
|
|
236
|
+
* - Session dead (run-state says running/resumed, no live session) →
|
|
237
|
+
* `crew resume` to bring the agent back; the worktree is preserved.
|
|
238
|
+
*
|
|
239
|
+
* No hint when the probe is unavailable (we genuinely don't know whether
|
|
240
|
+
* there's a disagreement) or when the row is healthy.
|
|
241
|
+
*/
|
|
242
|
+
function inventoryHint(runState, probe, ticket) {
|
|
126
243
|
if (probe.kind === "unavailable") {
|
|
127
|
-
return
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
const lifecycle = runState?.state ?? "idle";
|
|
247
|
+
const sessionLive = probe.names.has(ticket);
|
|
248
|
+
if (lifecycle === "idle" && sessionLive) {
|
|
249
|
+
return `run 'crew cleanup ${ticket}' to clear this stray session`;
|
|
128
250
|
}
|
|
129
|
-
|
|
251
|
+
if ((lifecycle === "running" || lifecycle === "resumed") && !sessionLive) {
|
|
252
|
+
return `run 'crew resume ${ticket}' to bring the session back`;
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
const INVENTORY_LABEL_WIDTH = "worktree:".length;
|
|
257
|
+
function inventoryField(label, value) {
|
|
258
|
+
return ` ${`${label}:`.padEnd(INVENTORY_LABEL_WIDTH)} ${value}`;
|
|
259
|
+
}
|
|
260
|
+
function formatPullRequests(prs) {
|
|
261
|
+
return prs.map((pr) => `${pr.url} (${pr.state})`).join(", ");
|
|
130
262
|
}
|
|
131
|
-
function writeInventoryWorktrees(config, probe) {
|
|
263
|
+
async function writeInventoryWorktrees(config, probe) {
|
|
132
264
|
writeSection("Worktrees");
|
|
133
265
|
const entries = worktrees
|
|
134
266
|
.list(config)
|
|
@@ -137,31 +269,179 @@ function writeInventoryWorktrees(config, probe) {
|
|
|
137
269
|
writeOutput("(none)");
|
|
138
270
|
return;
|
|
139
271
|
}
|
|
272
|
+
const accessHints = await collectAccessHints(config, entries);
|
|
273
|
+
const pullRequests = await collectPullRequests(entries);
|
|
140
274
|
const runStates = new Map();
|
|
141
|
-
|
|
275
|
+
const now = new Date();
|
|
276
|
+
for (const [index, entry] of entries.entries()) {
|
|
142
277
|
if (!runStates.has(entry.ticket)) {
|
|
143
278
|
runStates.set(entry.ticket, readRunState(config, entry.ticket));
|
|
144
279
|
}
|
|
145
280
|
const runState = runStates.get(entry.ticket);
|
|
146
|
-
|
|
147
|
-
|
|
281
|
+
const accessHint = accessHints.get(entry.ticket);
|
|
282
|
+
// `collectPullRequests` guarantees an entry for every (repo, branch)
|
|
283
|
+
// pair seen in `entries`; the lookup always returns the array.
|
|
284
|
+
/* v8 ignore next @preserve -- defensive fallback for a Map key that collectPullRequests always populates */
|
|
285
|
+
const prs = pullRequests.get(pullRequestKey(entry.repository, entry.branchName)) ?? [];
|
|
286
|
+
if (index > 0) {
|
|
287
|
+
writeOutput();
|
|
288
|
+
}
|
|
289
|
+
writeOutput(runState?.url === undefined ? entry.ticket : `${entry.ticket} ${runState.url}`);
|
|
290
|
+
if (runState?.title !== undefined) {
|
|
291
|
+
writeOutput(inventoryField("title", runState.title));
|
|
292
|
+
}
|
|
293
|
+
writeOutput(inventoryField("state", inventoryStateText(runState, probe, entry.ticket, now)));
|
|
294
|
+
writeOutput(inventoryField("repo", entry.repository));
|
|
295
|
+
writeOutput(inventoryField("worktree", entry.dir));
|
|
296
|
+
if (accessHint !== undefined) {
|
|
297
|
+
writeOutput(inventoryField("attach", accessHint.command));
|
|
298
|
+
}
|
|
299
|
+
if (prs.length > 0) {
|
|
300
|
+
writeOutput(inventoryField("pr", formatPullRequests(prs)));
|
|
301
|
+
}
|
|
302
|
+
const hint = inventoryHint(runState, probe, entry.ticket);
|
|
303
|
+
if (hint !== undefined) {
|
|
304
|
+
writeOutput(inventoryField("hint", hint));
|
|
305
|
+
}
|
|
148
306
|
}
|
|
149
307
|
}
|
|
150
|
-
function
|
|
151
|
-
|
|
308
|
+
function pullRequestKey(repository, branchName) {
|
|
309
|
+
return `${repository} ${branchName}`;
|
|
310
|
+
}
|
|
311
|
+
async function collectAccessHints(config, entries) {
|
|
312
|
+
const uniqueTickets = [...new Set(entries.map((entry) => entry.ticket))];
|
|
313
|
+
const results = await Promise.allSettled(uniqueTickets.map(async (ticket) => await workspaces.accessHint(config, ticket)));
|
|
314
|
+
return new Map(uniqueTickets.map((ticket, index) => {
|
|
315
|
+
const result = results[index];
|
|
316
|
+
return [ticket, result?.status === "fulfilled" ? result.value : undefined];
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
async function collectPullRequests(entries) {
|
|
320
|
+
// Same-(repo, branch) entries collapse to one lookup; later inserts
|
|
321
|
+
// overwrite earlier ones with the same identifier, which is fine because
|
|
322
|
+
// gh would return the same PR list for both.
|
|
323
|
+
const uniqueKeys = new Map();
|
|
324
|
+
for (const entry of entries) {
|
|
325
|
+
uniqueKeys.set(pullRequestKey(entry.repository, entry.branchName), {
|
|
326
|
+
repository: entry.repository,
|
|
327
|
+
branchName: entry.branchName,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
const results = await Promise.allSettled([...uniqueKeys.entries()].map(async ([key, { repository, branchName }]) => {
|
|
331
|
+
const prs = await findPullRequestsForBranch({ repository, branchName });
|
|
332
|
+
return [key, prs];
|
|
333
|
+
}));
|
|
334
|
+
return new Map([...uniqueKeys.keys()].map((key, index) => {
|
|
335
|
+
const result = results[index];
|
|
336
|
+
return [key, result?.status === "fulfilled" ? result.value[1] : []];
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
function writeStraySessions(probe, worktreeTickets) {
|
|
152
340
|
if (probe.kind === "unavailable") {
|
|
341
|
+
// Surface probe failures so the user knows we couldn't classify strays
|
|
342
|
+
// (silently dropping the section would hide that diagnostic).
|
|
343
|
+
writeSection("Stray sessions");
|
|
153
344
|
writeOutput(workspaceProbeUnavailableLine(probe));
|
|
154
345
|
return;
|
|
155
346
|
}
|
|
156
|
-
const
|
|
157
|
-
|
|
347
|
+
const strays = [...probe.names].filter((name) => !worktreeTickets.has(name)).toSorted();
|
|
348
|
+
if (strays.length === 0) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
writeSection("Stray sessions");
|
|
352
|
+
writeOutput(strays.join("\n"));
|
|
353
|
+
}
|
|
354
|
+
function isTodoSourceIssue(issue) {
|
|
355
|
+
return issue.status === "todo";
|
|
356
|
+
}
|
|
357
|
+
function hasOpenBlocker(issue) {
|
|
358
|
+
return issue.blockers.some((b) => b.status !== "done");
|
|
359
|
+
}
|
|
360
|
+
function describeOpenBlockers(issue) {
|
|
361
|
+
return issue.blockers
|
|
362
|
+
.filter((b) => b.status !== "done")
|
|
363
|
+
.map((b) => `${naturalIdFromCanonical(b.id)} (${b.nativeStatus ?? b.status})`)
|
|
364
|
+
.join(", ");
|
|
365
|
+
}
|
|
366
|
+
function writeQueueIssue(issue) {
|
|
367
|
+
const naturalId = naturalIdFromCanonical(issue.id);
|
|
368
|
+
writeOutput(issue.url === undefined ? naturalId : `${naturalId} ${issue.url}`);
|
|
369
|
+
writeOutput(inventoryField("title", issue.title));
|
|
370
|
+
writeOutput(inventoryField("repo", issue.repository));
|
|
371
|
+
writeOutput(inventoryField("model", issue.model));
|
|
372
|
+
}
|
|
373
|
+
async function buildBoardForStatus(config) {
|
|
374
|
+
const sources = await buildSources(sourcesFromConfig(config), { globalConfig: config });
|
|
375
|
+
return createBoard(sources);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Single board fetch used by both the slot count header and the
|
|
379
|
+
* Queue/Blocked sections. `sourcesFromConfig` prepends an implicit Linear
|
|
380
|
+
* source when none are configured, so we always attempt; failures
|
|
381
|
+
* (e.g., missing API key) are captured and rendered later as
|
|
382
|
+
* `unavailable: ...` in the Queue section.
|
|
383
|
+
*/
|
|
384
|
+
async function fetchBoardForStatus(config) {
|
|
385
|
+
try {
|
|
386
|
+
const board = await buildBoardForStatus(config);
|
|
387
|
+
const { issues } = await withLogOutputSuppressed(async () => await board.fetch());
|
|
388
|
+
return { kind: "ok", issues };
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
return { kind: "error", error };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function writeQueueSections(boardResult) {
|
|
395
|
+
if (boardResult.kind === "error") {
|
|
396
|
+
writeSection("Queue");
|
|
397
|
+
writeOutput(`unavailable: ${errorMessage(boardResult.error)}`);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
// Only groundcrew-eligible Todos are dispatchable; non-eligible ones lack
|
|
401
|
+
// a repo or model, so `crew run` would skip them.
|
|
402
|
+
const todos = boardResult.issues.filter(isTodoSourceIssue).filter(isGroundcrewIssue);
|
|
403
|
+
const ready = todos.filter((i) => !hasOpenBlocker(i));
|
|
404
|
+
const blocked = todos.filter(hasOpenBlocker);
|
|
405
|
+
// Hide the section entirely when nothing's queued and nothing's blocked.
|
|
406
|
+
if (ready.length > 0) {
|
|
407
|
+
writeSection("Queue");
|
|
408
|
+
for (const [index, issue] of ready.entries()) {
|
|
409
|
+
if (index > 0) {
|
|
410
|
+
writeOutput();
|
|
411
|
+
}
|
|
412
|
+
writeQueueIssue(issue);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (blocked.length > 0) {
|
|
416
|
+
writeSection("Blocked");
|
|
417
|
+
for (const [index, issue] of blocked.entries()) {
|
|
418
|
+
if (index > 0) {
|
|
419
|
+
writeOutput();
|
|
420
|
+
}
|
|
421
|
+
writeQueueIssue(issue);
|
|
422
|
+
writeOutput(inventoryField("blocked by", describeOpenBlockers(issue)));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function inProgressCount(issues) {
|
|
427
|
+
return issues.filter((issue) => issue.status === "in-progress").length;
|
|
158
428
|
}
|
|
159
429
|
async function writeInventoryStatus(config) {
|
|
160
|
-
|
|
161
|
-
|
|
430
|
+
// Banner ("groundcrew status\n=================") dropped: the command
|
|
431
|
+
// you just ran already tells you what report you're looking at, and the
|
|
432
|
+
// section headers (`Worktrees`, `Queue`, etc.) carry the visual anchors.
|
|
433
|
+
const boardResultPromise = fetchBoardForStatus(config);
|
|
162
434
|
const probe = await withLogOutputSuppressed(async () => await workspaces.probe(config));
|
|
163
|
-
writeInventoryWorktrees(config, probe);
|
|
164
|
-
|
|
435
|
+
await writeInventoryWorktrees(config, probe);
|
|
436
|
+
const worktreeTickets = new Set(worktrees.list(config).map((entry) => entry.ticket));
|
|
437
|
+
writeStraySessions(probe, worktreeTickets);
|
|
438
|
+
const boardResult = await boardResultPromise;
|
|
439
|
+
if (boardResult.kind === "ok") {
|
|
440
|
+
const used = inProgressCount(boardResult.issues);
|
|
441
|
+
writeOutput();
|
|
442
|
+
writeOutput(`slots: ${used}/${config.orchestrator.maximumInProgress} used`);
|
|
443
|
+
}
|
|
444
|
+
writeQueueSections(boardResult);
|
|
165
445
|
}
|
|
166
446
|
export async function status(config, options = {}) {
|
|
167
447
|
const ticket = options.ticket?.trim();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upgrade.d.ts","sourceRoot":"","sources":["../../src/commands/upgrade.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"upgrade.d.ts","sourceRoot":"","sources":["../../src/commands/upgrade.ts"],"names":[],"mappings":"AAKA,OAAO,EAML,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,qBAAqB,CAAC;AAK7B,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,OAAO,CAAC,qBAAqB,CAAC,CAAC;IACrD,UAAU,EAAE,CAAC,OAAO,EAAE;QACpB,WAAW,EAAE,MAAM,CAAC;QACpB,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;IAC5B,oBAAoB,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CACnE;AAED,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,WAAW,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAYD,MAAM,MAAM,sBAAsB,GAAG,iBAAiB,GAAG,CAAC,MAAM,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC;AAiD5F,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,EAAE,EACd,YAAY,EAAE,sBAAsB,GACnC,OAAO,CAAC,IAAI,CAAC,CAkBf;AAmED,MAAM,WAAW,wBAAwB;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,8BAA8B,CAClD,IAAI,EAAE,wBAAwB,GAC7B,OAAO,CAAC,iBAAiB,CAAC,CAsB5B"}
|