@clipboard-health/groundcrew 3.1.2 → 3.1.3

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 (39) hide show
  1. package/README.md +31 -2
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +12 -0
  4. package/dist/commands/cleaner.d.ts.map +1 -1
  5. package/dist/commands/cleaner.js +2 -0
  6. package/dist/commands/cleanupWorkspace.d.ts.map +1 -1
  7. package/dist/commands/cleanupWorkspace.js +2 -0
  8. package/dist/commands/interruptWorkspace.d.ts +8 -0
  9. package/dist/commands/interruptWorkspace.d.ts.map +1 -0
  10. package/dist/commands/interruptWorkspace.js +108 -0
  11. package/dist/commands/resumeWorkspace.d.ts +7 -0
  12. package/dist/commands/resumeWorkspace.d.ts.map +1 -0
  13. package/dist/commands/resumeWorkspace.js +163 -0
  14. package/dist/commands/setupWorkspace.d.ts.map +1 -1
  15. package/dist/commands/setupWorkspace.js +77 -79
  16. package/dist/commands/ticketDoctor.d.ts +18 -3
  17. package/dist/commands/ticketDoctor.d.ts.map +1 -1
  18. package/dist/commands/ticketDoctor.js +77 -8
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -0
  22. package/dist/lib/agentLaunch.d.ts +29 -0
  23. package/dist/lib/agentLaunch.d.ts.map +1 -0
  24. package/dist/lib/agentLaunch.js +53 -0
  25. package/dist/lib/runState.d.ts +46 -0
  26. package/dist/lib/runState.d.ts.map +1 -0
  27. package/dist/lib/runState.js +137 -0
  28. package/dist/lib/runStateCleanup.d.ts +4 -0
  29. package/dist/lib/runStateCleanup.d.ts.map +1 -0
  30. package/dist/lib/runStateCleanup.js +12 -0
  31. package/dist/lib/stagedLaunch.d.ts +32 -0
  32. package/dist/lib/stagedLaunch.d.ts.map +1 -0
  33. package/dist/lib/stagedLaunch.js +58 -0
  34. package/dist/lib/workspaces.d.ts +19 -1
  35. package/dist/lib/workspaces.d.ts.map +1 -1
  36. package/dist/lib/workspaces.js +29 -9
  37. package/dist/lib/worktrees.d.ts.map +1 -1
  38. package/dist/lib/worktrees.js +12 -4
  39. package/package.json +1 -1
package/README.md CHANGED
@@ -316,14 +316,16 @@ crew run # one-shot dispatch
316
316
  crew run --watch # poll forever
317
317
  crew run --ticket <TICKET> # provision one ticket and exit
318
318
  crew setup repos [--dry-run] [<repo>...]
319
+ crew interrupt <TICKET> [--reason <text>] # stop the live workspace, keep the worktree
320
+ crew resume <TICKET> # reopen an existing ticket worktree
319
321
  crew cleanup <TICKET> # tear down every worktree carrying this ticket
320
322
  ```
321
323
 
322
- `crew doctor --ticket <TICKET>` covers the full per-ticket lifecycle: pre-dispatch eligibility (Todo status, `agent-*` label, model resolution, repository mention, local clone, blockers, model session usage, in-progress capacity) **and** post-dispatch local-state recovery (host worktree, workspace pane, local branch, remote branch, open PR). Verdict precedence runs from post-dispatch outcomes down: `pr-open` > `pr-merged` > `in-flight` > `recoverable` > `unresolvable` > `ineligible` > `would-dispatch` > `lost`. Exits 0 on `would-dispatch`, `pr-open`, or `pr-merged`; any other verdict exits 1. `--watch` and `--ticket` are mutually exclusive. To inspect codexbar session windows directly, run `codexbar usage`.
324
+ `crew doctor --ticket <TICKET>` covers the full per-ticket lifecycle: pre-dispatch eligibility (Todo status, `agent-*` label, model resolution, repository mention, local clone, blockers, model session usage, in-progress capacity) **and** post-dispatch local-state recovery (recorded run state, host worktree, workspace pane, local branch, remote branch, open PR). Verdict precedence starts with PR outcomes (`pr-open` > `pr-merged`). Recorded failed launches report before ordinary local recovery, interrupted runs report concrete recoverable git work first when it exists and otherwise report `interrupted`, and ordinary post-dispatch cases report `in-flight` before `recoverable`. If none of those apply, doctor falls through to `unresolvable` > `ineligible` > `would-dispatch` > `lost`. Exits 0 on `would-dispatch`, `pr-open`, or `pr-merged`; any other verdict exits 1. `--watch` and `--ticket` are mutually exclusive. To inspect codexbar session windows directly, run `codexbar usage`.
323
325
 
324
326
  ### `crew doctor --ticket <ticket>`
325
327
 
326
- Diagnose where a ticket is in its lifecycle and what to do next. Runs the same resolution and eligibility chain as the dispatcher, plus probes the host worktree, workspace pane, local branch, remote branch, and PR; prints a single verdict with a copy-pasteable recovery step when one applies.
328
+ Diagnose where a ticket is in its lifecycle and what to do next. Runs the same resolution and eligibility chain as the dispatcher, plus probes recorded run state, host worktree, workspace pane, local branch, remote branch, and PR; prints a single verdict with a copy-pasteable recovery step when one applies.
327
329
 
328
330
  Flags:
329
331
 
@@ -344,6 +346,13 @@ Resolution
344
346
  Eligibility
345
347
  (skipped — post-dispatch — pre-dispatch checks are irrelevant)
346
348
 
349
+ Run state
350
+ [ok] Local run state (running)
351
+ [ok] Recorded model (claude)
352
+ [ok] Recorded worktree (/Users/paul/dev/groundcrew-workspaces/herds-social/herds-hrd-442)
353
+ [ok] Recorded branch (paul-hrd-442)
354
+ [ok] Resume count (0)
355
+
347
356
  Worktree
348
357
  [ok] Host worktree exists (/Users/paul/dev/groundcrew-workspaces/herds-social/herds-hrd-442)
349
358
  [--] Working tree clean (0 modified, 1 untracked)
@@ -374,11 +383,31 @@ The verdict on the last line maps to a recovery action:
374
383
  | `pr-merged` | Done. |
375
384
  | `in-flight` | The ticket is still being worked on; the verdict line names the workspace pane to attach to. |
376
385
  | `recoverable` | Run the printed `nextStep` exactly. |
386
+ | `interrupted` | Resume the preserved worktree with `crew resume <ticket>` or inspect it by hand. |
387
+ | `failed-launch` | Fix the launch failure, then run `crew resume <ticket>` or `crew cleanup <ticket>`. |
377
388
  | `would-dispatch` | Pre-dispatch checks pass; the orchestrator will pick the ticket up on its next tick. |
378
389
  | `ineligible` | A resolution or eligibility check failed; the reason after the colon names the failing check. |
379
390
  | `unresolvable` | The Linear ticket couldn't be fetched; the reason after the colon names the error. |
380
391
  | `lost` | No trace exists. Re-dispatch via `crew run --ticket <ticket>`. |
381
392
 
393
+ ### `crew interrupt <ticket>`
394
+
395
+ Stop a live workspace pane while preserving the ticket worktree and branch. This is the manual pause button for cases where you need terminal capacity back, want to stop an agent that is going in the wrong direction, or need to inspect the diff before letting another agent continue.
396
+
397
+ ```bash
398
+ crew interrupt HRD-442 --reason "wrong implementation direction"
399
+ crew doctor --ticket HRD-442
400
+ crew resume HRD-442
401
+ ```
402
+
403
+ The command closes the cmux/tmux workspace when it exists, records local run state under the groundcrew state directory, and never tears down the worktree. If the workspace was already gone but the worktree is still present, interrupt records that fact so doctor can point at the preserved branch instead of reporting a mystery ticket.
404
+
405
+ ### `crew resume <ticket>`
406
+
407
+ Reopen an existing ticket worktree with a continuation prompt. Resume never creates a new worktree; if none exists, it fails and leaves re-dispatch to `crew run --ticket <ticket>`.
408
+
409
+ The resume prompt tells the agent to inspect current git status and diff before editing, includes the previous interrupt reason when recorded, and reuses the recorded model, repository, branch, runner, sandbox, and workspace backend. When no run-state file exists but a worktree does, resume falls back to Linear resolution for the model and ticket context.
410
+
382
411
  ## Troubleshooting
383
412
 
384
413
  First stop for "labeled but not on the board": `crew doctor --ticket <ticket>` lists every check the dispatcher runs and flags the failing one.
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAiJA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvD"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AA6JA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BvD"}
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { cleanupWorkspaceCli } from "./commands/cleanupWorkspace.js";
3
3
  import { doctor } from "./commands/doctor.js";
4
+ import { interruptWorkspaceCli } from "./commands/interruptWorkspace.js";
4
5
  import { orchestrate } from "./commands/orchestrator.js";
6
+ import { resumeWorkspaceCli } from "./commands/resumeWorkspace.js";
5
7
  import { setupReposCli } from "./commands/setupRepos.js";
6
8
  import { setupWorkspaceCli } from "./commands/setupWorkspace.js";
7
9
  import { errorMessage, readTicketArgument, writeError, writeOutput } from "./lib/util.js";
@@ -90,6 +92,16 @@ const SUBCOMMANDS = {
90
92
  usage: "[--force] <ticket>",
91
93
  invoke: cleanupWorkspaceCli,
92
94
  },
95
+ interrupt: {
96
+ summary: "Stop a live ticket workspace while preserving its worktree",
97
+ usage: "<ticket> [--reason <text>]",
98
+ invoke: interruptWorkspaceCli,
99
+ },
100
+ resume: {
101
+ summary: "Reopen an existing ticket worktree with a continuation prompt",
102
+ usage: "<ticket>",
103
+ invoke: resumeWorkspaceCli,
104
+ },
93
105
  setup: {
94
106
  summary: "Project-level setup commands (currently: repos)",
95
107
  usage: "repos [--dry-run] [<repo>...]",
@@ -1 +1 @@
1
- {"version":3,"file":"cleaner.d.ts","sourceRoot":"","sources":["../../src/commands/cleaner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,uBAAuB,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,EAAE,KAAK,aAAa,EAAa,MAAM,qBAAqB,CAAC;AAGpE,UAAU,WAAW;IACnB,MAAM,EAAE,cAAc,CAAC;CACxB;AAED,MAAM,WAAW,OAAO;IACtB,OAAO,CAAC,UAAU,EAAE;QAClB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;KACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAqDxD"}
1
+ {"version":3,"file":"cleaner.d.ts","sourceRoot":"","sources":["../../src/commands/cleaner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,uBAAuB,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGvD,OAAO,EAAE,KAAK,aAAa,EAAa,MAAM,qBAAqB,CAAC;AAGpE,UAAU,WAAW;IACnB,MAAM,EAAE,cAAc,CAAC;CACxB;AAED,MAAM,WAAW,OAAO;IACtB,OAAO,CAAC,UAAU,EAAE;QAClB,KAAK,EAAE,UAAU,CAAC;QAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;QAC1C,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,WAAW,CAAC;KACtB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAsDxD"}
@@ -4,6 +4,7 @@
4
4
  * invocation; stateless across iterations. Mirrors `Dispatcher`.
5
5
  */
6
6
  import { isTerminalStatus } from "../lib/boardSource.js";
7
+ import { recordCleanedUpRuns } from "../lib/runStateCleanup.js";
7
8
  import { log, logEvent } from "../lib/util.js";
8
9
  import { worktrees } from "../lib/worktrees.js";
9
10
  import { logTeardown, recordTeardownEvents } from "./teardownReporter.js";
@@ -41,6 +42,7 @@ export function createCleaner(deps) {
41
42
  const result = signal === undefined
42
43
  ? await worktrees.teardown(config, stale)
43
44
  : await worktrees.teardown(config, stale, { signal });
45
+ recordCleanedUpRuns(config, result.removed);
44
46
  logTeardown(result);
45
47
  recordTeardownEvents(result);
46
48
  }
@@ -1 +1 @@
1
- {"version":3,"file":"cleanupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/cleanupWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAKnE,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAwBD,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAcf;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAIvE"}
1
+ {"version":3,"file":"cleanupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/cleanupWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAMnE,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAwBD,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAef;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAIvE"}
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from "../lib/config.js";
2
+ import { recordCleanedUpRuns } from "../lib/runStateCleanup.js";
2
3
  import { log } from "../lib/util.js";
3
4
  import { worktrees } from "../lib/worktrees.js";
4
5
  import { logTeardown } from "./teardownReporter.js";
@@ -29,6 +30,7 @@ export async function cleanupWorkspace(config, options) {
29
30
  return;
30
31
  }
31
32
  const result = await worktrees.teardown(config, entries, { force });
33
+ recordCleanedUpRuns(config, result.removed);
32
34
  logTeardown(result);
33
35
  if (result.failures.length > 0) {
34
36
  throw result.failures[0]?.error;
@@ -0,0 +1,8 @@
1
+ import { type ResolvedConfig } from "../lib/config.ts";
2
+ export interface InterruptWorkspaceOptions {
3
+ ticket: string;
4
+ reason?: string;
5
+ }
6
+ export declare function interruptWorkspace(config: ResolvedConfig, options: InterruptWorkspaceOptions): Promise<void>;
7
+ export declare function interruptWorkspaceCli(argv: string[]): Promise<void>;
8
+ //# sourceMappingURL=interruptWorkspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interruptWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/interruptWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAMnE,MAAM,WAAW,yBAAyB;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAuGD,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,yBAAyB,GACjC,OAAO,CAAC,IAAI,CAAC,CAyBf;AAED,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGzE"}
@@ -0,0 +1,108 @@
1
+ import { loadConfig } from "../lib/config.js";
2
+ import { readRunState, recordRunState } from "../lib/runState.js";
3
+ import { errorMessage, log } from "../lib/util.js";
4
+ import { workspaces } from "../lib/workspaces.js";
5
+ import { worktrees } from "../lib/worktrees.js";
6
+ function parseArguments(argv) {
7
+ let reason;
8
+ const positionals = [];
9
+ for (let index = 0; index < argv.length; index += 1) {
10
+ const argument = argv[index];
11
+ /* v8 ignore next @preserve -- loop bounds ensure argv[index] exists; guard satisfies noUncheckedIndexedAccess */
12
+ if (argument === undefined) {
13
+ continue;
14
+ }
15
+ if (argument === "--reason") {
16
+ const value = argv[index + 1];
17
+ if (value === undefined || value.length === 0 || value.startsWith("-")) {
18
+ throw new Error("crew interrupt --reason: reason text is required");
19
+ }
20
+ reason = value;
21
+ index += 1;
22
+ continue;
23
+ }
24
+ if (argument.startsWith("-")) {
25
+ throw new Error(`Unknown option: ${argument}\nUsage: crew interrupt <ticket> [--reason <text>]`);
26
+ }
27
+ positionals.push(argument);
28
+ }
29
+ const [ticket, ...extras] = positionals;
30
+ if (ticket === undefined || ticket.length === 0 || extras.length > 0) {
31
+ throw new Error("Usage: crew interrupt <ticket> [--reason <text>]");
32
+ }
33
+ return { ticket: ticket.toLowerCase(), ...(reason === undefined ? {} : { reason }) };
34
+ }
35
+ function sourceFromState(state) {
36
+ return {
37
+ ticket: state.ticket,
38
+ repository: state.repository,
39
+ model: state.model,
40
+ worktreeDir: state.worktreeDir,
41
+ branchName: state.branchName,
42
+ workspaceName: state.workspaceName,
43
+ resumeCount: state.resumeCount,
44
+ };
45
+ }
46
+ function sourceFromWorktree(config, ticket, entry) {
47
+ return {
48
+ ticket,
49
+ repository: entry.repository,
50
+ model: config.models.default,
51
+ worktreeDir: entry.dir,
52
+ branchName: entry.branchName,
53
+ workspaceName: ticket,
54
+ resumeCount: 0,
55
+ };
56
+ }
57
+ function resolveInterruptSource(arguments_) {
58
+ if (arguments_.state !== undefined) {
59
+ return sourceFromState(arguments_.state);
60
+ }
61
+ if (arguments_.entry !== undefined) {
62
+ return sourceFromWorktree(arguments_.config, arguments_.ticket, arguments_.entry);
63
+ }
64
+ throw new Error(`No run state or worktree found for ${arguments_.ticket}; nothing to interrupt.`);
65
+ }
66
+ function interruptDetail(result) {
67
+ if (result.kind === "missing") {
68
+ return "workspace missing";
69
+ }
70
+ return undefined;
71
+ }
72
+ function failOnUnavailable(result) {
73
+ if (result.kind !== "unavailable") {
74
+ return;
75
+ }
76
+ const detail = result.error === undefined ? "workspace adapter unavailable" : errorMessage(result.error);
77
+ throw new Error(`Could not interrupt workspace: ${detail}`);
78
+ }
79
+ export async function interruptWorkspace(config, options) {
80
+ const ticket = options.ticket.toLowerCase();
81
+ const state = readRunState(config, ticket);
82
+ const [entry] = worktrees.findByTicket(config, ticket);
83
+ const source = resolveInterruptSource({ config, ticket, state, entry });
84
+ const result = await workspaces.interrupt(config, source.workspaceName);
85
+ failOnUnavailable(result);
86
+ const detail = interruptDetail(result);
87
+ recordRunState({
88
+ config,
89
+ state: {
90
+ ticket,
91
+ repository: source.repository,
92
+ model: source.model,
93
+ worktreeDir: source.worktreeDir,
94
+ branchName: source.branchName,
95
+ workspaceName: source.workspaceName,
96
+ state: "interrupted",
97
+ resumeCount: source.resumeCount,
98
+ ...(options.reason === undefined ? {} : { reason: options.reason }),
99
+ ...(detail === undefined ? {} : { detail }),
100
+ },
101
+ });
102
+ log(`Interrupted ${ticket}; worktree preserved at ${source.worktreeDir}`);
103
+ log(`Next: crew doctor --ticket ${ticket}`);
104
+ }
105
+ export async function interruptWorkspaceCli(argv) {
106
+ const config = await loadConfig();
107
+ await interruptWorkspace(config, parseArguments(argv));
108
+ }
@@ -0,0 +1,7 @@
1
+ import { type ResolvedConfig } from "../lib/config.ts";
2
+ export interface ResumeWorkspaceOptions {
3
+ ticket: string;
4
+ }
5
+ export declare function resumeWorkspace(config: ResolvedConfig, options: ResumeWorkspaceOptions): Promise<void>;
6
+ export declare function resumeWorkspaceCli(argv: string[]): Promise<void>;
7
+ //# sourceMappingURL=resumeWorkspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resumeWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/resumeWorkspace.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAcnE,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;CAChB;AA6HD,wBAAsB,eAAe,CACnC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,IAAI,CAAC,CA6Df;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGtE"}
@@ -0,0 +1,163 @@
1
+ import { fetchResolvedIssue } from "../lib/boardSource.js";
2
+ import { loadConfig } from "../lib/config.js";
3
+ import { ensureAgentSandbox, openAgentWorkspace, prepareAgentLaunch } from "../lib/agentLaunch.js";
4
+ import { buildLaunchCommand } from "../lib/launchCommand.js";
5
+ import { readRunState, recordRunState } from "../lib/runState.js";
6
+ import { removeStagedPrompt, stageBuildSecrets, stagePromptText, stageWorkspaceLaunchCommand, } from "../lib/stagedLaunch.js";
7
+ import { errorMessage, getLinearClient, log } from "../lib/util.js";
8
+ import { workspaces } from "../lib/workspaces.js";
9
+ import { worktrees } from "../lib/worktrees.js";
10
+ function parseArguments(argv) {
11
+ const [ticket, ...extras] = argv;
12
+ if (ticket === undefined || ticket.length === 0 || extras.length > 0 || ticket.startsWith("-")) {
13
+ throw new Error("Usage: crew resume <ticket>");
14
+ }
15
+ return { ticket: ticket.toLowerCase() };
16
+ }
17
+ async function fetchTicketDetails(ticket) {
18
+ try {
19
+ const issue = await getLinearClient().issue(ticket.toUpperCase());
20
+ return {
21
+ title: issue.title,
22
+ description: issue.description ?? "",
23
+ };
24
+ }
25
+ catch (error) {
26
+ log(`Resume Linear detail lookup failed for ${ticket}: ${errorMessage(error)}`);
27
+ return undefined;
28
+ }
29
+ }
30
+ async function contextFromLinear(config, ticket, worktree) {
31
+ const resolved = await fetchResolvedIssue({ client: getLinearClient(), config, ticket });
32
+ return {
33
+ ticket,
34
+ repository: resolved.repository,
35
+ model: resolved.model,
36
+ worktree,
37
+ title: resolved.title,
38
+ description: resolved.description,
39
+ resumeCount: 0,
40
+ };
41
+ }
42
+ async function contextFromState(ticket, state, worktree) {
43
+ const details = await fetchTicketDetails(ticket);
44
+ return {
45
+ ticket,
46
+ repository: state.repository,
47
+ model: state.model,
48
+ worktree,
49
+ title: details?.title ?? ticket.toUpperCase(),
50
+ description: details?.description ?? "",
51
+ ...(state.reason === undefined ? {} : { reason: state.reason }),
52
+ resumeCount: state.resumeCount,
53
+ };
54
+ }
55
+ async function buildResumeContext(config, ticket) {
56
+ const state = readRunState(config, ticket);
57
+ const entries = worktrees.findByTicket(config, ticket);
58
+ const worktree = state === undefined
59
+ ? entries[0]
60
+ : (entries.find((entry) => entry.repository === state.repository) ?? entries[0]);
61
+ if (worktree === undefined) {
62
+ throw new Error(`No worktree found for ${ticket}; cannot resume.`);
63
+ }
64
+ if (state !== undefined) {
65
+ return await contextFromState(ticket, state, worktree);
66
+ }
67
+ return await contextFromLinear(config, ticket, worktree);
68
+ }
69
+ function renderResumePrompt(context) {
70
+ return [
71
+ `You are resuming Groundcrew ticket ${context.ticket} (${context.title}) in an existing worktree.`,
72
+ "",
73
+ "Ticket description:",
74
+ "",
75
+ context.description,
76
+ "",
77
+ "## Continuation context",
78
+ "",
79
+ `- Worktree: ${context.worktree.dir}`,
80
+ `- Branch: ${context.worktree.branchName}`,
81
+ context.reason === undefined
82
+ ? "- Previous interrupt reason: none recorded"
83
+ : `- Previous interrupt reason: ${context.reason}`,
84
+ "",
85
+ "Before editing, inspect the current git status and diff. Continue from the work already present in this worktree; do not restart from scratch unless the diff proves that is necessary.",
86
+ "",
87
+ "Run the repository's documented verification before stopping, then leave the branch ready or open a PR when possible.",
88
+ ].join("\n");
89
+ }
90
+ async function failIfWorkspaceAlreadyLive(config, ticket) {
91
+ const probe = await workspaces.probe(config);
92
+ if (probe.kind === "unavailable") {
93
+ const detail = probe.error === undefined ? "" : `: ${errorMessage(probe.error)}`;
94
+ throw new Error(`Could not verify whether workspace for ${ticket} is already live${detail}. Retry or inspect the workspace backend manually before resuming.`);
95
+ }
96
+ if (probe.names.has(ticket)) {
97
+ throw new Error(`Workspace for ${ticket} is already live; attach to it instead of resuming.`);
98
+ }
99
+ }
100
+ export async function resumeWorkspace(config, options) {
101
+ const ticket = options.ticket.toLowerCase();
102
+ await failIfWorkspaceAlreadyLive(config, ticket);
103
+ const context = await buildResumeContext(config, ticket);
104
+ const definition = config.models.definitions[context.model];
105
+ if (definition === undefined) {
106
+ throw new Error(`Unknown model: ${context.model}`);
107
+ }
108
+ const { runner, sandboxName } = await prepareAgentLaunch({
109
+ config,
110
+ model: context.model,
111
+ definition,
112
+ purpose: "resumes",
113
+ });
114
+ const stagedPrompt = stagePromptText({
115
+ prefix: "groundcrew-resume",
116
+ ticket,
117
+ text: renderResumePrompt(context),
118
+ });
119
+ const secretsFile = stageBuildSecrets(stagedPrompt.directory);
120
+ await ensureAgentSandbox({ config, definition, sandboxName });
121
+ const launchCommand = buildLaunchCommand({
122
+ definition,
123
+ promptFile: stagedPrompt.file,
124
+ worktreeDir: context.worktree.dir,
125
+ secretsFile,
126
+ runner,
127
+ sandboxName,
128
+ });
129
+ const launchCmd = stageWorkspaceLaunchCommand(stagedPrompt.directory, launchCommand);
130
+ try {
131
+ await openAgentWorkspace({
132
+ config,
133
+ name: ticket,
134
+ cwd: context.worktree.dir,
135
+ command: launchCmd,
136
+ model: context.model,
137
+ color: definition.color,
138
+ });
139
+ }
140
+ catch (error) {
141
+ removeStagedPrompt(stagedPrompt.directory);
142
+ throw error;
143
+ }
144
+ recordRunState({
145
+ config,
146
+ state: {
147
+ ticket,
148
+ repository: context.repository,
149
+ model: context.model,
150
+ worktreeDir: context.worktree.dir,
151
+ branchName: context.worktree.branchName,
152
+ workspaceName: ticket,
153
+ state: "resumed",
154
+ resumeCount: context.resumeCount + 1,
155
+ ...(context.reason === undefined ? {} : { reason: context.reason }),
156
+ },
157
+ });
158
+ log(`Resumed ${ticket} in ${context.worktree.dir} (${context.model})`);
159
+ }
160
+ export async function resumeWorkspaceCli(argv) {
161
+ const config = await loadConfig();
162
+ await resumeWorkspace(config, parseArguments(argv));
163
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAOA,OAAO,EAAkC,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAUvF,UAAU,aAAa;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAgBD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAmED,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CAuGf;AA0FD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAoBf"}
1
+ {"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAenE,UAAU,aAAa;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAWD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAqBD,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,qBAAqB,EAC9B,UAAU,GAAE,wBAA6B,GACxC,OAAO,CAAC,IAAI,CAAC,CA6Gf;AAwHD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAoBf"}
@@ -1,15 +1,12 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join, resolve } from "node:path";
4
- import { ensureClearance } from "@clipboard-health/clearance";
1
+ import { rmSync } from "node:fs";
5
2
  import { fetchResolvedIssue } from "../lib/boardSource.js";
6
- import { BUILD_SECRET_NAMES, loadConfig } from "../lib/config.js";
7
- import { ensureSandbox, sandboxNameFor } from "../lib/dockerSandbox.js";
8
- import { detectHostCapabilities } from "../lib/host.js";
9
- import { buildLaunchCommand, shellSingleQuote } from "../lib/launchCommand.js";
3
+ import { loadConfig } from "../lib/config.js";
4
+ import { ensureAgentSandbox, openAgentWorkspace, prepareAgentLaunch } from "../lib/agentLaunch.js";
5
+ import { buildLaunchCommand } from "../lib/launchCommand.js";
10
6
  import { createLinearIssueStatusUpdater } from "../lib/linearIssueStatus.js";
11
- import { assertLocalRunnerRequirements, resolveLocalRunner } from "../lib/localRunner.js";
12
- import { errorMessage, getLinearClient, log, readEnvironmentVariable } from "../lib/util.js";
7
+ import { recordRunState } from "../lib/runState.js";
8
+ import { stageBuildSecrets, stagePromptFromTemplate, stageWorkspaceLaunchCommand, } from "../lib/stagedLaunch.js";
9
+ import { errorMessage, getLinearClient, log } from "../lib/util.js";
13
10
  import { workspaces } from "../lib/workspaces.js";
14
11
  import { isWorktreeAlreadyExistsError, worktrees } from "../lib/worktrees.js";
15
12
  async function fetchTicket(ticket) {
@@ -20,54 +17,18 @@ async function fetchTicket(ticket) {
20
17
  description: issue.description ?? "",
21
18
  };
22
19
  }
23
- function renderPrompt(template, variables) {
24
- return template
25
- .replaceAll("{{ticket}}", variables.ticket)
26
- .replaceAll("{{worktree}}", variables.worktree)
27
- .replaceAll("{{title}}", variables.title)
28
- .replaceAll("{{description}}", variables.description);
29
- }
30
- /**
31
- * Stage a `KEY='value'` env file for any populated build-time secret so
32
- * the launch command can source it. Returns `undefined` when groundcrew
33
- * has nothing to forward, leaving the launch command unchanged. The temp
34
- * dir is `rm -rf`'d by the launch command (and rollback path), so cleanup
35
- * is already handled.
36
- */
37
- function stageBuildSecrets(promptDir) {
38
- const lines = [];
39
- for (const name of BUILD_SECRET_NAMES) {
40
- const value = readEnvironmentVariable(name);
41
- if (value === undefined || value.length === 0) {
42
- continue;
43
- }
44
- lines.push(`${name}=${shellSingleQuote(value)}`);
45
- }
46
- if (lines.length === 0) {
47
- return undefined;
48
- }
49
- const secretsFile = join(promptDir, "secrets.env");
50
- writeFileSync(secretsFile, `${lines.join("\n")}\n`, { mode: 0o600 });
51
- return secretsFile;
52
- }
53
- function stageLaunchScript(promptDir, command) {
54
- const launcherFile = join(promptDir, "launch.sh");
55
- writeFileSync(launcherFile, `#!/usr/bin/env bash\n${command}\n`, { mode: 0o700 });
56
- return launcherFile;
57
- }
58
- function stageWorkspaceLaunchCommand(promptDir, command) {
59
- return `bash ${shellSingleQuote(stageLaunchScript(promptDir, command))}`;
60
- }
61
20
  function stagePrompt(input) {
62
- const promptDir = mkdtempSync(join(tmpdir(), `groundcrew-${input.ticket}-`));
63
- const promptFile = join(promptDir, "prompt.txt");
64
- writeFileSync(promptFile, renderPrompt(input.config.prompts.initial, {
21
+ return stagePromptFromTemplate({
22
+ config: input.config,
23
+ prefix: "groundcrew",
65
24
  ticket: input.ticket,
66
- worktree: input.worktreeName,
67
- title: input.ticketDetails.title,
68
- description: input.ticketDetails.description,
69
- }));
70
- return { directory: promptDir, file: promptFile };
25
+ variables: {
26
+ ticket: input.ticket,
27
+ worktree: input.worktreeName,
28
+ title: input.ticketDetails.title,
29
+ description: input.ticketDetails.description,
30
+ },
31
+ });
71
32
  }
72
33
  export async function setupWorkspace(config, options, runOptions = {}) {
73
34
  const { ticket, repository, model } = options;
@@ -76,16 +37,13 @@ export async function setupWorkspace(config, options, runOptions = {}) {
76
37
  if (!definition) {
77
38
  throw new Error(`Unknown model: ${model}`);
78
39
  }
79
- const host = await detectHostCapabilities(signal);
80
- const runner = resolveLocalRunner(config.local.runner, host);
81
- assertLocalRunnerRequirements(host, runner);
82
- if (runner === "safehouse") {
83
- await ensureClearance({ logger: log });
84
- }
85
- if (runner === "sdx" && definition.sandbox === undefined) {
86
- throw new Error(`Local groundcrew runs with the sdx runner require a sandbox config on model '${model}'. ` +
87
- "Add `sandbox: { agent: '<sbx-agent-name>' }` to the model in your config.ts.");
88
- }
40
+ const { runner, sandboxName } = await prepareAgentLaunch({
41
+ config,
42
+ model,
43
+ definition,
44
+ purpose: "runs",
45
+ ...(signal === undefined ? {} : { signal }),
46
+ });
89
47
  const spec = { repository, ticket };
90
48
  let created;
91
49
  try {
@@ -122,16 +80,12 @@ export async function setupWorkspace(config, options, runOptions = {}) {
122
80
  const stagedPrompt = stagePrompt({ config, ticket, ticketDetails, worktreeName });
123
81
  promptDir = stagedPrompt.directory;
124
82
  const secretsFile = stageBuildSecrets(promptDir);
125
- const sandboxName = runner === "sdx" && definition.sandbox !== undefined
126
- ? sandboxNameFor({ agent: definition.sandbox.agent })
127
- : undefined;
128
- if (runner === "sdx" && sandboxName !== undefined && definition.sandbox !== undefined) {
129
- await ensureSandbox({
130
- sandboxName,
131
- sandbox: definition.sandbox,
132
- mountPath: resolve(config.workspace.projectDir),
133
- }, signal);
134
- }
83
+ await ensureAgentSandbox({
84
+ config,
85
+ definition,
86
+ sandboxName,
87
+ ...(signal === undefined ? {} : { signal }),
88
+ });
135
89
  const launchCommand = buildLaunchCommand({
136
90
  definition,
137
91
  promptFile: stagedPrompt.file,
@@ -142,12 +96,25 @@ export async function setupWorkspace(config, options, runOptions = {}) {
142
96
  });
143
97
  const launchCmd = stageWorkspaceLaunchCommand(promptDir, launchCommand);
144
98
  log("Opening workspace...");
145
- await workspaces.open(config, {
99
+ await openAgentWorkspace({
100
+ config,
146
101
  name: ticket,
147
102
  cwd: launchDir,
148
103
  command: launchCmd,
149
- status: { text: model, color: definition.color, icon: "sparkle" },
150
- }, signal);
104
+ model,
105
+ color: definition.color,
106
+ ...(signal === undefined ? {} : { signal }),
107
+ });
108
+ recordRunStateBestEffort({
109
+ config,
110
+ ticket,
111
+ repository,
112
+ model,
113
+ worktreeDir: launchDir,
114
+ branchName,
115
+ workspaceName: ticket,
116
+ state: "running",
117
+ });
151
118
  log(`Workspace "${ticket}" launched (${model})`);
152
119
  log(` Worktree: ${launchDir}`);
153
120
  log(` Branch: ${branchName}`);
@@ -155,6 +122,17 @@ export async function setupWorkspace(config, options, runOptions = {}) {
155
122
  }
156
123
  catch (error) {
157
124
  await rollbackWorktree({ config, entry: created, promptDir });
125
+ recordRunStateBestEffort({
126
+ config,
127
+ ticket,
128
+ repository,
129
+ model,
130
+ worktreeDir: launchDir,
131
+ branchName,
132
+ workspaceName: ticket,
133
+ state: "failed-to-launch",
134
+ detail: errorMessage(error),
135
+ });
158
136
  throw error;
159
137
  }
160
138
  }
@@ -188,6 +166,26 @@ async function logWorkspaceAccessHint(arguments_) {
188
166
  function logAccessHint(accessHint) {
189
167
  log(` Attach: ${accessHint.command}`);
190
168
  }
169
+ function recordRunStateBestEffort(arguments_) {
170
+ try {
171
+ recordRunState({
172
+ config: arguments_.config,
173
+ state: {
174
+ ticket: arguments_.ticket,
175
+ repository: arguments_.repository,
176
+ model: arguments_.model,
177
+ worktreeDir: arguments_.worktreeDir,
178
+ branchName: arguments_.branchName,
179
+ workspaceName: arguments_.workspaceName,
180
+ state: arguments_.state,
181
+ ...(arguments_.detail === undefined ? {} : { detail: arguments_.detail }),
182
+ },
183
+ });
184
+ }
185
+ catch (error) {
186
+ log(`Run state update failed for ${arguments_.ticket}: ${errorMessage(error)}`);
187
+ }
188
+ }
191
189
  async function rollbackWorktree(arguments_) {
192
190
  log(`Setup failed; rolling back worktree ${arguments_.entry.repository}-${arguments_.entry.ticket}...`);
193
191
  let result;