create-claude-workspace 2.3.8 → 2.3.10

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.
@@ -1,11 +1,15 @@
1
1
  // ─── CI pipeline status polling ───
2
2
  // Uses gh/glab CLI to watch pipeline status after push.
3
- import { execSync } from 'node:child_process';
3
+ import { execFileSync } from 'node:child_process';
4
4
  const POLL_INTERVAL = 15_000;
5
5
  const MAX_POLL_TIME = 30 * 60_000; // 30 minutes
6
+ const CLI_TIMEOUT = 15_000;
7
+ function run(cmd, args, cwd, timeout = CLI_TIMEOUT) {
8
+ return execFileSync(cmd, args, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout }).trim();
9
+ }
6
10
  export function detectCIPlatform(projectDir) {
7
11
  try {
8
- const remote = execSync('git remote get-url origin', { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
12
+ const remote = run('git', ['remote', 'get-url', 'origin'], projectDir);
9
13
  if (remote.includes('github.com') || remote.includes('github:'))
10
14
  return 'github';
11
15
  if (remote.includes('gitlab.com') || remote.includes('gitlab:') || remote.includes('gitlab'))
@@ -51,13 +55,13 @@ export async function watchPipeline(branch, platform, projectDir, logger, signal
51
55
  }
52
56
  function pollGitHub(branch, cwd, logger) {
53
57
  try {
54
- const output = execSync(`gh run list --branch "${branch}" --limit 1 --json status,conclusion`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 15_000 }).trim();
58
+ const output = run('gh', ['run', 'list', '--branch', branch, '--limit', '1', '--json', 'status,conclusion'], cwd);
55
59
  const runs = JSON.parse(output);
56
60
  if (!runs.length)
57
61
  return 'not-found';
58
- const run = runs[0];
59
- if (run.status === 'completed') {
60
- return run.conclusion === 'success' ? 'passed' : 'failed';
62
+ const run_ = runs[0];
63
+ if (run_.status === 'completed') {
64
+ return run_.conclusion === 'success' ? 'passed' : 'failed';
61
65
  }
62
66
  return 'pending';
63
67
  }
@@ -68,7 +72,7 @@ function pollGitHub(branch, cwd, logger) {
68
72
  }
69
73
  function pollGitLab(branch, cwd, logger) {
70
74
  try {
71
- const output = execSync(`glab ci list --branch "${branch}" --output json`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 15_000 }).trim();
75
+ const output = run('glab', ['ci', 'list', '--branch', branch, '--output', 'json'], cwd);
72
76
  const pipelines = JSON.parse(output);
73
77
  if (!pipelines.length)
74
78
  return 'not-found';
@@ -89,16 +93,15 @@ function pollGitLab(branch, cwd, logger) {
89
93
  export function fetchFailureLogs(branch, platform, cwd) {
90
94
  try {
91
95
  if (platform === 'github') {
92
- // Get the latest failed run ID and fetch logs
93
- const runsOutput = execSync(`gh run list --branch "${branch}" --limit 1 --json databaseId,conclusion`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 15_000 }).trim();
96
+ const runsOutput = run('gh', ['run', 'list', '--branch', branch, '--limit', '1', '--json', 'databaseId,conclusion'], cwd);
94
97
  const runs = JSON.parse(runsOutput);
95
98
  if (!runs.length)
96
99
  return undefined;
97
- const runId = runs[0].databaseId;
98
- return execSync(`gh run view ${runId} --log-failed`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
100
+ const runId = String(runs[0].databaseId);
101
+ return run('gh', ['run', 'view', runId, '--log-failed'], cwd, 30_000);
99
102
  }
100
103
  if (platform === 'gitlab') {
101
- return execSync(`glab ci trace --branch "${branch}"`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
104
+ return run('glab', ['ci', 'trace', '--branch', branch], cwd, 30_000);
102
105
  }
103
106
  }
104
107
  catch { /* ignore */ }
@@ -1,12 +1,12 @@
1
1
  // ─── Deterministic git operations ───
2
- // All operations use execSyncgit is fast and must complete before the next step.
3
- import { execSync } from 'node:child_process';
2
+ // All operations use execFileSyncsafe from shell injection.
3
+ import { execFileSync } from 'node:child_process';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { resolve } from 'node:path';
6
6
  const GIT_TIMEOUT = 30_000;
7
7
  const PUSH_TIMEOUT = 60_000;
8
8
  function git(args, cwd, timeout = GIT_TIMEOUT) {
9
- return execSync(`git ${args}`, { cwd, timeout, stdio: 'pipe', encoding: 'utf-8' }).trim();
9
+ return execFileSync('git', args, { cwd, timeout, stdio: 'pipe', encoding: 'utf-8' }).trim();
10
10
  }
11
11
  // ─── Worktree operations ───
12
12
  export function createWorktree(projectDir, branchSlug, baseBranch) {
@@ -15,21 +15,27 @@ export function createWorktree(projectDir, branchSlug, baseBranch) {
15
15
  if (existsSync(worktreePath)) {
16
16
  return worktreePath;
17
17
  }
18
- git(`worktree add "${worktreePath}" -b "${branchSlug}" "${base}"`, projectDir);
18
+ git(['worktree', 'add', worktreePath, '-b', branchSlug, base], projectDir);
19
19
  return worktreePath;
20
20
  }
21
21
  export function cleanupWorktree(projectDir, worktreePath, branch) {
22
22
  try {
23
- git(`worktree remove "${worktreePath}" --force`, projectDir);
23
+ git(['worktree', 'remove', worktreePath, '--force'], projectDir);
24
24
  }
25
25
  catch { /* may already be removed */ }
26
26
  try {
27
- git(`branch -d "${branch}"`, projectDir);
27
+ git(['branch', '-d', branch], projectDir);
28
+ }
29
+ catch {
30
+ // -d may fail if not fully merged — force delete since we know it was merged or abandoned
31
+ try {
32
+ git(['branch', '-D', branch], projectDir);
33
+ }
34
+ catch { /* may already be deleted */ }
28
35
  }
29
- catch { /* may already be deleted */ }
30
36
  }
31
37
  export function listWorktrees(projectDir) {
32
- const output = git('worktree list --porcelain', projectDir);
38
+ const output = git(['worktree', 'list', '--porcelain'], projectDir);
33
39
  const mainNorm = normalizePath(resolve(projectDir));
34
40
  const paths = [];
35
41
  for (const line of output.split('\n')) {
@@ -50,24 +56,24 @@ export function listOrphanedWorktrees(projectDir, knownWorktrees) {
50
56
  }
51
57
  // ─── Commit operations ───
52
58
  export function commitInWorktree(worktreePath, message) {
53
- git('add -A', worktreePath);
59
+ git(['add', '-A'], worktreePath);
54
60
  // Check if there's anything to commit
55
- const status = git('status --porcelain', worktreePath);
61
+ const status = git(['status', '--porcelain'], worktreePath);
56
62
  if (!status)
57
63
  return '';
58
- git(`commit -m "${escapeMsg(message)}"`, worktreePath);
59
- return git('rev-parse HEAD', worktreePath);
64
+ git(['commit', '-m', message], worktreePath);
65
+ return git(['rev-parse', 'HEAD'], worktreePath);
60
66
  }
61
67
  export function getChangedFiles(worktreePath) {
62
68
  try {
63
69
  const mainBranch = getMainBranch(worktreePath);
64
- const output = git(`diff --name-only "${mainBranch}"...HEAD`, worktreePath);
70
+ const output = git(['diff', '--name-only', `${mainBranch}...HEAD`], worktreePath);
65
71
  return output ? output.split('\n').filter(f => f.length > 0) : [];
66
72
  }
67
73
  catch {
68
74
  // Fallback: diff against parent
69
75
  try {
70
- const output = git('diff --name-only HEAD~1', worktreePath);
76
+ const output = git(['diff', '--name-only', 'HEAD~1'], worktreePath);
71
77
  return output ? output.split('\n').filter(f => f.length > 0) : [];
72
78
  }
73
79
  catch {
@@ -76,14 +82,14 @@ export function getChangedFiles(worktreePath) {
76
82
  }
77
83
  }
78
84
  export function hasUncommittedChanges(dir) {
79
- const status = git('status --porcelain', dir);
85
+ const status = git(['status', '--porcelain'], dir);
80
86
  return status.length > 0;
81
87
  }
82
88
  // ─── Push operations ───
83
89
  export function pushWorktree(worktreePath) {
84
90
  try {
85
- const branch = git('rev-parse --abbrev-ref HEAD', worktreePath);
86
- git(`push -u origin "${branch}"`, worktreePath, PUSH_TIMEOUT);
91
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
92
+ git(['push', '-u', 'origin', branch], worktreePath, PUSH_TIMEOUT);
87
93
  return true;
88
94
  }
89
95
  catch {
@@ -92,8 +98,8 @@ export function pushWorktree(worktreePath) {
92
98
  }
93
99
  export function forcePushWorktree(worktreePath) {
94
100
  try {
95
- const branch = git('rev-parse --abbrev-ref HEAD', worktreePath);
96
- git(`push --force-with-lease -u origin "${branch}"`, worktreePath, PUSH_TIMEOUT);
101
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
102
+ git(['push', '--force-with-lease', '-u', 'origin', branch], worktreePath, PUSH_TIMEOUT);
97
103
  return true;
98
104
  }
99
105
  catch {
@@ -101,39 +107,66 @@ export function forcePushWorktree(worktreePath) {
101
107
  }
102
108
  }
103
109
  export function mergeToMain(projectDir, branch) {
104
- const main = getMainBranch(projectDir);
105
110
  try {
106
- git(`merge "${branch}" --no-ff -m "Merge ${branch}"`, projectDir);
107
- const sha = git('rev-parse HEAD', projectDir);
111
+ git(['merge', branch, '--no-ff', '-m', `Merge ${branch}`], projectDir);
112
+ const sha = git(['rev-parse', 'HEAD'], projectDir);
108
113
  return { success: true, sha, conflict: false };
109
114
  }
110
115
  catch (err) {
111
116
  const isConflict = hasConflicts(projectDir);
117
+ const conflictFiles = isConflict ? getConflictFiles(projectDir) : undefined;
112
118
  if (isConflict) {
113
- git('merge --abort', projectDir);
119
+ git(['merge', '--abort'], projectDir);
114
120
  }
115
121
  return {
116
122
  success: false,
117
123
  sha: null,
118
124
  conflict: isConflict,
125
+ conflictFiles,
119
126
  error: err.message,
120
127
  };
121
128
  }
122
129
  }
123
130
  function hasConflicts(dir) {
124
131
  try {
125
- const status = git('status --porcelain', dir);
132
+ const status = git(['status', '--porcelain'], dir);
126
133
  return status.includes('UU ') || status.includes('AA ') || status.includes('DD ');
127
134
  }
128
135
  catch {
129
136
  return false;
130
137
  }
131
138
  }
139
+ function getConflictFiles(dir) {
140
+ try {
141
+ const status = git(['status', '--porcelain'], dir);
142
+ return status.split('\n')
143
+ .filter(l => /^(UU|AA|DD|DU|UD|AU|UA) /.test(l))
144
+ .map(l => l.slice(3));
145
+ }
146
+ catch {
147
+ return [];
148
+ }
149
+ }
150
+ export function abortMerge(dir) {
151
+ try {
152
+ git(['merge', '--abort'], dir);
153
+ }
154
+ catch { /* no merge in progress */ }
155
+ }
156
+ export function deleteBranchRemote(cwd, branch) {
157
+ try {
158
+ git(['push', 'origin', '--delete', branch], cwd, PUSH_TIMEOUT);
159
+ return true;
160
+ }
161
+ catch {
162
+ return false;
163
+ }
164
+ }
132
165
  // ─── Branch / sync operations ───
133
166
  export function syncMain(projectDir) {
134
167
  try {
135
- git('fetch --quiet', projectDir);
136
- git('pull --ff-only --quiet', projectDir, PUSH_TIMEOUT);
168
+ git(['fetch', '--quiet'], projectDir);
169
+ git(['pull', '--ff-only', '--quiet'], projectDir, PUSH_TIMEOUT);
137
170
  return true;
138
171
  }
139
172
  catch {
@@ -143,13 +176,13 @@ export function syncMain(projectDir) {
143
176
  export function getMainBranch(projectDir) {
144
177
  try {
145
178
  // Check for remote HEAD
146
- const ref = git('symbolic-ref refs/remotes/origin/HEAD', projectDir);
179
+ const ref = git(['symbolic-ref', 'refs/remotes/origin/HEAD'], projectDir);
147
180
  return ref.replace('refs/remotes/origin/', '');
148
181
  }
149
182
  catch {
150
183
  // Fallback: check common names
151
184
  try {
152
- git('rev-parse --verify main', projectDir);
185
+ git(['rev-parse', '--verify', 'main'], projectDir);
153
186
  return 'main';
154
187
  }
155
188
  catch {
@@ -158,11 +191,11 @@ export function getMainBranch(projectDir) {
158
191
  }
159
192
  }
160
193
  export function getCurrentBranch(dir) {
161
- return git('rev-parse --abbrev-ref HEAD', dir);
194
+ return git(['rev-parse', '--abbrev-ref', 'HEAD'], dir);
162
195
  }
163
196
  export function branchExists(projectDir, branch) {
164
197
  try {
165
- git(`rev-parse --verify "${branch}"`, projectDir);
198
+ git(['rev-parse', '--verify', branch], projectDir);
166
199
  return true;
167
200
  }
168
201
  catch {
@@ -173,7 +206,7 @@ export function isBranchMerged(projectDir, branch) {
173
206
  try {
174
207
  const main = getMainBranch(projectDir);
175
208
  // Check if branch tip is an ancestor of main (i.e., was merged)
176
- git(`merge-base --is-ancestor "${branch}" "${main}"`, projectDir);
209
+ git(['merge-base', '--is-ancestor', branch, main], projectDir);
177
210
  return true;
178
211
  }
179
212
  catch {
@@ -183,8 +216,8 @@ export function isBranchMerged(projectDir, branch) {
183
216
  // ─── Git identity ───
184
217
  export function hasGitIdentity(projectDir) {
185
218
  try {
186
- git('config user.name', projectDir);
187
- git('config user.email', projectDir);
219
+ git(['config', 'user.name'], projectDir);
220
+ git(['config', 'user.email'], projectDir);
188
221
  return true;
189
222
  }
190
223
  catch {
@@ -194,8 +227,10 @@ export function hasGitIdentity(projectDir) {
194
227
  // ─── Tag operations ───
195
228
  export function createTag(projectDir, tag, message) {
196
229
  try {
197
- const msgArg = message ? `-m "${escapeMsg(message)}"` : '';
198
- git(`tag -a "${tag}" ${msgArg}`, projectDir);
230
+ const args = message
231
+ ? ['tag', '-a', tag, '-m', message]
232
+ : ['tag', tag];
233
+ git(args, projectDir);
199
234
  return true;
200
235
  }
201
236
  catch {
@@ -204,7 +239,7 @@ export function createTag(projectDir, tag, message) {
204
239
  }
205
240
  export function getLatestTag(projectDir) {
206
241
  try {
207
- return git('describe --tags --abbrev=0', projectDir);
242
+ return git(['describe', '--tags', '--abbrev=0'], projectDir);
208
243
  }
209
244
  catch {
210
245
  return null;
@@ -212,7 +247,7 @@ export function getLatestTag(projectDir) {
212
247
  }
213
248
  export function pushTags(projectDir) {
214
249
  try {
215
- git('push --tags', projectDir, PUSH_TIMEOUT);
250
+ git(['push', '--tags'], projectDir, PUSH_TIMEOUT);
216
251
  return true;
217
252
  }
218
253
  catch {
@@ -222,23 +257,23 @@ export function pushTags(projectDir) {
222
257
  export function rebaseOnMain(worktreePath, projectDir) {
223
258
  try {
224
259
  const main = getMainBranch(projectDir);
225
- git(`fetch origin "${main}"`, worktreePath, PUSH_TIMEOUT);
226
- git(`rebase "origin/${main}"`, worktreePath, PUSH_TIMEOUT);
260
+ git(['fetch', 'origin', main], worktreePath, PUSH_TIMEOUT);
261
+ git(['rebase', `origin/${main}`], worktreePath, PUSH_TIMEOUT);
227
262
  return { success: true, conflict: false };
228
263
  }
229
264
  catch (err) {
230
265
  // Check if rebase is in progress (conflict)
231
266
  try {
232
- const status = git('status --porcelain', worktreePath);
267
+ const status = git(['status', '--porcelain'], worktreePath);
233
268
  if (status.includes('UU ') || status.includes('AA ') || status.includes('DD ')) {
234
- git('rebase --abort', worktreePath);
269
+ git(['rebase', '--abort'], worktreePath);
235
270
  return { success: false, conflict: true, error: err.message };
236
271
  }
237
272
  }
238
273
  catch { /* ignore */ }
239
274
  // Abort any in-progress rebase
240
275
  try {
241
- git('rebase --abort', worktreePath);
276
+ git(['rebase', '--abort'], worktreePath);
242
277
  }
243
278
  catch { /* ignore */ }
244
279
  return { success: false, conflict: false, error: err.message };
@@ -253,30 +288,30 @@ export function rebaseOnMain(worktreePath, projectDir) {
253
288
  export function cleanMainForMerge(projectDir) {
254
289
  // Abort any in-progress merge (conflict state from previous failed merge)
255
290
  try {
256
- git('merge --abort', projectDir);
291
+ git(['merge', '--abort'], projectDir);
257
292
  }
258
293
  catch { /* no merge in progress */ }
259
294
  // Abort any in-progress rebase
260
295
  try {
261
- git('rebase --abort', projectDir);
296
+ git(['rebase', '--abort'], projectDir);
262
297
  }
263
298
  catch { /* no rebase in progress */ }
264
299
  // Reset any staged changes back to unstaged
265
300
  try {
266
- git('reset HEAD', projectDir);
301
+ git(['reset', 'HEAD'], projectDir);
267
302
  }
268
303
  catch { /* nothing staged */ }
269
304
  // If there are still uncommitted changes, stash them (preserves user edits)
270
- const status = git('status --porcelain', projectDir);
305
+ const status = git(['status', '--porcelain'], projectDir);
271
306
  if (status) {
272
307
  try {
273
- git('stash --include-untracked -m "scheduler: auto-stash before merge"', projectDir);
308
+ git(['stash', '--include-untracked', '-m', 'scheduler: auto-stash before merge'], projectDir);
274
309
  return true;
275
310
  }
276
311
  catch {
277
312
  // If stash fails, force-clean as last resort
278
313
  try {
279
- git('checkout -- .', projectDir);
314
+ git(['checkout', '--', '.'], projectDir);
280
315
  }
281
316
  catch { /* ignore */ }
282
317
  return false;
@@ -286,11 +321,11 @@ export function cleanMainForMerge(projectDir) {
286
321
  }
287
322
  // ─── Stash operations ───
288
323
  export function stashChanges(dir) {
289
- const status = git('status --porcelain', dir);
324
+ const status = git(['status', '--porcelain'], dir);
290
325
  if (!status)
291
326
  return false;
292
327
  try {
293
- git('stash --include-untracked', dir);
328
+ git(['stash', '--include-untracked'], dir);
294
329
  return true;
295
330
  }
296
331
  catch {
@@ -299,7 +334,7 @@ export function stashChanges(dir) {
299
334
  }
300
335
  export function popStash(dir) {
301
336
  try {
302
- git('stash pop', dir);
337
+ git(['stash', 'pop'], dir);
303
338
  return true;
304
339
  }
305
340
  catch {
@@ -309,7 +344,7 @@ export function popStash(dir) {
309
344
  // ─── Dirty main evaluation ───
310
345
  const TRACKING_FILES = new Set(['TODO.md', 'MEMORY.md']);
311
346
  export function evaluateDirtyMain(projectDir) {
312
- const status = git('status --porcelain', projectDir);
347
+ const status = git(['status', '--porcelain'], projectDir);
313
348
  if (!status)
314
349
  return { trackingFiles: [], userFiles: [] };
315
350
  const trackingFiles = [];
@@ -331,16 +366,12 @@ export function evaluateDirtyMain(projectDir) {
331
366
  export function discardFiles(dir, files) {
332
367
  if (files.length === 0)
333
368
  return;
334
- const fileArgs = files.map(f => `"${f}"`).join(' ');
335
369
  try {
336
- git(`checkout -- ${fileArgs}`, dir);
370
+ git(['checkout', '--', ...files], dir);
337
371
  }
338
372
  catch { /* file may not exist in index */ }
339
373
  }
340
374
  // ─── Helpers ───
341
- function escapeMsg(msg) {
342
- return msg.replace(/"/g, '\\"').replace(/\n/g, '\\n');
343
- }
344
375
  function normalizePath(p) {
345
376
  return p.replace(/\\/g, '/').toLowerCase();
346
377
  }
@@ -10,6 +10,15 @@ function run(bin, args, cwd, timeout = READ_TIMEOUT) {
10
10
  return execFileSync(bin, args, { cwd, timeout, stdio: 'pipe', encoding: 'utf-8' }).trim();
11
11
  }
12
12
  export function createPR(opts) {
13
+ // Check if a PR/MR already exists for this branch
14
+ try {
15
+ const existing = getPRStatus(opts.cwd, opts.platform, opts.branch);
16
+ if (existing.status === 'open' || existing.status === 'merged') {
17
+ return existing;
18
+ }
19
+ // If closed, fall through to create a new one
20
+ }
21
+ catch { /* no existing PR — create one */ }
13
22
  const body = opts.issueNumber
14
23
  ? `${opts.body}\n\nCloses #${opts.issueNumber}`
15
24
  : opts.body;
@@ -248,6 +248,8 @@ export async function runScheduler(opts) {
248
248
  if (!opts.resume) {
249
249
  state.pipelines = {};
250
250
  }
251
+ // Always re-run recovery on fresh scheduler start
252
+ state._recoveryDone = false;
251
253
  logger.info(`Loaded state: ${state.completedTasks.length} completed, ${state.skippedTasks.length} skipped, iteration ${state.iteration}`);
252
254
  }
253
255
  else {
@@ -7,7 +7,7 @@ import { fetchOpenIssues, issueToTask, updateIssueStatus } from './tasks/issue-s
7
7
  import { buildGraph, getParallelBatches, isPhaseComplete, getNextPhase, isProjectComplete } from './tasks/queue.mjs';
8
8
  import { writeState, appendEvent, createEvent, rotateLog } from './state/state.mjs';
9
9
  import { recordSession, getSession, clearSession } from './state/session.mjs';
10
- import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, } from './git/manager.mjs';
10
+ import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, abortMerge, deleteBranchRemote, } from './git/manager.mjs';
11
11
  import { createPR, getPRStatus, getPRComments, mergePR } from './git/pr-manager.mjs';
12
12
  import { scanAgents } from './agents/health-checker.mjs';
13
13
  import { detectCIPlatform, fetchFailureLogs } from './git/ci-watcher.mjs';
@@ -58,8 +58,19 @@ export async function runIteration(deps) {
58
58
  // Caller (scheduler.mts) handles pause state
59
59
  }
60
60
  }
61
- // Recover orphaned worktrees once per scheduler run (first iteration only)
61
+ // Pre-flight: commit uncommitted changes on main before any work
62
62
  if (!state._recoveryDone) {
63
+ if (hasUncommittedChanges(projectDir)) {
64
+ logger.warn('Uncommitted changes on main — auto-committing before starting work');
65
+ try {
66
+ commitInWorktree(projectDir, 'chore: auto-commit uncommitted changes before scheduler start');
67
+ logger.info('Uncommitted changes committed on main');
68
+ }
69
+ catch (err) {
70
+ logger.error(`Failed to commit main changes: ${err.message}`);
71
+ }
72
+ }
73
+ // Recover orphaned worktrees — once per scheduler run (first iteration only)
63
74
  state._recoveryDone = true;
64
75
  await recoverOrphanedWorktrees(projectDir, state, logger, deps);
65
76
  }
@@ -71,8 +82,14 @@ export async function runIteration(deps) {
71
82
  logger.error('Platform mode requires a git remote (github/gitlab)');
72
83
  return false;
73
84
  }
74
- tasks = fetchOpenIssues(projectDir, platform)
75
- .map(i => issueToTask(i, state.currentPhase));
85
+ try {
86
+ tasks = fetchOpenIssues(projectDir, platform)
87
+ .map(i => issueToTask(i, state.currentPhase));
88
+ }
89
+ catch (err) {
90
+ logger.error(`Failed to fetch remote issues: ${err.message?.split('\n')[0]}`);
91
+ return false;
92
+ }
76
93
  }
77
94
  else {
78
95
  const todoPath = resolve(projectDir, '.claude/scheduler/tasks.json');
@@ -433,6 +450,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
433
450
  // Sync main to get the merge commit
434
451
  syncMain(projectDir);
435
452
  cleanupWorktree(projectDir, worktreePath, slug);
453
+ deleteBranchRemote(projectDir, slug);
436
454
  pipeline.step = 'done';
437
455
  clearSession(state, task.id);
438
456
  delete state.pipelines[task.id];
@@ -490,8 +508,9 @@ async function runTaskPipeline(task, workerId, agents, deps) {
490
508
  }
491
509
  if (stashed)
492
510
  popStash(projectDir);
493
- // Cleanup
511
+ // Cleanup: worktree + local branch + remote branch
494
512
  cleanupWorktree(projectDir, worktreePath, slug);
513
+ deleteBranchRemote(projectDir, slug);
495
514
  pipeline.step = 'done';
496
515
  clearSession(state, task.id);
497
516
  delete state.pipelines[task.id];
@@ -555,6 +574,22 @@ function isRefactoringTask(task) {
555
574
  || lower.includes('rename') || lower.includes('migrate') || lower.includes('upgrade');
556
575
  }
557
576
  // ─── Orphaned worktree recovery ───
577
+ /** Mark issue as done on the platform after a successful recovery merge */
578
+ function closeRecoveredIssue(projectDir, branch, logger) {
579
+ const platform = detectCIPlatform(projectDir);
580
+ if (platform === 'none')
581
+ return;
582
+ const issueNum = extractIssueFromBranch(branch);
583
+ if (!issueNum)
584
+ return;
585
+ try {
586
+ updateIssueStatus(projectDir, platform, issueNum, 'done');
587
+ logger.info(`[recovery] Issue #${issueNum} marked as done and closed`);
588
+ }
589
+ catch (err) {
590
+ logger.warn(`[recovery] Failed to close issue #${issueNum}: ${err.message}`);
591
+ }
592
+ }
558
593
  async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
559
594
  const knownPaths = Object.values(state.pipelines).map(p => p.worktreePath);
560
595
  const orphans = listOrphanedWorktrees(projectDir, knownPaths);
@@ -569,6 +604,8 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
569
604
  // Already merged — just clean up
570
605
  logger.info(`[recovery] ${branch} already merged — cleaning up worktree`);
571
606
  cleanupWorktree(projectDir, worktreePath, branch);
607
+ deleteBranchRemote(projectDir, branch);
608
+ closeRecoveredIssue(projectDir, branch, logger);
572
609
  continue;
573
610
  }
574
611
  // Check if worktree has commits ahead of main
@@ -615,6 +652,8 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
615
652
  logger.info(`[recovery] ${branch} merged via platform`);
616
653
  syncMain(projectDir);
617
654
  cleanupWorktree(projectDir, worktreePath, branch);
655
+ deleteBranchRemote(projectDir, branch);
656
+ closeRecoveredIssue(projectDir, branch, logger);
618
657
  if (stashed)
619
658
  popStash(projectDir);
620
659
  continue;
@@ -635,24 +674,69 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
635
674
  if (mergeResult.success) {
636
675
  logger.info(`[recovery] ${branch} merged locally (${mergeResult.sha?.slice(0, 7)})`);
637
676
  appendEvent(projectDir, createEvent('merge_completed', { detail: `recovery: ${branch}` }));
677
+ closeRecoveredIssue(projectDir, branch, logger);
638
678
  }
639
679
  else if (mergeResult.conflict) {
640
- // Try rebase
641
- const rebaseResult = rebaseOnMain(worktreePath, projectDir);
642
- if (rebaseResult.success) {
643
- const retryResult = mergeToMain(projectDir, branch);
644
- if (retryResult.success) {
645
- logger.info(`[recovery] ${branch} merged after rebase`);
680
+ // mergeToMain already aborted the merge internally
681
+ // Delegate conflict resolution to orchestrator AI
682
+ logger.info(`[recovery] ${branch} has merge conflicts — delegating to AI`);
683
+ let resolved = false;
684
+ try {
685
+ const conflictDecision = await deps.orchestrator.handleMergeConflict(branch, mergeResult.conflictFiles ?? []);
686
+ if (conflictDecision.action === 'resolve') {
687
+ // Spawn agent to resolve conflicts in the worktree via rebase
688
+ logger.info(`[recovery] Spawning agent to resolve conflicts in ${branch}`);
689
+ const conflictPrompt = [
690
+ `You are in a worktree for branch "${branch}".`,
691
+ `Rebase this branch onto main and resolve all merge conflicts.`,
692
+ ``,
693
+ `Steps:`,
694
+ `1. Run: git rebase main`,
695
+ `2. For each conflict, read the conflicting files and resolve them sensibly`,
696
+ `3. After resolving each file: git add <file>`,
697
+ `4. Continue rebase: git rebase --continue`,
698
+ `5. Repeat until rebase is complete`,
699
+ ``,
700
+ `Conflicting files: ${(mergeResult.conflictFiles ?? []).join(', ') || 'unknown'}`,
701
+ ``,
702
+ `Important: preserve the intent of both sides. When in doubt, prefer the feature branch changes.`,
703
+ ].join('\n');
704
+ const slot = deps.pool.idleSlot();
705
+ if (slot) {
706
+ await deps.pool.spawn(slot.id, {
707
+ cwd: worktreePath,
708
+ prompt: conflictPrompt,
709
+ model: 'claude-sonnet-4-6',
710
+ onMessage: deps.onMessage,
711
+ });
712
+ // Retry merge after agent resolved conflicts
713
+ const retryResult = mergeToMain(projectDir, branch);
714
+ if (retryResult.success) {
715
+ logger.info(`[recovery] ${branch} merged after AI conflict resolution`);
716
+ appendEvent(projectDir, createEvent('merge_completed', { detail: `recovery-ai-resolve: ${branch}` }));
717
+ resolved = true;
718
+ }
719
+ }
646
720
  }
647
- else {
648
- logger.error(`[recovery] ${branch} merge failed after rebase — skipping`);
649
- if (stashed)
650
- popStash(projectDir);
651
- continue;
721
+ else if (conflictDecision.action === 'rebase') {
722
+ const rebaseResult = rebaseOnMain(worktreePath, projectDir);
723
+ if (rebaseResult.success) {
724
+ const retryResult = mergeToMain(projectDir, branch);
725
+ if (retryResult.success) {
726
+ logger.info(`[recovery] ${branch} merged after rebase`);
727
+ resolved = true;
728
+ }
729
+ }
652
730
  }
731
+ // action === 'skip' → resolved stays false
653
732
  }
654
- else {
733
+ catch (err) {
734
+ logger.warn(`[recovery] AI conflict resolution failed for ${branch}: ${err.message}`);
735
+ abortMerge(projectDir);
736
+ }
737
+ if (!resolved) {
655
738
  logger.error(`[recovery] ${branch} has unresolvable conflicts — skipping`);
739
+ abortMerge(projectDir);
656
740
  if (stashed)
657
741
  popStash(projectDir);
658
742
  continue;
@@ -660,11 +744,15 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
660
744
  }
661
745
  else {
662
746
  logger.error(`[recovery] ${branch} merge failed: ${mergeResult.error} — skipping`);
747
+ abortMerge(projectDir);
663
748
  if (stashed)
664
749
  popStash(projectDir);
665
750
  continue;
666
751
  }
752
+ // Post-merge cleanup: worktree + local branch + remote branch + close issue
667
753
  cleanupWorktree(projectDir, worktreePath, branch);
754
+ deleteBranchRemote(projectDir, branch);
755
+ closeRecoveredIssue(projectDir, branch, logger);
668
756
  if (stashed)
669
757
  popStash(projectDir);
670
758
  }
@@ -788,6 +876,11 @@ function extractIssueNumber(marker) {
788
876
  const match = marker.match(/#(\d+)/);
789
877
  return match ? parseInt(match[1], 10) : null;
790
878
  }
879
+ /** Extract issue number from branch name like "feat/#94-description" or "feat/94-description" */
880
+ function extractIssueFromBranch(branch) {
881
+ const match = branch.match(/#?(\d+)/);
882
+ return match ? parseInt(match[1], 10) : null;
883
+ }
791
884
  function getCurrentBranchFromProject(projectDir) {
792
885
  return getMainBranch(projectDir);
793
886
  }
@@ -1,9 +1,9 @@
1
1
  // ─── Platform issue source ───
2
2
  // Reads tasks from GitHub/GitLab issues instead of TODO.md.
3
- import { execSync } from 'node:child_process';
3
+ import { execFileSync } from 'node:child_process';
4
4
  const CLI_TIMEOUT = 30_000;
5
- function cli(cmd, cwd) {
6
- return execSync(cmd, { cwd, timeout: CLI_TIMEOUT, stdio: 'pipe', encoding: 'utf-8' }).trim();
5
+ function run(cmd, args, cwd) {
6
+ return execFileSync(cmd, args, { cwd, timeout: CLI_TIMEOUT, stdio: 'pipe', encoding: 'utf-8' }).trim();
7
7
  }
8
8
  // ─── Fetch open issues ───
9
9
  export function fetchOpenIssues(cwd, platform) {
@@ -13,39 +13,29 @@ export function fetchOpenIssues(cwd, platform) {
13
13
  return fetchGitLabIssues(cwd);
14
14
  }
15
15
  function fetchGitHubIssues(cwd) {
16
- try {
17
- const output = cli('gh issue list --json number,title,body,labels,milestone,assignees --state open --limit 200', cwd);
18
- const issues = JSON.parse(output);
19
- // Filter out pull requests (GitHub API sometimes includes them)
20
- return issues.map(i => ({
21
- issueNumber: i.number,
22
- title: i.title,
23
- body: i.body ?? '',
24
- labels: i.labels.map(l => l.name),
25
- milestone: i.milestone?.title ?? null,
26
- assignee: i.assignees[0]?.login ?? null,
27
- }));
28
- }
29
- catch {
30
- return [];
31
- }
16
+ const output = run('gh', ['issue', 'list', '--json', 'number,title,body,labels,milestone,assignees', '--state', 'open', '--limit', '200'], cwd);
17
+ const issues = JSON.parse(output);
18
+ // Filter out pull requests (GitHub API sometimes includes them)
19
+ return issues.map(i => ({
20
+ issueNumber: i.number,
21
+ title: i.title,
22
+ body: i.body ?? '',
23
+ labels: i.labels.map(l => l.name),
24
+ milestone: i.milestone?.title ?? null,
25
+ assignee: i.assignees[0]?.login ?? null,
26
+ }));
32
27
  }
33
28
  function fetchGitLabIssues(cwd) {
34
- try {
35
- const output = cli('glab issue list --output json --per-page 200', cwd);
36
- const issues = JSON.parse(output);
37
- return issues.map(i => ({
38
- issueNumber: i.iid,
39
- title: i.title,
40
- body: i.description ?? '',
41
- labels: i.labels,
42
- milestone: i.milestone?.title ?? null,
43
- assignee: i.assignees[0]?.username ?? null,
44
- }));
45
- }
46
- catch {
47
- return [];
48
- }
29
+ const output = run('glab', ['issue', 'list', '--output', 'json', '--per-page', '200'], cwd);
30
+ const issues = JSON.parse(output);
31
+ return issues.map(i => ({
32
+ issueNumber: i.iid,
33
+ title: i.title,
34
+ body: i.description ?? '',
35
+ labels: i.labels,
36
+ milestone: i.milestone?.title ?? null,
37
+ assignee: i.assignees[0]?.username ?? null,
38
+ }));
49
39
  }
50
40
  // ─── Convert issue to Task ───
51
41
  const PHASE_RE = /^Phase\s+(\d+)/i;
@@ -137,20 +127,27 @@ export function updateIssueStatus(cwd, platform, issueNumber, status) {
137
127
  const statusLabel = `status::${status}`;
138
128
  const allStatuses = ['status::todo', 'status::in-progress', 'status::in-review', 'status::done', 'status::skipped', 'status::blocked'];
139
129
  const removeLabels = allStatuses.filter(s => s !== statusLabel);
140
- try {
141
- if (platform === 'github') {
142
- for (const label of removeLabels) {
143
- try {
144
- cli(`gh issue edit ${issueNumber} --remove-label "${label}"`, cwd);
145
- }
146
- catch { /* label may not exist */ }
130
+ const num = String(issueNumber);
131
+ if (platform === 'github') {
132
+ for (const label of removeLabels) {
133
+ try {
134
+ run('gh', ['issue', 'edit', num, '--remove-label', label], cwd);
147
135
  }
148
- cli(`gh issue edit ${issueNumber} --add-label "${statusLabel}"`, cwd);
136
+ catch { /* label may not exist */ }
137
+ }
138
+ run('gh', ['issue', 'edit', num, '--add-label', statusLabel], cwd);
139
+ }
140
+ else {
141
+ run('glab', ['issue', 'update', num, '--unlabel', removeLabels.join(',')], cwd);
142
+ run('glab', ['issue', 'update', num, '--label', statusLabel], cwd);
143
+ }
144
+ // Close the issue when done or skipped
145
+ if (status === 'done' || status === 'skipped') {
146
+ if (platform === 'github') {
147
+ run('gh', ['issue', 'close', num], cwd);
149
148
  }
150
149
  else {
151
- cli(`glab issue update ${issueNumber} --unlabel "${removeLabels.join(',')}"`, cwd);
152
- cli(`glab issue update ${issueNumber} --label "${statusLabel}"`, cwd);
150
+ run('glab', ['issue', 'close', num], cwd);
153
151
  }
154
152
  }
155
- catch { /* best-effort */ }
156
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-workspace",
3
- "version": "2.3.8",
3
+ "version": "2.3.10",
4
4
  "author": "",
5
5
  "repository": {
6
6
  "type": "git",