@clipboard-health/groundcrew 4.52.12 → 4.53.0

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 (64) hide show
  1. package/crew.config.example.ts +9 -0
  2. package/dist/commands/cleanupWorkspace.js +1 -1
  3. package/dist/commands/dispatcher.d.ts +3 -0
  4. package/dist/commands/dispatcher.d.ts.map +1 -1
  5. package/dist/commands/dispatcher.js +15 -4
  6. package/dist/commands/eligibility.d.ts +59 -8
  7. package/dist/commands/eligibility.d.ts.map +1 -1
  8. package/dist/commands/eligibility.js +193 -8
  9. package/dist/commands/resumeWorkspace.d.ts.map +1 -1
  10. package/dist/commands/resumeWorkspace.js +3 -0
  11. package/dist/commands/reviewer.d.ts.map +1 -1
  12. package/dist/commands/reviewer.js +14 -0
  13. package/dist/commands/setupWorkspace.d.ts +4 -0
  14. package/dist/commands/setupWorkspace.d.ts.map +1 -1
  15. package/dist/commands/setupWorkspace.js +60 -18
  16. package/dist/commands/stackRetarget.d.ts +64 -0
  17. package/dist/commands/stackRetarget.d.ts.map +1 -0
  18. package/dist/commands/stackRetarget.js +627 -0
  19. package/dist/commands/status.d.ts.map +1 -1
  20. package/dist/commands/status.js +10 -0
  21. package/dist/commands/statusCollect.d.ts.map +1 -1
  22. package/dist/commands/statusCollect.js +3 -0
  23. package/dist/commands/teardownReporter.js +1 -1
  24. package/dist/lib/adapters/linear/factory.d.ts.map +1 -1
  25. package/dist/lib/adapters/linear/factory.js +2 -0
  26. package/dist/lib/adapters/linear/fetch.d.ts +4 -1
  27. package/dist/lib/adapters/linear/fetch.d.ts.map +1 -1
  28. package/dist/lib/adapters/linear/fetch.js +6 -2
  29. package/dist/lib/adapters/linear/parsing.d.ts +6 -1
  30. package/dist/lib/adapters/linear/parsing.d.ts.map +1 -1
  31. package/dist/lib/adapters/linear/parsing.js +5 -1
  32. package/dist/lib/config.d.ts +12 -1
  33. package/dist/lib/config.d.ts.map +1 -1
  34. package/dist/lib/config.js +8 -2
  35. package/dist/lib/githubStacks.d.ts +51 -0
  36. package/dist/lib/githubStacks.d.ts.map +1 -0
  37. package/dist/lib/githubStacks.js +116 -0
  38. package/dist/lib/launchCommand.d.ts +6 -0
  39. package/dist/lib/launchCommand.d.ts.map +1 -1
  40. package/dist/lib/launchCommand.js +4 -1
  41. package/dist/lib/pullRequests.d.ts +16 -0
  42. package/dist/lib/pullRequests.d.ts.map +1 -1
  43. package/dist/lib/pullRequests.js +31 -2
  44. package/dist/lib/runState.d.ts +53 -0
  45. package/dist/lib/runState.d.ts.map +1 -1
  46. package/dist/lib/runState.js +113 -24
  47. package/dist/lib/runStateCleanup.d.ts +8 -2
  48. package/dist/lib/runStateCleanup.d.ts.map +1 -1
  49. package/dist/lib/runStateCleanup.js +18 -2
  50. package/dist/lib/statusSnapshot.d.ts +6 -0
  51. package/dist/lib/statusSnapshot.d.ts.map +1 -1
  52. package/dist/lib/taskSource.d.ts +4 -0
  53. package/dist/lib/taskSource.d.ts.map +1 -1
  54. package/dist/lib/util.d.ts +6 -0
  55. package/dist/lib/util.d.ts.map +1 -1
  56. package/dist/lib/util.js +18 -0
  57. package/dist/lib/worktreeRunState.d.ts +19 -0
  58. package/dist/lib/worktreeRunState.d.ts.map +1 -1
  59. package/dist/lib/worktreeRunState.js +20 -1
  60. package/dist/lib/worktrees.d.ts +44 -0
  61. package/dist/lib/worktrees.d.ts.map +1 -1
  62. package/dist/lib/worktrees.js +95 -18
  63. package/docs/configuration.md +1 -0
  64. package/package.json +1 -1
@@ -149,6 +149,15 @@ export default {
149
149
  //
150
150
  // git: { remote: "origin", defaultBranch: "main" },
151
151
  //
152
+ // Stacked PRs: when a Todo task is blocked only by unresolved same-repo
153
+ // blockers that form a single chain, dispatch it as a child stacked on
154
+ // the chain tip's branch instead of waiting for the blockers to merge.
155
+ // Off by default.
156
+ // Config is cached per process, so a running `crew run --watch` needs a
157
+ // restart before it picks this up. Opt an individual task out with the
158
+ // `groundcrew-no-stack` Linear label.
159
+ // git: { stacking: true },
160
+ //
152
161
  // orchestrator: {
153
162
  // maximumInProgress: 4,
154
163
  // pollIntervalMilliseconds: 120_000,
@@ -54,7 +54,7 @@ function isWorkspaceInUse(probe, task) {
54
54
  }
55
55
  async function teardownEntries(config, entries, force) {
56
56
  const result = await worktrees.teardown(config, entries, { force });
57
- recordCleanedUpRuns(config, result.removed);
57
+ await recordCleanedUpRuns(config, result.removed);
58
58
  logTeardown(result);
59
59
  if (result.failures.length > 0) {
60
60
  throw result.failures[0]?.error;
@@ -11,9 +11,12 @@ import type { ResolvedConfig } from "../lib/config.ts";
11
11
  import { type BoardState, type Issue } from "../lib/taskSource.ts";
12
12
  import type { UsageByAgent } from "../lib/usage.ts";
13
13
  import type { WorktreeEntry } from "../lib/worktrees.ts";
14
+ import { type EligibilityDeps } from "./eligibility.ts";
14
15
  interface DispatcherDeps {
15
16
  config: ResolvedConfig;
16
17
  board: Board;
18
+ /** Injection point for the stacking decision's run-state read and git probe; defaults to the real implementations. */
19
+ eligibilityDeps?: EligibilityDeps;
17
20
  }
18
21
  export interface Dispatcher {
19
22
  runOnce: (arguments_: {
@@ -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;AAE7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGvD,OAAO,EACL,KAAK,UAAU,EAGf,KAAK,KAAK,EAEX,MAAM,sBAAsB,CAAC;AAC9B,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,EAAE,CAAC,UAAU,EAAE;QACpB,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,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrB;AAiCD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,UAAU,CA8OjE;AA8BD,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;AAE7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGvD,OAAO,EACL,KAAK,UAAU,EAGf,KAAK,KAAK,EAEX,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAGpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAML,KAAK,eAAe,EAGrB,MAAM,kBAAkB,CAAC;AAG1B,UAAU,cAAc;IACtB,MAAM,EAAE,cAAc,CAAC;IACvB,KAAK,EAAE,KAAK,CAAC;IACb,sHAAsH;IACtH,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,CAAC,UAAU,EAAE;QACpB,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,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrB;AAuCD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,UAAU,CAwPjE;AA8BD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,KAAK,EAAE,GAAG,MAAM,CAQrE"}
@@ -12,8 +12,13 @@ import { sourceSupportsMarkDone } from "../lib/sourceCapabilities.js";
12
12
  import { isGroundcrewIssue, naturalIdFromCanonical, } from "../lib/taskSource.js";
13
13
  import { errorMessage, failMark, log, logEvent, styleWarning } from "../lib/util.js";
14
14
  import { workspaces } from "../lib/workspaces.js";
15
- import { classifyBlockers, classifyEligibility, classifyUsageExhaustion, } from "./eligibility.js";
15
+ import { classifyBlockers, classifyEligibility, classifyUsageExhaustion, defaultEligibilityDeps, } from "./eligibility.js";
16
16
  import { setupWorkspace } from "./setupWorkspace.js";
17
+ function logStackDispatch(input) {
18
+ const logContext = { flow: "stack-dispatch", ...input };
19
+ log("Dispatching stacked task on its parent's branch");
20
+ logEvent("dispatch", { ...logContext, outcome: "stacking" });
21
+ }
17
22
  function logSkip(verdict) {
18
23
  log(verdict.message);
19
24
  logEvent("dispatch", {
@@ -36,7 +41,7 @@ function logMissingRepositorySkip(issue, agent, knownRepositories) {
36
41
  });
37
42
  }
38
43
  export function createDispatcher(deps) {
39
- const { config, board } = deps;
44
+ const { config, board, eligibilityDeps = defaultEligibilityDeps } = deps;
40
45
  const rawSources = sourcesFromConfig(config);
41
46
  function buildExhaustedSet(usage) {
42
47
  const exhausted = new Set();
@@ -47,7 +52,7 @@ export function createDispatcher(deps) {
47
52
  return exhausted;
48
53
  }
49
54
  async function startEligibleIssue(start, dryRun, signal) {
50
- const { issue, recovery } = start;
55
+ const { issue, recovery, baseBranch, parentTask } = start;
51
56
  const taskId = naturalIdFromCanonical(issue.id);
52
57
  if (start.resolvedFromAny) {
53
58
  log(`Resolved agent-any for ${taskId} → ${issue.agent}`);
@@ -71,6 +76,9 @@ export function createDispatcher(deps) {
71
76
  log(`Worktree and workspace already exist for ${taskId}; resuming with markInProgress`);
72
77
  }
73
78
  else {
79
+ if (baseBranch !== undefined && parentTask !== undefined) {
80
+ logStackDispatch({ task: taskId, parentTask, baseBranch });
81
+ }
74
82
  const setupOptions = {
75
83
  repository: issue.repository,
76
84
  task: taskId,
@@ -83,6 +91,8 @@ export function createDispatcher(deps) {
83
91
  ...(issue.worktreePreparation === undefined
84
92
  ? {}
85
93
  : { worktreePreparation: issue.worktreePreparation }),
94
+ ...(baseBranch === undefined ? {} : { baseBranch }),
95
+ ...(parentTask === undefined ? {} : { parentTask }),
86
96
  details: {
87
97
  title: issue.title,
88
98
  description: issue.description,
@@ -159,7 +169,7 @@ export function createDispatcher(deps) {
159
169
  }
160
170
  // Run the blocker pre-pass first so an all-blocked board short-circuits
161
171
  // before the codexbar HTTP call and the cmux/tmux shell-out fire.
162
- const { unblocked, skips: blockerSkips } = classifyBlockers(todo);
172
+ const { unblocked, stackDecisions, skips: blockerSkips, } = await classifyBlockers(todo, config, eligibilityDeps);
163
173
  for (const skip of blockerSkips) {
164
174
  logSkip(skip);
165
175
  }
@@ -207,6 +217,7 @@ export function createDispatcher(deps) {
207
217
  const verdicts = classifyEligibility({
208
218
  config,
209
219
  unblocked: dispatchableUnblocked,
220
+ stackDecisions,
210
221
  worktreeEntries,
211
222
  workspaceProbe,
212
223
  usage: fetchedUsage,
@@ -1,23 +1,35 @@
1
1
  /**
2
2
  * Pure eligibility classifier — takes the per-iteration board snapshot plus
3
3
  * derived state (worktrees, live workspaces, usage, slot count) and returns
4
- * a verdict per Todo task. No logging, no Linear calls, no shell-outs.
4
+ * a verdict per Todo task. No logging, no Linear calls, no shell-outs of its
5
+ * own: the one exception is the stacking decision in `classifyBlockers`,
6
+ * which reads the blocker's run state and probes its branch's availability.
7
+ * Both effects are routed through the injectable `EligibilityDeps` so
8
+ * callers can fake them the way the rest of the codebase fakes git.
9
+ * `probeParentBranch` also logs at WARN on a persistent `ls-remote` failure —
10
+ * the one logging exception, kept because a silent auth failure would
11
+ * otherwise hide forever behind the optimistic "unpushed, retry" verdict.
5
12
  *
6
13
  * The Dispatcher consumes the verdict list to drive logging and side
7
14
  * effects.
8
15
  */
9
16
  import { type ResolvedConfig } from "../lib/config.ts";
17
+ import { type RunState } from "../lib/runState.ts";
10
18
  import { type GroundcrewIssue } from "../lib/taskSource.ts";
11
19
  import type { UsageByAgent } from "../lib/usage.ts";
12
20
  import type { WorkspaceProbe } from "../lib/workspaces.ts";
13
- import type { WorktreeEntry } from "../lib/worktrees.ts";
14
- type SkipReason = "blocked" | "blockers_paginated" | "agent_any_capacity" | "agent_exhausted" | "workspace_list_unavailable" | "workspace_missing";
21
+ import { type WorktreeEntry } from "../lib/worktrees.ts";
22
+ type SkipReason = "blocked" | "blockers_paginated" | "agent_any_capacity" | "agent_exhausted" | "workspace_list_unavailable" | "workspace_missing" | "stack_multiple_blockers" | "stack_parent_unknown" | "stack_parent_unpushed" | "stack_cross_repo_blocker" | "stack_provisioned_repo" | "stack_opted_out";
15
23
  export interface StartVerdict {
16
24
  kind: "start";
17
25
  issue: GroundcrewIssue;
18
26
  recovery: boolean;
19
27
  /** Set when the verdict resolved an `agent-any` label to a concrete agent. */
20
28
  resolvedFromAny: boolean;
29
+ /** Parent branch this task's worktree and PR should be based on, when stacked. */
30
+ baseBranch?: string;
31
+ /** Natural (unprefixed) id of the blocker task this task is stacked on. */
32
+ parentTask?: string;
21
33
  }
22
34
  export interface SkipVerdict {
23
35
  kind: "skip";
@@ -26,7 +38,7 @@ export interface SkipVerdict {
26
38
  message: string;
27
39
  /** Stable kebab-case enum surfaced as `logEvent.reason`. */
28
40
  eventReason: SkipReason;
29
- /** Set for `blocked` and `blockers_paginated`. */
41
+ /** Set for `blocked`, `blockers_paginated`, and every `stack_*` reason. */
30
42
  blockers?: string[];
31
43
  /**
32
44
  * Set when the skip event should carry the resolved agent (i.e. the
@@ -37,6 +49,39 @@ export interface SkipVerdict {
37
49
  agent?: string;
38
50
  }
39
51
  type Verdict = StartVerdict | SkipVerdict;
52
+ /** A stacking decision for a single blocked issue: where its branch and PR should be based. */
53
+ interface StackDecision {
54
+ baseBranch: string;
55
+ parentTask: string;
56
+ }
57
+ /**
58
+ * Result of probing a parent branch's availability for stacking (spec
59
+ * section 8's error table):
60
+ *
61
+ * - `"pushed"`: `git ls-remote` finds the branch on the remote.
62
+ * - `"unpushed"`: the branch may still show up — either `ls-remote` came
63
+ * back empty but a local branch of the same name exists (the parent's
64
+ * agent hasn't pushed yet), or the `ls-remote` probe itself failed
65
+ * (network/auth). Both retry next tick under the same optimistic reason.
66
+ * - `"unknown"`: the branch is missing both locally and on the remote, so it
67
+ * will never appear (deleted, or the parent's run-state file is stale).
68
+ */
69
+ type BranchAvailability = "pushed" | "unpushed" | "unknown";
70
+ /**
71
+ * Side-effecting boundary for the stacking decision — the only I/O
72
+ * `classifyBlockers` performs. Production uses the real run-state reader and
73
+ * a `git ls-remote`/local-branch probe; tests substitute fakes instead of
74
+ * mocking modules.
75
+ */
76
+ export interface EligibilityDeps {
77
+ readParentRunState: (config: ResolvedConfig, task: string) => RunState | undefined;
78
+ probeParentBranch: (arguments_: {
79
+ repoDir: string;
80
+ remote: string;
81
+ branch: string;
82
+ }) => Promise<BranchAvailability>;
83
+ }
84
+ export declare const defaultEligibilityDeps: EligibilityDeps;
40
85
  export type AgentUsageExhaustion = {
41
86
  kind: "unavailable";
42
87
  agent: string;
@@ -63,6 +108,8 @@ export interface ClassifyArguments {
63
108
  * when every Todo is blocked.
64
109
  */
65
110
  unblocked: readonly GroundcrewIssue[];
111
+ /** Stacking decisions from `classifyBlockers`, keyed by `issue.id`. Defaults to empty (no stacking). */
112
+ stackDecisions?: ReadonlyMap<string, StackDecision>;
66
113
  worktreeEntries: readonly WorktreeEntry[];
67
114
  workspaceProbe: WorkspaceProbe;
68
115
  usage: UsageByAgent;
@@ -72,8 +119,10 @@ export interface ClassifyArguments {
72
119
  slots: number;
73
120
  dryRun: boolean;
74
121
  }
75
- interface BlockerClassification {
122
+ export interface BlockerClassification {
76
123
  unblocked: GroundcrewIssue[];
124
+ /** Stacking decisions for issues in `unblocked` that stack on a parent, keyed by `issue.id`. */
125
+ stackDecisions: ReadonlyMap<string, StackDecision>;
77
126
  skips: SkipVerdict[];
78
127
  }
79
128
  /**
@@ -87,12 +136,14 @@ interface BlockerClassification {
87
136
  export declare function pickBestAgent(config: ResolvedConfig, usage: UsageByAgent, exhausted: Set<string>): string | undefined;
88
137
  export declare function classifyUsageExhaustion(config: ResolvedConfig, usage: UsageByAgent): AgentUsageExhaustion[];
89
138
  /**
90
- * Cheap pre-pass — partitions Todo into unblocked issues and blocker
139
+ * Cheap pre-pass — partitions Todo into unblocked issues (including those
140
+ * stacking on a single unresolved blocker, per `stackDecisions`) and blocker
91
141
  * skip verdicts. Runs before the dispatcher fetches usage or probes the
92
142
  * workspace adapter, so a board where every Todo is blocked short-circuits
93
- * without paying for either.
143
+ * without paying for either. `config.git.stacking` off reproduces today's
144
+ * behavior exactly and performs no run-state reads or git calls.
94
145
  */
95
- export declare function classifyBlockers(todo: readonly GroundcrewIssue[]): BlockerClassification;
146
+ export declare function classifyBlockers(todo: readonly GroundcrewIssue[], config: ResolvedConfig, deps?: EligibilityDeps): Promise<BlockerClassification>;
96
147
  /**
97
148
  * Eligibility verdicts for already-unblocked Todo issues — handles
98
149
  * agent-any resolution, session exhaustion, worktree/workspace recovery,
@@ -1 +1 @@
1
- {"version":3,"file":"eligibility.d.ts","sourceRoot":"","sources":["../../src/commands/eligibility.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAa,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClE,OAAO,EAAwC,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAClG,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAOzD,KAAK,UAAU,GACX,SAAS,GACT,oBAAoB,GACpB,oBAAoB,GACpB,iBAAiB,GACjB,4BAA4B,GAC5B,mBAAmB,CAAC;AAExB,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,eAAe,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,8EAA8E;IAC9E,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,eAAe,CAAC;IACvB,sBAAsB;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,WAAW,EAAE,UAAU,CAAC;IACxB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,OAAO,GAAG,YAAY,GAAG,WAAW,CAAC;AAE1C,MAAM,MAAM,oBAAoB,GAC5B;IACE,IAAI,EAAE,aAAa,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB,GACD;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B,GACD;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEN,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,cAAc,CAAC;IACvB;;;;;OAKG;IACH,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;IACtC,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,cAAc,EAAE,cAAc,CAAC;IAC/B,KAAK,EAAE,YAAY,CAAC;IACpB,oDAAoD;IACpD,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACvB,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,UAAU,qBAAqB;IAC7B,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,KAAK,EAAE,WAAW,EAAE,CAAC;CACtB;AAgCD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,YAAY,EACnB,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,GACrB,MAAM,GAAG,SAAS,CAkBpB;AAaD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,YAAY,GAClB,oBAAoB,EAAE,CA2CxB;AA6CD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,eAAe,EAAE,GAAG,qBAAqB,CAYxF;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,iBAAiB,GAAG,OAAO,EAAE,CAgE5E"}
1
+ {"version":3,"file":"eligibility.d.ts","sourceRoot":"","sources":["../../src/commands/eligibility.ts"],"names":[],"mappings":"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"}
@@ -1,21 +1,184 @@
1
1
  /**
2
2
  * Pure eligibility classifier — takes the per-iteration board snapshot plus
3
3
  * derived state (worktrees, live workspaces, usage, slot count) and returns
4
- * a verdict per Todo task. No logging, no Linear calls, no shell-outs.
4
+ * a verdict per Todo task. No logging, no Linear calls, no shell-outs of its
5
+ * own: the one exception is the stacking decision in `classifyBlockers`,
6
+ * which reads the blocker's run state and probes its branch's availability.
7
+ * Both effects are routed through the injectable `EligibilityDeps` so
8
+ * callers can fake them the way the rest of the codebase fakes git.
9
+ * `probeParentBranch` also logs at WARN on a persistent `ls-remote` failure —
10
+ * the one logging exception, kept because a silent auth failure would
11
+ * otherwise hide forever behind the optimistic "unpushed, retry" verdict.
5
12
  *
6
13
  * The Dispatcher consumes the verdict list to drive logging and side
7
14
  * effects.
8
15
  */
16
+ import { runCommandAsync } from "../lib/commandRunner.js";
9
17
  import { AGENT_ANY } from "../lib/config.js";
18
+ import { readRunState } from "../lib/runState.js";
10
19
  import { naturalIdFromCanonical } from "../lib/taskSource.js";
20
+ import { errorMessage, log, memoizeAsync } from "../lib/util.js";
21
+ import { localBranchExists, resolveRepoDir } from "../lib/worktrees.js";
11
22
  const PERCENT_FRACTION_DIVISOR = 100;
12
23
  const DAYS_PER_WEEK = 7;
13
24
  const MINUTES_PER_DAY = 24 * 60;
14
25
  const MINUTES_PER_WEEK = DAYS_PER_WEEK * MINUTES_PER_DAY;
26
+ async function isBranchOnRemote(arguments_) {
27
+ try {
28
+ const output = await runCommandAsync("git", [
29
+ "-C",
30
+ arguments_.repoDir,
31
+ "ls-remote",
32
+ "--heads",
33
+ arguments_.remote,
34
+ `refs/heads/${arguments_.branch}`,
35
+ ]);
36
+ return output.length > 0;
37
+ }
38
+ catch (error) {
39
+ // Network/auth failures are common and expected (retried next tick), but
40
+ // silent forever would hide a persistent auth failure. WARN once per
41
+ // branch per tick — probeParentBranch is memoized per branch within a
42
+ // single classifyBlockers call, so this can't spam.
43
+ log(`Stack parent branch probe failed for ${arguments_.branch}; treating as unpushed and retrying next tick: ${errorMessage(error)}`);
44
+ return undefined;
45
+ }
46
+ }
47
+ async function probeParentBranch(arguments_) {
48
+ const onRemote = await isBranchOnRemote(arguments_);
49
+ if (onRemote === true) {
50
+ return "pushed";
51
+ }
52
+ if (onRemote === undefined) {
53
+ return "unpushed";
54
+ }
55
+ return (await localBranchExists(arguments_.repoDir, arguments_.branch)) ? "unpushed" : "unknown";
56
+ }
57
+ /**
58
+ * Memoizes `probeParentBranch` by branch for the lifetime of a single
59
+ * `classifyBlockers` call, so N siblings blocked by the same parent do one
60
+ * `git ls-remote` probe instead of N. Wrapping happens fresh per call — the
61
+ * cache must never survive past one dispatcher tick, or a since-pushed
62
+ * branch could keep reading as unpushed.
63
+ */
64
+ function memoizedProbeParentBranch(probeParentBranchDep) {
65
+ const cache = new Map();
66
+ return async (arguments_) => await memoizeAsync(cache, arguments_.branch, async () => await probeParentBranchDep(arguments_));
67
+ }
68
+ export const defaultEligibilityDeps = {
69
+ readParentRunState: readRunState,
70
+ probeParentBranch,
71
+ };
15
72
  function blockerSummary(blocker) {
16
73
  return `${blocker.id}:${blocker.status}`;
17
74
  }
18
- function blockerVerdictFor(issue) {
75
+ function stackSkip(issue, blockers, eventReason, message) {
76
+ return {
77
+ kind: "skip",
78
+ issue,
79
+ message,
80
+ eventReason,
81
+ blockers: blockers.map(blockerSummary),
82
+ };
83
+ }
84
+ function normalizedTaskId(task) {
85
+ return task.toLowerCase();
86
+ }
87
+ function readBlockerRunStates(blockers, config, deps) {
88
+ return new Map(blockers.map((blocker) => {
89
+ const task = naturalIdFromCanonical(blocker.id);
90
+ return [normalizedTaskId(task), deps.readParentRunState(config, task)];
91
+ }));
92
+ }
93
+ /**
94
+ * Every task reachable from `start` by following `parentTask` links, read
95
+ * lazily so intermediate ancestors that are not themselves blockers (already
96
+ * merged, or never blocking this issue) still connect the chain. The visited
97
+ * set is the cycle guard.
98
+ */
99
+ function stackAncestorsOf(start, runStates, config, deps) {
100
+ const ancestors = new Set();
101
+ let current = runStates.get(start);
102
+ while (current?.parentTask !== undefined) {
103
+ const parent = normalizedTaskId(current.parentTask);
104
+ if (ancestors.has(parent) || parent === start) {
105
+ break;
106
+ }
107
+ ancestors.add(parent);
108
+ current = runStates.has(parent)
109
+ ? runStates.get(parent)
110
+ : deps.readParentRunState(config, current.parentTask);
111
+ }
112
+ return ancestors;
113
+ }
114
+ /**
115
+ * With several unresolved blockers, the child can still stack when they form
116
+ * a chain: exactly one blocker (the tip) has every other blocker among its
117
+ * stack ancestors, so its branch already carries all of their work. Siblings
118
+ * off the default branch have no single base and yield `undefined`.
119
+ */
120
+ function chainTipOf(blockers, runStates, config, deps) {
121
+ const candidates = blockers.map((blocker) => ({
122
+ blocker,
123
+ task: normalizedTaskId(naturalIdFromCanonical(blocker.id)),
124
+ }));
125
+ const tips = candidates.filter(({ task }) => {
126
+ const ancestors = stackAncestorsOf(task, runStates, config, deps);
127
+ return candidates.every((other) => other.task === task || ancestors.has(other.task));
128
+ });
129
+ const [tip, ...rest] = tips;
130
+ return rest.length === 0 ? tip?.blocker : undefined;
131
+ }
132
+ /**
133
+ * The stacking check from the stacked-PRs design (spec section 1). Cheap,
134
+ * dependency-free checks (provisioner, opt-out label) run before the
135
+ * run-state reads, which run before the `git ls-remote` probe, so a task that
136
+ * fails early never pays for the network call. Multiple unresolved blockers
137
+ * stack only when they form a chain (see `chainTipOf`).
138
+ */
139
+ async function stackDecisionFor(issue, unresolved, config, deps) {
140
+ const repositoryEntry = config.workspace.repositories.find((entry) => entry.name === issue.repository);
141
+ if (repositoryEntry?.provision !== undefined) {
142
+ return stackSkip(issue, unresolved, "stack_provisioned_repo", `Skipping ${issue.id}: stacking is refused for scripted-provisioner repositories`);
143
+ }
144
+ if (issue.stacking === "opted-out") {
145
+ return stackSkip(issue, unresolved, "stack_opted_out", `Skipping ${issue.id}: opted out of stacking via the groundcrew-no-stack label`);
146
+ }
147
+ const runStates = readBlockerRunStates(unresolved, config, deps);
148
+ const foreign = unresolved.filter((blocker) => {
149
+ const repository = runStates.get(normalizedTaskId(naturalIdFromCanonical(blocker.id)))?.repository;
150
+ return repository !== undefined && repository !== issue.repository;
151
+ });
152
+ if (foreign.length > 0) {
153
+ return stackSkip(issue, unresolved, "stack_cross_repo_blocker", `Skipping ${issue.id}: blocked by ${foreign.map(blockerSummary).join(", ")} in another repository; a branch cannot stack across repositories`);
154
+ }
155
+ const [singleBlocker] = unresolved;
156
+ const blocker = unresolved.length === 1 ? singleBlocker : chainTipOf(unresolved, runStates, config, deps);
157
+ if (blocker === undefined) {
158
+ return stackSkip(issue, unresolved, "stack_multiple_blockers", `Skipping ${issue.id}: blocked by ${unresolved.map(blockerSummary).join(", ")}, which do not form a single stack`);
159
+ }
160
+ return await stackOntoBlocker(issue, blocker, runStates, config, deps);
161
+ }
162
+ async function stackOntoBlocker(issue, blocker, runStates, config, deps) {
163
+ const parentTask = naturalIdFromCanonical(blocker.id);
164
+ const parentRunState = runStates.get(normalizedTaskId(parentTask));
165
+ if (parentRunState === undefined) {
166
+ return stackSkip(issue, [blocker], "stack_parent_unknown", `Skipping ${issue.id}: blocker ${parentTask} has no run state in repository ${issue.repository}`);
167
+ }
168
+ const availability = await deps.probeParentBranch({
169
+ repoDir: resolveRepoDir(config, issue.repository),
170
+ remote: config.git.remote,
171
+ branch: parentRunState.branchName,
172
+ });
173
+ if (availability === "unknown") {
174
+ return stackSkip(issue, [blocker], "stack_parent_unknown", `Skipping ${issue.id}: blocker ${parentTask}'s branch ${parentRunState.branchName} is missing locally and on the remote`);
175
+ }
176
+ if (availability === "unpushed") {
177
+ return stackSkip(issue, [blocker], "stack_parent_unpushed", `Skipping ${issue.id}: blocker ${parentTask}'s branch isn't pushed yet`);
178
+ }
179
+ return { kind: "stack", issue, baseBranch: parentRunState.branchName, parentTask };
180
+ }
181
+ async function blockerVerdictFor(issue, config, deps) {
19
182
  if (issue.hasMoreBlockers) {
20
183
  const blockers = issue.blockers.map(blockerSummary);
21
184
  return {
@@ -30,6 +193,9 @@ function blockerVerdictFor(issue) {
30
193
  if (unresolved.length === 0) {
31
194
  return undefined;
32
195
  }
196
+ if (config.git.stacking === true) {
197
+ return await stackDecisionFor(issue, unresolved, config, deps);
198
+ }
33
199
  const blockers = unresolved.map(blockerSummary);
34
200
  return {
35
201
  kind: "skip",
@@ -145,24 +311,39 @@ function classifyRecovery(arguments_) {
145
311
  return { kind: "go", recovery: true };
146
312
  }
147
313
  /**
148
- * Cheap pre-pass — partitions Todo into unblocked issues and blocker
314
+ * Cheap pre-pass — partitions Todo into unblocked issues (including those
315
+ * stacking on a single unresolved blocker, per `stackDecisions`) and blocker
149
316
  * skip verdicts. Runs before the dispatcher fetches usage or probes the
150
317
  * workspace adapter, so a board where every Todo is blocked short-circuits
151
- * without paying for either.
318
+ * without paying for either. `config.git.stacking` off reproduces today's
319
+ * behavior exactly and performs no run-state reads or git calls.
152
320
  */
153
- export function classifyBlockers(todo) {
321
+ export async function classifyBlockers(todo, config, deps = defaultEligibilityDeps) {
154
322
  const unblocked = [];
155
323
  const skips = [];
324
+ const stackDecisions = new Map();
325
+ const memoizedDeps = {
326
+ ...deps,
327
+ probeParentBranch: memoizedProbeParentBranch(deps.probeParentBranch),
328
+ };
156
329
  for (const issue of todo) {
157
- const verdict = blockerVerdictFor(issue);
330
+ // oxlint-disable-next-line no-await-in-loop -- one blocker check at a time mirrors the dispatcher's own "one workspace at a time" git serialization
331
+ const verdict = await blockerVerdictFor(issue, config, memoizedDeps);
158
332
  if (verdict === undefined) {
159
333
  unblocked.push(issue);
160
334
  }
335
+ else if (verdict.kind === "stack") {
336
+ unblocked.push(issue);
337
+ stackDecisions.set(issue.id, {
338
+ baseBranch: verdict.baseBranch,
339
+ parentTask: verdict.parentTask,
340
+ });
341
+ }
161
342
  else {
162
343
  skips.push(verdict);
163
344
  }
164
345
  }
165
- return { unblocked, skips };
346
+ return { unblocked, stackDecisions, skips };
166
347
  }
167
348
  /**
168
349
  * Eligibility verdicts for already-unblocked Todo issues — handles
@@ -171,7 +352,7 @@ export function classifyBlockers(todo) {
171
352
  * the snapshots in.
172
353
  */
173
354
  export function classifyEligibility(arguments_) {
174
- const { config, unblocked, worktreeEntries, workspaceProbe, usage, exhausted, slots, dryRun } = arguments_;
355
+ const { config, unblocked, stackDecisions = new Map(), worktreeEntries, workspaceProbe, usage, exhausted, slots, dryRun, } = arguments_;
175
356
  const verdicts = [];
176
357
  let started = 0;
177
358
  for (const original of unblocked) {
@@ -217,11 +398,15 @@ export function classifyEligibility(arguments_) {
217
398
  verdicts.push({ ...recovery, agent: resolved.agent });
218
399
  continue;
219
400
  }
401
+ const stackDecision = stackDecisions.get(resolved.id);
220
402
  verdicts.push({
221
403
  kind: "start",
222
404
  issue: resolved,
223
405
  recovery: recovery.recovery,
224
406
  resolvedFromAny,
407
+ ...(stackDecision === undefined
408
+ ? {}
409
+ : { baseBranch: stackDecision.baseBranch, parentTask: stackDecision.parentTask }),
225
410
  });
226
411
  started += 1;
227
412
  }
@@ -1 +1 @@
1
- {"version":3,"file":"resumeWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/resumeWorkspace.ts"],"names":[],"mappings":"AAGA,OAAO,EAAoC,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAwBzF,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAoMD,wBAAsB,eAAe,CACnC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAiIf;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGtE"}
1
+ {"version":3,"file":"resumeWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/resumeWorkspace.ts"],"names":[],"mappings":"AAGA,OAAO,EAAoC,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAwBzF,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAuMD,wBAAsB,eAAe,CACnC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAmIf;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGtE"}
@@ -90,6 +90,7 @@ async function contextFromState(config, task, state, worktree) {
90
90
  }),
91
91
  ...(state.reason === undefined ? {} : { reason: state.reason }),
92
92
  resumeCount: state.resumeCount,
93
+ ...(state.baseBranch === undefined ? {} : { baseBranch: state.baseBranch }),
93
94
  };
94
95
  }
95
96
  function taskUsesLinearSource(arguments_) {
@@ -222,6 +223,8 @@ export async function resumeWorkspace(config, options) {
222
223
  workerEnvironment: workerEnvironmentForTask({
223
224
  taskId: context.completionTaskId,
224
225
  markDoneSupported: context.completionMarkDoneSupported,
226
+ remote: config.git.remote,
227
+ ...(context.baseBranch === undefined ? {} : { baseBranch: context.baseBranch }),
225
228
  }),
226
229
  taskSourceWritePaths,
227
230
  safehouse: config.local.safehouse,
@@ -1 +1 @@
1
- {"version":3,"file":"reviewer.d.ts","sourceRoot":"","sources":["../../src/commands/reviewer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,KAAK,UAAU,EAIhB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIzD;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,UAAU,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,KAAK,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAAC;AAE7C,UAAU,YAAY;IACpB,KAAK,EAAE,KAAK,CAAC;IACb,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,MAAM,CAAC,EAAE,cAAc,CAAC;CACzB;AAED,sEAAsE;AACtE,UAAU,eAAe;IACvB,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,CAAC,UAAU,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AA+CD,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG,QAAQ,CAiJ3D"}
1
+ {"version":3,"file":"reviewer.d.ts","sourceRoot":"","sources":["../../src/commands/reviewer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,KAAK,UAAU,EAIhB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAKzD;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,UAAU,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,KAAK,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAAC;AAE7C,UAAU,YAAY;IACpB,KAAK,EAAE,KAAK,CAAC;IACb,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,MAAM,CAAC,EAAE,cAAc,CAAC;CACzB;AAED,sEAAsE;AACtE,UAAU,eAAe;IACvB,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,CAAC,UAAU,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AA+CD,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG,QAAQ,CA+J3D"}
@@ -27,6 +27,7 @@
27
27
  import { naturalIdFromCanonical, } from "../lib/taskSource.js";
28
28
  import { debug, errorMessage, log, logEvent } from "../lib/util.js";
29
29
  import { effectiveBranchName } from "../lib/worktreeRunState.js";
30
+ import { createStackRetarget } from "./stackRetarget.js";
30
31
  import { reapWorktrees } from "./teardownReporter.js";
31
32
  // Maps a worktree's PRs to the transition its task should make. A merged PR
32
33
  // means the work landed → done; an open PR on an in-progress task means it's
@@ -58,8 +59,21 @@ function matchingWorktreeEntries(arguments_) {
58
59
  }
59
60
  export function createReviewer(deps) {
60
61
  const { board, config, findPullRequests } = deps;
62
+ const stackRetarget = createStackRetarget({ findPullRequests });
61
63
  async function runOnce(arguments_) {
62
64
  const { state, worktreeEntries, dryRun, signal } = arguments_;
65
+ // Independent of the in-progress/in-review candidate scan below: a
66
+ // stacked task's baseBranch correction must not wait on there being an
67
+ // ordinary transition to make this tick.
68
+ if (config !== undefined) {
69
+ await stackRetarget.runOnce({
70
+ config,
71
+ state,
72
+ worktreeEntries,
73
+ dryRun,
74
+ ...(signal === undefined ? {} : { signal }),
75
+ });
76
+ }
63
77
  const candidates = state.issues.filter((issue) => issue.status === "in-progress" || issue.status === "in-review");
64
78
  if (candidates.length === 0) {
65
79
  return;
@@ -16,6 +16,10 @@ export interface SetupWorkspaceOptions {
16
16
  agent: string;
17
17
  /** Set to `skip` to bypass configured prepareWorktree hooks. */
18
18
  worktreePreparation?: WorktreePreparation;
19
+ /** Parent branch this task's worktree and PR are based on, when stacked. */
20
+ baseBranch?: string;
21
+ /** Canonical id of the blocker task this task is stacked on. */
22
+ parentTask?: string;
19
23
  details: TaskDetails;
20
24
  }
21
25
  export interface SetupWorkspaceRunOptions {
@@ -1 +1 @@
1
- {"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAiBnE,OAAO,EAA0B,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAWxF,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6FAA6F;IAC7F,2BAA2B,CAAC,EAAE,OAAO,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;IAC1C,OAAO,EAAE,WAAW,CAAC;CACtB;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,CA8Jf;AAiOD,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAyDf"}
1
+ {"version":3,"file":"setupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/setupWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAiBnE,OAAO,EAA0B,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAWxF,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6FAA6F;IAC7F,2BAA2B,CAAC,EAAE,OAAO,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;IAC1C,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,WAAW,CAAC;CACtB;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,CA0Jf;AA2RD,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAyDf"}