@clipboard-health/groundcrew 1.12.0 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -126,37 +126,37 @@ This installs the `crew` binary. `@clipboard-health/clearance` is pulled in tran
126
126
 
127
127
  Required fields are marked **required**; everything else has a default and can be omitted from `config.ts`.
128
128
 
129
- | Key | Default | What it does |
130
- | --------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
- | `linear.projectSlug` | **required** | Linear project URL slug (e.g. `ai-strategy-5152195762f3`). The trailing 12-char hex `slugId` is what's matched against Linear's API; the leading name keeps `config.ts` self-documenting and the lookup survives project renames. |
132
- | `linear.statuses.todo` | `"Todo"` | Status name picked up for new work. |
133
- | `linear.statuses.inProgress` | `"In Progress"` | Status set after a workspace is provisioned; counts toward `maximumInProgress`. |
134
- | `linear.statuses.done` | `"Done"` | Status that triggers worktree cleanup. |
135
- | `linear.statuses.terminal` | `["Done"]` | Additional status names treated as terminal for cleanup, board remaining counts, and blocker checks. The `done` status is always included. |
136
- | `git.remote` | `"origin"` | Remote used for `fetch` and as the worktree base ref. |
137
- | `git.defaultBranch` | `"main"` | Branch fetched from `git.remote` and used as the worktree base. |
138
- | `workspace.projectDir` | **required** | Parent dir for cloned repos and local sibling ticket worktrees. Remote ticket worktrees live in `remote.worktreeRoot` on the remote runner. |
139
- | `workspace.knownRepositories` | **required** | Repos searched for in ticket descriptions to infer where work belongs. A ticket labeled for groundcrew (`agent-*`) fails fast when no known repo appears; unlabeled tickets are ignored. |
140
- | `orchestrator.maximumInProgress` | `4` | Cap on tickets in `linear.statuses.inProgress` at once. |
141
- | `orchestrator.pollIntervalMilliseconds` | `120_000` | Poll interval in `--watch` mode. |
142
- | `orchestrator.sessionLimitPercentage` | `85` | Number in `(0, 100]`. A model whose codexbar session window exceeds this percentage is skipped that tick. |
143
- | `models.default` | `"claude"` | Tiebreak for `agent-any` resolution and fallback for explicit but unknown `agent-*` labels. Also used by `crew setup <TICKET>` for unlabeled tickets. `crew run` ignores unlabeled tickets and does not apply this default. Must exist in `models.definitions`. |
144
- | `models.definitions` | `{ claude, codex }` | Agent definitions. Additive merge with shipped defaults. |
145
- | `models.definitions.<name>.cmd` | — | Shell command launched for the model. Local macOS runs execute in the worktree through Safehouse/clearance; remote runs execute inside the remote worktree. `{{worktree}}` is replaced before launch and legacy `{{sandbox}}` expands to an empty string. |
146
- | `models.definitions.<name>.color` | — | Color for the workspace status pill (cmux only; tmux silently drops it). |
147
- | `models.definitions.<name>.usage` | optional | If set, codexbar usage is fetched for this model and gated by `sessionLimitPercentage`. Omit to never gate. When `usage.codexbar.source` is omitted, groundcrew uses `auto` on macOS and `cli` elsewhere. |
148
- | `models.definitions.<name>.disabled` | optional | When set to exactly `true`, drops the named shipped default (`claude` or `codex`). Doctor skips probing it; `agent-<name>` labels fall back to `models.default` with a warning. See "Disabling a shipped default" below. |
149
- | `prompts.initial` | (template) | First message sent to the agent. Placeholders: `{{ticket}}`, `{{worktree}}`, `{{title}}`, `{{description}}`. |
150
- | `workspaceKind` | `"auto"` | Terminal session manager. `"auto"` picks `cmux` when on PATH, else `tmux`. Set to `"cmux"` or `"tmux"` to fail loudly when the chosen backend is missing. tmux windows live in a dedicated `groundcrew` session. |
151
- | `remote.provider` | `"sprite"` | Remote runner provider used for tickets labeled `agent-remote`. Sprite is currently the only provider. |
152
- | `remote.runnerName` | `"crew-claude-1"` | Remote runner used for tickets labeled `agent-remote`. |
153
- | `remote.owner` | `"ClipboardHealth"` | GitHub owner used when cloning bare repository names inside the remote runner. |
154
- | `remote.repoRoot` | `"/home/sprite/dev"` | Shared clone root inside the remote runner. |
155
- | `remote.worktreeRoot` | `"/home/sprite/groundcrew/worktrees"` | Per-ticket remote worktree root inside the remote runner. |
156
- | `remote.secretNames` | `["NPM_TOKEN", "BUF_TOKEN"]` | Build-only env vars that may be uploaded for remote dependency setup, then unset before the agent process starts. |
157
- | `logging.file` | XDG state path | Append-mode log file destination. `log()` / `logEvent()` tee here in addition to stdout, so a vanished workspace doesn't take the evidence with it. Defaults to `${XDG_STATE_HOME:-$HOME/.local/state}/groundcrew/groundcrew.log`. |
158
-
159
- The branch prefix (`<prefix>-<TICKET>`) is derived from your OS username (`os.userInfo().username`), not configured. Agent selection looks for a top-level Linear label named `agent-<model>` (e.g. `agent-claude`, `agent-codex`). Add `agent-remote` to run that ticket in the configured remote runner instead of locally; `agent-remote` is a modifier label, not a model. **`crew run` only fetches tickets with an `agent-*` label** — the GraphQL query filters them server-side, so unlabeled tickets are never returned by Linear's API and do not appear in the rendered board. Use `crew setup <TICKET>` to provision an unlabeled ticket on demand (manual setup falls back to `models.default`). The reserved label `agent-any` routes the ticket to the configured model with the most available session capacity (lowest codexbar session-used percent), skipping any model already over `sessionLimitPercentage`. With no usage data, `agent-any` resolves to `models.default`. The name `any` cannot be used in `models.definitions`. Todo tickets blocked by Linear issues that are not in `linear.statuses.terminal` are skipped until their blockers reach a terminal status.
129
+ | Key | Default | What it does |
130
+ | --------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | `linear.projectSlug` | **required** | Linear project URL slug (e.g. `ai-strategy-5152195762f3`). The trailing 12-char hex `slugId` is what's matched against Linear's API; the leading name keeps `config.ts` self-documenting and the lookup survives project renames. |
132
+ | `linear.statuses.todo` | `"Todo"` | Status name picked up for new work. |
133
+ | `linear.statuses.inProgress` | `"In Progress"` | Status set after a workspace is provisioned; counts toward `maximumInProgress`. |
134
+ | `linear.statuses.done` | `"Done"` | Status that triggers worktree cleanup. |
135
+ | `linear.statuses.terminal` | `["Done"]` | Additional status names treated as terminal for cleanup, board remaining counts, and blocker checks. The `done` status is always included. |
136
+ | `git.remote` | `"origin"` | Remote used for `fetch` and as the worktree base ref. |
137
+ | `git.defaultBranch` | `"main"` | Branch fetched from `git.remote` and used as the worktree base. |
138
+ | `workspace.projectDir` | **required** | Parent dir for cloned repos and local sibling ticket worktrees. Remote ticket worktrees live in `remote.worktreeRoot` on the remote runner. |
139
+ | `workspace.knownRepositories` | **required** | Repos searched for in ticket descriptions to infer where work belongs. A ticket labeled for groundcrew (`agent-*`) fails fast when no known repo appears; unlabeled tickets are ignored. |
140
+ | `orchestrator.maximumInProgress` | `4` | Cap on tickets in `linear.statuses.inProgress` at once. |
141
+ | `orchestrator.pollIntervalMilliseconds` | `120_000` | Poll interval in `--watch` mode. |
142
+ | `orchestrator.sessionLimitPercentage` | `85` | Number in `(0, 100]`. A model whose codexbar session window exceeds this percentage is skipped that tick. |
143
+ | `models.default` | `"claude"` | Tiebreak for `agent-any` resolution and fallback for explicit but unknown `agent-*` labels. Also used by `crew run --ticket <TICKET>` for unlabeled tickets. `crew run` without `--ticket` ignores unlabeled tickets and does not apply this default. Must exist in `models.definitions`. |
144
+ | `models.definitions` | `{ claude, codex }` | Agent definitions. Additive merge with shipped defaults. |
145
+ | `models.definitions.<name>.cmd` | — | Shell command launched for the model. Local macOS runs execute in the worktree through Safehouse/clearance; remote runs execute inside the remote worktree. `{{worktree}}` is replaced before launch and legacy `{{sandbox}}` expands to an empty string. |
146
+ | `models.definitions.<name>.color` | — | Color for the workspace status pill (cmux only; tmux silently drops it). |
147
+ | `models.definitions.<name>.usage` | optional | If set, codexbar usage is fetched for this model and gated by `sessionLimitPercentage`. Omit to never gate. When `usage.codexbar.source` is omitted, groundcrew uses `auto` on macOS and `cli` elsewhere. |
148
+ | `models.definitions.<name>.disabled` | optional | When set to exactly `true`, drops the named shipped default (`claude` or `codex`). Doctor skips probing it; `agent-<name>` labels fall back to `models.default` with a warning. See "Disabling a shipped default" below. |
149
+ | `prompts.initial` | (template) | First message sent to the agent. Placeholders: `{{ticket}}`, `{{worktree}}`, `{{title}}`, `{{description}}`. |
150
+ | `workspaceKind` | `"auto"` | Terminal session manager. `"auto"` picks `cmux` when on PATH, else `tmux`. Set to `"cmux"` or `"tmux"` to fail loudly when the chosen backend is missing. tmux windows live in a dedicated `groundcrew` session. |
151
+ | `remote.provider` | `"sprite"` | Remote runner provider used for tickets labeled `agent-remote`. Sprite is currently the only provider. |
152
+ | `remote.runnerName` | `"crew-claude-1"` | Remote runner used for tickets labeled `agent-remote`. |
153
+ | `remote.owner` | `"ClipboardHealth"` | GitHub owner used when cloning bare repository names inside the remote runner. |
154
+ | `remote.repoRoot` | `"/home/sprite/dev"` | Shared clone root inside the remote runner. |
155
+ | `remote.worktreeRoot` | `"/home/sprite/groundcrew/worktrees"` | Per-ticket remote worktree root inside the remote runner. |
156
+ | `remote.secretNames` | `["NPM_TOKEN", "BUF_TOKEN"]` | Build-only env vars that may be uploaded for remote dependency setup, then unset before the agent process starts. |
157
+ | `logging.file` | XDG state path | Append-mode log file destination. `log()` / `logEvent()` tee here in addition to stdout, so a vanished workspace doesn't take the evidence with it. Defaults to `${XDG_STATE_HOME:-$HOME/.local/state}/groundcrew/groundcrew.log`. |
158
+
159
+ The branch prefix (`<prefix>-<TICKET>`) is derived from your OS username (`os.userInfo().username`), not configured. Agent selection looks for a top-level Linear label named `agent-<model>` (e.g. `agent-claude`, `agent-codex`). Add `agent-remote` to run that ticket in the configured remote runner instead of locally; `agent-remote` is a modifier label, not a model. **`crew run` without `--ticket` only fetches tickets with an `agent-*` label** — the GraphQL query filters them server-side, so unlabeled tickets are never returned by Linear's API and do not appear in the rendered board. Use `crew run --ticket <TICKET>` to provision an unlabeled ticket on demand (manual setup falls back to `models.default`). The reserved label `agent-any` routes the ticket to the configured model with the most available session capacity (lowest codexbar session-used percent), skipping any model already over `sessionLimitPercentage`. With no usage data, `agent-any` resolves to `models.default`. The name `any` cannot be used in `models.definitions`. Todo tickets blocked by Linear issues that are not in `linear.statuses.terminal` are skipped until their blockers reach a terminal status.
160
160
 
161
161
  ### Disabling a shipped default
162
162
 
@@ -202,7 +202,7 @@ crew setup repos [--dry-run] [<repo>...]
202
202
  crew cleanup <TICKET>
203
203
  ```
204
204
 
205
- `crew run --ticket <TICKET>` provisions a single ticket the same way the orchestrator would: the repo is parsed from the ticket's Linear description, the model comes from the ticket's `agent-*` label, and `agent-remote` is honored. If the description does not mention a repo from `workspace.knownRepositories`, setup fails before provisioning. `--watch` and `--ticket` are mutually exclusive — `--watch` drives the orchestrator loop; `--ticket` provisions one ticket and exits. `crew cleanup <TICKET>` resolves to every tracked worktree carrying that ticket id (host and remote kinds, across repos) and tears them all down. To inspect remote sessions, run `crew remote sessions` or pass an explicit runner name. To attach to a listed session id or command selector, run `crew remote attach <session-id-or-command>`. If an attached agent appears stuck in a long-running shell tool, use `crew remote ps <runner-name>` to find the child process group (`PGID`) under the agent, then use `crew remote interrupt <PGID> --runner <runner-name>` to send SIGINT to that child command without killing the agent session. With the Sprite provider, if cleanup cannot remove a remote worktree because the agent is still running, stop that session with `sprite sessions kill -s crew-claude-1 <session-id>` and retry cleanup. To inspect codexbar session windows directly, run `codexbar usage`; the orchestrator already gates on this internally via `orchestrator.sessionLimitPercentage`.
205
+ `crew run --ticket <TICKET>` provisions a single ticket the same way the orchestrator would: the repo is parsed from the ticket's Linear description, the model comes from the ticket's `agent-*` label, manual setup falls back to `models.default` for unlabeled tickets, and `agent-remote` is honored. If the description does not mention a repo from `workspace.knownRepositories`, setup fails before provisioning. `--watch` and `--ticket` are mutually exclusive — `--watch` drives the orchestrator loop; `--ticket` provisions one ticket and exits. `crew cleanup <TICKET>` resolves to every tracked worktree carrying that ticket id (host and remote kinds, across repos) and tears them all down. To inspect remote sessions, run `crew remote sessions` or pass an explicit runner name. To attach to a listed session id or command selector, run `crew remote attach <session-id-or-command>`. If an attached agent appears stuck in a long-running shell tool, use `crew remote ps <runner-name>` to find the child process group (`PGID`) under the agent, then use `crew remote interrupt <PGID> --runner <runner-name>` to send SIGINT to that child command without killing the agent session. With the Sprite provider, if cleanup cannot remove a remote worktree because the agent is still running, stop that session with `sprite sessions kill -s crew-claude-1 <session-id>` and retry cleanup. To inspect codexbar session windows directly, run `codexbar usage`; the orchestrator already gates on this internally via `orchestrator.sessionLimitPercentage`.
206
206
 
207
207
  ## Gotchas
208
208
 
@@ -1 +1 @@
1
- {"version":3,"file":"worktrees.d.ts","sourceRoot":"","sources":["../../src/lib/worktrees.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAcH,OAAO,EAA8B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAMpG,OAAO,EAAE,KAAK,cAAc,EAAc,MAAM,iBAAiB,CAAC;AAIlE,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE7C,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,YAAY,CAAC;IACnB,mCAAmC;IACnC,cAAc,CAAC,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAC,UAAU,CAAC,CAAC;IACtD,mCAAmC;IACnC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,mCAAmC;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAaD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAW7E;AAmVD,4FAA4F;AAC5F,iBAAS,IAAI,CAAC,MAAM,EAAE,cAAc,GAAG,aAAa,EAAE,CAErD;AAED,iBAAS,YAAY,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAE7E;AAED,iBAAS,YAAY,CACnB,MAAM,EAAE,cAAc,EACtB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,aAAa,GAAG,SAAS,CAI3B;AAED,iBAAe,MAAM,CACnB,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,YAAY,EAClB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAUxB;AAED,iBAAe,MAAM,CACnB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,aAAa,EACpB,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,MAAM,MAAM,YAAY,GAAG,iBAAiB,GAAG,iBAAiB,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,aAAa,CAAC;IACrB,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,+DAA+D;IAC/D,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,sCAAsC;IACtC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,wDAAwD;IACxD,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,cAAc,EAAE,cAAc,CAAC;CAChC;AAKD,iBAAe,QAAQ,CACrB,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,SAAS,aAAa,EAAE,EACjC,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAClD,OAAO,CAAC,cAAc,CAAC,CAkDzB;AAED,eAAO,MAAM,SAAS;;;;;;;CAOrB,CAAC"}
1
+ {"version":3,"file":"worktrees.d.ts","sourceRoot":"","sources":["../../src/lib/worktrees.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAiBH,OAAO,EAA8B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAMpG,OAAO,EAAE,KAAK,cAAc,EAAc,MAAM,iBAAiB,CAAC;AAIlE,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE7C,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,YAAY,CAAC;IACnB,mCAAmC;IACnC,cAAc,CAAC,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAC,UAAU,CAAC,CAAC;IACtD,mCAAmC;IACnC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,mCAAmC;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAaD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAW7E;AAyYD,4FAA4F;AAC5F,iBAAS,IAAI,CAAC,MAAM,EAAE,cAAc,GAAG,aAAa,EAAE,CAErD;AAED,iBAAS,YAAY,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAE7E;AAED,iBAAS,YAAY,CACnB,MAAM,EAAE,cAAc,EACtB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,aAAa,GAAG,SAAS,CAI3B;AAED,iBAAe,MAAM,CACnB,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,YAAY,EAClB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAUxB;AAED,iBAAe,MAAM,CACnB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,aAAa,EACpB,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,MAAM,MAAM,YAAY,GAAG,iBAAiB,GAAG,iBAAiB,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,aAAa,CAAC;IACrB,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,+DAA+D;IAC/D,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,sCAAsC;IACtC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,wDAAwD;IACxD,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,cAAc,EAAE,cAAc,CAAC;CAChC;AAKD,iBAAe,QAAQ,CACrB,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,SAAS,aAAa,EAAE,EACjC,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAClD,OAAO,CAAC,cAAc,CAAC,CAkDzB;AAED,eAAO,MAAM,SAAS;;;;;;;CAOrB,CAAC"}
@@ -10,7 +10,8 @@
10
10
  * pick the adapter by `spec.runner` (for `create`) or `entry.kind` (for
11
11
  * `remove`), mirroring the `workspaces` module's adapter pattern.
12
12
  */
13
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
14
+ import { randomUUID } from "node:crypto";
14
15
  import { homedir, userInfo } from "node:os";
15
16
  import { dirname, resolve } from "node:path";
16
17
  import { runCommandAsync } from "./commandRunner.js";
@@ -222,12 +223,33 @@ function readRemoteEntries() {
222
223
  }
223
224
  function writeRemoteEntries(entries) {
224
225
  const path = remoteStateFilePath();
225
- mkdirSync(dirname(path), { recursive: true });
226
- writeFileSync(path, `${JSON.stringify({ entries }, undefined, 2)}\n`);
226
+ const directory = dirname(path);
227
+ mkdirSync(directory, { recursive: true });
228
+ const temporaryPath = resolve(directory, `.remote-worktrees-${process.pid}-${randomUUID()}.tmp`);
229
+ let didReplaceStateFile = false;
230
+ try {
231
+ writeFileSync(temporaryPath, `${JSON.stringify({ entries }, undefined, 2)}\n`);
232
+ renameSync(temporaryPath, path);
233
+ didReplaceStateFile = true;
234
+ }
235
+ finally {
236
+ if (!didReplaceStateFile) {
237
+ removeTemporaryRemoteStateFileBestEffort(temporaryPath);
238
+ }
239
+ }
227
240
  }
228
241
  function upsertRemoteEntry(entry) {
229
242
  writeRemoteEntries([...readRemoteEntries(), entry]);
230
243
  }
244
+ function removeTemporaryRemoteStateFileBestEffort(path) {
245
+ try {
246
+ rmSync(path, { force: true });
247
+ }
248
+ catch (error) {
249
+ /* v8 ignore next @preserve -- only external filesystem races can make best-effort temp cleanup fail */
250
+ log(`Temporary remote state file cleanup skipped: ${errorMessage(error)}`);
251
+ }
252
+ }
231
253
  function deleteRemoteEntry(entry) {
232
254
  writeRemoteEntries(readRemoteEntries().filter((candidate) => !(candidate.repository === entry.repository &&
233
255
  candidate.ticket === entry.ticket &&
@@ -237,6 +259,20 @@ function deleteRemoteEntry(entry) {
237
259
  function remoteProviderFor(config, entry) {
238
260
  return getRemoteRunnerProvider(entry?.remoteProvider ?? config.remote.provider);
239
261
  }
262
+ async function removeCreatedRemoteWorktreeBestEffort(arguments_) {
263
+ try {
264
+ log(`Rolling back remote worktree ${arguments_.entry.dir} after local state persistence failed...`);
265
+ await arguments_.provider.removeWorktree({
266
+ config: arguments_.config.remote,
267
+ entry: arguments_.entry,
268
+ force: true,
269
+ ...signalProperty(arguments_.signal),
270
+ });
271
+ }
272
+ catch (error) {
273
+ log(`Remote worktree rollback skipped after local state persistence failed: ${errorMessage(error)}`);
274
+ }
275
+ }
240
276
  const remoteWorktreeAdapter = {
241
277
  async create(config, spec, signal) {
242
278
  const base = basePaths(config, spec.repository, spec.ticket);
@@ -260,7 +296,18 @@ const remoteWorktreeAdapter = {
260
296
  remoteRunnerName: config.remote.runnerName,
261
297
  remoteRepoDir,
262
298
  };
263
- upsertRemoteEntry(entry);
299
+ try {
300
+ upsertRemoteEntry(entry);
301
+ }
302
+ catch (error) {
303
+ await removeCreatedRemoteWorktreeBestEffort({
304
+ config,
305
+ provider,
306
+ entry,
307
+ ...signalProperty(signal),
308
+ });
309
+ throw error;
310
+ }
264
311
  return entry;
265
312
  },
266
313
  list(config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle, remote runners, and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",