@clipboard-health/groundcrew 4.52.7 → 4.52.8

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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Pure PR-stage and ticket derivation rules, ported from the standalone
3
+ * `crew-pr-stages.jq` / `crew-ticket.jq` poller this feature replaces. Keeping
4
+ * these as pure functions (no I/O) is what makes the 17 stage fixtures and 8
5
+ * ticket fixtures portable as plain unit tests.
6
+ */
7
+ const PR_STAGES = [
8
+ "merged",
9
+ "closed",
10
+ "ci_failing",
11
+ "ci_running",
12
+ "my_review",
13
+ "needs_testing",
14
+ "changes_requested",
15
+ "ready_to_merge",
16
+ "peer_review",
17
+ ];
18
+ const FAILING_CHECK_STATES = new Set(["FAILURE", "ERROR"]);
19
+ const RUNNING_CHECK_STATES = new Set(["PENDING", "EXPECTED"]);
20
+ /**
21
+ * Rule order is significant (first match wins) and mirrors the jq filter's
22
+ * `if/elif` chain exactly: self-review and testing are explicit manual gates
23
+ * (PR labels toggled from the sidebar), not proxies like draft state — a PR
24
+ * sits at `my_review` until self-reviewed is applied (draft or not), and at
25
+ * `needs_testing` until tested is applied, ahead of CI and review-decision.
26
+ */
27
+ export function derivePrStage(input, labelNames) {
28
+ if (input.state === "MERGED") {
29
+ return "merged";
30
+ }
31
+ if (input.state === "CLOSED") {
32
+ return "closed";
33
+ }
34
+ if (input.checks !== null && FAILING_CHECK_STATES.has(input.checks)) {
35
+ return "ci_failing";
36
+ }
37
+ if (input.checks !== null && RUNNING_CHECK_STATES.has(input.checks)) {
38
+ return "ci_running";
39
+ }
40
+ if (input.isDraft || !input.labels.includes(labelNames.selfReviewed)) {
41
+ return "my_review";
42
+ }
43
+ if (!input.labels.includes(labelNames.tested)) {
44
+ return "needs_testing";
45
+ }
46
+ if (input.reviewDecision === "CHANGES_REQUESTED") {
47
+ return "changes_requested";
48
+ }
49
+ if (input.reviewDecision === "APPROVED") {
50
+ return "ready_to_merge";
51
+ }
52
+ return "peer_review";
53
+ }
54
+ /** The managed-label subset of a PR's labels, sorted and comma-joined — the `crew_labels` sidebar value. */
55
+ export function managedLabelsField(labels, labelNames) {
56
+ const managed = new Set([labelNames.selfReviewed, labelNames.tested]);
57
+ return labels
58
+ .filter((label) => managed.has(label))
59
+ .toSorted((a, b) => a.localeCompare(b))
60
+ .join(",");
61
+ }
62
+ const TICKET_PREFIX_RE = /^[A-Za-z]+$/;
63
+ const TICKET_NUMBER_RE = /^[0-9]+$/;
64
+ const TICKET_PREFIX_MAX_LENGTH = 5;
65
+ /**
66
+ * Mirrors the retired sidebar `ticketOf`: a prefix must be 1-5 ASCII letters
67
+ * and the trailing segment a plain integer.
68
+ */
69
+ function ticketFromSegments(segments) {
70
+ if (segments.length < 2) {
71
+ return undefined;
72
+ }
73
+ const number = segments.at(-1);
74
+ const prefix = segments.at(-2);
75
+ /* v8 ignore next 3 @preserve -- segments.length >= 2 above guarantees both are defined; this only satisfies noUncheckedIndexedAccess */
76
+ if (number === undefined || prefix === undefined) {
77
+ return undefined;
78
+ }
79
+ if (TICKET_NUMBER_RE.test(number) &&
80
+ prefix.length <= TICKET_PREFIX_MAX_LENGTH &&
81
+ TICKET_PREFIX_RE.test(prefix)) {
82
+ return `${prefix}-${number}`.toUpperCase();
83
+ }
84
+ return undefined;
85
+ }
86
+ /** Worktree dirs are named `<repo>-<prefix>-<number>`; try the last two hyphen segments of the basename. */
87
+ function ticketFromDirectory(currentDirectory) {
88
+ /* v8 ignore next @preserve -- String.split always returns at least one element, so .at(-1) is never undefined here; the fallback only satisfies noUncheckedIndexedAccess */
89
+ const base = (currentDirectory ?? "").split("/").at(-1) ?? "";
90
+ if (base.length === 0) {
91
+ return undefined;
92
+ }
93
+ return ticketFromSegments(base.split("-"));
94
+ }
95
+ /** A title must lead with exactly one `PREFIX-NUMBER` token to count, unlike the directory form. */
96
+ function ticketFromTitle(title) {
97
+ /* v8 ignore next @preserve -- String.split always returns at least one element, so index 0 is never undefined here; the fallback only satisfies noUncheckedIndexedAccess */
98
+ const token = (title ?? "").split(" ")[0] ?? "";
99
+ const segments = token.split("-");
100
+ if (segments.length !== 2) {
101
+ return undefined;
102
+ }
103
+ return ticketFromSegments(segments);
104
+ }
105
+ /** Directory wins over title, matching the retired sidebar's precedence. */
106
+ export function deriveTicket(input) {
107
+ return ticketFromDirectory(input.currentDirectory) ?? ticketFromTitle(input.title);
108
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Batched GitHub PR detail lookup for `pr-stage-sync`: one GraphQL call per
3
+ * tick covers every tracked PR, each resolved through an aliased
4
+ * `resource(url:)` field with the url passed as a `URI!` variable (never
5
+ * interpolated into the query body). Mirrors the standalone
6
+ * `crew-pr-stages` poller's single-batch query shape.
7
+ */
8
+ import type { PrStageInput } from "./prStageRules.ts";
9
+ export type PullRequestDetail = PrStageInput & {
10
+ url: string;
11
+ };
12
+ interface FetchPullRequestDetailsArgs {
13
+ urls: readonly string[];
14
+ signal?: AbortSignal;
15
+ timeoutMs?: number;
16
+ }
17
+ /**
18
+ * Resolves every url to its detail, or to `undefined` when the GraphQL
19
+ * response has no matching resource (deleted, inaccessible, or not a PR).
20
+ * Throws only when the whole batch failed (gh missing, unauthenticated,
21
+ * network error, malformed response) — callers treat that as "couldn't
22
+ * refresh this tick", never as "no PRs".
23
+ */
24
+ export declare function fetchPullRequestDetails(args: FetchPullRequestDetailsArgs): Promise<Map<string, PullRequestDetail | undefined>>;
25
+ export {};
26
+ //# sourceMappingURL=pullRequestDetails.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pullRequestDetails.d.ts","sourceRoot":"","sources":["../../src/lib/pullRequestDetails.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EACV,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAE3B,MAAM,MAAM,iBAAiB,GAAG,YAAY,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/D,UAAU,2BAA2B;IACnC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA0FD;;;;;;GAMG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,2BAA2B,GAChC,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,iBAAiB,GAAG,SAAS,CAAC,CAAC,CA6BrD"}
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Batched GitHub PR detail lookup for `pr-stage-sync`: one GraphQL call per
3
+ * tick covers every tracked PR, each resolved through an aliased
4
+ * `resource(url:)` field with the url passed as a `URI!` variable (never
5
+ * interpolated into the query body). Mirrors the standalone
6
+ * `crew-pr-stages` poller's single-batch query shape.
7
+ */
8
+ import { runCommandAsync } from "./commandRunner.js";
9
+ const DEFAULT_GRAPHQL_TIMEOUT_MS = 60_000;
10
+ function resourceAlias(index) {
11
+ return `p${index}`;
12
+ }
13
+ function variableName(index) {
14
+ return `u${index}`;
15
+ }
16
+ function buildQuery(urls) {
17
+ const variableDefs = urls.map((_, index) => `$${variableName(index)}:URI!`).join(",");
18
+ const resources = urls
19
+ .map((_, index) => `${resourceAlias(index)}: resource(url: $${variableName(index)}) { ... on PullRequest { url state isDraft reviewDecision labels(first:20){nodes{name}} commits(last:1){nodes{commit{statusCheckRollup{state}}}} } }`)
20
+ .join(" ");
21
+ return `query(${variableDefs}){${resources} }`;
22
+ }
23
+ function isRecord(value) {
24
+ return typeof value === "object" && value !== null;
25
+ }
26
+ function isLifecycleState(value) {
27
+ return value === "OPEN" || value === "CLOSED" || value === "MERGED";
28
+ }
29
+ function isReviewDecision(value) {
30
+ return (value === null ||
31
+ value === "APPROVED" ||
32
+ value === "CHANGES_REQUESTED" ||
33
+ value === "REVIEW_REQUIRED");
34
+ }
35
+ function parseLabels(node) {
36
+ const labelsNode = node["labels"];
37
+ const nodes = isRecord(labelsNode) && Array.isArray(labelsNode["nodes"]) ? labelsNode["nodes"] : [];
38
+ const names = [];
39
+ for (const entry of nodes) {
40
+ const name = isRecord(entry) ? entry["name"] : undefined;
41
+ if (typeof name === "string") {
42
+ names.push(name);
43
+ }
44
+ }
45
+ return names;
46
+ }
47
+ function parseChecks(node) {
48
+ const commitsNode = node["commits"];
49
+ const commitNodes = isRecord(commitsNode) && Array.isArray(commitsNode["nodes"]) ? commitsNode["nodes"] : [];
50
+ const firstCommit = commitNodes[0];
51
+ if (!isRecord(firstCommit)) {
52
+ return null;
53
+ }
54
+ const commit = firstCommit["commit"];
55
+ if (!isRecord(commit)) {
56
+ return null;
57
+ }
58
+ const rollup = commit["statusCheckRollup"];
59
+ const state = isRecord(rollup) ? rollup["state"] : undefined;
60
+ return typeof state === "string" ? state : null;
61
+ }
62
+ function parsePullRequestDetail(node) {
63
+ if (!isRecord(node)) {
64
+ return undefined;
65
+ }
66
+ const { url, state, isDraft } = node;
67
+ if (typeof url !== "string" || !isLifecycleState(state) || typeof isDraft !== "boolean") {
68
+ return undefined;
69
+ }
70
+ const reviewDecision = node["reviewDecision"];
71
+ return {
72
+ url,
73
+ state,
74
+ isDraft,
75
+ labels: parseLabels(node),
76
+ reviewDecision: isReviewDecision(reviewDecision) ? reviewDecision : null,
77
+ checks: parseChecks(node),
78
+ };
79
+ }
80
+ /**
81
+ * Resolves every url to its detail, or to `undefined` when the GraphQL
82
+ * response has no matching resource (deleted, inaccessible, or not a PR).
83
+ * Throws only when the whole batch failed (gh missing, unauthenticated,
84
+ * network error, malformed response) — callers treat that as "couldn't
85
+ * refresh this tick", never as "no PRs".
86
+ */
87
+ export async function fetchPullRequestDetails(args) {
88
+ const { urls, signal, timeoutMs } = args;
89
+ const result = new Map();
90
+ if (urls.length === 0) {
91
+ return result;
92
+ }
93
+ const ghArguments = ["api", "graphql"];
94
+ urls.forEach((url, index) => {
95
+ ghArguments.push("-f", `${variableName(index)}=${url}`);
96
+ });
97
+ ghArguments.push("-f", `query=${buildQuery(urls)}`);
98
+ const output = await runCommandAsync("gh", ghArguments, {
99
+ timeoutMs: timeoutMs ?? DEFAULT_GRAPHQL_TIMEOUT_MS,
100
+ ...(signal === undefined ? {} : { signal }),
101
+ });
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(output);
105
+ }
106
+ catch (error) {
107
+ throw new Error("gh api graphql returned non-JSON output", { cause: error });
108
+ }
109
+ const data = isRecord(parsed) && isRecord(parsed["data"]) ? parsed["data"] : {};
110
+ urls.forEach((url, index) => {
111
+ result.set(url, parsePullRequestDetail(data[resourceAlias(index)]));
112
+ });
113
+ return result;
114
+ }
@@ -58,5 +58,12 @@ interface ResolvePullRequestArgs {
58
58
  */
59
59
  export declare function resolvePullRequest(arguments_: ResolvePullRequestArgs): Promise<ResolvedPullRequest>;
60
60
  export declare function findPullRequestsForBranch(arguments_: LookupArgs): Promise<readonly PullRequestSummary[]>;
61
+ /**
62
+ * Same lookup as `findPullRequestsForBranch`, but lets failures propagate
63
+ * instead of degrading to "no PR info". `pr-stage-sync` needs to tell "the
64
+ * branch truly has no PR" apart from "couldn't confirm this tick" so a flaky
65
+ * `gh` call never clears a saved stage or label.
66
+ */
67
+ export declare function findPullRequestsForBranchOrThrow(arguments_: LookupArgs): Promise<readonly PullRequestSummary[]>;
61
68
  export {};
62
69
  //# sourceMappingURL=pullRequests.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pullRequests.d.ts","sourceRoot":"","sources":["../../src/lib/pullRequests.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AASD,UAAU,UAAU;IAClB,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,oCAAoC;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAgDD,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAED,UAAU,sBAAsB;IAC9B,yFAAyF;IACzF,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AA2BD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,sBAAsB,GACjC,OAAO,CAAC,mBAAmB,CAAC,CAsC9B;AAED,wBAAsB,yBAAyB,CAC7C,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CA6BxC"}
1
+ {"version":3,"file":"pullRequests.d.ts","sourceRoot":"","sources":["../../src/lib/pullRequests.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AASD,UAAU,UAAU;IAClB,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,oCAAoC;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAgDD,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAED,UAAU,sBAAsB;IAC9B,yFAAyF;IACzF,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AA2BD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,sBAAsB,GACjC,OAAO,CAAC,mBAAmB,CAAC,CAsC9B;AA0BD,wBAAsB,yBAAyB,CAC7C,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAWxC;AAED;;;;;GAKG;AACH,wBAAsB,gCAAgC,CACpD,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAExC"}
@@ -114,26 +114,29 @@ export async function resolvePullRequest(arguments_) {
114
114
  isCrossRepository: parsed.isCrossRepository,
115
115
  };
116
116
  }
117
- export async function findPullRequestsForBranch(arguments_) {
117
+ async function listPullRequestsForBranch(arguments_) {
118
118
  const { cwd, branchName, signal } = arguments_;
119
119
  const options = signal === undefined ? { cwd } : { cwd, signal };
120
+ const output = await runCommandAsync("gh", [
121
+ "pr",
122
+ "list",
123
+ "--head",
124
+ branchName,
125
+ "--state",
126
+ "all",
127
+ "--limit",
128
+ String(GH_PR_LIST_LIMIT),
129
+ "--json",
130
+ "url,number,state,title",
131
+ ], options);
132
+ return parsePullRequests(output);
133
+ }
134
+ export async function findPullRequestsForBranch(arguments_) {
120
135
  try {
121
- const output = await runCommandAsync("gh", [
122
- "pr",
123
- "list",
124
- "--head",
125
- branchName,
126
- "--state",
127
- "all",
128
- "--limit",
129
- String(GH_PR_LIST_LIMIT),
130
- "--json",
131
- "url,number,state,title",
132
- ], options);
133
- return parsePullRequests(output);
136
+ return await listPullRequestsForBranch(arguments_);
134
137
  }
135
138
  catch (error) {
136
- if (signal?.aborted === true) {
139
+ if (arguments_.signal?.aborted === true) {
137
140
  throw error;
138
141
  }
139
142
  // gh not installed / not authenticated / non-GitHub remote / network
@@ -141,3 +144,12 @@ export async function findPullRequestsForBranch(arguments_) {
141
144
  return [];
142
145
  }
143
146
  }
147
+ /**
148
+ * Same lookup as `findPullRequestsForBranch`, but lets failures propagate
149
+ * instead of degrading to "no PR info". `pr-stage-sync` needs to tell "the
150
+ * branch truly has no PR" apart from "couldn't confirm this tick" so a flaky
151
+ * `gh` call never clears a saved stage or label.
152
+ */
153
+ export async function findPullRequestsForBranchOrThrow(arguments_) {
154
+ return await listPullRequestsForBranch(arguments_);
155
+ }
package/docs/commands.md CHANGED
@@ -176,6 +176,18 @@ When `--prompt`/`--prompt-file` is given, the agent starts with that prompt; oth
176
176
 
177
177
  `crew cleanup <task>` removes the opened worktree but never deletes the remote PR branch. Fork (cross-repository) PRs and `provision`/sparse-checkout repositories are not supported; for a fork, check the branch out locally and use `--branch`.
178
178
 
179
+ ## Stage
180
+
181
+ `crew stage` manages `cmux.prStages`'s two gating labels and runs an on-demand sync pass. The sync pass requires the resolved `workspaceKind` to be `cmux`; it runs even when `cmux.prStages.enabled` is `false` (see [Configuration](./configuration.md)) — that flag only gates the per-tick sync inside the watch loop.
182
+
183
+ ```bash
184
+ crew stage label-add <pr-url> <label> # add a configured gating label to a PR
185
+ crew stage label-remove <pr-url> <label> # remove a configured gating label from a PR
186
+ crew stage refresh # run one pr-stage-sync pass now
187
+ ```
188
+
189
+ `<pr-url>` must be a full `https://github.com/<owner>/<repo>/pull/<number>` link. `<label>` must be one of the two names configured under `cmux.prStages.labels` (`self-reviewed`/`tested` by default) — any other label is rejected. `label-add` creates the label in the PR's repo on first use (`self-reviewed`: `#5319E7`, `tested`: `#0E8A16`) before applying it with `gh pr edit`. Both `label-add` and `label-remove` trigger a sync pass afterward so the cmux sidebar reflects the change immediately, without waiting for the next watch tick.
190
+
179
191
  ## Completions
180
192
 
181
193
  `crew completions <bash|zsh|fish>` prints a shell completion script to stdout. The script completes command names, subcommands (for `crew source` and `crew task`), flags, and enumerated flag values (`--runner`, `--status`, `--agent`); `--prompt-file` and `--project-dir` fall back to file and directory completion. Task IDs, source names, and repositories are configuration-specific and are not completed.
@@ -325,36 +325,39 @@ and hook contract.
325
325
 
326
326
  ## Full Reference
327
327
 
328
- | Key | Default | What it does |
329
- | ----------------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
330
- | `sources` | `[]` | Additional pluggable task sources, dispatched alongside the built-in Linear adapter. Built-in kinds: `shell`, `linear`. Declare `{ kind: "linear", team: "ENG", statuses: { ... } }` to configure Linear task creation's default team and/or override Linear status names used for `in-progress` / `in-review` disambiguation. Disable the implicit Linear source with `{ kind: "linear", enabled: false }` (no API key required) — useful for shell-only setups. |
331
- | `sources[].sandboxWritePaths` | optional | Shell sources only. Local directories that the `safehouse` sandbox runner opens for read and write while an agent works a task from that source. Use this when a shell-backed task store lives outside the worktree, such as `~/plans`. Paths may be absolute, relative to the agent working directory, or `~`-prefixed. Not supported by the `sdx` runner. |
332
- | `git.remote` | `"origin"` | Remote used for `fetch` and as the worktree base ref. |
333
- | `git.defaultBranch` | `"main"` | Branch fetched from `git.remote` and used as the worktree base. |
334
- | `git.branchPrefix` | OS username | Prefix groundcrew puts before the task id when naming a worktree branch (`<branchPrefix>-<task>`). Must be a slash-free slug of letters, digits, `.`, `_`, or `-`. Defaults to the OS account username. Changing it only affects newly created worktrees; existing local branches keep their original names until cleaned up. Prefer a per-user config for personal prefixes — a committed `git.branchPrefix` gives every contributor the same branch prefix. |
335
- | `workspace.projectDir` | **required** | Parent dir for cloned repos and the default task worktree root. |
336
- | `workspace.worktreeDir` | optional | Parent dir for task worktrees. When unset, worktrees are created under `workspace.projectDir`. Changing this only affects worktrees discovered under the new root; clean up existing worktrees before switching it, or temporarily unset it when you need `crew cleanup` to find old worktrees. |
337
- | `workspace.knownRepositories` | **required** | Repos searched for in task descriptions to infer where work belongs. Entries can be strings under `workspace.projectDir` or `{ name, projectDirOverride }` objects when a repo clone lives under a different parent dir. A task labeled for groundcrew (`agent-*`) fails fast when no known repo appears; unlabeled tasks are ignored. |
338
- | `workspace.useTaskTitleForPanelName` | `false` | When `true`, cmux workspace panels are titled with the task's ticket title instead of the task id; only a literal `true` opts in. Identity still keys on the task id (stored in the cmux description marker), so renaming a panel is safe. Only cmux paints a panel title — tmux and zellij ignore it. |
339
- | `workspace.knownRepositories[].hooks.prepareWorktree` | optional | Per-repo operator hook. Used when the repo has no committed `.groundcrew/config.json` `hooks.prepareWorktree`; beats `defaults.hooks.prepareWorktree`. Set it on a repo's object entry for repos you can't or don't want to commit groundcrew files into. Reuses the same `hooks` container and hook contract as the other two layers. |
340
- | `defaults.hooks.prepareWorktree` | optional | Global fallback repo-preparation command used only when neither the worktree's `.groundcrew/config.json` `hooks.prepareWorktree` nor the per-repo `knownRepositories[].hooks.prepareWorktree` is set. The hook runs after worktree creation and before the agent starts. Repo-local config and the per-repo layer both win. |
341
- | `orchestrator.maximumInProgress` | `4` | Cap on in-progress tasks at once for this `crew` instance. |
342
- | `orchestrator.pollIntervalMilliseconds` | `120_000` | Poll interval in `--watch` mode. |
343
- | `orchestrator.sessionLimitPercentage` | `85` | Number in `(0, 100]`. An agent whose codexbar session window exceeds this percentage is skipped that tick. Agents are also skipped when codexbar reports weekly usage over the current weekly paced budget. |
344
- | `agents.default` | `"claude"` | Tiebreak for `agent-any` resolution and fallback for explicit but unknown `agent-*` labels. Also used by `crew start <TASK>` for unlabeled tasks. `crew run` ignores unlabeled tasks and does not apply this default. Must exist in `agents.definitions`. If you enable only `codex`, set `default: "codex"`. |
345
- | `agents.definitions` | **required** | Enabled launch profile set. Built-in keys (`claude`, `codex`, `cursor`, `cursor-grok`, `pi`) can use `{}` to opt into the shipped preset. Custom profile names must provide `cmd` and `color`; use custom profiles such as `claude-fable` and `claude-opus` to select model-specific commands per task. |
346
- | `agents.definitions.<name>.weight` | `1` (when omitted) | Positive finite number dividing session usage for `agent-any` ranking. Higher weight favors an agent; session and weekly exhaustion gates are unchanged. |
347
- | `agents.definitions.<name>.cmd` | preset for built-ins | Shell command launched for the agent. Required for custom profiles. Runs in the worktree through the resolved `local.runner`. `{{worktree}}` is replaced before launch; `{{sandbox}}` expands to the sbx sandbox name under the sdx runner and an empty string otherwise. |
348
- | `agents.definitions.<name>.color` | preset for built-ins | Color for the workspace status pill (cmux only; tmux and zellij silently drop it). Required for custom profiles. |
349
- | `agents.definitions.<name>.usage` | preset for built-ins | If set, codexbar usage is fetched for this agent and gated by `sessionLimitPercentage` plus the weekly paced budget when codexbar exposes a weekly window. When `usage.codexbar.source` is omitted, groundcrew uses `oauth` for Codex on macOS, `cli` for Claude on macOS (its OAuth probe breaks whenever the token rotates, and codexbar's CLI cannot refresh it), `auto` for other macOS providers, and `cli` elsewhere. Set to `{ disabled: true }` to disable usage gating while keeping the agent enabled. |
350
- | `agents.definitions.<name>.sandbox` | optional | Docker Sandboxes binding for the agent. Required at launch when `local.runner` resolves to `sdx`. Field: `agent` (required sbx agent name). Groundcrew assumes the `groundcrew-<agent>` sandbox already exists. |
351
- | `agents.definitions.<name>.preLaunch` | optional | Host-only shell snippet run before the agent exec and outside Safehouse/sdx. Exports survive into the launch shell; under the default `safehouse` runner they are only forwarded to the agent when listed via `preLaunchEnv` or when `cmd` includes its own `safehouse --env-pass=NAMES`. `{{worktree}}` is substituted. A non-zero exit aborts launch. Not supported when `local.runner` resolves to `sdx` in v1. |
352
- | `agents.definitions.<name>.preLaunchEnv` | optional | Companion to `preLaunch`: list of env var names to append to groundcrew's Safehouse `--env-pass=` flag, so `preLaunch` exports reach the agent without overriding `cmd`. Each entry must match `[A-Za-z_][A-Za-z0-9_]*`. `none` clears those names before `preLaunch`; its exports then inherit as-is. An empty array is a uniform no-op under every runner; non-empty lists reject a Safehouse-prefixed `cmd`, `sdx` runners, or Groundcrew-managed worker env vars. |
353
- | `agents.definitions.<name>.resumeArgs` | preset default | Shell args appended to `cmd` on `crew resume` so the agent [reopens its previous conversation](#resuming-the-agents-conversation) in the worktree. Defaults to `"--continue"` (claude, cursor, cursor-grok, pi) and `"resume --last"` (codex) on the built-in presets; set it for custom agents or to override. `crew resume --new` ignores it and cold-starts. No session id is stored. |
354
- | `prompts.initial` | unattended template | First message sent to the agent: the execution wrapper around each task. The task description is the task-specific prompt. Placeholders: `{{task}}`, `{{worktree}}`, `{{title}}`, `{{description}}`. Override only to change the execution contract for every task, such as team-wide review rules or tool conventions. Mutually exclusive with `prompts.promptFile`. |
355
- | `prompts.promptFile` | optional | Path to a UTF-8 file whose contents become `prompts.initial`, read at load time. Resolved relative to the config file's directory; `~` is expanded and absolute paths are used as-is. The JSON-friendly alternative to inlining a large prompt or `readFileSync`. Mutually exclusive with `prompts.initial`. |
356
- | `workspaceKind` | `"auto"` | Terminal session manager. `"auto"` picks `cmux` when on PATH, else `tmux`. Set to `"cmux"`, `"tmux"`, or `"zellij"` to fail loudly when the chosen backend is missing. |
357
- | `local.runner` | `"auto"` | Local isolation backend. `"auto"` uses `safehouse` on macOS and `sdx` on Linux/WSL. Explicit: `"safehouse"`, `"sdx"`, `"none"`. `"none"` is never picked implicitly. |
358
- | `local.networkEgress` | `"allowlisted"` | Network egress posture. With `local.runner: "safehouse"`, `"allowlisted"` wraps with Clearance and `"open"` keeps the Safehouse filesystem sandbox while opening network egress. Ignored by `sdx` and `none`. |
359
- | `local.readOnlyDirs` | `~/.config/tfenv` | Host directories re-opened read-only inside the Safehouse sandbox, for toolchains the profile masks but does not re-open. `~` is expanded. Defaults to tfenv's config root so `terraform`/`tfenv` work in the sandbox; set your own list to add or replace entries. Ignored by `sdx` and `none`. |
360
- | `logging.file` | XDG state path | Append-mode log file. `log()` / `logEvent()` tee here in addition to stdout. Defaults to `${XDG_STATE_HOME:-$HOME/.local/state}/groundcrew/groundcrew.log`. |
328
+ | Key | Default | What it does |
329
+ | ----------------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
330
+ | `sources` | `[]` | Additional pluggable task sources, dispatched alongside the built-in Linear adapter. Built-in kinds: `shell`, `linear`. Declare `{ kind: "linear", team: "ENG", statuses: { ... } }` to configure Linear task creation's default team and/or override Linear status names used for `in-progress` / `in-review` disambiguation. Disable the implicit Linear source with `{ kind: "linear", enabled: false }` (no API key required) — useful for shell-only setups. |
331
+ | `sources[].sandboxWritePaths` | optional | Shell sources only. Local directories that the `safehouse` sandbox runner opens for read and write while an agent works a task from that source. Use this when a shell-backed task store lives outside the worktree, such as `~/plans`. Paths may be absolute, relative to the agent working directory, or `~`-prefixed. Not supported by the `sdx` runner. |
332
+ | `git.remote` | `"origin"` | Remote used for `fetch` and as the worktree base ref. |
333
+ | `git.defaultBranch` | `"main"` | Branch fetched from `git.remote` and used as the worktree base. |
334
+ | `git.branchPrefix` | OS username | Prefix groundcrew puts before the task id when naming a worktree branch (`<branchPrefix>-<task>`). Must be a slash-free slug of letters, digits, `.`, `_`, or `-`. Defaults to the OS account username. Changing it only affects newly created worktrees; existing local branches keep their original names until cleaned up. Prefer a per-user config for personal prefixes — a committed `git.branchPrefix` gives every contributor the same branch prefix. |
335
+ | `workspace.projectDir` | **required** | Parent dir for cloned repos and the default task worktree root. |
336
+ | `workspace.worktreeDir` | optional | Parent dir for task worktrees. When unset, worktrees are created under `workspace.projectDir`. Changing this only affects worktrees discovered under the new root; clean up existing worktrees before switching it, or temporarily unset it when you need `crew cleanup` to find old worktrees. |
337
+ | `workspace.knownRepositories` | **required** | Repos searched for in task descriptions to infer where work belongs. Entries can be strings under `workspace.projectDir` or `{ name, projectDirOverride }` objects when a repo clone lives under a different parent dir. A task labeled for groundcrew (`agent-*`) fails fast when no known repo appears; unlabeled tasks are ignored. |
338
+ | `workspace.useTaskTitleForPanelName` | `false` | When `true`, cmux workspace panels are titled with the task's ticket title instead of the task id; only a literal `true` opts in. Identity still keys on the task id (stored in the cmux description marker), so renaming a panel is safe. Only cmux paints a panel title — tmux and zellij ignore it. |
339
+ | `workspace.knownRepositories[].hooks.prepareWorktree` | optional | Per-repo operator hook. Used when the repo has no committed `.groundcrew/config.json` `hooks.prepareWorktree`; beats `defaults.hooks.prepareWorktree`. Set it on a repo's object entry for repos you can't or don't want to commit groundcrew files into. Reuses the same `hooks` container and hook contract as the other two layers. |
340
+ | `defaults.hooks.prepareWorktree` | optional | Global fallback repo-preparation command used only when neither the worktree's `.groundcrew/config.json` `hooks.prepareWorktree` nor the per-repo `knownRepositories[].hooks.prepareWorktree` is set. The hook runs after worktree creation and before the agent starts. Repo-local config and the per-repo layer both win. |
341
+ | `orchestrator.maximumInProgress` | `4` | Cap on in-progress tasks at once for this `crew` instance. |
342
+ | `orchestrator.pollIntervalMilliseconds` | `120_000` | Poll interval in `--watch` mode. |
343
+ | `orchestrator.sessionLimitPercentage` | `85` | Number in `(0, 100]`. An agent whose codexbar session window exceeds this percentage is skipped that tick. Agents are also skipped when codexbar reports weekly usage over the current weekly paced budget. |
344
+ | `agents.default` | `"claude"` | Tiebreak for `agent-any` resolution and fallback for explicit but unknown `agent-*` labels. Also used by `crew start <TASK>` for unlabeled tasks. `crew run` ignores unlabeled tasks and does not apply this default. Must exist in `agents.definitions`. If you enable only `codex`, set `default: "codex"`. |
345
+ | `agents.definitions` | **required** | Enabled launch profile set. Built-in keys (`claude`, `codex`, `cursor`, `cursor-grok`, `pi`) can use `{}` to opt into the shipped preset. Custom profile names must provide `cmd` and `color`; use custom profiles such as `claude-fable` and `claude-opus` to select model-specific commands per task. |
346
+ | `agents.definitions.<name>.weight` | `1` (when omitted) | Positive finite number dividing session usage for `agent-any` ranking. Higher weight favors an agent; session and weekly exhaustion gates are unchanged. |
347
+ | `agents.definitions.<name>.cmd` | preset for built-ins | Shell command launched for the agent. Required for custom profiles. Runs in the worktree through the resolved `local.runner`. `{{worktree}}` is replaced before launch; `{{sandbox}}` expands to the sbx sandbox name under the sdx runner and an empty string otherwise. |
348
+ | `agents.definitions.<name>.color` | preset for built-ins | Color for the workspace status pill (cmux only; tmux and zellij silently drop it). Required for custom profiles. |
349
+ | `agents.definitions.<name>.usage` | preset for built-ins | If set, codexbar usage is fetched for this agent and gated by `sessionLimitPercentage` plus the weekly paced budget when codexbar exposes a weekly window. When `usage.codexbar.source` is omitted, groundcrew uses `oauth` for Codex on macOS, `cli` for Claude on macOS (its OAuth probe breaks whenever the token rotates, and codexbar's CLI cannot refresh it), `auto` for other macOS providers, and `cli` elsewhere. Set to `{ disabled: true }` to disable usage gating while keeping the agent enabled. |
350
+ | `agents.definitions.<name>.sandbox` | optional | Docker Sandboxes binding for the agent. Required at launch when `local.runner` resolves to `sdx`. Field: `agent` (required sbx agent name). Groundcrew assumes the `groundcrew-<agent>` sandbox already exists. |
351
+ | `agents.definitions.<name>.preLaunch` | optional | Host-only shell snippet run before the agent exec and outside Safehouse/sdx. Exports survive into the launch shell; under the default `safehouse` runner they are only forwarded to the agent when listed via `preLaunchEnv` or when `cmd` includes its own `safehouse --env-pass=NAMES`. `{{worktree}}` is substituted. A non-zero exit aborts launch. Not supported when `local.runner` resolves to `sdx` in v1. |
352
+ | `agents.definitions.<name>.preLaunchEnv` | optional | Companion to `preLaunch`: list of env var names to append to groundcrew's Safehouse `--env-pass=` flag, so `preLaunch` exports reach the agent without overriding `cmd`. Each entry must match `[A-Za-z_][A-Za-z0-9_]*`. `none` clears those names before `preLaunch`; its exports then inherit as-is. An empty array is a uniform no-op under every runner; non-empty lists reject a Safehouse-prefixed `cmd`, `sdx` runners, or Groundcrew-managed worker env vars. |
353
+ | `agents.definitions.<name>.resumeArgs` | preset default | Shell args appended to `cmd` on `crew resume` so the agent [reopens its previous conversation](#resuming-the-agents-conversation) in the worktree. Defaults to `"--continue"` (claude, cursor, cursor-grok, pi) and `"resume --last"` (codex) on the built-in presets; set it for custom agents or to override. `crew resume --new` ignores it and cold-starts. No session id is stored. |
354
+ | `prompts.initial` | unattended template | First message sent to the agent: the execution wrapper around each task. The task description is the task-specific prompt. Placeholders: `{{task}}`, `{{worktree}}`, `{{title}}`, `{{description}}`. Override only to change the execution contract for every task, such as team-wide review rules or tool conventions. Mutually exclusive with `prompts.promptFile`. |
355
+ | `prompts.promptFile` | optional | Path to a UTF-8 file whose contents become `prompts.initial`, read at load time. Resolved relative to the config file's directory; `~` is expanded and absolute paths are used as-is. The JSON-friendly alternative to inlining a large prompt or `readFileSync`. Mutually exclusive with `prompts.initial`. |
356
+ | `workspaceKind` | `"auto"` | Terminal session manager. `"auto"` picks `cmux` when on PATH, else `tmux`. Set to `"cmux"`, `"tmux"`, or `"zellij"` to fail loudly when the chosen backend is missing. |
357
+ | `local.runner` | `"auto"` | Local isolation backend. `"auto"` uses `safehouse` on macOS and `sdx` on Linux/WSL. Explicit: `"safehouse"`, `"sdx"`, `"none"`. `"none"` is never picked implicitly. |
358
+ | `local.networkEgress` | `"allowlisted"` | Network egress posture. With `local.runner: "safehouse"`, `"allowlisted"` wraps with Clearance and `"open"` keeps the Safehouse filesystem sandbox while opening network egress. Ignored by `sdx` and `none`. |
359
+ | `local.readOnlyDirs` | `~/.config/tfenv` | Host directories re-opened read-only inside the Safehouse sandbox, for toolchains the profile masks but does not re-open. `~` is expanded. Defaults to tfenv's config root so `terraform`/`tfenv` work in the sandbox; set your own list to add or replace entries. Ignored by `sdx` and `none`. |
360
+ | `logging.file` | XDG state path | Append-mode log file. `log()` / `logEvent()` tee here in addition to stdout. Defaults to `${XDG_STATE_HOME:-$HOME/.local/state}/groundcrew/groundcrew.log`. |
361
+ | `cmux.prStages.enabled` | `false` | Opt-in: paints each task's cmux sidebar with its pull request's review stage (`crew_stage`) and gating labels (`crew_labels`) once per watch tick. Only takes effect when the resolved `workspaceKind` is `cmux`; other backends no-op. The ticket id (`crew_ticket`) is written for every matched cmux task workspace regardless of this flag, so sidebars that read `crew_ticket` work whether or not PR-stage sync is enabled. Manage the gating labels with `crew stage label-add`/`label-remove`, or run a pass on demand with `crew stage refresh`. |
362
+ | `cmux.prStages.labels.selfReviewed` | `"self-reviewed"` | Label name gating the `my_review` → `needs_testing` transition: a PR sits at `my_review` until this label is applied, regardless of draft state. |
363
+ | `cmux.prStages.labels.tested` | `"tested"` | Label name gating the `needs_testing` → review-decision stages: a PR sits at `needs_testing` until this label is applied. Must be distinct from `selfReviewed`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "4.52.7",
3
+ "version": "4.52.8",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",
@@ -84,12 +84,12 @@
84
84
  "@tsconfig/strictest": "2.0.8",
85
85
  "@types/node": "26.6.3",
86
86
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
87
- "@vitest/coverage-v8": "5.0.2",
88
- "dependency-cruiser": "18.4.0",
87
+ "@vitest/coverage-v8": "5.0.3",
88
+ "dependency-cruiser": "18.5.0",
89
89
  "husky": "9.1.7",
90
90
  "jscpd": "5.3.3",
91
- "knip": "6.35.1",
92
- "lint-staged": "17.5.1",
91
+ "knip": "6.38.0",
92
+ "lint-staged": "17.6.0",
93
93
  "markdownlint-cli2": "0.23.3",
94
94
  "nx": "23.1.0",
95
95
  "oxfmt": "0.63.0",
@@ -97,7 +97,7 @@
97
97
  "oxlint-tsgolint": "7.0.2003",
98
98
  "syncpack": "15.3.3",
99
99
  "vite": "8.3.1",
100
- "vitest": "5.0.2"
100
+ "vitest": "5.0.3"
101
101
  },
102
102
  "engines": {
103
103
  "node": "24.14.1",