@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.
Files changed (36) hide show
  1. package/README.md +21 -36
  2. package/dist/commands/dispatcher.d.ts.map +1 -1
  3. package/dist/commands/dispatcher.js +5 -1
  4. package/dist/commands/setupWorkspace.d.ts +2 -0
  5. package/dist/commands/setupWorkspace.d.ts.map +1 -1
  6. package/dist/commands/setupWorkspace.js +11 -1
  7. package/dist/commands/status.d.ts.map +1 -1
  8. package/dist/commands/status.js +332 -52
  9. package/dist/commands/upgrade.d.ts +1 -0
  10. package/dist/commands/upgrade.d.ts.map +1 -1
  11. package/dist/commands/upgrade.js +57 -8
  12. package/dist/lib/adapters/linear/factory.d.ts.map +1 -1
  13. package/dist/lib/adapters/linear/factory.js +2 -0
  14. package/dist/lib/adapters/linear/fetch.d.ts +5 -0
  15. package/dist/lib/adapters/linear/fetch.d.ts.map +1 -1
  16. package/dist/lib/adapters/linear/fetch.js +6 -0
  17. package/dist/lib/adapters/shell/factory.d.ts.map +1 -1
  18. package/dist/lib/adapters/shell/factory.js +1 -0
  19. package/dist/lib/adapters/shell/schema.d.ts +2 -0
  20. package/dist/lib/adapters/shell/schema.d.ts.map +1 -1
  21. package/dist/lib/adapters/shell/schema.js +5 -0
  22. package/dist/lib/npmGlobal.d.ts +3 -2
  23. package/dist/lib/npmGlobal.d.ts.map +1 -1
  24. package/dist/lib/npmGlobal.js +9 -8
  25. package/dist/lib/pullRequests.d.ts +23 -0
  26. package/dist/lib/pullRequests.d.ts.map +1 -0
  27. package/dist/lib/pullRequests.js +74 -0
  28. package/dist/lib/runState.d.ts +15 -0
  29. package/dist/lib/runState.d.ts.map +1 -1
  30. package/dist/lib/runState.js +11 -0
  31. package/dist/lib/ticketSource.d.ts +7 -0
  32. package/dist/lib/ticketSource.d.ts.map +1 -1
  33. package/dist/lib/workspaces.d.ts +2 -1
  34. package/dist/lib/workspaces.d.ts.map +1 -1
  35. package/dist/lib/workspaces.js +5 -4
  36. 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
- Config snapshot
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: resolved config, matching worktrees, workspace probe result, recorded run state, recent log lines for that ticket, and the latest Linear status. It does not fetch, recover, tear down, resume, or mutate any local/remote state.
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 presence plus live workspaces reported by the configured backend.
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
- Run state
230
+ Worktrees
239
231
  ---------
240
- running; model=claude; updated=2026-05-26T00:01:00.000Z; resumes=0
241
-
242
- Worktree
243
- --------
244
- - herds-social host
232
+ - herds-social/herds host
245
233
  branch: paul-hrd-442
246
- dir: /Users/paul/dev/groundcrew-workspaces/herds-social/herds-hrd-442
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
- Workspace
250
- ---------
251
- live: yes
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 latest Linear status. Use `crew doctor` when you need to verify host setup.
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,CAiNjE;AAUD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,KAAK,EAAE,GAAG,MAAM,CAQrE"}
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: { title: issue.title, description: issue.description },
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;CACrB;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,CAyGf;AAiHD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAwCf"}
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: { title: resolved.title, description: resolved.description },
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;AAMnE,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAyLD,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"}
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"}
@@ -1,8 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { getLinearClient } from "../lib/adapters/linear/client.js";
3
- import { fetchRawLinearIssue } from "../lib/adapters/linear/fetch.js";
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("Worktree state");
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({ worktreeDir: entry.dir });
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 writeTicketWorkspace(probe, ticket) {
67
- writeSection("Workspace probe");
67
+ function ticketWorkspaceText(probe, ticket) {
68
68
  if (probe.kind === "unavailable") {
69
- writeOutput(workspaceProbeUnavailableLine(probe));
70
- return;
69
+ return workspaceProbeUnavailableLine(probe);
71
70
  }
72
- writeOutput(`live: ${probe.names.has(ticket) ? "yes" : "no"}`);
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 linearStatus(ticket) {
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 fetchRawLinearIssue({ client: getLinearClient(), ticket });
99
- // `stateType` is "" when Linear returned a stateless ticket; surface that
100
- // as "unknown" rather than an empty token.
101
- return `${issue.stateName} (state.type=${issue.stateType || "unknown"}) — ${issue.title}`;
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 `unavailable: ${errorMessage(error)}`;
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
- writeOutput(`ticket: ${ticket}`);
113
- writeConfigSnapshot(config);
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
- const workspaceProbe = await withLogOutputSuppressed(async () => await workspaces.probe(config));
116
- writeTicketWorkspace(workspaceProbe, ticket);
117
- writeSection("Run state");
118
- writeOutput(formatRunState(readRunState(config, ticket)));
119
- writeSection("Recent logs");
120
- const logLines = recentTicketLogLines(config, ticket);
121
- writeOutput(logLines.length === 0 ? "(none)" : logLines.join("\n"));
122
- writeSection("Last Linear status");
123
- writeOutput(await linearStatus(ticket));
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
- function workspacePresence(probe, ticket) {
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 "unknown";
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
- return probe.names.has(ticket) ? "yes" : "no";
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
- for (const entry of entries) {
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
- writeOutput(`${entry.ticket} ${entry.repository} ${entry.kind} workspace=${workspacePresence(probe, entry.ticket)} run=${runState?.state ?? "none"}`);
147
- writeOutput(` ${entry.branchName} ${entry.dir}`);
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 writeInventoryWorkspaces(probe) {
151
- writeSection("Live workspaces");
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 names = [...probe.names].toSorted();
157
- writeOutput(names.length === 0 ? "(none)" : names.join("\n"));
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
- writeOutput("groundcrew status");
161
- writeOutput("=================");
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
- writeInventoryWorkspaces(probe);
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();
@@ -7,6 +7,7 @@ export interface UpgradeCliOptions {
7
7
  version: string;
8
8
  npmBin: string;
9
9
  }) => Promise<NpmRunResult>;
10
+ readInstalledVersion: (installPath: string) => string | undefined;
10
11
  }
11
12
  export interface UpgradeInstallDetails {
12
13
  installKind: InstallKind;
@@ -1 +1 @@
1
- {"version":3,"file":"upgrade.d.ts","sourceRoot":"","sources":["../../src/commands/upgrade.ts"],"names":[],"mappings":"AAEA,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;CAC7B;AAED,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,WAAW,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAOD,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;AAsCD,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,CAqB5B"}
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"}