@ktpartners/dgs-platform 2.8.0 → 3.0.4

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 (94) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +41 -13
  3. package/agents/dgs-plan-checker.md +29 -3
  4. package/agents/dgs-planner.md +10 -0
  5. package/commands/dgs/abandon-quick.md +28 -0
  6. package/commands/dgs/add-tests.md +2 -2
  7. package/commands/dgs/audit-milestone.md +2 -2
  8. package/commands/dgs/capture-principle.md +11 -11
  9. package/commands/dgs/cleanup.md +2 -2
  10. package/commands/dgs/complete-milestone.md +11 -11
  11. package/commands/dgs/complete-quick.md +28 -0
  12. package/commands/dgs/create-milestone-job.md +2 -2
  13. package/commands/dgs/debug.md +3 -3
  14. package/commands/dgs/develop-idea.md +1 -1
  15. package/commands/dgs/fast.md +3 -1
  16. package/commands/dgs/health.md +1 -1
  17. package/commands/dgs/map-codebase.md +6 -6
  18. package/commands/dgs/new-milestone.md +5 -5
  19. package/commands/dgs/new-project.md +6 -6
  20. package/commands/dgs/plan-milestone-gaps.md +1 -1
  21. package/commands/dgs/progress.md +3 -3
  22. package/commands/dgs/quick-abandon.md +8 -0
  23. package/commands/dgs/quick-complete.md +8 -0
  24. package/commands/dgs/quick.md +10 -3
  25. package/commands/dgs/research-idea.md +2 -2
  26. package/commands/dgs/research-phase.md +3 -3
  27. package/commands/dgs/switch-project.md +1 -1
  28. package/commands/dgs/write-spec.md +3 -3
  29. package/deliver-great-systems/bin/dgs-tools.cjs +284 -30
  30. package/deliver-great-systems/bin/lib/commands.cjs +316 -31
  31. package/deliver-great-systems/bin/lib/commands.test.cjs +336 -0
  32. package/deliver-great-systems/bin/lib/config.cjs +39 -6
  33. package/deliver-great-systems/bin/lib/context.cjs +120 -0
  34. package/deliver-great-systems/bin/lib/core.cjs +28 -11
  35. package/deliver-great-systems/bin/lib/execution.cjs +49 -17
  36. package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
  37. package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
  38. package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
  39. package/deliver-great-systems/bin/lib/init.cjs +306 -39
  40. package/deliver-great-systems/bin/lib/init.test.cjs +416 -6
  41. package/deliver-great-systems/bin/lib/jobs.cjs +124 -21
  42. package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
  43. package/deliver-great-systems/bin/lib/migration.cjs +409 -1
  44. package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
  45. package/deliver-great-systems/bin/lib/milestone.cjs +54 -29
  46. package/deliver-great-systems/bin/lib/phase.cjs +128 -2
  47. package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
  48. package/deliver-great-systems/bin/lib/projects.cjs +28 -8
  49. package/deliver-great-systems/bin/lib/projects.test.cjs +86 -0
  50. package/deliver-great-systems/bin/lib/quick.cjs +584 -0
  51. package/deliver-great-systems/bin/lib/quick.test.cjs +596 -0
  52. package/deliver-great-systems/bin/lib/repos.cjs +25 -1
  53. package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
  54. package/deliver-great-systems/bin/lib/specs.cjs +3 -81
  55. package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
  56. package/deliver-great-systems/bin/lib/state.cjs +142 -54
  57. package/deliver-great-systems/bin/lib/sync.cjs +75 -0
  58. package/deliver-great-systems/bin/lib/verify.cjs +80 -1
  59. package/deliver-great-systems/bin/lib/worktrees.cjs +764 -0
  60. package/deliver-great-systems/bin/lib/worktrees.test.cjs +887 -0
  61. package/deliver-great-systems/templates/claude-md.md +16 -0
  62. package/deliver-great-systems/workflows/abandon-quick.md +89 -0
  63. package/deliver-great-systems/workflows/add-idea.md +3 -3
  64. package/deliver-great-systems/workflows/add-tests.md +14 -0
  65. package/deliver-great-systems/workflows/add-todo.md +1 -0
  66. package/deliver-great-systems/workflows/approve-spec.md +25 -4
  67. package/deliver-great-systems/workflows/audit-phase.md +15 -5
  68. package/deliver-great-systems/workflows/cancel-job.md +1 -1
  69. package/deliver-great-systems/workflows/check-todos.md +2 -3
  70. package/deliver-great-systems/workflows/complete-milestone.md +197 -22
  71. package/deliver-great-systems/workflows/complete-quick.md +68 -0
  72. package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
  73. package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
  74. package/deliver-great-systems/workflows/develop-idea.md +11 -11
  75. package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
  76. package/deliver-great-systems/workflows/discuss-idea.md +1 -1
  77. package/deliver-great-systems/workflows/execute-phase.md +121 -32
  78. package/deliver-great-systems/workflows/execute-plan.md +12 -21
  79. package/deliver-great-systems/workflows/help.md +33 -29
  80. package/deliver-great-systems/workflows/init-product.md +2 -18
  81. package/deliver-great-systems/workflows/new-milestone.md +40 -24
  82. package/deliver-great-systems/workflows/new-project.md +22 -680
  83. package/deliver-great-systems/workflows/progress-all.md +133 -0
  84. package/deliver-great-systems/workflows/quick-abandon.md +89 -0
  85. package/deliver-great-systems/workflows/quick-complete.md +68 -0
  86. package/deliver-great-systems/workflows/quick.md +152 -23
  87. package/deliver-great-systems/workflows/refine-spec.md +1 -1
  88. package/deliver-great-systems/workflows/research-idea.md +8 -8
  89. package/deliver-great-systems/workflows/resume-project.md +2 -2
  90. package/deliver-great-systems/workflows/run-job.md +8 -8
  91. package/deliver-great-systems/workflows/validate-phase.md +39 -1
  92. package/deliver-great-systems/workflows/verify-work.md +14 -0
  93. package/deliver-great-systems/workflows/write-spec.md +2 -2
  94. package/package.json +1 -1
@@ -0,0 +1,764 @@
1
+ /**
2
+ * Worktrees -- git worktree lifecycle management
3
+ *
4
+ * Create, remove, list, setup, and prune worktrees for code repos.
5
+ * State tracked in config.local.json under projects.{project}.worktrees.
6
+ *
7
+ * Worktree paths are siblings to main checkout:
8
+ * {repoParent}/{repoName}--{projectSlug}-{slug}
9
+ *
10
+ * Branch naming:
11
+ * milestone/{slug} or quick/{slug}
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+ const { execSync } = require('child_process');
20
+ const { execGit, output, error, loadConfig } = require('./core.cjs');
21
+ const { getLocalConfigPath } = require('./config.cjs');
22
+ const { getPlanningRoot } = require('./paths.cjs');
23
+ const { parseReposMd } = require('./repos.cjs');
24
+
25
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Read config.local.json safely.
29
+ * @param {string} cwd
30
+ * @returns {object}
31
+ */
32
+ function _readLocalConfig(cwd) {
33
+ const localPath = getLocalConfigPath(cwd);
34
+ try {
35
+ if (fs.existsSync(localPath)) {
36
+ return JSON.parse(fs.readFileSync(localPath, 'utf-8'));
37
+ }
38
+ } catch { /* ignore */ }
39
+ return {};
40
+ }
41
+
42
+ /**
43
+ * Write config.local.json atomically.
44
+ * @param {string} cwd
45
+ * @param {object} data
46
+ */
47
+ function _writeLocalConfig(cwd, data) {
48
+ const localPath = getLocalConfigPath(cwd);
49
+ const dir = path.dirname(localPath);
50
+ if (!fs.existsSync(dir)) {
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ }
53
+ const tmpPath = localPath + '.tmp.' + process.pid;
54
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
55
+ fs.renameSync(tmpPath, localPath);
56
+ }
57
+
58
+ /**
59
+ * Get worktree state for a slug.
60
+ * @param {string} cwd
61
+ * @param {string} project
62
+ * @param {string} slug
63
+ * @returns {object|null}
64
+ */
65
+ function _getWorktreeState(cwd, project, slug) {
66
+ const data = _readLocalConfig(cwd);
67
+ return (data.projects && data.projects[project]
68
+ && data.projects[project].worktrees
69
+ && data.projects[project].worktrees[slug]) || null;
70
+ }
71
+
72
+ /**
73
+ * Set worktree state for a slug.
74
+ * @param {string} cwd
75
+ * @param {string} project
76
+ * @param {string} slug
77
+ * @param {object} entry - { type, mode, setup_complete, repos: { repoName: absPath } }
78
+ */
79
+ function _setWorktreeState(cwd, project, slug, entry) {
80
+ const data = _readLocalConfig(cwd);
81
+ if (!data.projects) data.projects = {};
82
+ if (!data.projects[project]) data.projects[project] = {};
83
+ if (!data.projects[project].worktrees) data.projects[project].worktrees = {};
84
+
85
+ // Merge repos into existing entry if present (multi-repo iterative creation)
86
+ const existing = data.projects[project].worktrees[slug];
87
+ if (existing && existing.repos && entry.repos) {
88
+ entry.repos = { ...existing.repos, ...entry.repos };
89
+ }
90
+
91
+ data.projects[project].worktrees[slug] = entry;
92
+ _writeLocalConfig(cwd, data);
93
+ }
94
+
95
+ /**
96
+ * Remove worktree state for a slug.
97
+ * @param {string} cwd
98
+ * @param {string} project
99
+ * @param {string} slug
100
+ */
101
+ function _removeWorktreeState(cwd, project, slug) {
102
+ const data = _readLocalConfig(cwd);
103
+ if (data.projects && data.projects[project]
104
+ && data.projects[project].worktrees
105
+ && data.projects[project].worktrees[slug]) {
106
+ delete data.projects[project].worktrees[slug];
107
+ _writeLocalConfig(cwd, data);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Build worktree path as sibling to main checkout.
113
+ * Format: {repoParent}/{repoBaseName}--{projectSlug}-{slug}
114
+ * @param {string} repoAbsPath - Absolute path to the repo's main checkout
115
+ * @param {string} projectSlug
116
+ * @param {string} slug
117
+ * @returns {string}
118
+ */
119
+ function _buildWorktreePath(repoAbsPath, projectSlug, slug) {
120
+ return path.join(
121
+ path.dirname(repoAbsPath),
122
+ path.basename(repoAbsPath) + '--' + projectSlug + '-' + slug
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Build branch name from type and slug.
128
+ * @param {string} type - 'milestone' or 'quick'
129
+ * @param {string} slug
130
+ * @returns {string}
131
+ */
132
+ function _buildBranchName(type, slug) {
133
+ if (type === 'milestone') return 'milestone/' + slug;
134
+ if (type === 'quick') return 'quick/' + slug;
135
+ return slug;
136
+ }
137
+
138
+ /**
139
+ * Sanitize a string into a valid slug.
140
+ * @param {string} input
141
+ * @returns {string}
142
+ */
143
+ function _sanitizeSlug(input) {
144
+ if (!input) return '';
145
+ return input
146
+ .toLowerCase()
147
+ .replace(/[^a-z0-9-]/g, '-')
148
+ .replace(/-{2,}/g, '-')
149
+ .replace(/^-+|-+$/g, '')
150
+ .slice(0, 50)
151
+ .replace(/-+$/, '');
152
+ }
153
+
154
+ /**
155
+ * Detect collision and disambiguate path if needed.
156
+ * @param {string} targetPath
157
+ * @param {string} slug
158
+ * @returns {string}
159
+ */
160
+ function _detectCollision(targetPath, slug) {
161
+ if (!fs.existsSync(targetPath)) return targetPath;
162
+ // Append first 4 chars of a hash for disambiguation
163
+ const hash = crypto.createHash('sha256')
164
+ .update(slug + '-' + Date.now())
165
+ .digest('hex')
166
+ .slice(0, 4);
167
+ return targetPath + '-' + hash;
168
+ }
169
+
170
+ /**
171
+ * Resolve a relative repo path from REPOS.md to an absolute path.
172
+ * @param {string} cwd
173
+ * @param {string} repoRelPath
174
+ * @returns {string}
175
+ */
176
+ function _resolveRepoAbsPath(cwd, repoRelPath) {
177
+ const root = getPlanningRoot(cwd);
178
+ return path.resolve(root, repoRelPath);
179
+ }
180
+
181
+ /**
182
+ * Execute setup command in worktree directory.
183
+ * Setup script receives: $1=slug, $2=worktree_path (absolute).
184
+ * cwd is set to the worktree directory.
185
+ * @param {string} worktreePath - Absolute path to the worktree directory
186
+ * @param {string} setupCmd - Setup command from REPOS.md
187
+ * @param {string} slug - Worktree slug
188
+ * @returns {{ success: boolean, skipped: boolean, error?: string, stderr?: string }}
189
+ */
190
+ function _runSetupCommand(worktreePath, setupCmd, slug) {
191
+ if (!setupCmd) return { success: true, skipped: true };
192
+ try {
193
+ const cmd = setupCmd + ' ' + slug + ' "' + worktreePath + '"';
194
+ execSync(cmd, {
195
+ cwd: worktreePath,
196
+ stdio: ['pipe', 'pipe', 'pipe'],
197
+ encoding: 'utf-8',
198
+ timeout: 300000, // 5 minutes
199
+ });
200
+ return { success: true, skipped: false };
201
+ } catch (err) {
202
+ return {
203
+ success: false,
204
+ skipped: false,
205
+ error: err.message || String(err),
206
+ stderr: err.stderr || '',
207
+ };
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Find the main checkout path for a repo given its worktree path and branch.
213
+ * Derives from the worktree naming convention.
214
+ * @param {string} cwd
215
+ * @param {string} repoName
216
+ * @returns {string|null}
217
+ */
218
+ function _findMainCheckoutPath(cwd, repoName) {
219
+ const parsed = parseReposMd(cwd);
220
+ if (!parsed || !parsed.repos) return null;
221
+ const repo = parsed.repos.find(function(r) { return r.name === repoName; });
222
+ if (!repo || !repo.path) return null;
223
+ return _resolveRepoAbsPath(cwd, repo.path);
224
+ }
225
+
226
+ // ─── Exported command functions ───────────────────────────────────────────────
227
+
228
+ /**
229
+ * Create worktrees for a project's repos.
230
+ * @param {string} cwd
231
+ * @param {string[]} args - [slug, --type milestone|quick, --mode full|debug|null, --repo name]
232
+ */
233
+ function cmdWorktreesCreate(cwd, args) {
234
+ // Parse args
235
+ const slug = _sanitizeSlug(args[0]);
236
+ if (!slug) error('Usage: worktrees create <slug> --type milestone|quick');
237
+
238
+ const typeIdx = args.indexOf('--type');
239
+ const type = typeIdx !== -1 ? args[typeIdx + 1] : null;
240
+ if (!type || (type !== 'milestone' && type !== 'quick')) {
241
+ error('--type must be "milestone" or "quick"');
242
+ }
243
+
244
+ const modeIdx = args.indexOf('--mode');
245
+ const mode = modeIdx !== -1 ? args[modeIdx + 1] : null;
246
+
247
+ const repoIdx = args.indexOf('--repo');
248
+ const repoFilter = repoIdx !== -1 ? args[repoIdx + 1] : null;
249
+
250
+ // Load config
251
+ const config = loadConfig(cwd);
252
+ const project = config.current_project;
253
+ if (!project) error('No current project set. Use dgs-tools projects switch <name>');
254
+
255
+ const baseBranch = config.base_branch || 'main';
256
+
257
+ // Parse REPOS.md
258
+ const parsed = parseReposMd(cwd);
259
+ if (!parsed || !parsed.repos || parsed.repos.length === 0) {
260
+ error('No repos found in REPOS.md');
261
+ }
262
+
263
+ let repos = parsed.repos;
264
+ if (repoFilter) {
265
+ repos = repos.filter(function(r) { return r.name === repoFilter; });
266
+ if (repos.length === 0) error('Repo "' + repoFilter + '" not found in REPOS.md');
267
+ }
268
+
269
+ const createdRepos = {};
270
+
271
+ for (const repo of repos) {
272
+ const repoAbsPath = _resolveRepoAbsPath(cwd, repo.path);
273
+ let worktreePath = _buildWorktreePath(repoAbsPath, project, slug);
274
+ worktreePath = _detectCollision(worktreePath, slug);
275
+ const branchName = _buildBranchName(type, slug);
276
+
277
+ // Check existing state
278
+ const existingState = _getWorktreeState(cwd, project, slug);
279
+ if (existingState && existingState.repos && existingState.repos[repo.name]
280
+ && fs.existsSync(existingState.repos[repo.name])) {
281
+ process.stderr.write('Worktree for ' + repo.name + ' already exists at ' + existingState.repos[repo.name] + ', skipping.\n');
282
+ createdRepos[repo.name] = existingState.repos[repo.name];
283
+ continue;
284
+ }
285
+
286
+ // Verify main checkout is on base branch
287
+ const headResult = execGit(repoAbsPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
288
+ if (headResult.exitCode !== 0) {
289
+ error('Cannot determine branch for ' + repo.name + ': ' + headResult.stderr);
290
+ }
291
+ if (headResult.stdout !== baseBranch) {
292
+ error('Main checkout of ' + repo.name + ' is not on ' + baseBranch + ' (currently on ' + headResult.stdout + '). Cannot create worktree.');
293
+ }
294
+
295
+ // Check if branch already exists and is checked out elsewhere
296
+ const worktreeListResult = execGit(repoAbsPath, ['worktree', 'list', '--porcelain']);
297
+ if (worktreeListResult.exitCode === 0 && worktreeListResult.stdout.includes(branchName)) {
298
+ // Try pruning stale worktrees first
299
+ execGit(repoAbsPath, ['worktree', 'prune']);
300
+ }
301
+
302
+ // Create worktree
303
+ const createResult = execGit(repoAbsPath, ['worktree', 'add', '-b', branchName, worktreePath, baseBranch]);
304
+ if (createResult.exitCode !== 0) {
305
+ // Branch might already exist (e.g., from a previous partial creation)
306
+ // Try without -b (checkout existing branch)
307
+ const retryResult = execGit(repoAbsPath, ['worktree', 'add', worktreePath, branchName]);
308
+ if (retryResult.exitCode !== 0) {
309
+ error('Failed to create worktree for ' + repo.name + ': ' + createResult.stderr + ' | Retry: ' + retryResult.stderr);
310
+ }
311
+ }
312
+
313
+ // Run setup
314
+ const setupResult = _runSetupCommand(worktreePath, repo.setup, slug);
315
+ const setupComplete = setupResult.success;
316
+
317
+ if (!setupResult.success && !setupResult.skipped) {
318
+ process.stderr.write('Warning: Setup failed for ' + repo.name + ': ' + (setupResult.error || '') + '\n');
319
+ process.stderr.write('Re-run setup: dgs-tools worktrees setup ' + slug + '\n');
320
+ }
321
+
322
+ // Write state
323
+ _setWorktreeState(cwd, project, slug, {
324
+ type: type,
325
+ mode: mode,
326
+ setup_complete: setupComplete,
327
+ repos: { [repo.name]: worktreePath },
328
+ });
329
+
330
+ createdRepos[repo.name] = worktreePath;
331
+ }
332
+
333
+ output({ created: true, slug: slug, type: type, repos: createdRepos });
334
+ }
335
+
336
+ /**
337
+ * Remove a worktree.
338
+ * @param {string} cwd
339
+ * @param {string[]} args - [slug]
340
+ */
341
+ function cmdWorktreesRemove(cwd, args) {
342
+ const slug = args[0];
343
+ if (!slug) error('Usage: worktrees remove <slug>');
344
+
345
+ const config = loadConfig(cwd);
346
+ const project = config.current_project;
347
+ if (!project) error('No current project set');
348
+
349
+ const state = _getWorktreeState(cwd, project, slug);
350
+ if (!state) error('No worktree tracked for slug \'' + slug + '\'');
351
+
352
+ const branchName = _buildBranchName(state.type || 'milestone', slug);
353
+
354
+ for (const repoName of Object.keys(state.repos)) {
355
+ const worktreePath = state.repos[repoName];
356
+ const mainCheckout = _findMainCheckoutPath(cwd, repoName);
357
+
358
+ if (!mainCheckout) {
359
+ process.stderr.write('Warning: Cannot find main checkout for ' + repoName + ', skipping git cleanup.\n');
360
+ // Still try to remove directory
361
+ if (fs.existsSync(worktreePath)) {
362
+ fs.rmSync(worktreePath, { recursive: true, force: true });
363
+ }
364
+ continue;
365
+ }
366
+
367
+ // Remove worktree via git
368
+ const removeResult = execGit(mainCheckout, ['worktree', 'remove', worktreePath]);
369
+ if (removeResult.exitCode !== 0) {
370
+ // Directory already gone or other issue — try prune
371
+ execGit(mainCheckout, ['worktree', 'prune']);
372
+ // Force remove directory if still exists
373
+ if (fs.existsSync(worktreePath)) {
374
+ fs.rmSync(worktreePath, { recursive: true, force: true });
375
+ execGit(mainCheckout, ['worktree', 'prune']);
376
+ }
377
+ }
378
+
379
+ // Delete branch (try -d first, then -D)
380
+ const delResult = execGit(mainCheckout, ['branch', '-d', branchName]);
381
+ if (delResult.exitCode !== 0) {
382
+ execGit(mainCheckout, ['branch', '-D', branchName]);
383
+ }
384
+ }
385
+
386
+ // Remove from config
387
+ _removeWorktreeState(cwd, project, slug);
388
+
389
+ // Clear active_context if it points to this slug
390
+ const localData = _readLocalConfig(cwd);
391
+ if (localData.execution && localData.execution.active_context === slug) {
392
+ localData.execution.active_context = null;
393
+ _writeLocalConfig(cwd, localData);
394
+ }
395
+
396
+ output({ removed: true, slug: slug });
397
+ }
398
+
399
+ /**
400
+ * List tracked worktrees.
401
+ * @param {string} cwd
402
+ * @param {string[]} args
403
+ */
404
+ function cmdWorktreesList(cwd, args) {
405
+ const config = loadConfig(cwd);
406
+ const project = config.current_project;
407
+ if (!project) error('No current project set');
408
+
409
+ const data = _readLocalConfig(cwd);
410
+ const worktrees = (data.projects && data.projects[project]
411
+ && data.projects[project].worktrees) || {};
412
+
413
+ const result = [];
414
+ for (const slug of Object.keys(worktrees)) {
415
+ const entry = worktrees[slug];
416
+ const repoStatus = {};
417
+ let stale = false;
418
+
419
+ if (entry.repos) {
420
+ for (const repoName of Object.keys(entry.repos)) {
421
+ const repoPath = entry.repos[repoName];
422
+ const exists = fs.existsSync(repoPath);
423
+ if (!exists) stale = true;
424
+ repoStatus[repoName] = { path: repoPath, exists: exists };
425
+ }
426
+ }
427
+
428
+ result.push({
429
+ slug: slug,
430
+ type: entry.type || 'milestone',
431
+ mode: entry.mode || null,
432
+ setup_complete: entry.setup_complete || false,
433
+ repos: repoStatus,
434
+ stale: stale,
435
+ });
436
+ }
437
+
438
+ output(result);
439
+ }
440
+
441
+ /**
442
+ * Re-run setup for a worktree.
443
+ * @param {string} cwd
444
+ * @param {string[]} args - [slug]
445
+ */
446
+ function cmdWorktreesSetup(cwd, args) {
447
+ const slug = args[0];
448
+ if (!slug) error('Usage: worktrees setup <slug>');
449
+
450
+ const config = loadConfig(cwd);
451
+ const project = config.current_project;
452
+ if (!project) error('No current project set');
453
+
454
+ const state = _getWorktreeState(cwd, project, slug);
455
+ if (!state) error('No worktree tracked for slug \'' + slug + '\'');
456
+
457
+ const parsed = parseReposMd(cwd);
458
+ if (!parsed || !parsed.repos) error('Cannot parse REPOS.md');
459
+
460
+ const results = {};
461
+ let allSuccess = true;
462
+
463
+ for (const repoName of Object.keys(state.repos)) {
464
+ const worktreePath = state.repos[repoName];
465
+ const repoEntry = parsed.repos.find(function(r) { return r.name === repoName; });
466
+
467
+ if (!repoEntry) {
468
+ results[repoName] = 'skipped';
469
+ continue;
470
+ }
471
+
472
+ if (!repoEntry.setup) {
473
+ results[repoName] = 'no_setup_command';
474
+ continue;
475
+ }
476
+
477
+ const setupResult = _runSetupCommand(worktreePath, repoEntry.setup, slug);
478
+ results[repoName] = setupResult.success ? 'success' : 'failed';
479
+ if (!setupResult.success) {
480
+ allSuccess = false;
481
+ process.stderr.write('Setup failed for ' + repoName + ': ' + (setupResult.error || '') + '\n');
482
+ }
483
+ }
484
+
485
+ // Update setup_complete flag
486
+ _setWorktreeState(cwd, project, slug, {
487
+ ...state,
488
+ setup_complete: allSuccess,
489
+ });
490
+
491
+ output({ setup_complete: allSuccess, slug: slug, results: results });
492
+ }
493
+
494
+ /**
495
+ * Prune orphaned worktree entries.
496
+ * @param {string} cwd
497
+ * @param {string[]} args
498
+ */
499
+ function cmdWorktreesPrune(cwd, args) {
500
+ const config = loadConfig(cwd);
501
+ const project = config.current_project;
502
+ if (!project) error('No current project set');
503
+
504
+ const data = _readLocalConfig(cwd);
505
+ const worktrees = (data.projects && data.projects[project]
506
+ && data.projects[project].worktrees) || {};
507
+
508
+ const pruned = [];
509
+
510
+ for (const slug of Object.keys(worktrees)) {
511
+ const entry = worktrees[slug];
512
+ if (!entry.repos) continue;
513
+
514
+ // Check if ALL repo directories are missing
515
+ const allMissing = Object.keys(entry.repos).every(function(repoName) {
516
+ return !fs.existsSync(entry.repos[repoName]);
517
+ });
518
+
519
+ if (!allMissing) continue;
520
+
521
+ const branchName = _buildBranchName(entry.type || 'milestone', slug);
522
+
523
+ // Clean up git metadata and branches
524
+ for (const repoName of Object.keys(entry.repos)) {
525
+ const mainCheckout = _findMainCheckoutPath(cwd, repoName);
526
+ if (mainCheckout) {
527
+ execGit(mainCheckout, ['worktree', 'prune']);
528
+ execGit(mainCheckout, ['branch', '-D', branchName]);
529
+ }
530
+ }
531
+
532
+ // Remove config entry
533
+ delete worktrees[slug];
534
+ pruned.push(slug);
535
+ }
536
+
537
+ if (pruned.length > 0) {
538
+ _writeLocalConfig(cwd, data);
539
+
540
+ // Clear active_context if it points to a pruned slug
541
+ const freshData = _readLocalConfig(cwd);
542
+ if (freshData.execution && pruned.includes(freshData.execution.active_context)) {
543
+ freshData.execution.active_context = null;
544
+ _writeLocalConfig(cwd, freshData);
545
+ }
546
+ }
547
+
548
+ output({ pruned: pruned, count: pruned.length });
549
+ }
550
+
551
+ // ─── Shared functions ─────────────────────────────────────────────────────────
552
+
553
+ /**
554
+ * Rebase a worktree branch onto base_branch and fast-forward merge.
555
+ * Used by complete-milestone and complete-quick for clean rebase-before-merge completion.
556
+ *
557
+ * Steps:
558
+ * 1. Pull base_branch in main checkout (fetch + pull)
559
+ * 2. Check if rebase is needed (idempotent -- skip if already rebased)
560
+ * 3. Rebase worktree branch onto base_branch in worktree directory
561
+ * 4. On conflict: git rebase --abort, return error with manual instructions
562
+ * 5. Fast-forward merge to base_branch in main checkout
563
+ * 6. Push to remote (always -- not gated by sync config)
564
+ *
565
+ * @param {string} cwd - Planning root for config resolution
566
+ * @param {string} repoName - Repo name from REPOS.md
567
+ * @param {string} slug - Worktree slug (e.g., 'v19-0')
568
+ * @param {object} [options] - { push: true }
569
+ * @returns {{ success: boolean, merged: boolean, skipped: boolean, conflicted: boolean, pushed: boolean, error?: string, manualInstructions?: string }}
570
+ */
571
+ function rebaseAndMerge(cwd, repoName, slug, options) {
572
+ options = options || {};
573
+ const shouldPush = options.push !== false; // default true
574
+
575
+ const config = loadConfig(cwd);
576
+ const project = config.current_project;
577
+ const baseBranch = config.base_branch || 'main';
578
+
579
+ if (!project) return { success: false, merged: false, skipped: false, conflicted: false, pushed: false, error: 'No current project set' };
580
+
581
+ const state = _getWorktreeState(cwd, project, slug);
582
+ if (!state) return { success: false, merged: false, skipped: false, conflicted: false, pushed: false, error: 'No worktree tracked for slug \'' + slug + '\'' };
583
+
584
+ const worktreePath = state.repos && state.repos[repoName];
585
+ if (!worktreePath) return { success: false, merged: false, skipped: false, conflicted: false, pushed: false, error: 'Repo \'' + repoName + '\' not in worktree \'' + slug + '\'' };
586
+
587
+ const mainCheckout = _findMainCheckoutPath(cwd, repoName);
588
+ if (!mainCheckout) return { success: false, merged: false, skipped: false, conflicted: false, pushed: false, error: 'Cannot find main checkout for \'' + repoName + '\'' };
589
+
590
+ const branchName = _buildBranchName(state.type || 'milestone', slug);
591
+
592
+ // Step 1: Pull base_branch in main checkout
593
+ const fetchResult = execGit(mainCheckout, ['fetch', 'origin']);
594
+ if (fetchResult.exitCode !== 0) {
595
+ process.stderr.write('Warning: fetch failed: ' + fetchResult.stderr + '\n');
596
+ // Continue anyway -- local base_branch may still be usable
597
+ }
598
+
599
+ // Ensure main checkout is on base_branch before pulling
600
+ const mainHead = execGit(mainCheckout, ['rev-parse', '--abbrev-ref', 'HEAD']);
601
+ if (mainHead.exitCode === 0 && mainHead.stdout.trim() === baseBranch) {
602
+ const pullResult = execGit(mainCheckout, ['pull', 'origin', baseBranch]);
603
+ if (pullResult.exitCode !== 0) {
604
+ process.stderr.write('Warning: pull failed: ' + pullResult.stderr + '\n');
605
+ // Continue anyway
606
+ }
607
+ }
608
+
609
+ // Step 2: Check if rebase is already done (idempotent re-run)
610
+ // If the milestone branch's merge-base with base_branch equals base_branch tip,
611
+ // the branch is already rebased onto base_branch.
612
+ const baseTip = execGit(mainCheckout, ['rev-parse', baseBranch]);
613
+ const mergeBase = execGit(mainCheckout, ['merge-base', baseBranch, branchName]);
614
+ let rebaseSkipped = false;
615
+ if (baseTip.exitCode === 0 && mergeBase.exitCode === 0
616
+ && baseTip.stdout.trim() === mergeBase.stdout.trim()) {
617
+ // Already rebased -- skip to merge
618
+ process.stderr.write('Branch ' + branchName + ' already rebased onto ' + baseBranch + '. Skipping rebase.\n');
619
+ rebaseSkipped = true;
620
+ } else {
621
+ // Step 3: Rebase worktree branch onto base_branch
622
+ // First ensure worktree is on the correct branch
623
+ const headCheck = execGit(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']);
624
+ if (headCheck.exitCode !== 0 || headCheck.stdout.trim() !== branchName) {
625
+ return {
626
+ success: false,
627
+ merged: false,
628
+ skipped: false,
629
+ conflicted: false,
630
+ pushed: false,
631
+ error: 'Worktree is not on expected branch ' + branchName + ' (on ' + (headCheck.stdout || 'unknown').trim() + ')',
632
+ };
633
+ }
634
+
635
+ const rebaseResult = execGit(worktreePath, ['rebase', baseBranch]);
636
+ if (rebaseResult.exitCode !== 0) {
637
+ // Rebase failed -- abort cleanly
638
+ execGit(worktreePath, ['rebase', '--abort']);
639
+
640
+ const instructions = [
641
+ 'Rebase conflict in ' + repoName + '. Manual resolution required:',
642
+ '',
643
+ ' cd ' + worktreePath,
644
+ ' git rebase ' + baseBranch,
645
+ ' # Resolve conflicts in each file',
646
+ ' git add <resolved-files>',
647
+ ' git rebase --continue',
648
+ ' # Repeat for each conflicting commit',
649
+ '',
650
+ 'Then re-run: /dgs:complete-milestone',
651
+ ].join('\n');
652
+
653
+ return {
654
+ success: false,
655
+ merged: false,
656
+ skipped: false,
657
+ conflicted: true,
658
+ pushed: false,
659
+ error: 'Rebase conflict in ' + repoName,
660
+ manualInstructions: instructions,
661
+ };
662
+ }
663
+ }
664
+
665
+ // Step 4: Fast-forward merge to base_branch in main checkout
666
+ // Ensure main checkout is on base_branch
667
+ const checkoutBase = execGit(mainCheckout, ['checkout', baseBranch]);
668
+ if (checkoutBase.exitCode !== 0) {
669
+ return {
670
+ success: false,
671
+ merged: false,
672
+ skipped: rebaseSkipped,
673
+ conflicted: false,
674
+ pushed: false,
675
+ error: 'Failed to checkout ' + baseBranch + ' in main checkout: ' + checkoutBase.stderr,
676
+ };
677
+ }
678
+
679
+ const mergeResult = execGit(mainCheckout, ['merge', '--ff-only', branchName]);
680
+ if (mergeResult.exitCode !== 0) {
681
+ return {
682
+ success: false,
683
+ merged: false,
684
+ skipped: rebaseSkipped,
685
+ conflicted: false,
686
+ pushed: false,
687
+ error: 'Fast-forward merge failed: ' + mergeResult.stderr,
688
+ };
689
+ }
690
+
691
+ // Step 5: Push to remote (always -- milestone completion is a definitive push point)
692
+ let pushed = false;
693
+ if (shouldPush) {
694
+ const pushResult = execGit(mainCheckout, ['push', 'origin', baseBranch]);
695
+ pushed = pushResult.exitCode === 0;
696
+ if (!pushed) {
697
+ process.stderr.write('Warning: push failed: ' + pushResult.stderr + '\n');
698
+ }
699
+ }
700
+
701
+ return {
702
+ success: true,
703
+ merged: true,
704
+ skipped: rebaseSkipped,
705
+ conflicted: false,
706
+ pushed: pushed,
707
+ };
708
+ }
709
+
710
+ /**
711
+ * Verify worktree health before execution.
712
+ * Checks: correct branch checked out, no uncommitted changes.
713
+ *
714
+ * @param {string} cwd - Planning root
715
+ * @param {string} slug - Worktree slug
716
+ * @returns {{ healthy: boolean, issues: string[] }}
717
+ */
718
+ function checkWorktreeHealth(cwd, slug) {
719
+ const config = loadConfig(cwd);
720
+ const project = config.current_project;
721
+ if (!project) return { healthy: false, issues: ['No current project set'] };
722
+
723
+ const state = _getWorktreeState(cwd, project, slug);
724
+ if (!state) return { healthy: false, issues: ['No worktree tracked for slug \'' + slug + '\''] };
725
+
726
+ const branchName = _buildBranchName(state.type || 'milestone', slug);
727
+ const issues = [];
728
+
729
+ for (const repoName of Object.keys(state.repos || {})) {
730
+ const worktreePath = state.repos[repoName];
731
+
732
+ // Check directory exists
733
+ if (!fs.existsSync(worktreePath)) {
734
+ issues.push(repoName + ': worktree directory missing at ' + worktreePath);
735
+ continue;
736
+ }
737
+
738
+ // Check correct branch
739
+ const headResult = execGit(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']);
740
+ if (headResult.exitCode !== 0) {
741
+ issues.push(repoName + ': cannot determine branch');
742
+ } else if (headResult.stdout.trim() !== branchName) {
743
+ issues.push(repoName + ': expected branch ' + branchName + ' but on ' + headResult.stdout.trim());
744
+ }
745
+
746
+ // Check no uncommitted changes
747
+ const statusResult = execGit(worktreePath, ['status', '--porcelain']);
748
+ if (statusResult.exitCode === 0 && statusResult.stdout.trim()) {
749
+ issues.push(repoName + ': has uncommitted changes');
750
+ }
751
+ }
752
+
753
+ return { healthy: issues.length === 0, issues: issues };
754
+ }
755
+
756
+ module.exports = {
757
+ cmdWorktreesCreate,
758
+ cmdWorktreesRemove,
759
+ cmdWorktreesList,
760
+ cmdWorktreesSetup,
761
+ cmdWorktreesPrune,
762
+ rebaseAndMerge,
763
+ checkWorktreeHealth,
764
+ };