@ran-sh/dsh-crew 1.9.0 → 1.10.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 (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +126 -126
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +3 -2
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -340
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +463 -455
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. package/zcode/commands/dsh-status.md +5 -5
@@ -1,455 +1,463 @@
1
- // Git worktree isolation for parallel coding jobs. Every isolated coding job
2
- // runs in its own detached worktree at a captured base revision, so concurrent
3
- // workers never write the same mutable working tree. The Main Agent receives
4
- // an auditable change candidate (bounded, redacted patch + name status) and
5
- // decides accept / reject / revise from there.
6
- //
7
- // Discipline: NEVER touches the primary working tree with reset / stash /
8
- // clean / checkout — the primary tree may stay dirty. All worktree mutation
9
- // targets the allocated worktree dir only. Windows-safe: node:path only, no
10
- // shell string building, execFile(args array), and clear errors when a file
11
- // lock blocks cleanup.
12
-
13
- import { execFile } from 'node:child_process';
14
- import { existsSync, mkdirSync, rmSync } from 'node:fs';
15
- import { readFile, lstat } from 'node:fs/promises';
16
- import { tmpdir } from 'node:os';
17
- import { join, resolve, basename } from 'node:path';
18
- import { randomBytes, createHash } from 'node:crypto';
19
- import { promisify } from 'node:util';
20
- import { isSensitivePath, parseChanges, DIFF_LIMIT, GIT_TIMEOUT_MS } from './workspace-audit.mjs';
21
-
22
- const execFileAsync = promisify(execFile);
23
-
24
- export const NOT_GIT_REPOSITORY = 'NOT_GIT_REPOSITORY';
25
- export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
26
- export const GIT_TIMEOUT = 'GIT_TIMEOUT';
27
- export const GIT_ERROR = 'GIT_ERROR';
28
- export const WORKTREE_LOCKED = 'WORKTREE_LOCKED';
29
- export const WORKTREE_RESERVE_FAILED = 'WORKTREE_RESERVE_FAILED';
30
- export const CANDIDATE_CAPTURE_FAILED = 'CANDIDATE_CAPTURE_FAILED';
31
- export const MAX_PARALLEL_CAP = 16;
32
- export const DEFAULT_MAX_PARALLEL = 3;
33
- // Worktree names read `Crew_YYYYMMDD_HHMMSS_<purpose>` so an operator can tell
34
- // from the directory alone when a job ran and what it was for. The legacy
35
- // prefix stays recognised as ours, so worktrees created by an earlier release
36
- // are still adopted and cleaned up rather than orphaned.
37
- const WORKTREE_PREFIX = 'Crew_';
38
- const LEGACY_WORKTREE_PREFIXES = Object.freeze(['dsh-crew-']);
39
-
40
- /** Whether a directory name is a worktree Crew created. */
41
- export function isCrewWorktreeName(name) {
42
- const value = String(name ?? '');
43
- return value.startsWith(WORKTREE_PREFIX)
44
- || LEGACY_WORKTREE_PREFIXES.some((prefix) => value.startsWith(prefix));
45
- }
46
-
47
- async function defaultRunner(args, { cwd }) {
48
- try {
49
- const out = await execFileAsync('git', args, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
50
- return { code: 0, stdout: out.stdout ?? '', stderr: out.stderr ?? '' };
51
- } catch (error) {
52
- const missing = error?.code === 'ENOENT' || /spawn git ENOENT/i.test(error?.message ?? '');
53
- if (!missing) return { code: error?.code ?? error?.status ?? 1, stdout: '', stderr: error?.stderr ?? error?.message ?? String(error) };
54
- return { code: -1, stdout: '', stderr: 'spawn git ENOENT' };
55
- }
56
- }
57
-
58
- async function runGit(runner, args, opts) {
59
- try {
60
- const r = await runner(args, opts);
61
- const stderr = r.stderr ?? '';
62
- if (/not a git repository/i.test(stderr)) return { ok: false, reason: NOT_GIT_REPOSITORY, error: stderr.trim() };
63
- if (r.code != null && r.code !== 0) return { ok: false, reason: r.code === -1 ? GIT_NOT_FOUND : GIT_ERROR, code: r.code, error: stderr.trim() || 'git exited non-zero' };
64
- return { ok: true, code: r.code, stdout: r.stdout ?? '', stderr };
65
- } catch (err) {
66
- const msg = err?.message ?? String(err);
67
- if (err?.code === 'ETIMEDOUT' || /timed out|timeout/i.test(msg)) return { ok: false, reason: GIT_TIMEOUT, error: msg };
68
- if (/ENOENT|spawn git/i.test(msg)) return { ok: false, reason: GIT_NOT_FOUND, error: msg };
69
- return { ok: false, reason: NOT_GIT_REPOSITORY, error: msg };
70
- }
71
- }
72
-
73
- const PURPOSE_MAX = 32;
74
-
75
- /** Local wall-clock stamp: readable, and what an operator expects to see. */
76
- function stamp(at) {
77
- const d = at instanceof Date ? at : new Date(at ?? Date.now());
78
- const p = (n) => String(n).padStart(2, '0');
79
- return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
80
- + `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
81
- }
82
-
83
- /**
84
- * `Crew_<date>_<time>_<purpose>`, with a numeric suffix only when that name is
85
- * already taken. Parallel jobs can start inside the same second, so the suffix is
86
- * what keeps the name unique without putting random noise in every name.
87
- */
88
- /**
89
- * `Crew_<date>_<time>_<purpose>`, reserved by creating the directory.
90
- *
91
- * The reservation is a mkdir, not a lookup: two jobs starting in the same second
92
- * both probe before either has created anything, so a check-then-act name would
93
- * hand them the same path and one `git worktree add` would fail. mkdir
94
- * fails on EEXIST, which makes the suffix loop race-free.
95
- */
96
- function reserveWorktreeDir({ root, purpose, at }) {
97
- mkdirSync(root, { recursive: true });
98
- const safe = String(purpose ?? 'job').replace(/[^A-Za-z0-9]+/g, '-')
99
- .replace(/^-+|-+$/g, '').slice(0, PURPOSE_MAX) || 'job';
100
- const base = `${WORKTREE_PREFIX}${stamp(at)}_${safe}`;
101
- for (let n = 1; n <= 100; n += 1) {
102
- const name = n === 1 ? base : `${base}-${n}`;
103
- const dir = join(root, name);
104
- try {
105
- mkdirSync(dir);
106
- return { ok: true, dir, name };
107
- } catch (error) {
108
- if (error?.code !== 'EEXIST') return { ok: false, error: String(error?.message ?? error) };
109
- }
110
- }
111
- return { ok: false, error: 'no free worktree name' };
112
- }
113
- export function defaultWorktreeRoot() {
114
- return join(tmpdir(), 'dsh-crew-worktrees');
115
- }
116
-
117
- /**
118
- * Resolve repository root + HEAD. Dirty detection is advisory: inability to
119
- * read `git status` must not turn an otherwise valid repository into a hard
120
- * isolation failure. `dirty=null` means unknown.
121
- */
122
- export async function inspectRepository({ cwd, git, runner } = {}) {
123
- const run = git ?? runner ?? defaultRunner;
124
- if (!cwd) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'cwd required' };
125
- const [root, head, status] = await Promise.all([
126
- runGit(run, ['rev-parse', '--show-toplevel'], { cwd }),
127
- runGit(run, ['rev-parse', 'HEAD'], { cwd }),
128
- runGit(run, ['status', '--porcelain', '-uall'], { cwd }),
129
- ]);
130
- if (!root.ok) return { ok: false, reason: root.reason, error: root.error };
131
- if (!head.ok) return { ok: false, reason: head.reason, error: head.error };
132
- return {
133
- ok: true,
134
- repoRoot: resolve(root.stdout.trim()),
135
- baseRevision: head.stdout.trim(),
136
- headRevision: head.stdout.trim(),
137
- dirty: status.ok ? status.stdout.trim() !== '' : null,
138
- };
139
- }
140
-
141
- export async function createIsolatedWorkspace({ cwd, jobId, purpose, baseRevision, at, root = defaultWorktreeRoot(), git } = {}) {
142
- const run = git ?? defaultRunner;
143
- const repo = await inspectRepository({ cwd, git: run });
144
- if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
145
- const rev = baseRevision ?? repo.baseRevision;
146
- const reserved = reserveWorktreeDir({ root, purpose, at });
147
- if (!reserved.ok) return { ok: false, reason: WORKTREE_RESERVE_FAILED, error: reserved.error };
148
- const { dir, name } = reserved;
149
- const res = await runGit(run, ['worktree', 'add', '--detach', dir, rev], { cwd: repo.repoRoot });
150
- if (!res.ok) {
151
- // Release the name so a retry is not blocked by an empty directory.
152
- try { rmSync(dir, { recursive: true, force: true }); } catch {}
153
- return { ok: false, reason: res.reason, error: res.error };
154
- }
155
- return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name };
156
- }
157
-
158
- function splitFirstTab(line) {
159
- const i = line.indexOf('\t');
160
- return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
161
- }
162
-
163
- async function firstLinkComponent(cwd, relPath) {
164
- let current = resolve(cwd);
165
- const segments = String(relPath ?? '').replace(/\\/g, '/').split('/').filter(Boolean);
166
- for (let i = 0; i < segments.length; i += 1) {
167
- current = join(current, segments[i]);
168
- try {
169
- const st = await lstat(current);
170
- if (st.isSymbolicLink()) return segments.slice(0, i + 1).join('/');
171
- } catch {
172
- return null;
173
- }
174
- }
175
- return null;
176
- }
177
-
178
- async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limit }) {
179
- const tracked = [];
180
- const sensitive = new Set();
181
- for (const line of String(nameStatus ?? '').split('\n')) {
182
- const trimmed = line.trim();
183
- if (!trimmed) continue;
184
- const [, rest] = splitFirstTab(trimmed);
185
- const involved = rest.split('\t').filter(Boolean).map((p) => p.replace(/\\/g, '/'));
186
- if (involved.length === 0) continue;
187
- if (involved.some(isSensitivePath)) {
188
- for (const p of involved) sensitive.add(p);
189
- } else {
190
- for (const p of involved) if (!tracked.includes(p)) tracked.push(p);
191
- }
192
- }
193
-
194
- let out = '';
195
- const redacted = [];
196
- const incompleteReasons = [];
197
- const untrackedFiles = [];
198
- const seenLinks = new Set();
199
- if (tracked.length > 0) {
200
- const r = await runGit(run, ['diff', '--binary', base, '--', ...tracked], { cwd });
201
- if (!r.ok) return { failed: true, reason: r.reason, error: r.error };
202
- out += r.stdout;
203
- }
204
- for (const p of sensitive) {
205
- redacted.push(p);
206
- out += `[REDACTED SENSITIVE FILE: ${p}]\n`;
207
- }
208
- if (redacted.length > 0) incompleteReasons.push('sensitive_content_redacted');
209
-
210
- for (const p of untracked) {
211
- // Walk every path component with lstat so a symlink/junction anywhere in
212
- // the path is never followed. Out-of-worktree targets are treated as
213
- // non-replayable; only the link boundary is exposed.
214
- const link = await firstLinkComponent(cwd, p);
215
- if (link) {
216
- if (!seenLinks.has(link)) {
217
- seenLinks.add(link);
218
- untrackedFiles.push(link);
219
- out += `[UNTRACKED SYMLINK: ${link}]\n`;
220
- incompleteReasons.push(`untracked_symlink:${link}`);
221
- }
222
- continue;
223
- }
224
- untrackedFiles.push(p);
225
- if (isSensitivePath(p)) {
226
- redacted.push(p);
227
- out += `[REDACTED SENSITIVE FILE: ${p} (untracked)]\n`;
228
- if (!incompleteReasons.includes('sensitive_content_redacted')) incompleteReasons.push('sensitive_content_redacted');
229
- continue;
230
- }
231
- const abs = join(cwd, ...p.split('/'));
232
- if (!existsSync(abs)) {
233
- out += `[UNTRACKED FILE: ${p}]\n`;
234
- incompleteReasons.push(`untracked_missing:${p}`);
235
- continue;
236
- }
237
- const next = await safeNewFilePatch(p, abs);
238
- out += next.patch;
239
- if (!next.complete) incompleteReasons.push(next.reason ?? `untracked_unreplayable:${p}`);
240
- }
241
-
242
- // Fingerprint identity must represent the complete pre-truncation workspace
243
- // diff, not the truncated patch bytes that are actually retained.
244
- const workspaceDigest = createHash('sha256').update(out).digest('hex');
245
- const truncated = Buffer.byteLength(out, 'utf8') > limit;
246
- if (truncated) {
247
- out = Buffer.from(out, 'utf8').subarray(0, limit).toString('utf8');
248
- incompleteReasons.push('patch_truncated');
249
- }
250
- const complete = incompleteReasons.length === 0;
251
- return { failed: false, patch: out, truncated, redacted, complete, incompleteReasons, untrackedFiles, workspaceDigest };
252
- }
253
-
254
- async function safeNewFilePatch(relPath, absPath) {
255
- try {
256
- const buf = await readFile(absPath);
257
- if (buf.includes(0)) return { patch: `[NEW BINARY FILE: ${relPath} (${buf.length} bytes)]\n`, complete: false, reason: `binary_untracked:${relPath}` };
258
- const lines = buf.toString('utf8').split(/\r?\n/);
259
- if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
260
- if (lines.length === 0) return { patch: `[NEW EMPTY FILE: ${relPath}]\n`, complete: true };
261
- const body = lines.map((l) => `+${l}`).join('\n');
262
- return {
263
- patch: `diff --git a/${relPath} b/${relPath}\nnew file mode 100644\n--- /dev/null\n+++ b/${relPath}\n@@ -0,0 +1,${lines.length} @@\n${body}\n`,
264
- complete: true,
265
- };
266
- } catch {
267
- return { patch: `[UNTRACKED FILE: ${relPath}]\n`, complete: false, reason: `untracked_read_failed:${relPath}` };
268
- }
269
- }
270
-
271
- function candidateFingerprint({ base, nameStatus, workspaceDigest }) {
272
- return createHash('sha256').update(`${base}\n${nameStatus ?? ''}\n${workspaceDigest ?? ''}`).digest('hex');
273
- }
274
-
275
- export async function captureCandidate({ worktreePath, baseRevision, git, limit = DIFF_LIMIT } = {}) {
276
- const run = git ?? defaultRunner;
277
- if (!worktreePath || !existsSync(worktreePath)) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path missing' };
278
- const baseCheck = baseRevision ? await runGit(run, ['rev-parse', '--verify', `${baseRevision}^{commit}`], { cwd: worktreePath }) : { ok: true };
279
- if (baseRevision && !baseCheck.ok) return { ok: false, reason: CANDIDATE_CAPTURE_FAILED, error: `invalid base revision ${baseRevision}: ${(baseCheck.error ?? '').trim()}` };
280
- const base = baseRevision ?? (await runGit(run, ['rev-parse', 'HEAD'], { cwd: worktreePath })).stdout.trim();
281
-
282
- const [nameStatus, statRel, status, head] = await Promise.all([
283
- runGit(run, ['diff', '--name-status', base], { cwd: worktreePath }),
284
- runGit(run, ['diff', '--stat', base], { cwd: worktreePath }),
285
- runGit(run, ['status', '--porcelain', '-uall'], { cwd: worktreePath }),
286
- runGit(run, ['rev-parse', 'HEAD'], { cwd: worktreePath }),
287
- ]);
288
- for (const r of [nameStatus, statRel, status]) if (!r.ok) return { ok: false, reason: r.reason, error: r.error };
289
-
290
- const changes = parseChanges(nameStatus.stdout, status.stdout);
291
- const untracked = changes.untracked;
292
- const built = await buildCandidatePatch(run, { cwd: worktreePath, base, nameStatus: nameStatus.stdout, untracked, limit });
293
- if (built.failed) return { ok: false, reason: built.reason ?? CANDIDATE_CAPTURE_FAILED, error: built.error };
294
-
295
- const untrackedFiles = built.untrackedFiles ?? untracked;
296
-
297
- const nameStatusOut = nameStatus.stdout;
298
- return {
299
- ok: true,
300
- kind: 'git-worktree',
301
- base_revision: base,
302
- committed_head: head.ok ? head.stdout.trim() : null,
303
- worktree_path: worktreePath,
304
- changed_files: [...new Set([...trackedIn(nameStatusOut, untrackedFiles), ...built.redacted, ...untrackedFiles])],
305
- name_status: nameStatusOut,
306
- diff_stat: statRel.stdout,
307
- patch: built.patch,
308
- patch_truncated: built.truncated,
309
- sensitive_paths_redacted: built.redacted,
310
- untracked_files: untrackedFiles,
311
- complete: built.complete,
312
- replayable: built.complete,
313
- incomplete_reasons: built.incompleteReasons,
314
- fingerprint: candidateFingerprint({ base, nameStatus: nameStatusOut, workspaceDigest: built.workspaceDigest }),
315
- candidate_commit: null,
316
- };
317
- }
318
-
319
- function trackedIn(nameStatus, untracked) {
320
- const out = [];
321
- for (const line of String(nameStatus ?? '').split('\n')) {
322
- const trimmed = line.trim();
323
- if (!trimmed) continue;
324
- const [, rest] = splitFirstTab(trimmed);
325
- const involved = rest.split('\t').filter(Boolean).map((p) => p.replace(/\\/g, '/'));
326
- for (const p of involved) if (!out.includes(p) && !untracked.includes(p)) out.push(p);
327
- }
328
- return out;
329
- }
330
-
331
- async function mainRepoRoot(run, worktreePath) {
332
- const common = await runGit(run, ['rev-parse', '--git-common-dir'], { cwd: worktreePath });
333
- if (!common.ok) return null;
334
- const dir = String(common.stdout ?? '').trim();
335
- if (!dir) return null;
336
- return resolve(dir, '..');
337
- }
338
-
339
- const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
340
-
341
- const pathIdentity = (value) => {
342
- const resolved = resolve(String(value));
343
- return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
344
- };
345
-
346
- async function worktreeRegistered({ worktreePath, git, cwd }) {
347
- const res = await runGit(git, ['worktree', 'list', '--porcelain'], { cwd });
348
- if (!res.ok) return null;
349
- const target = pathIdentity(worktreePath);
350
- for (const block of String(res.stdout).split('\n\n')) {
351
- const path = block.split('\n').find((line) => line.startsWith('worktree '))?.slice('worktree '.length)?.trim();
352
- if (path && pathIdentity(path) === target) return true;
353
- }
354
- return false;
355
- }
356
-
357
- /**
358
- * Bounded, truthful cleanup of a Crew-owned disposable worktree. Transient
359
- * Windows locks (index lock, AV scan, lingering handle) are retried with a
360
- * small backoff; a persistent failure surfaces `cleanupBlocked: true` with the
361
- * real reason and is never reported as removed. The filesystem fallback only
362
- * runs for Crew-owned worktree paths and claims success only after it verifies
363
- * the registration is gone and no directory remains — success is never claimed
364
- * while a worktree stays registered or on disk.
365
- */
366
- export const WORKTREE_CLEANUP_RETRIES = 3;
367
- export const WORKTREE_CLEANUP_BACKOFF_MS = 150;
368
-
369
- export async function cleanupIsolatedWorkspace({
370
- worktreePath,
371
- repoRoot,
372
- git,
373
- retries = WORKTREE_CLEANUP_RETRIES,
374
- backoffMs = WORKTREE_CLEANUP_BACKOFF_MS,
375
- } = {}) {
376
- const run = git ?? defaultRunner;
377
- if (!worktreePath) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path required' };
378
- const root = repoRoot ?? (await mainRepoRoot(run, worktreePath));
379
- const owned = isCrewWorktreeName(basename(resolve(worktreePath)));
380
-
381
- if (root) {
382
- // Preferred path: `git worktree remove --force` (removes registration and
383
- // directory atomically). Retry bounded times to recover transient locks.
384
- for (let attempt = 0; attempt < retries; attempt += 1) {
385
- const res = await runGit(run, ['worktree', 'remove', '--force', worktreePath], { cwd: root });
386
- if (res.ok) return { ok: true, removed: true, actions: [`removed worktree ${worktreePath}`] };
387
- if (attempt < retries - 1) await sleep(backoffMs);
388
- }
389
- }
390
-
391
- // Last resort, only for Crew-owned disposable paths: remove the directory and
392
- // verify the git registration actually went away before claiming success.
393
- if (owned && root && pathIdentity(worktreePath) !== pathIdentity(root)) {
394
- try {
395
- rmSync(worktreePath, { recursive: true, force: true });
396
- } catch (err) {
397
- return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed: ${err?.message ?? String(err)}`, cleanupBlocked: true };
398
- }
399
- const registered = root ? await worktreeRegistered({ worktreePath, git: run, cwd: root }) : null;
400
- if (registered === false && !existsSync(worktreePath)) {
401
- return { ok: true, removed: true, actions: [`cleaned worktree files ${worktreePath}`] };
402
- }
403
- if (registered === true) {
404
- return { ok: false, reason: WORKTREE_LOCKED, error: `worktree still registered after cleanup: ${worktreePath}`, cleanupBlocked: true };
405
- }
406
- return { ok: false, reason: WORKTREE_LOCKED, error: `could not verify worktree removal for ${worktreePath}`, cleanupBlocked: true };
407
- }
408
-
409
- return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed while ${worktreePath} remains (${root ? 'not a Crew-owned disposable path' : 'main repository root unresolvable'})`, cleanupBlocked: true };
410
- }
411
-
412
- export async function staleWorktrees({ git, allowed = [] } = {}) {
413
- const run = git ?? defaultRunner;
414
- const set = new Set(allowed.map((p) => resolve(p)));
415
- const stale = [];
416
- try {
417
- const root = await inspectRepository({ cwd: allowed[0] ?? process.cwd(), git: run });
418
- if (root.ok) {
419
- const res = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: root.repoRoot });
420
- if (res.ok) {
421
- for (const block of String(res.stdout).split('\n\n')) {
422
- const path = block.split('\n').find((l) => l.startsWith('worktree '))?.slice('worktree '.length)?.trim();
423
- if (!path) continue;
424
- const abs = resolve(path);
425
- if (isCrewWorktreeName(basename(abs)) && !set.has(abs)) stale.push(abs);
426
- }
427
- }
428
- }
429
- } catch {}
430
- return stale;
431
- }
432
-
433
- export async function pruneWorktrees({ git, allowed = [] } = {}) {
434
- const run = git ?? defaultRunner;
435
- const stale = await staleWorktrees({ git: run, allowed });
436
- const actions = [];
437
- for (const w of stale) {
438
- const r = await cleanupIsolatedWorkspace({ worktreePath: w, git: run });
439
- actions.push(...(r.actions ?? [r.error ?? `stale worktree ${w}`]));
440
- }
441
- return { ok: true, removed: stale.length, actions };
442
- }
443
-
444
- export function clampMaxParallel(raw) {
445
- const n = Number.isInteger(raw) ? raw : DEFAULT_MAX_PARALLEL;
446
- if (n < 1) return 1;
447
- if (n > MAX_PARALLEL_CAP) return MAX_PARALLEL_CAP;
448
- return n;
449
- }
450
-
451
- export function concurrencyGate({ maxParallel = DEFAULT_MAX_PARALLEL, active = 0 } = {}) {
452
- const cap = clampMaxParallel(maxParallel);
453
- const ok = active < cap;
454
- return { ok, active, maxParallel: cap, blocked: !ok };
455
- }
1
+ // Git worktree isolation for parallel coding jobs. Every isolated coding job
2
+ // runs in its own detached worktree at a captured base revision, so concurrent
3
+ // workers never write the same mutable working tree. The Main Agent receives
4
+ // an auditable change candidate (bounded, redacted patch + name status) and
5
+ // decides accept / reject / revise from there.
6
+ //
7
+ // Discipline: NEVER touches the primary working tree with reset / stash /
8
+ // clean / checkout — the primary tree may stay dirty. All worktree mutation
9
+ // targets the allocated worktree dir only. Windows-safe: node:path only, no
10
+ // shell string building, execFile(args array), and clear errors when a file
11
+ // lock blocks cleanup.
12
+
13
+ import { execFile } from 'node:child_process';
14
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
15
+ import { readFile, lstat } from 'node:fs/promises';
16
+ import { tmpdir } from 'node:os';
17
+ import { join, resolve, basename } from 'node:path';
18
+ import { randomBytes, createHash } from 'node:crypto';
19
+ import { promisify } from 'node:util';
20
+ import { isSensitivePath, parseChanges, DIFF_LIMIT, GIT_TIMEOUT_MS } from './workspace-audit.mjs';
21
+
22
+ const execFileAsync = promisify(execFile);
23
+
24
+ export const NOT_GIT_REPOSITORY = 'NOT_GIT_REPOSITORY';
25
+ export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
26
+ export const GIT_TIMEOUT = 'GIT_TIMEOUT';
27
+ export const GIT_ERROR = 'GIT_ERROR';
28
+ export const WORKTREE_LOCKED = 'WORKTREE_LOCKED';
29
+ export const WORKTREE_RESERVE_FAILED = 'WORKTREE_RESERVE_FAILED';
30
+ export const CANDIDATE_CAPTURE_FAILED = 'CANDIDATE_CAPTURE_FAILED';
31
+ export const MAX_PARALLEL_CAP = 16;
32
+ export const DEFAULT_MAX_PARALLEL = 3;
33
+ // Worktree names read `Crew_YYYYMMDD_HHMMSS_<purpose>` so an operator can tell
34
+ // from the directory alone when a job ran and what it was for. The legacy
35
+ // prefix stays recognised as ours, so worktrees created by an earlier release
36
+ // are still adopted and cleaned up rather than orphaned.
37
+ const WORKTREE_PREFIX = 'Crew_';
38
+ const LEGACY_WORKTREE_PREFIXES = Object.freeze(['dsh-crew-']);
39
+
40
+ /** Whether a directory name is a worktree Crew created. */
41
+ export function isCrewWorktreeName(name) {
42
+ const value = String(name ?? '');
43
+ return value.startsWith(WORKTREE_PREFIX)
44
+ || LEGACY_WORKTREE_PREFIXES.some((prefix) => value.startsWith(prefix));
45
+ }
46
+
47
+ async function defaultRunner(args, { cwd }) {
48
+ try {
49
+ const out = await execFileAsync('git', args, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
50
+ return { code: 0, stdout: out.stdout ?? '', stderr: out.stderr ?? '' };
51
+ } catch (error) {
52
+ const missing = error?.code === 'ENOENT' || /spawn git ENOENT/i.test(error?.message ?? '');
53
+ if (!missing) return { code: error?.code ?? error?.status ?? 1, stdout: '', stderr: error?.stderr ?? error?.message ?? String(error) };
54
+ return { code: -1, stdout: '', stderr: 'spawn git ENOENT' };
55
+ }
56
+ }
57
+
58
+ async function runGit(runner, args, opts) {
59
+ try {
60
+ const r = await runner(args, opts);
61
+ const stderr = r.stderr ?? '';
62
+ if (/not a git repository/i.test(stderr)) return { ok: false, reason: NOT_GIT_REPOSITORY, error: stderr.trim() };
63
+ if (r.code != null && r.code !== 0) return { ok: false, reason: r.code === -1 ? GIT_NOT_FOUND : GIT_ERROR, code: r.code, error: stderr.trim() || 'git exited non-zero' };
64
+ return { ok: true, code: r.code, stdout: r.stdout ?? '', stderr };
65
+ } catch (err) {
66
+ const msg = err?.message ?? String(err);
67
+ if (err?.code === 'ETIMEDOUT' || /timed out|timeout/i.test(msg)) return { ok: false, reason: GIT_TIMEOUT, error: msg };
68
+ if (/ENOENT|spawn git/i.test(msg)) return { ok: false, reason: GIT_NOT_FOUND, error: msg };
69
+ return { ok: false, reason: NOT_GIT_REPOSITORY, error: msg };
70
+ }
71
+ }
72
+
73
+ const PURPOSE_MAX = 32;
74
+
75
+ /** Local wall-clock stamp: readable, and what an operator expects to see. */
76
+ function stamp(at) {
77
+ const d = at instanceof Date ? at : new Date(at ?? Date.now());
78
+ const p = (n) => String(n).padStart(2, '0');
79
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
80
+ + `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
81
+ }
82
+
83
+ /**
84
+ * `Crew_<date>_<time>_<purpose>`, with a numeric suffix only when that name is
85
+ * already taken. Parallel jobs can start inside the same second, so the suffix is
86
+ * what keeps the name unique without putting random noise in every name.
87
+ */
88
+ /**
89
+ * `Crew_<date>_<time>_<purpose>`, reserved by creating the directory.
90
+ *
91
+ * The reservation is a mkdir, not a lookup: two jobs starting in the same second
92
+ * both probe before either has created anything, so a check-then-act name would
93
+ * hand them the same path and one `git worktree add` would fail. mkdir
94
+ * fails on EEXIST, which makes the suffix loop race-free.
95
+ */
96
+ function reserveWorktreeDir({ root, purpose, at }) {
97
+ mkdirSync(root, { recursive: true });
98
+ const safe = String(purpose ?? 'job').replace(/[^A-Za-z0-9]+/g, '-')
99
+ .replace(/^-+|-+$/g, '').slice(0, PURPOSE_MAX) || 'job';
100
+ const base = `${WORKTREE_PREFIX}${stamp(at)}_${safe}`;
101
+ for (let n = 1; n <= 100; n += 1) {
102
+ const name = n === 1 ? base : `${base}-${n}`;
103
+ const dir = join(root, name);
104
+ try {
105
+ mkdirSync(dir);
106
+ return { ok: true, dir, name };
107
+ } catch (error) {
108
+ if (error?.code !== 'EEXIST') return { ok: false, error: String(error?.message ?? error) };
109
+ }
110
+ }
111
+ return { ok: false, error: 'no free worktree name' };
112
+ }
113
+ export function defaultWorktreeRoot() {
114
+ return join(tmpdir(), 'dsh-crew-worktrees');
115
+ }
116
+
117
+ /**
118
+ * Resolve repository root + HEAD. Dirty detection is advisory: inability to
119
+ * read `git status` must not turn an otherwise valid repository into a hard
120
+ * isolation failure. `dirty=null` means unknown.
121
+ */
122
+ export async function inspectRepository({ cwd, git, runner } = {}) {
123
+ const run = git ?? runner ?? defaultRunner;
124
+ if (!cwd) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'cwd required' };
125
+ const [root, head, status] = await Promise.all([
126
+ runGit(run, ['rev-parse', '--show-toplevel'], { cwd }),
127
+ runGit(run, ['rev-parse', 'HEAD'], { cwd }),
128
+ runGit(run, ['status', '--porcelain', '-uall'], { cwd }),
129
+ ]);
130
+ if (!root.ok) return { ok: false, reason: root.reason, error: root.error };
131
+ if (!head.ok) return { ok: false, reason: head.reason, error: head.error };
132
+ return {
133
+ ok: true,
134
+ repoRoot: resolve(root.stdout.trim()),
135
+ baseRevision: head.stdout.trim(),
136
+ headRevision: head.stdout.trim(),
137
+ dirty: status.ok ? status.stdout.trim() !== '' : null,
138
+ };
139
+ }
140
+
141
+ export async function createIsolatedWorkspace({ cwd, jobId, purpose, baseRevision, at, root = defaultWorktreeRoot(), git } = {}) {
142
+ const run = git ?? defaultRunner;
143
+ const repo = await inspectRepository({ cwd, git: run });
144
+ if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
145
+ const rev = baseRevision ?? repo.baseRevision;
146
+ const reserved = reserveWorktreeDir({ root, purpose, at });
147
+ if (!reserved.ok) return { ok: false, reason: WORKTREE_RESERVE_FAILED, error: reserved.error };
148
+ const { dir, name } = reserved;
149
+ const res = await runGit(run, ['worktree', 'add', '--detach', dir, rev], { cwd: repo.repoRoot });
150
+ if (!res.ok) {
151
+ // Release the name so a retry is not blocked by an empty directory.
152
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
153
+ return { ok: false, reason: res.reason, error: res.error };
154
+ }
155
+ return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name };
156
+ }
157
+
158
+ function splitFirstTab(line) {
159
+ const i = line.indexOf('\t');
160
+ return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
161
+ }
162
+
163
+ async function firstLinkComponent(cwd, relPath) {
164
+ let current = resolve(cwd);
165
+ const segments = String(relPath ?? '').replace(/\\/g, '/').split('/').filter(Boolean);
166
+ for (let i = 0; i < segments.length; i += 1) {
167
+ current = join(current, segments[i]);
168
+ try {
169
+ const st = await lstat(current);
170
+ if (st.isSymbolicLink()) return segments.slice(0, i + 1).join('/');
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+
178
+ async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limit }) {
179
+ const tracked = [];
180
+ const sensitive = new Set();
181
+ for (const line of String(nameStatus ?? '').split('\n')) {
182
+ const trimmed = line.trim();
183
+ if (!trimmed) continue;
184
+ const [, rest] = splitFirstTab(trimmed);
185
+ const involved = rest.split('\t').filter(Boolean).map((p) => p.replace(/\\/g, '/'));
186
+ if (involved.length === 0) continue;
187
+ if (involved.some(isSensitivePath)) {
188
+ for (const p of involved) sensitive.add(p);
189
+ } else {
190
+ for (const p of involved) if (!tracked.includes(p)) tracked.push(p);
191
+ }
192
+ }
193
+
194
+ let out = '';
195
+ const redacted = [];
196
+ const incompleteReasons = [];
197
+ const untrackedFiles = [];
198
+ const seenLinks = new Set();
199
+ if (tracked.length > 0) {
200
+ const r = await runGit(run, ['diff', '--binary', base, '--', ...tracked], { cwd });
201
+ if (!r.ok) return { failed: true, reason: r.reason, error: r.error };
202
+ out += r.stdout;
203
+ }
204
+ for (const p of sensitive) {
205
+ redacted.push(p);
206
+ out += `[REDACTED SENSITIVE FILE: ${p}]\n`;
207
+ }
208
+ if (redacted.length > 0) incompleteReasons.push('sensitive_content_redacted');
209
+
210
+ for (const p of untracked) {
211
+ // Walk every path component with lstat so a symlink/junction anywhere in
212
+ // the path is never followed. Out-of-worktree targets are treated as
213
+ // non-replayable; only the link boundary is exposed.
214
+ const link = await firstLinkComponent(cwd, p);
215
+ if (link) {
216
+ if (!seenLinks.has(link)) {
217
+ seenLinks.add(link);
218
+ untrackedFiles.push(link);
219
+ out += `[UNTRACKED SYMLINK: ${link}]\n`;
220
+ incompleteReasons.push(`untracked_symlink:${link}`);
221
+ }
222
+ continue;
223
+ }
224
+ untrackedFiles.push(p);
225
+ if (isSensitivePath(p)) {
226
+ redacted.push(p);
227
+ out += `[REDACTED SENSITIVE FILE: ${p} (untracked)]\n`;
228
+ if (!incompleteReasons.includes('sensitive_content_redacted')) incompleteReasons.push('sensitive_content_redacted');
229
+ continue;
230
+ }
231
+ const abs = join(cwd, ...p.split('/'));
232
+ if (!existsSync(abs)) {
233
+ out += `[UNTRACKED FILE: ${p}]\n`;
234
+ incompleteReasons.push(`untracked_missing:${p}`);
235
+ continue;
236
+ }
237
+ const next = await safeNewFilePatch(p, abs);
238
+ out += next.patch;
239
+ if (!next.complete) incompleteReasons.push(next.reason ?? `untracked_unreplayable:${p}`);
240
+ }
241
+
242
+ // Fingerprint identity must represent the complete pre-truncation workspace
243
+ // diff, not the truncated patch bytes that are actually retained.
244
+ const workspaceDigest = createHash('sha256').update(out).digest('hex');
245
+ const truncated = Buffer.byteLength(out, 'utf8') > limit;
246
+ if (truncated) {
247
+ out = Buffer.from(out, 'utf8').subarray(0, limit).toString('utf8');
248
+ incompleteReasons.push('patch_truncated');
249
+ }
250
+ const complete = incompleteReasons.length === 0;
251
+ return { failed: false, patch: out, truncated, redacted, complete, incompleteReasons, untrackedFiles, workspaceDigest };
252
+ }
253
+
254
+ async function safeNewFilePatch(relPath, absPath) {
255
+ try {
256
+ const buf = await readFile(absPath);
257
+ if (buf.includes(0)) return { patch: `[NEW BINARY FILE: ${relPath} (${buf.length} bytes)]\n`, complete: false, reason: `binary_untracked:${relPath}` };
258
+ const lines = buf.toString('utf8').split(/\r?\n/);
259
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
260
+ if (lines.length === 0) return { patch: `[NEW EMPTY FILE: ${relPath}]\n`, complete: true };
261
+ const body = lines.map((l) => `+${l}`).join('\n');
262
+ return {
263
+ patch: `diff --git a/${relPath} b/${relPath}\nnew file mode 100644\n--- /dev/null\n+++ b/${relPath}\n@@ -0,0 +1,${lines.length} @@\n${body}\n`,
264
+ complete: true,
265
+ };
266
+ } catch {
267
+ return { patch: `[UNTRACKED FILE: ${relPath}]\n`, complete: false, reason: `untracked_read_failed:${relPath}` };
268
+ }
269
+ }
270
+
271
+ function candidateFingerprint({ base, nameStatus, workspaceDigest }) {
272
+ return createHash('sha256').update(`${base}\n${nameStatus ?? ''}\n${workspaceDigest ?? ''}`).digest('hex');
273
+ }
274
+
275
+ export async function captureCandidate({ worktreePath, baseRevision, git, limit = DIFF_LIMIT } = {}) {
276
+ const run = git ?? defaultRunner;
277
+ if (!worktreePath || !existsSync(worktreePath)) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path missing' };
278
+ const baseCheck = baseRevision ? await runGit(run, ['rev-parse', '--verify', `${baseRevision}^{commit}`], { cwd: worktreePath }) : { ok: true };
279
+ if (baseRevision && !baseCheck.ok) return { ok: false, reason: CANDIDATE_CAPTURE_FAILED, error: `invalid base revision ${baseRevision}: ${(baseCheck.error ?? '').trim()}` };
280
+ const base = baseRevision ?? (await runGit(run, ['rev-parse', 'HEAD'], { cwd: worktreePath })).stdout.trim();
281
+
282
+ const [nameStatus, statRel, status, head] = await Promise.all([
283
+ runGit(run, ['diff', '--name-status', base], { cwd: worktreePath }),
284
+ runGit(run, ['diff', '--stat', base], { cwd: worktreePath }),
285
+ runGit(run, ['status', '--porcelain', '-uall'], { cwd: worktreePath }),
286
+ runGit(run, ['rev-parse', 'HEAD'], { cwd: worktreePath }),
287
+ ]);
288
+ for (const r of [nameStatus, statRel, status]) if (!r.ok) return { ok: false, reason: r.reason, error: r.error };
289
+
290
+ const changes = parseChanges(nameStatus.stdout, status.stdout);
291
+ const untracked = changes.untracked;
292
+ const built = await buildCandidatePatch(run, { cwd: worktreePath, base, nameStatus: nameStatus.stdout, untracked, limit });
293
+ if (built.failed) return { ok: false, reason: built.reason ?? CANDIDATE_CAPTURE_FAILED, error: built.error };
294
+
295
+ const untrackedFiles = built.untrackedFiles ?? untracked;
296
+
297
+ const nameStatusOut = nameStatus.stdout;
298
+ return {
299
+ ok: true,
300
+ kind: 'git-worktree',
301
+ base_revision: base,
302
+ committed_head: head.ok ? head.stdout.trim() : null,
303
+ worktree_path: worktreePath,
304
+ changed_files: [...new Set([...trackedIn(nameStatusOut, untrackedFiles), ...built.redacted, ...untrackedFiles])],
305
+ name_status: nameStatusOut,
306
+ diff_stat: statRel.stdout,
307
+ patch: built.patch,
308
+ patch_truncated: built.truncated,
309
+ sensitive_paths_redacted: built.redacted,
310
+ untracked_files: untrackedFiles,
311
+ complete: built.complete,
312
+ replayable: built.complete,
313
+ incomplete_reasons: built.incompleteReasons,
314
+ fingerprint: candidateFingerprint({ base, nameStatus: nameStatusOut, workspaceDigest: built.workspaceDigest }),
315
+ candidate_commit: null,
316
+ };
317
+ }
318
+
319
+ function trackedIn(nameStatus, untracked) {
320
+ const out = [];
321
+ for (const line of String(nameStatus ?? '').split('\n')) {
322
+ const trimmed = line.trim();
323
+ if (!trimmed) continue;
324
+ const [, rest] = splitFirstTab(trimmed);
325
+ const involved = rest.split('\t').filter(Boolean).map((p) => p.replace(/\\/g, '/'));
326
+ for (const p of involved) if (!out.includes(p) && !untracked.includes(p)) out.push(p);
327
+ }
328
+ return out;
329
+ }
330
+
331
+ async function mainRepoRoot(run, worktreePath) {
332
+ const common = await runGit(run, ['rev-parse', '--git-common-dir'], { cwd: worktreePath });
333
+ if (!common.ok) return null;
334
+ const dir = String(common.stdout ?? '').trim();
335
+ if (!dir) return null;
336
+ return resolve(dir, '..');
337
+ }
338
+
339
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
340
+
341
+ const pathIdentity = (value) => {
342
+ const resolved = resolve(String(value));
343
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
344
+ };
345
+
346
+ async function worktreeRegistered({ worktreePath, git, cwd }) {
347
+ const res = await runGit(git, ['worktree', 'list', '--porcelain'], { cwd });
348
+ if (!res.ok) return null;
349
+ const target = pathIdentity(worktreePath);
350
+ for (const block of String(res.stdout).split('\n\n')) {
351
+ const path = block.split('\n').find((line) => line.startsWith('worktree '))?.slice('worktree '.length)?.trim();
352
+ if (path && pathIdentity(path) === target) return true;
353
+ }
354
+ return false;
355
+ }
356
+
357
+ /**
358
+ * Bounded, truthful cleanup of a Crew-owned disposable worktree. Transient
359
+ * Windows locks (index lock, AV scan, lingering handle) are retried with a
360
+ * small backoff; a persistent failure surfaces `cleanupBlocked: true` with the
361
+ * real reason and is never reported as removed. The filesystem fallback only
362
+ * runs for Crew-owned worktree paths and claims success only after it verifies
363
+ * the registration is gone and no directory remains — success is never claimed
364
+ * while a worktree stays registered or on disk.
365
+ */
366
+ export const WORKTREE_CLEANUP_RETRIES = 3;
367
+ export const WORKTREE_CLEANUP_BACKOFF_MS = 150;
368
+
369
+ export async function cleanupIsolatedWorkspace({
370
+ worktreePath,
371
+ repoRoot,
372
+ git,
373
+ retries = WORKTREE_CLEANUP_RETRIES,
374
+ backoffMs = WORKTREE_CLEANUP_BACKOFF_MS,
375
+ } = {}) {
376
+ const run = git ?? defaultRunner;
377
+ if (!worktreePath) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path required' };
378
+ const root = repoRoot ?? (await mainRepoRoot(run, worktreePath));
379
+ const owned = isCrewWorktreeName(basename(resolve(worktreePath)));
380
+
381
+ if (root) {
382
+ // Preferred path: `git worktree remove --force` (removes registration and
383
+ // directory atomically). Retry bounded times to recover transient locks.
384
+ for (let attempt = 0; attempt < retries; attempt += 1) {
385
+ const res = await runGit(run, ['worktree', 'remove', '--force', worktreePath], { cwd: root });
386
+ if (res.ok) return { ok: true, removed: true, actions: [`removed worktree ${worktreePath}`] };
387
+ if (attempt < retries - 1) await sleep(backoffMs);
388
+ }
389
+ }
390
+
391
+ // Last resort, only for Crew-owned disposable paths: remove the directory and
392
+ // verify the git registration actually went away before claiming success.
393
+ if (owned && root && pathIdentity(worktreePath) !== pathIdentity(root)) {
394
+ try {
395
+ rmSync(worktreePath, { recursive: true, force: true });
396
+ } catch (err) {
397
+ return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed: ${err?.message ?? String(err)}`, cleanupBlocked: true };
398
+ }
399
+ const registered = root ? await worktreeRegistered({ worktreePath, git: run, cwd: root }) : null;
400
+ if (registered === false && !existsSync(worktreePath)) {
401
+ return { ok: true, removed: true, actions: [`cleaned worktree files ${worktreePath}`] };
402
+ }
403
+ if (registered === true) {
404
+ return { ok: false, reason: WORKTREE_LOCKED, error: `worktree still registered after cleanup: ${worktreePath}`, cleanupBlocked: true };
405
+ }
406
+ return { ok: false, reason: WORKTREE_LOCKED, error: `could not verify worktree removal for ${worktreePath}`, cleanupBlocked: true };
407
+ }
408
+
409
+ return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed while ${worktreePath} remains (${root ? 'not a Crew-owned disposable path' : 'main repository root unresolvable'})`, cleanupBlocked: true };
410
+ }
411
+
412
+ export async function staleWorktrees({ git, allowed = [] } = {}) {
413
+ const run = git ?? defaultRunner;
414
+ const set = new Set(allowed.map((p) => resolve(p)));
415
+ const stale = [];
416
+ try {
417
+ const root = await inspectRepository({ cwd: allowed[0] ?? process.cwd(), git: run });
418
+ if (root.ok) {
419
+ const res = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: root.repoRoot });
420
+ if (res.ok) {
421
+ // `git worktree list` puts the main working tree first. Inspecting from
422
+ // inside a linked worktree reports that worktree as the top level, so the
423
+ // repo root cannot be matched by path — the position in the list is what
424
+ // identifies the main tree.
425
+ const blocks = String(res.stdout).split('\n\n');
426
+ for (let index = 1; index < blocks.length; index += 1) {
427
+ const path = blocks[index].split('\n').find((l) => l.startsWith('worktree '))?.slice('worktree '.length)?.trim();
428
+ if (!path) continue;
429
+ const abs = resolve(path);
430
+ // A repo whose directory happens to start with a Crew prefix (say, a
431
+ // checkout named dsh-crew-something) is not a disposable worktree, and
432
+ // treating it as stale would have the cleanup path fighting over it.
433
+ if (isCrewWorktreeName(basename(abs)) && !set.has(abs)) stale.push(abs);
434
+ }
435
+ }
436
+ }
437
+ } catch {}
438
+ return stale;
439
+ }
440
+
441
+ export async function pruneWorktrees({ git, allowed = [] } = {}) {
442
+ const run = git ?? defaultRunner;
443
+ const stale = await staleWorktrees({ git: run, allowed });
444
+ const actions = [];
445
+ for (const w of stale) {
446
+ const r = await cleanupIsolatedWorkspace({ worktreePath: w, git: run });
447
+ actions.push(...(r.actions ?? [r.error ?? `stale worktree ${w}`]));
448
+ }
449
+ return { ok: true, removed: stale.length, actions };
450
+ }
451
+
452
+ export function clampMaxParallel(raw) {
453
+ const n = Number.isInteger(raw) ? raw : DEFAULT_MAX_PARALLEL;
454
+ if (n < 1) return 1;
455
+ if (n > MAX_PARALLEL_CAP) return MAX_PARALLEL_CAP;
456
+ return n;
457
+ }
458
+
459
+ export function concurrencyGate({ maxParallel = DEFAULT_MAX_PARALLEL, active = 0 } = {}) {
460
+ const cap = clampMaxParallel(maxParallel);
461
+ const ok = active < cap;
462
+ return { ok, active, maxParallel: cap, blocked: !ok };
463
+ }