create-claude-workspace 2.3.12 → 2.3.13

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.
@@ -676,7 +676,7 @@ function isRefactoringTask(task) {
676
676
  }
677
677
  // ─── Orphaned worktree recovery ───
678
678
  /** Mark issue as done on the platform after a successful recovery merge */
679
- function closeRecoveredIssue(projectDir, branch, logger) {
679
+ export function closeRecoveredIssue(projectDir, branch, logger) {
680
680
  const platform = detectCIPlatform(projectDir);
681
681
  if (platform === 'none')
682
682
  return;
@@ -698,7 +698,7 @@ function closeRecoveredIssue(projectDir, branch, logger) {
698
698
  * This does NOT spawn custom recovery agents — it just figures out the
699
699
  * right pipeline step and lets runTaskPipeline do the real work.
700
700
  */
701
- async function recoverOrphanedWorktrees(projectDir, state, logger, _deps) {
701
+ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps) {
702
702
  const knownPaths = Object.values(state.pipelines).map(p => p.worktreePath);
703
703
  const orphans = listOrphanedWorktrees(projectDir, knownPaths);
704
704
  const ciPlatform = detectCIPlatform(projectDir);
@@ -767,7 +767,7 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, _deps) {
767
767
  }
768
768
  }
769
769
  /** Diagnose which pipeline step a recovered worktree should resume from */
770
- function diagnoseStep(projectDir, branch, ciPlatform) {
770
+ export function diagnoseStep(projectDir, branch, ciPlatform) {
771
771
  // Check if there's an open MR/PR for this branch
772
772
  if (ciPlatform !== 'none') {
773
773
  try {
@@ -791,7 +791,7 @@ function diagnoseStep(projectDir, branch, ciPlatform) {
791
791
  return 'review';
792
792
  }
793
793
  /** Re-inject open MRs that have no local worktree into the pipeline */
794
- function recoverOpenMRs(projectDir, platform, state, logger) {
794
+ export function recoverOpenMRs(projectDir, platform, state, logger) {
795
795
  try {
796
796
  const openMRs = getOpenMRs(projectDir, platform);
797
797
  if (openMRs.length === 0)
@@ -864,7 +864,7 @@ function recoverOpenMRs(projectDir, platform, state, logger) {
864
864
  logger.warn(`[recovery] MR scan failed: ${err.message?.split('\n')[0]}`);
865
865
  }
866
866
  }
867
- function getOpenMRs(projectDir, platform) {
867
+ export function getOpenMRs(projectDir, platform) {
868
868
  try {
869
869
  if (platform === 'github') {
870
870
  const output = execFileSync('gh', ['pr', 'list', '--json', 'number,headRefName', '--state', 'open'], { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
@@ -997,7 +997,7 @@ function extractIssueNumber(marker) {
997
997
  return match ? parseInt(match[1], 10) : null;
998
998
  }
999
999
  /** Extract issue number from branch name like "feat/#94-description" or "feat/94-description" */
1000
- function extractIssueFromBranch(branch) {
1000
+ export function extractIssueFromBranch(branch) {
1001
1001
  const match = branch.match(/#?(\d+)/);
1002
1002
  return match ? parseInt(match[1], 10) : null;
1003
1003
  }
@@ -139,3 +139,156 @@ describe('pipeline state tracking', () => {
139
139
  expect(pipeline.reviewCycles < 5).toBe(true);
140
140
  });
141
141
  });
142
+ // ─── Test exports of recovery functions ───
143
+ describe('recovery function exports', () => {
144
+ it('exports diagnoseStep', async () => {
145
+ const mod = await import('./loop.mjs');
146
+ expect(typeof mod.diagnoseStep).toBe('function');
147
+ });
148
+ it('exports recoverOrphanedWorktrees', async () => {
149
+ const mod = await import('./loop.mjs');
150
+ expect(typeof mod.recoverOrphanedWorktrees).toBe('function');
151
+ });
152
+ it('exports recoverOpenMRs', async () => {
153
+ const mod = await import('./loop.mjs');
154
+ expect(typeof mod.recoverOpenMRs).toBe('function');
155
+ });
156
+ it('exports getOpenMRs', async () => {
157
+ const mod = await import('./loop.mjs');
158
+ expect(typeof mod.getOpenMRs).toBe('function');
159
+ });
160
+ it('exports extractIssueFromBranch', async () => {
161
+ const mod = await import('./loop.mjs');
162
+ expect(typeof mod.extractIssueFromBranch).toBe('function');
163
+ });
164
+ it('exports closeRecoveredIssue', async () => {
165
+ const mod = await import('./loop.mjs');
166
+ expect(typeof mod.closeRecoveredIssue).toBe('function');
167
+ });
168
+ });
169
+ // ─── Test shouldSkip pattern used in runTaskPipeline ───
170
+ describe('shouldSkip pattern (pipeline resume logic)', () => {
171
+ const stepOrder = ['plan', 'implement', 'test', 'review', 'rework', 'commit', 'pr-create', 'pr-watch', 'merge'];
172
+ function shouldSkip(step, resumeFrom) {
173
+ const skipToIndex = stepOrder.indexOf(resumeFrom);
174
+ return stepOrder.indexOf(step) < skipToIndex;
175
+ }
176
+ it('fresh pipeline (plan) skips nothing', () => {
177
+ for (const step of stepOrder) {
178
+ expect(shouldSkip(step, 'plan')).toBe(false);
179
+ }
180
+ });
181
+ it('resuming at "implement" skips only plan', () => {
182
+ expect(shouldSkip('plan', 'implement')).toBe(true);
183
+ expect(shouldSkip('implement', 'implement')).toBe(false);
184
+ expect(shouldSkip('test', 'implement')).toBe(false);
185
+ });
186
+ it('resuming at "review" skips plan, implement, test', () => {
187
+ expect(shouldSkip('plan', 'review')).toBe(true);
188
+ expect(shouldSkip('implement', 'review')).toBe(true);
189
+ expect(shouldSkip('test', 'review')).toBe(true);
190
+ expect(shouldSkip('review', 'review')).toBe(false);
191
+ expect(shouldSkip('commit', 'review')).toBe(false);
192
+ expect(shouldSkip('merge', 'review')).toBe(false);
193
+ });
194
+ it('resuming at "merge" skips everything before merge', () => {
195
+ for (const step of stepOrder.slice(0, -1)) {
196
+ expect(shouldSkip(step, 'merge')).toBe(true);
197
+ }
198
+ expect(shouldSkip('merge', 'merge')).toBe(false);
199
+ });
200
+ it('steps not in stepOrder return -1 index (treated as "skip nothing")', () => {
201
+ // 'done' and 'failed' are not in stepOrder, so indexOf returns -1
202
+ // This means skipToIndex = -1, and all steps have index >= 0, so nothing is skipped
203
+ // unless the step itself is also not in the list
204
+ const skipToIndex = stepOrder.indexOf('done');
205
+ expect(skipToIndex).toBe(-1);
206
+ });
207
+ });
208
+ // ─── Test extractIssueFromBranch pattern ───
209
+ describe('extractIssueFromBranch pattern', () => {
210
+ // Mirrors the actual implementation: branch.match(/#?(\d+)/)
211
+ function extractIssueFromBranch(branch) {
212
+ const match = branch.match(/#?(\d+)/);
213
+ return match ? parseInt(match[1], 10) : null;
214
+ }
215
+ it('extracts from "feat/#42-auth"', () => {
216
+ expect(extractIssueFromBranch('feat/#42-auth')).toBe(42);
217
+ });
218
+ it('extracts from "feat/42-auth"', () => {
219
+ expect(extractIssueFromBranch('feat/42-auth')).toBe(42);
220
+ });
221
+ it('returns null for "feat/add-login"', () => {
222
+ expect(extractIssueFromBranch('feat/add-login')).toBeNull();
223
+ });
224
+ it('extracts first number from "feat/#42-fix-issue-99"', () => {
225
+ expect(extractIssueFromBranch('feat/#42-fix-issue-99')).toBe(42);
226
+ });
227
+ it('handles plain number branches like "42-auth"', () => {
228
+ expect(extractIssueFromBranch('42-auth')).toBe(42);
229
+ });
230
+ });
231
+ // ─── Test recovered pipeline identification pattern ───
232
+ describe('recovered pipeline identification', () => {
233
+ it('identifies recovered pipelines by workerId === -1', () => {
234
+ const state = emptyState(1);
235
+ state.pipelines['#42'] = {
236
+ taskId: '#42',
237
+ workerId: -1,
238
+ worktreePath: '/wt/feat-42',
239
+ step: 'review',
240
+ architectPlan: null,
241
+ apiContract: null,
242
+ reviewFindings: null,
243
+ testingSection: null,
244
+ reviewCycles: 0,
245
+ ciFixes: 0,
246
+ buildFixes: 0,
247
+ assignedAgent: null,
248
+ prState: null,
249
+ };
250
+ state.pipelines['p1-1'] = {
251
+ taskId: 'p1-1',
252
+ workerId: 0,
253
+ worktreePath: '/wt/feat-p1-1',
254
+ step: 'implement',
255
+ architectPlan: null,
256
+ apiContract: null,
257
+ reviewFindings: null,
258
+ testingSection: null,
259
+ reviewCycles: 0,
260
+ ciFixes: 0,
261
+ buildFixes: 0,
262
+ assignedAgent: null,
263
+ prState: null,
264
+ };
265
+ const recoveredIds = Object.keys(state.pipelines).filter(id => {
266
+ const p = state.pipelines[id];
267
+ return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
268
+ });
269
+ expect(recoveredIds).toEqual(['#42']);
270
+ });
271
+ it('excludes done and failed pipelines from recovery', () => {
272
+ const state = emptyState(1);
273
+ state.pipelines['#50'] = {
274
+ taskId: '#50',
275
+ workerId: -1,
276
+ worktreePath: '/wt/feat-50',
277
+ step: 'done',
278
+ architectPlan: null,
279
+ apiContract: null,
280
+ reviewFindings: null,
281
+ testingSection: null,
282
+ reviewCycles: 0,
283
+ ciFixes: 0,
284
+ buildFixes: 0,
285
+ assignedAgent: null,
286
+ prState: null,
287
+ };
288
+ const recoveredIds = Object.keys(state.pipelines).filter(id => {
289
+ const p = state.pipelines[id];
290
+ return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
291
+ });
292
+ expect(recoveredIds).toHaveLength(0);
293
+ });
294
+ });
@@ -0,0 +1,121 @@
1
+ // ─── Recovery & git workflow tests ───
2
+ // Tests for diagnoseStep, extractIssueFromBranch, getOpenMRs,
3
+ // closeRecoveredIssue, and shouldSkip pipeline resume logic.
4
+ //
5
+ // Pure function tests — no mocks needed.
6
+ // @ts-ignore bun:test
7
+ import { describe, it, expect } from 'bun:test';
8
+ import { diagnoseStep, extractIssueFromBranch, getOpenMRs, } from './loop.mjs';
9
+ // ─── diagnoseStep ───
10
+ // Note: diagnoseStep calls getPRStatus which calls execFileSync.
11
+ // For unit tests we test the logic indirectly — the function falls back
12
+ // to 'review' when getPRStatus throws (which happens when not in a real repo).
13
+ describe('diagnoseStep', () => {
14
+ it('returns "review" when CI platform is "none"', () => {
15
+ expect(diagnoseStep('/nonexistent', 'feat/42-auth', 'none')).toBe('review');
16
+ });
17
+ it('returns "review" when getPRStatus throws (no PR/no repo)', () => {
18
+ // /nonexistent is not a repo, so getPRStatus will throw
19
+ expect(diagnoseStep('/nonexistent', 'feat/42-auth', 'github')).toBe('review');
20
+ });
21
+ it('returns "review" for gitlab when no PR exists', () => {
22
+ expect(diagnoseStep('/nonexistent', 'feat/42-auth', 'gitlab')).toBe('review');
23
+ });
24
+ });
25
+ // ─── extractIssueFromBranch ───
26
+ describe('extractIssueFromBranch', () => {
27
+ it('extracts number from "feat/#94-description"', () => {
28
+ expect(extractIssueFromBranch('feat/#94-user-auth')).toBe(94);
29
+ });
30
+ it('extracts number from "feat/94-description"', () => {
31
+ expect(extractIssueFromBranch('feat/94-add-login')).toBe(94);
32
+ });
33
+ it('returns null for branches without numbers', () => {
34
+ expect(extractIssueFromBranch('feat/add-login-page')).toBeNull();
35
+ });
36
+ it('extracts first number from branch with multiple numbers', () => {
37
+ expect(extractIssueFromBranch('feat/#42-fix-issue-99')).toBe(42);
38
+ });
39
+ it('extracts number from simple format', () => {
40
+ expect(extractIssueFromBranch('#7')).toBe(7);
41
+ });
42
+ it('returns null for empty string', () => {
43
+ expect(extractIssueFromBranch('')).toBeNull();
44
+ });
45
+ });
46
+ // ─── getOpenMRs ───
47
+ describe('getOpenMRs', () => {
48
+ it('returns empty array when CLI not available', () => {
49
+ // /nonexistent is not a repo, so CLI will fail
50
+ expect(getOpenMRs('/nonexistent', 'github')).toEqual([]);
51
+ });
52
+ it('returns empty array for gitlab when CLI not available', () => {
53
+ expect(getOpenMRs('/nonexistent', 'gitlab')).toEqual([]);
54
+ });
55
+ });
56
+ // ─── shouldSkip pipeline resume (unit test of the pattern) ───
57
+ describe('shouldSkip pipeline resume', () => {
58
+ // Replicate the exact shouldSkip logic from runTaskPipeline
59
+ const stepOrder = ['plan', 'implement', 'test', 'review', 'rework', 'commit', 'pr-create', 'pr-watch', 'merge'];
60
+ function shouldSkip(step, resumeStep) {
61
+ const skipToIndex = stepOrder.indexOf(resumeStep);
62
+ return stepOrder.indexOf(step) < skipToIndex;
63
+ }
64
+ it('pipeline at "review" skips plan, implement, test', () => {
65
+ expect(shouldSkip('plan', 'review')).toBe(true);
66
+ expect(shouldSkip('implement', 'review')).toBe(true);
67
+ expect(shouldSkip('test', 'review')).toBe(true);
68
+ expect(shouldSkip('review', 'review')).toBe(false);
69
+ expect(shouldSkip('commit', 'review')).toBe(false);
70
+ });
71
+ it('pipeline at "rework" skips plan through review', () => {
72
+ expect(shouldSkip('plan', 'rework')).toBe(true);
73
+ expect(shouldSkip('implement', 'rework')).toBe(true);
74
+ expect(shouldSkip('test', 'rework')).toBe(true);
75
+ expect(shouldSkip('review', 'rework')).toBe(true);
76
+ expect(shouldSkip('rework', 'rework')).toBe(false);
77
+ });
78
+ it('pipeline at "merge" skips everything before merge', () => {
79
+ expect(shouldSkip('plan', 'merge')).toBe(true);
80
+ expect(shouldSkip('commit', 'merge')).toBe(true);
81
+ expect(shouldSkip('pr-watch', 'merge')).toBe(true);
82
+ expect(shouldSkip('merge', 'merge')).toBe(false);
83
+ });
84
+ it('pipeline at "plan" skips nothing (fresh)', () => {
85
+ expect(shouldSkip('plan', 'plan')).toBe(false);
86
+ expect(shouldSkip('implement', 'plan')).toBe(false);
87
+ expect(shouldSkip('review', 'plan')).toBe(false);
88
+ });
89
+ it('pipeline at "pr-watch" skips through pr-create', () => {
90
+ expect(shouldSkip('pr-create', 'pr-watch')).toBe(true);
91
+ expect(shouldSkip('pr-watch', 'pr-watch')).toBe(false);
92
+ expect(shouldSkip('merge', 'pr-watch')).toBe(false);
93
+ });
94
+ it('pipeline at "commit" skips through rework', () => {
95
+ expect(shouldSkip('rework', 'commit')).toBe(true);
96
+ expect(shouldSkip('commit', 'commit')).toBe(false);
97
+ });
98
+ it('step not in stepOrder is treated as before all steps (indexOf=-1)', () => {
99
+ // 'done' is not in stepOrder, indexOf returns -1, which is < any skipToIndex
100
+ expect(shouldSkip('done', 'review')).toBe(true);
101
+ // But if resumeStep is 'plan' (index 0), -1 < 0 is true
102
+ expect(shouldSkip('done', 'plan')).toBe(true);
103
+ });
104
+ });
105
+ // ─── Recovered pipeline identification ───
106
+ describe('recovered pipeline identification', () => {
107
+ it('workerId=-1 identifies recovered pipelines', () => {
108
+ const pipelines = {
109
+ '#1': { workerId: -1, step: 'review' },
110
+ '#2': { workerId: 0, step: 'implement' },
111
+ '#3': { workerId: -1, step: 'done' },
112
+ '#4': { workerId: -1, step: 'failed' },
113
+ '#5': { workerId: -1, step: 'rework' },
114
+ };
115
+ const recoveredIds = Object.keys(pipelines).filter(id => {
116
+ const p = pipelines[id];
117
+ return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
118
+ });
119
+ expect(recoveredIds).toEqual(['#1', '#5']);
120
+ });
121
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-workspace",
3
- "version": "2.3.12",
3
+ "version": "2.3.13",
4
4
  "author": "",
5
5
  "repository": {
6
6
  "type": "git",