@ktpartners/dgs-platform 2.6.3 → 2.7.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.
@@ -0,0 +1,878 @@
1
+ /**
2
+ * Sync — Pull/push operations across all registered repos
3
+ *
4
+ * Provides pullAll and pushAll that iterate planning repo + REPOS.md code repos,
5
+ * with pre-flight checks, error handling, and structured result reporting.
6
+ */
7
+
8
+ const { execSync } = require('child_process');
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+ const { execGit, safeReadFile, loadConfig } = require('./core.cjs');
12
+ const { parseReposMd } = require('./repos.cjs');
13
+ const { getPlanningRoot } = require('./paths.cjs');
14
+ const { getLocalConfigPath } = require('./config.cjs');
15
+
16
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Execute a git command with a timeout.
20
+ *
21
+ * Wrapper around execSync (NOT execGit) that adds a timeout option.
22
+ * The existing execGit in core.cjs does not support timeouts and we
23
+ * should not modify it (other callers don't need timeouts).
24
+ *
25
+ * @param {string} cwd - Working directory
26
+ * @param {string[]} args - Git command arguments
27
+ * @param {number} timeoutMs - Timeout in milliseconds (default 30000)
28
+ * @returns {{ exitCode: number, stdout: string, stderr: string, isTimeout?: boolean, isAuth?: boolean }}
29
+ */
30
+ function execGitWithTimeout(cwd, args, timeoutMs = 30000) {
31
+ try {
32
+ const escaped = args.map(a => {
33
+ if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a;
34
+ return "'" + a.replace(/'/g, "'\\''") + "'";
35
+ });
36
+ const stdout = execSync('git ' + escaped.join(' '), {
37
+ cwd,
38
+ stdio: 'pipe',
39
+ encoding: 'utf-8',
40
+ timeout: timeoutMs,
41
+ });
42
+ return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
43
+ } catch (err) {
44
+ // Classify error
45
+ const stderr = (err.stderr ?? '').toString().trim();
46
+ const isTimeout = err.killed || (err.signal === 'SIGTERM');
47
+ const isAuth = /authentication|permission denied|could not read.*credentials|fatal: unable to access/i.test(stderr);
48
+ return {
49
+ exitCode: err.status ?? 1,
50
+ stdout: (err.stdout ?? '').toString().trim(),
51
+ stderr,
52
+ isTimeout,
53
+ isAuth,
54
+ };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Check if a repo has a remote configured.
60
+ *
61
+ * @param {string} repoPath - Absolute path to the repo
62
+ * @returns {boolean}
63
+ */
64
+ function hasRemote(repoPath) {
65
+ const result = execGitWithTimeout(repoPath, ['remote'], 5000);
66
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
67
+ }
68
+
69
+ /**
70
+ * Get the current branch name for a repo.
71
+ *
72
+ * @param {string} repoPath - Absolute path to the repo
73
+ * @returns {string|null} Branch name, or null on error
74
+ */
75
+ function getCurrentBranch(repoPath) {
76
+ const result = execGitWithTimeout(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD'], 5000);
77
+ if (result.exitCode !== 0) return null;
78
+ return result.stdout.trim();
79
+ }
80
+
81
+ /**
82
+ * Check if a branch has an upstream tracking branch configured.
83
+ *
84
+ * @param {string} repoPath - Absolute path to the repo
85
+ * @param {string} branch - Branch name
86
+ * @returns {boolean}
87
+ */
88
+ function hasUpstreamTracking(repoPath, branch) {
89
+ const result = execGitWithTimeout(repoPath, ['config', '--get', `branch.${branch}.remote`], 5000);
90
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
91
+ }
92
+
93
+ /**
94
+ * Translate raw git errors into user-friendly messages.
95
+ *
96
+ * @param {Object} result - execGitWithTimeout result
97
+ * @param {string} operation - 'fetch', 'pull', or 'push'
98
+ * @param {string} repoName - Repo display name
99
+ * @param {string} repoPath - Absolute path to the repo
100
+ * @returns {string} User-friendly error message
101
+ */
102
+ function classifyError(result, operation, repoName, repoPath) {
103
+ if (result.isTimeout) {
104
+ return 'Network timeout after 30s -- check connection or try again';
105
+ }
106
+ if (result.isAuth) {
107
+ return 'Authentication failed -- check credentials for remote';
108
+ }
109
+
110
+ const stderr = result.stderr || '';
111
+
112
+ // Non-fast-forward on pull/fetch+merge
113
+ if ((operation === 'pull' || operation === 'fetch') && /not possible to fast-forward|non-fast-forward/i.test(stderr)) {
114
+ const branch = getCurrentBranch(repoPath) || 'main';
115
+ return `Cannot fast-forward -- history has diverged. Fix: cd ${repoPath} && git pull --rebase origin ${branch}`;
116
+ }
117
+
118
+ // Rejected push
119
+ if (operation === 'push' && /rejected|failed to push/i.test(stderr)) {
120
+ const branch = getCurrentBranch(repoPath) || 'main';
121
+ return `Push rejected -- remote has new commits. Fix: cd ${repoPath} && git pull --rebase origin ${branch} && git push`;
122
+ }
123
+
124
+ // Default: first line of stderr
125
+ const firstLine = stderr.split('\n')[0];
126
+ return firstLine || `Git ${operation} failed (exit code ${result.exitCode})`;
127
+ }
128
+
129
+ // ─── Repo Collection ─────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Build the list of repos to sync.
133
+ *
134
+ * Returns an array of { name, path, isPlanning } objects.
135
+ * First entry is the planning repo, remaining entries from REPOS.md.
136
+ *
137
+ * @param {string} cwd - Working directory (product root)
138
+ * @returns {Array<{ name: string, path: string, isPlanning: boolean }>}
139
+ */
140
+ function collectSyncRepos(cwd) {
141
+ const repos = [];
142
+
143
+ // First entry: the planning repo itself (cwd is the product root containing .planning/)
144
+ const resolvedCwd = path.resolve(cwd);
145
+ const basename = path.basename(resolvedCwd);
146
+
147
+ // Check if cwd is a git repo
148
+ const isGitRepo = fs.existsSync(path.join(resolvedCwd, '.git'));
149
+ if (isGitRepo) {
150
+ repos.push({
151
+ name: basename,
152
+ path: resolvedCwd,
153
+ isPlanning: true,
154
+ });
155
+ }
156
+
157
+ // Remaining entries: parse REPOS.md
158
+ const parsed = parseReposMd(cwd);
159
+ if (parsed && parsed.repos) {
160
+ for (const repo of parsed.repos) {
161
+ if (!repo.name || !repo.path) continue;
162
+ const absPath = path.isAbsolute(repo.path)
163
+ ? repo.path
164
+ : path.resolve(cwd, repo.path);
165
+ repos.push({
166
+ name: repo.name,
167
+ path: absPath,
168
+ isPlanning: false,
169
+ });
170
+ }
171
+ }
172
+
173
+ return repos;
174
+ }
175
+
176
+ // ─── Pre-flight Checks ──────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Check ALL repos before blocking -- reports all problems at once.
180
+ *
181
+ * @param {Array<{ name: string, path: string, isPlanning: boolean }>} repos
182
+ * @returns {{ ok: boolean, problems: Array<{ repo: string, path: string, issue: string, fix: string }> }}
183
+ */
184
+ function preFlightCheck(repos) {
185
+ const problems = [];
186
+
187
+ for (const repo of repos) {
188
+ const label = repo.isPlanning ? `${repo.name} (planning)` : repo.name;
189
+
190
+ // Find the actual .git directory (handles worktrees and submodules)
191
+ const gitDirResult = execGitWithTimeout(repo.path, ['rev-parse', '--git-dir'], 5000);
192
+ if (gitDirResult.exitCode !== 0) {
193
+ problems.push({
194
+ repo: label,
195
+ path: repo.path,
196
+ issue: 'not a git repository',
197
+ fix: `cd ${repo.path} && git init`,
198
+ });
199
+ continue;
200
+ }
201
+
202
+ const gitDir = path.isAbsolute(gitDirResult.stdout)
203
+ ? gitDirResult.stdout
204
+ : path.resolve(repo.path, gitDirResult.stdout);
205
+
206
+ // Dirty state: git status --porcelain
207
+ const statusResult = execGitWithTimeout(repo.path, ['status', '--porcelain'], 5000);
208
+ if (statusResult.exitCode === 0 && statusResult.stdout.trim().length > 0) {
209
+ problems.push({
210
+ repo: label,
211
+ path: repo.path,
212
+ issue: 'uncommitted changes',
213
+ fix: `cd ${repo.path} && git add -A && git commit -m "WIP" or git stash`,
214
+ });
215
+ }
216
+
217
+ // Mid-merge: check for MERGE_HEAD
218
+ if (fs.existsSync(path.join(gitDir, 'MERGE_HEAD'))) {
219
+ problems.push({
220
+ repo: label,
221
+ path: repo.path,
222
+ issue: 'merge in progress',
223
+ fix: `cd ${repo.path} && git merge --abort or git merge --continue`,
224
+ });
225
+ }
226
+
227
+ // Mid-rebase: check for rebase-merge/ or rebase-apply/
228
+ if (fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'))) {
229
+ problems.push({
230
+ repo: label,
231
+ path: repo.path,
232
+ issue: 'rebase in progress',
233
+ fix: `cd ${repo.path} && git rebase --abort or git rebase --continue`,
234
+ });
235
+ }
236
+
237
+ // Mid-cherry-pick: check for CHERRY_PICK_HEAD
238
+ if (fs.existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) {
239
+ problems.push({
240
+ repo: label,
241
+ path: repo.path,
242
+ issue: 'cherry-pick in progress',
243
+ fix: `cd ${repo.path} && git cherry-pick --abort`,
244
+ });
245
+ }
246
+
247
+ // Detached HEAD: git symbolic-ref HEAD
248
+ const headResult = execGitWithTimeout(repo.path, ['symbolic-ref', 'HEAD'], 5000);
249
+ if (headResult.exitCode !== 0) {
250
+ problems.push({
251
+ repo: label,
252
+ path: repo.path,
253
+ issue: 'detached HEAD',
254
+ fix: `cd ${repo.path} && git checkout <branch>`,
255
+ });
256
+ }
257
+ }
258
+
259
+ return {
260
+ ok: problems.length === 0,
261
+ problems,
262
+ };
263
+ }
264
+
265
+ // ─── Pull ────────────────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * Pull from all registered repos (planning + REPOS.md code repos).
269
+ *
270
+ * Uses fetch + merge --ff-only (not git pull) to get accurate commit counts
271
+ * before merging and to handle missing origin/<branch> gracefully.
272
+ *
273
+ * Continues processing all repos on failure rather than aborting on first failure.
274
+ *
275
+ * @param {string} cwd - Working directory (product root)
276
+ * @param {Object} [options]
277
+ * @param {boolean} [options.dryRun=false] - Report what would happen without modifying repos
278
+ * @param {boolean} [options.force=false] - Bypass pre-flight checks
279
+ * @returns {{ ok: boolean, results: Array<{ repo: string, path: string, status: string, commits: number|null, message: string }>, problems?: Array, summary: string }}
280
+ */
281
+ function pullAll(cwd, options = {}) {
282
+ const { dryRun = false, force = false } = options;
283
+ const repos = collectSyncRepos(cwd);
284
+ const results = [];
285
+
286
+ // Pre-flight (unless forced or dry-run)
287
+ if (!force && !dryRun) {
288
+ const preflight = preFlightCheck(repos);
289
+ if (!preflight.ok) {
290
+ return {
291
+ ok: false,
292
+ results: [],
293
+ problems: preflight.problems,
294
+ summary: `Pre-flight failed: ${preflight.problems.length} repo(s) have issues`,
295
+ };
296
+ }
297
+ }
298
+
299
+ for (const repo of repos) {
300
+ const label = repo.isPlanning ? `${repo.name} (planning)` : repo.name;
301
+
302
+ // Skip repos with no remote
303
+ if (!hasRemote(repo.path)) {
304
+ results.push({ repo: label, path: repo.path, status: 'skipped', commits: null, message: 'No remote configured' });
305
+ continue;
306
+ }
307
+
308
+ if (dryRun) {
309
+ results.push({ repo: label, path: repo.path, status: 'current', commits: null, message: 'Would pull' });
310
+ continue;
311
+ }
312
+
313
+ const branch = getCurrentBranch(repo.path);
314
+ if (!branch || branch === 'HEAD') {
315
+ results.push({ repo: label, path: repo.path, status: 'skipped', commits: null, message: 'Detached HEAD' });
316
+ continue;
317
+ }
318
+
319
+ // Fetch first to check what would change
320
+ const fetchResult = execGitWithTimeout(repo.path, ['fetch', 'origin', branch], 30000);
321
+ if (fetchResult.exitCode !== 0) {
322
+ const msg = classifyError(fetchResult, 'fetch', repo.name, repo.path);
323
+ results.push({ repo: label, path: repo.path, status: 'failed', commits: null, message: msg });
324
+ continue;
325
+ }
326
+
327
+ // Check if there are new commits to pull
328
+ const logResult = execGitWithTimeout(repo.path, ['rev-list', '--count', `HEAD..origin/${branch}`], 5000);
329
+ const commitCount = logResult.exitCode === 0 ? parseInt(logResult.stdout, 10) : 0;
330
+
331
+ if (commitCount === 0) {
332
+ results.push({ repo: label, path: repo.path, status: 'current', commits: 0, message: 'Already up to date' });
333
+ continue;
334
+ }
335
+
336
+ // Pull with --ff-only (via merge)
337
+ const pullResult = execGitWithTimeout(repo.path, ['merge', '--ff-only', `origin/${branch}`], 30000);
338
+ if (pullResult.exitCode !== 0) {
339
+ const msg = classifyError(pullResult, 'pull', repo.name, repo.path);
340
+ results.push({ repo: label, path: repo.path, status: 'failed', commits: null, message: msg });
341
+ continue;
342
+ }
343
+
344
+ results.push({ repo: label, path: repo.path, status: 'updated', commits: commitCount, message: `Pulled ${commitCount} commit${commitCount !== 1 ? 's' : ''}` });
345
+ }
346
+
347
+ // Build summary
348
+ const updated = results.filter(r => r.status === 'updated').length;
349
+ const current = results.filter(r => r.status === 'current').length;
350
+ const skipped = results.filter(r => r.status === 'skipped').length;
351
+ const failed = results.filter(r => r.status === 'failed').length;
352
+ const parts = [];
353
+ if (updated) parts.push(`${updated} updated`);
354
+ if (current) parts.push(`${current} current`);
355
+ if (skipped) parts.push(`${skipped} skipped`);
356
+ if (failed) parts.push(`${failed} failed`);
357
+ const summary = dryRun ? `Dry run: ${repos.length} repo(s) checked` : parts.join(', ');
358
+
359
+ return {
360
+ ok: failed === 0,
361
+ results,
362
+ summary,
363
+ };
364
+ }
365
+
366
+ // ─── Push ────────────────────────────────────────────────────────────────────
367
+
368
+ /**
369
+ * Push to all registered repos (planning + REPOS.md code repos).
370
+ *
371
+ * Uses -u origin <branch> on first push when no upstream tracking exists.
372
+ * Continues past failures and reports all results at end.
373
+ *
374
+ * @param {string} cwd - Working directory (product root)
375
+ * @param {Object} [options]
376
+ * @param {boolean} [options.dryRun=false] - Report what would happen without modifying repos
377
+ * @param {boolean} [options.force=false] - Bypass pre-flight checks
378
+ * @param {string[]|null} [options.repos=null] - Filter to push only specific repos by name
379
+ * @returns {{ ok: boolean, results: Array<{ repo: string, path: string, status: string, commits: number|null, message: string }>, problems?: Array, summary: string }}
380
+ */
381
+ function pushAll(cwd, options = {}) {
382
+ const { dryRun = false, force = false, repos: repoFilter = null } = options;
383
+ let allRepos = collectSyncRepos(cwd);
384
+
385
+ // Apply --repos filter if specified
386
+ if (repoFilter && repoFilter.length > 0) {
387
+ const filterSet = new Set(repoFilter.map(r => r.toLowerCase()));
388
+ allRepos = allRepos.filter(r => filterSet.has(r.name.toLowerCase()));
389
+ if (allRepos.length === 0) {
390
+ return { ok: false, results: [], summary: 'No matching repos found for filter' };
391
+ }
392
+ }
393
+
394
+ const results = [];
395
+
396
+ // Pre-flight (unless forced or dry-run)
397
+ if (!force && !dryRun) {
398
+ const preflight = preFlightCheck(allRepos);
399
+ if (!preflight.ok) {
400
+ return {
401
+ ok: false,
402
+ results: [],
403
+ problems: preflight.problems,
404
+ summary: `Pre-flight failed: ${preflight.problems.length} repo(s) have issues`,
405
+ };
406
+ }
407
+ }
408
+
409
+ for (const repo of allRepos) {
410
+ const label = repo.isPlanning ? `${repo.name} (planning)` : repo.name;
411
+
412
+ // Skip repos with no remote
413
+ if (!hasRemote(repo.path)) {
414
+ results.push({ repo: label, path: repo.path, status: 'skipped', commits: null, message: 'No remote configured' });
415
+ continue;
416
+ }
417
+
418
+ const branch = getCurrentBranch(repo.path);
419
+ if (!branch || branch === 'HEAD') {
420
+ results.push({ repo: label, path: repo.path, status: 'skipped', commits: null, message: 'Detached HEAD' });
421
+ continue;
422
+ }
423
+
424
+ if (dryRun) {
425
+ // Check if there are unpushed commits
426
+ const upstream = hasUpstreamTracking(repo.path, branch);
427
+ if (upstream) {
428
+ const logResult = execGitWithTimeout(repo.path, ['rev-list', '--count', `origin/${branch}..HEAD`], 5000);
429
+ const count = logResult.exitCode === 0 ? parseInt(logResult.stdout, 10) : 0;
430
+ if (count > 0) {
431
+ results.push({ repo: label, path: repo.path, status: 'pushed', commits: count, message: `Would push ${count} commit${count !== 1 ? 's' : ''}` });
432
+ } else {
433
+ results.push({ repo: label, path: repo.path, status: 'current', commits: 0, message: 'Nothing to push' });
434
+ }
435
+ } else {
436
+ results.push({ repo: label, path: repo.path, status: 'pushed', commits: null, message: 'Would push (first push, sets upstream)' });
437
+ }
438
+ continue;
439
+ }
440
+
441
+ // Check if there are commits to push
442
+ const upstream = hasUpstreamTracking(repo.path, branch);
443
+ let commitCount = null;
444
+
445
+ if (upstream) {
446
+ const logResult = execGitWithTimeout(repo.path, ['rev-list', '--count', `origin/${branch}..HEAD`], 5000);
447
+ commitCount = logResult.exitCode === 0 ? parseInt(logResult.stdout, 10) : null;
448
+
449
+ if (commitCount === 0) {
450
+ results.push({ repo: label, path: repo.path, status: 'current', commits: 0, message: 'Nothing to push' });
451
+ continue;
452
+ }
453
+ }
454
+
455
+ // Push -- use -u origin <branch> if no upstream tracking
456
+ const pushArgs = upstream
457
+ ? ['push', 'origin', branch]
458
+ : ['push', '-u', 'origin', branch];
459
+
460
+ const pushResult = execGitWithTimeout(repo.path, pushArgs, 30000);
461
+ if (pushResult.exitCode !== 0) {
462
+ const msg = classifyError(pushResult, 'push', repo.name, repo.path);
463
+ results.push({ repo: label, path: repo.path, status: 'failed', commits: null, message: msg });
464
+ continue;
465
+ }
466
+
467
+ const commitLabel = commitCount != null ? `${commitCount} commit${commitCount !== 1 ? 's' : ''}` : 'branch';
468
+ results.push({ repo: label, path: repo.path, status: 'pushed', commits: commitCount, message: `Pushed ${commitLabel}` });
469
+ }
470
+
471
+ // Build summary
472
+ const pushed = results.filter(r => r.status === 'pushed').length;
473
+ const current = results.filter(r => r.status === 'current').length;
474
+ const skipped = results.filter(r => r.status === 'skipped').length;
475
+ const failed = results.filter(r => r.status === 'failed').length;
476
+ const totalCommits = results.reduce((sum, r) => sum + (r.commits || 0), 0);
477
+ const parts = [];
478
+ if (pushed) parts.push(`${pushed} pushed`);
479
+ if (current) parts.push(`${current} current`);
480
+ if (skipped) parts.push(`${skipped} skipped`);
481
+ if (failed) parts.push(`${failed} failed`);
482
+ let summary = dryRun ? `Dry run: ${allRepos.length} repo(s) checked` : parts.join(', ');
483
+ if (!dryRun && totalCommits > 0) {
484
+ summary += ` (${totalCommits} commit${totalCommits !== 1 ? 's' : ''} total)`;
485
+ }
486
+
487
+ return {
488
+ ok: failed === 0,
489
+ results,
490
+ summary,
491
+ };
492
+ }
493
+
494
+ // ─── Suppression Tracking ─────────────────────────────────────────────────────
495
+
496
+ // Suppression window: 5 minutes. Persists across CLI invocations via temp files.
497
+ const SUPPRESSION_WINDOW_MS = 5 * 60 * 1000;
498
+ const SUPPRESSION_DIR = path.join(require('os').tmpdir(), 'dgs-sync');
499
+
500
+ function getSuppressionFile(direction) {
501
+ return path.join(SUPPRESSION_DIR, `${direction}.ts`);
502
+ }
503
+
504
+ function isWithinSuppressionWindow(direction) {
505
+ try {
506
+ const ts = parseInt(fs.readFileSync(getSuppressionFile(direction), 'utf-8'), 10);
507
+ return (Date.now() - ts) < SUPPRESSION_WINDOW_MS;
508
+ } catch { return false; }
509
+ }
510
+
511
+ function recordPromptYes(direction) {
512
+ fs.mkdirSync(SUPPRESSION_DIR, { recursive: true });
513
+ fs.writeFileSync(getSuppressionFile(direction), String(Date.now()));
514
+ }
515
+
516
+ // ─── First-Run Hint Tracking ─────────────────────────────────────────────────
517
+
518
+ function shouldShowFirstRunHint(cwd) {
519
+ // Show hint when: remote exists, sync mode is 'off' or 'manual', hint not already shown
520
+ const config = loadConfig(cwd);
521
+ const syncPush = config.sync_push || 'off';
522
+ const syncPull = config.sync_pull || 'off';
523
+ if (syncPush !== 'off' || syncPull !== 'off') return false; // sync is configured, no hint needed
524
+ if (config.sync_hint_shown) return false; // already shown
525
+ // Check if any repo has a remote
526
+ const repos = collectSyncRepos(cwd);
527
+ return repos.some(r => hasRemote(r.path));
528
+ }
529
+
530
+ function markFirstRunHintShown(cwd) {
531
+ // Write sync_hint_shown: true to config.local.json (top-level key, not nested under git.)
532
+ const configPath = getLocalConfigPath(cwd);
533
+ let raw = {};
534
+ try {
535
+ if (fs.existsSync(configPath)) {
536
+ raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
537
+ }
538
+ } catch { /* start fresh */ }
539
+ raw.sync_hint_shown = true;
540
+ const dir = path.dirname(configPath);
541
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
542
+ fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n');
543
+ }
544
+
545
+ // ─── Stale-State Detection ───────────────────────────────────────────────────
546
+
547
+ function checkStaleState(cwd) {
548
+ // Check if planning repo's remote is ahead. Returns { stale, commitsBehind, branch }.
549
+ const repos = collectSyncRepos(cwd);
550
+ const planningRepo = repos.find(r => r.isPlanning);
551
+ if (!planningRepo || !hasRemote(planningRepo.path)) return { stale: false, commitsBehind: 0, branch: null };
552
+
553
+ const branch = getCurrentBranch(planningRepo.path);
554
+ if (!branch) return { stale: false, commitsBehind: 0, branch: null };
555
+
556
+ // Fetch without merging (silent)
557
+ const fetchResult = execGitWithTimeout(planningRepo.path, ['fetch', 'origin', branch], 10000);
558
+ if (fetchResult.exitCode !== 0) return { stale: false, commitsBehind: 0, branch }; // can't tell, don't warn
559
+
560
+ // Count commits behind
561
+ const logResult = execGitWithTimeout(planningRepo.path, ['rev-list', '--count', `HEAD..origin/${branch}`], 5000);
562
+ const commitsBehind = logResult.exitCode === 0 ? parseInt(logResult.stdout, 10) : 0;
563
+
564
+ return { stale: commitsBehind > 0, commitsBehind, branch };
565
+ }
566
+
567
+ // ─── Workflow Pull ───────────────────────────────────────────────────────────
568
+
569
+ /**
570
+ * Pull before workflow starts, respecting sync mode.
571
+ *
572
+ * @param {string} cwd - Working directory
573
+ * @param {Object} options
574
+ * @param {string} options.syncMode - 'off'|'prompt'|'auto' (from init sync_pull)
575
+ * @param {boolean} options.cadencePull - Whether this workflow should pull (from init cadence_pull)
576
+ * @param {Object} [options.io] - I/O callbacks for prompt mode { prompt(msg) => Promise<string>, stderr(msg) => void }
577
+ * @returns {Promise<{ action: 'pulled'|'skipped'|'aborted', result?: Object, message: string }>}
578
+ */
579
+ async function workflowPull(cwd, options) {
580
+ const { syncMode, cadencePull, io } = options;
581
+
582
+ // No pull: cadence says no, or mode is off
583
+ if (!cadencePull || syncMode === 'off') {
584
+ // In manual/off mode with cadence_pull, show stale-state warning (WFL-08)
585
+ if (cadencePull && syncMode === 'off') {
586
+ const stale = checkStaleState(cwd);
587
+ if (stale.stale && io?.stderr) {
588
+ io.stderr(`Warning: origin/${stale.branch} is ${stale.commitsBehind} commit${stale.commitsBehind !== 1 ? 's' : ''} ahead. Consider pulling.`);
589
+ }
590
+ // First-run hint (WFL-12)
591
+ if (shouldShowFirstRunHint(cwd) && io?.stderr) {
592
+ io.stderr('Tip: Remote detected. Enable sync with /dgs:settings');
593
+ markFirstRunHintShown(cwd);
594
+ }
595
+ }
596
+ return { action: 'skipped', message: 'Sync pull not configured' };
597
+ }
598
+
599
+ // Prompt mode
600
+ if (syncMode === 'prompt') {
601
+ // Check suppression window (WFL-11)
602
+ if (isWithinSuppressionWindow('pull')) {
603
+ // Auto-pull silently (recent Yes answer)
604
+ const result = pullAll(cwd, { force: false });
605
+ if (!result.ok) {
606
+ // Even suppressed, report failures
607
+ if (io?.stderr) io.stderr(`Pull failed: ${result.summary}`);
608
+ return { action: 'aborted', result, message: result.summary };
609
+ }
610
+ return { action: 'pulled', result, message: result.summary };
611
+ }
612
+
613
+ // Prompt user
614
+ if (io?.prompt) {
615
+ const answer = await io.prompt('Pull from remote before starting? [Y/n]');
616
+ const no = /^n/i.test((answer || '').trim());
617
+ if (no) {
618
+ return { action: 'skipped', message: 'Pull declined by user' };
619
+ }
620
+ recordPromptYes('pull');
621
+ }
622
+ }
623
+
624
+ // Auto mode or prompt-accepted: execute pull
625
+ const result = pullAll(cwd, { force: false });
626
+
627
+ if (!result.ok) {
628
+ // Pull failure: detailed diagnosis (WFL-04)
629
+ const failedRepos = result.results?.filter(r => r.status === 'failed') || [];
630
+ const diagnosis = failedRepos.map(r => ` ${r.repo}: ${r.message}`).join('\n');
631
+ const preflightProblems = result.problems?.map(p => ` ${p.repo}: ${p.issue} -- ${p.fix}`).join('\n');
632
+ const detail = preflightProblems || diagnosis || result.summary;
633
+
634
+ if (io?.stderr) {
635
+ io.stderr(`Pull failed:\n${detail}`);
636
+ }
637
+
638
+ // Offer override in prompt mode (WFL-04)
639
+ if (syncMode === 'prompt' && io?.prompt) {
640
+ const override = await io.prompt('Continue without pulling? [y/N]');
641
+ const yes = /^y/i.test((override || '').trim());
642
+ if (yes) {
643
+ return { action: 'skipped', message: 'Pull failed, user chose to continue' };
644
+ }
645
+ }
646
+
647
+ return { action: 'aborted', result, message: detail };
648
+ }
649
+
650
+ return { action: 'pulled', result, message: result.summary };
651
+ }
652
+
653
+ // ─── Workflow Push ───────────────────────────────────────────────────────────
654
+
655
+ /**
656
+ * Push after workflow completes, respecting sync mode.
657
+ *
658
+ * @param {string} cwd - Working directory
659
+ * @param {Object} options
660
+ * @param {string} options.syncMode - 'off'|'prompt'|'auto' (from init sync_push)
661
+ * @param {boolean} options.cadencePush - Whether this workflow should push (from init cadence_push)
662
+ * @param {boolean} [options.midWorkflow=false] - True for mid-workflow pushes (no prompt, silent)
663
+ * @param {number} [options.maxRetries=1] - Max retries on failure (1 for prompt, 3 for auto)
664
+ * @param {Object} [options.io] - I/O callbacks { stderr(msg) => void, prompt(msg) => Promise<string> }
665
+ * @returns {Promise<{ action: 'pushed'|'skipped'|'warning', result?: Object, message: string }>}
666
+ */
667
+ async function workflowPush(cwd, options) {
668
+ const { syncMode, cadencePush, midWorkflow = false, maxRetries = 1, io } = options;
669
+
670
+ // No push: cadence says no, or mode is off
671
+ if (!cadencePush || syncMode === 'off') {
672
+ return { action: 'skipped', message: 'Sync push not configured' };
673
+ }
674
+
675
+ // Prompt mode (non-mid-workflow): ask user
676
+ if (syncMode === 'prompt' && !midWorkflow) {
677
+ // Check suppression window (WFL-11)
678
+ if (!isWithinSuppressionWindow('push')) {
679
+ if (io?.prompt) {
680
+ const answer = await io.prompt('Push to remote? [Y/n]');
681
+ const no = /^n/i.test((answer || '').trim());
682
+ if (no) {
683
+ return { action: 'skipped', message: 'Push declined by user' };
684
+ }
685
+ recordPromptYes('push');
686
+ }
687
+ }
688
+ }
689
+
690
+ // Execute push with retries
691
+ let lastResult;
692
+ const retries = midWorkflow ? maxRetries : 0; // Only retry mid-workflow pushes
693
+ for (let attempt = 0; attempt <= retries; attempt++) {
694
+ lastResult = pushAll(cwd, { force: true }); // force: bypass preflight for push (already committed)
695
+ if (lastResult.ok) break;
696
+ // Don't retry if not mid-workflow
697
+ if (!midWorkflow) break;
698
+ }
699
+
700
+ if (!lastResult.ok) {
701
+ // Push failure: warning only, do not abort (WFL-05)
702
+ const failedRepos = lastResult.results?.filter(r => r.status === 'failed') || [];
703
+ const failSummary = failedRepos.map(r => `${r.repo}: ${r.message}`).join(', ');
704
+ const msg = `Warning: push failed (${failSummary}). Work saved locally.`;
705
+ if (io?.stderr) io.stderr(msg);
706
+ return { action: 'warning', result: lastResult, message: msg };
707
+ }
708
+
709
+ // Build push summary (WFL-09)
710
+ const totalCommits = lastResult.results.reduce((sum, r) => sum + (r.commits || 0), 0);
711
+ const repoCount = lastResult.results.filter(r => r.status === 'pushed').length;
712
+ let summaryMsg;
713
+
714
+ const failedCount = lastResult.results.filter(r => r.status === 'failed').length;
715
+ if (failedCount > 0) {
716
+ const unpushed = lastResult.results.filter(r => r.status === 'failed').reduce((s, r) => s + (r.commits || 0), 0);
717
+ summaryMsg = `Pushed ${totalCommits} commit${totalCommits !== 1 ? 's' : ''} across ${repoCount} repo${repoCount !== 1 ? 's' : ''} (${unpushed} commits unpushed -- see warnings above)`;
718
+ } else if (repoCount > 1) {
719
+ summaryMsg = `Pushed ${totalCommits} commit${totalCommits !== 1 ? 's' : ''} across ${repoCount} repos`;
720
+ } else if (totalCommits > 0) {
721
+ const branch = getCurrentBranch(collectSyncRepos(cwd)[0]?.path || cwd) || 'main';
722
+ summaryMsg = `Pushed ${totalCommits} commit${totalCommits !== 1 ? 's' : ''} to origin/${branch}`;
723
+ } else {
724
+ summaryMsg = 'Nothing to push';
725
+ }
726
+
727
+ if (!midWorkflow && io?.stderr) {
728
+ io.stderr(summaryMsg);
729
+ }
730
+
731
+ // Log remote branch creation for mid-workflow (WFL-10)
732
+ if (midWorkflow) {
733
+ const newBranches = lastResult.results.filter(r => r.message?.includes('first push') || r.message?.includes('branch'));
734
+ for (const nb of newBranches) {
735
+ if (io?.stderr) {
736
+ const branch = getCurrentBranch(nb.path) || 'unknown';
737
+ io.stderr(`Created remote branch: ${branch}`);
738
+ }
739
+ }
740
+ }
741
+
742
+ return { action: 'pushed', result: lastResult, message: summaryMsg };
743
+ }
744
+
745
+ // ─── Cadence Table ───────────────────────────────────────────────────────────
746
+
747
+ /**
748
+ * Cadence lookup table mapping every DGS workflow to its sync classification.
749
+ *
750
+ * Each entry is keyed by the workflow name (slash command name without the
751
+ * `dgs:` prefix). Values are `{ pull: boolean, push: boolean }`.
752
+ *
753
+ * This is the source of truth for cadence classification. The documentation
754
+ * in sync-cadence.md (Plan 02) mirrors this table but is not parsed.
755
+ *
756
+ * Groups:
757
+ * Pull + Push (35): Workflows that read shared state AND produce artifacts
758
+ * Push only (17): Workflows that produce artifacts but don't need fresh state
759
+ * Pull only (4): Workflows that read shared state but don't produce artifacts
760
+ * No sync (14): Informational or diagnostic workflows
761
+ */
762
+ const CADENCE_TABLE = {
763
+ // ── Pull + Push (35) ──────────────────────────────────────────────────────
764
+ 'execute-phase': { pull: true, push: true },
765
+ 'plan-phase': { pull: true, push: true },
766
+ 'discuss-phase': { pull: true, push: true },
767
+ 'research-phase': { pull: true, push: true },
768
+ 'run-job': { pull: true, push: true },
769
+ 'create-milestone-job': { pull: true, push: true },
770
+ 'cancel-job': { pull: true, push: true },
771
+ 'rollback-job': { pull: true, push: true },
772
+ 'new-milestone': { pull: true, push: true },
773
+ 'complete-milestone': { pull: true, push: true },
774
+ 'audit-milestone': { pull: true, push: true },
775
+ 'plan-milestone-gaps': { pull: true, push: true },
776
+ 'cleanup': { pull: true, push: true },
777
+ 'discuss-idea': { pull: true, push: true },
778
+ 'develop-idea': { pull: true, push: true },
779
+ 'research-idea': { pull: true, push: true },
780
+ 'consolidate-ideas': { pull: true, push: true },
781
+ 'undo-consolidation': { pull: true, push: true },
782
+ 'write-spec': { pull: true, push: true },
783
+ 'refine-spec': { pull: true, push: true },
784
+ 'approve-spec': { pull: true, push: true },
785
+ 'quick': { pull: true, push: true },
786
+ 'fast': { pull: true, push: true },
787
+ 'add-todo': { pull: true, push: true },
788
+ 'check-todos': { pull: true, push: true },
789
+ 'new-project': { pull: true, push: true },
790
+ 'init-product': { pull: true, push: true },
791
+ 'add-phase': { pull: true, push: true },
792
+ 'insert-phase': { pull: true, push: true },
793
+ 'remove-phase': { pull: true, push: true },
794
+ 'audit-phase': { pull: true, push: true },
795
+ 'validate-phase': { pull: true, push: true },
796
+ 'verify-work': { pull: true, push: true },
797
+ 'add-tests': { pull: true, push: true },
798
+ 'settings': { pull: true, push: true },
799
+
800
+ // ── Push only (17) ────────────────────────────────────────────────────────
801
+ 'pause-work': { pull: false, push: true },
802
+ 'add-idea': { pull: false, push: true },
803
+ 'import-spec': { pull: false, push: true },
804
+ 'reject-idea': { pull: false, push: true },
805
+ 'restore-idea': { pull: false, push: true },
806
+ 'update-idea': { pull: false, push: true },
807
+ 'add-doc': { pull: false, push: true },
808
+ 'remove-doc': { pull: false, push: true },
809
+ 'add-repo': { pull: false, push: true },
810
+ 'remove-repo': { pull: false, push: true },
811
+ 'capture-principle': { pull: false, push: true },
812
+ 'complete-project': { pull: false, push: true },
813
+ 'reactivate-project': { pull: false, push: true },
814
+ 'switch-project': { pull: false, push: true },
815
+ 'set-profile': { pull: false, push: true },
816
+ 'map-codebase': { pull: false, push: true },
817
+ 'reapply-patches': { pull: false, push: true },
818
+
819
+ // ── Pull only (4) ─────────────────────────────────────────────────────────
820
+ 'resume-work': { pull: true, push: false },
821
+ 'progress': { pull: true, push: false },
822
+ 'find-related-ideas': { pull: true, push: false },
823
+ 'list-phase-assumptions': { pull: true, push: false },
824
+
825
+ // ── No sync (14) ──────────────────────────────────────────────────────────
826
+ 'help': { pull: false, push: false },
827
+ 'join-discord': { pull: false, push: false },
828
+ 'list-ideas': { pull: false, push: false },
829
+ 'list-specs': { pull: false, push: false },
830
+ 'list-docs': { pull: false, push: false },
831
+ 'list-jobs': { pull: false, push: false },
832
+ 'list-projects': { pull: false, push: false },
833
+ 'search': { pull: false, push: false },
834
+ 'overlap-check': { pull: false, push: false },
835
+ 'health': { pull: false, push: false },
836
+ 'update': { pull: false, push: false },
837
+ 'debug': { pull: false, push: false },
838
+ 'node-repair': { pull: false, push: false },
839
+ 'sync-upstream': { pull: false, push: false },
840
+ };
841
+
842
+ /**
843
+ * Get the cadence classification for a workflow.
844
+ *
845
+ * @param {string} workflowName - Workflow name (e.g., 'execute-phase', 'pause-work')
846
+ * @returns {{ pull: boolean, push: boolean }} Cadence flags. Unknown workflows default to no-sync.
847
+ */
848
+ function getCadence(workflowName) {
849
+ return CADENCE_TABLE[workflowName] || { pull: false, push: false };
850
+ }
851
+
852
+ // ─── Exports ─────────────────────────────────────────────────────────────────
853
+
854
+ module.exports = {
855
+ collectSyncRepos,
856
+ preFlightCheck,
857
+ pullAll,
858
+ pushAll,
859
+ // Workflow orchestration
860
+ workflowPull,
861
+ workflowPush,
862
+ checkStaleState,
863
+ shouldShowFirstRunHint,
864
+ markFirstRunHintShown,
865
+ // Cadence
866
+ CADENCE_TABLE,
867
+ getCadence,
868
+ // Exported for testing
869
+ execGitWithTimeout,
870
+ hasRemote,
871
+ getCurrentBranch,
872
+ hasUpstreamTracking,
873
+ classifyError,
874
+ isWithinSuppressionWindow,
875
+ recordPromptYes,
876
+ getSuppressionFile,
877
+ SUPPRESSION_WINDOW_MS,
878
+ };