@ktpartners/dgs-platform 2.7.0 → 2.7.2

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.
@@ -517,6 +517,56 @@ node "$HOME/.claude/deliver-great-systems/bin/dgs-tools.cjs" commit "docs({phase
517
517
  Separate from per-task commits — captures execution results only.
518
518
  </final_commit>
519
519
 
520
+ <codereview_gate>
521
+ Check if code review is enabled:
522
+
523
+ ```bash
524
+ CODEREVIEW=$(node "$HOME/.claude/deliver-great-systems/bin/dgs-tools.cjs" config-get workflow.codereview 2>/dev/null || echo "false")
525
+ ```
526
+
527
+ If `CODEREVIEW` is `true`:
528
+
529
+ Display:
530
+ ```
531
+ ------------------------------------------------------------
532
+ DGS > SPAWNING CODE REVIEW
533
+ ------------------------------------------------------------
534
+
535
+ Reviewing {phase}-{plan} changes across 3 passes...
536
+ ```
537
+
538
+ Compute diff reference for the plan's task commits:
539
+
540
+ ```bash
541
+ FIRST_TASK_COMMIT=$(git log --oneline --grep="feat(${PHASE}-${PLAN}):" --grep="fix(${PHASE}-${PLAN}):" --grep="test(${PHASE}-${PLAN}):" --grep="refactor(${PHASE}-${PLAN}):" --reverse | head -1 | cut -d' ' -f1)
542
+ ```
543
+
544
+ If FIRST_TASK_COMMIT is empty (no task commits found), skip codereview with message: "No task commits found for {phase}-{plan}, skipping code review."
545
+
546
+ Otherwise, invoke the codereview workflow:
547
+
548
+ ```
549
+ @~/.claude/deliver-great-systems/workflows/codereview.md
550
+ ```
551
+
552
+ With inputs:
553
+ - PHASE: ${PHASE}
554
+ - PLAN: ${PLAN}
555
+ - PLAN_PATH: ${phase_dir}/{phase}-{plan}-PLAN.md
556
+ - PHASE_DIR: ${phase_dir}
557
+ - DIFF_REF: ${FIRST_TASK_COMMIT}^..HEAD
558
+
559
+ After codereview completes:
560
+ - If auto-fixes were committed, the fix commit hash is noted in the codereview output
561
+ - The SUMMARY.md already has codereview findings appended and CODEREVIEW.md has been created with the full report (both done by codereview workflow)
562
+ - If the codereview workflow committed auto-fixes, amend the metadata commit to include the updated SUMMARY.md and CODEREVIEW.md:
563
+ ```bash
564
+ node "$HOME/.claude/deliver-great-systems/bin/dgs-tools.cjs" commit "" --files ${phase_dir}/{phase}-{plan}-SUMMARY.md ${phase_dir}/{phase}-{plan}-CODEREVIEW.md --amend
565
+ ```
566
+
567
+ If `CODEREVIEW` is `false` or absent: skip silently (no output).
568
+ </codereview_gate>
569
+
520
570
  <completion_format>
521
571
  ```markdown
522
572
  ## PLAN COMPLETE
@@ -546,5 +596,6 @@ Plan execution complete when:
546
596
  - [ ] STATE.md updated (position, decisions, issues, session)
547
597
  - [ ] ROADMAP.md updated with plan progress (via `roadmap update-plan-progress`)
548
598
  - [ ] Final metadata commit made (includes SUMMARY.md, STATE.md, ROADMAP.md)
599
+ - [ ] Codereview gate checked (if enabled: codereview invoked, if auto-fixes committed: metadata commit amended)
549
600
  - [ ] Completion format returned to orchestrator
550
601
  </success_criteria>
@@ -699,6 +699,7 @@ async function main() {
699
699
 
700
700
  // Existing single-repo commit logic
701
701
  const amend = args.includes('--amend');
702
+ const push = args.includes('--push');
702
703
  const message = args[1];
703
704
  // Parse --files flag (collect args after --files, stopping at other flags)
704
705
  const filesIndex = args.indexOf('--files');
@@ -706,7 +707,7 @@ async function main() {
706
707
  // Parse --phase-dir flag (available for context, passed through if supported)
707
708
  const phaseDirIdx = args.indexOf('--phase-dir');
708
709
  const phaseDir = phaseDirIdx !== -1 ? args[phaseDirIdx + 1] : null;
709
- commands.cmdCommit(cwd, message, files, raw, amend);
710
+ commands.cmdCommit(cwd, message, files, raw, amend, push);
710
711
  break;
711
712
  }
712
713
 
@@ -214,7 +214,7 @@ function cmdResolveModel(cwd, agentType, raw) {
214
214
  output(result, raw, model);
215
215
  }
216
216
 
217
- function cmdCommit(cwd, message, files, raw, amend) {
217
+ function cmdCommit(cwd, message, files, raw, amend, push) {
218
218
  if (!message && !amend) {
219
219
  error('commit message required');
220
220
  }
@@ -259,6 +259,27 @@ function cmdCommit(cwd, message, files, raw, amend) {
259
259
  const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
260
260
  const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
261
261
  const result = { committed: true, hash, reason: 'committed' };
262
+
263
+ // Handle push if requested
264
+ if (push) {
265
+ const syncPush = config.sync_push || 'off';
266
+
267
+ if (syncPush === 'auto') {
268
+ try {
269
+ const { pushAll } = require('./sync.cjs');
270
+ const pushResult = pushAll(cwd, { force: true });
271
+ result.pushed = pushResult.ok;
272
+ result.push_result = pushResult;
273
+ } catch (err) {
274
+ result.pushed = false;
275
+ result.push_result = { ok: false, error: err.message };
276
+ }
277
+ } else if (syncPush === 'prompt') {
278
+ result.needs_push = true;
279
+ }
280
+ // 'off' or any other value: no push fields added
281
+ }
282
+
262
283
  output(result, raw, hash || 'committed');
263
284
  }
264
285
 
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Tests for commands.cjs — cmdCommit --push behavior
3
+ *
4
+ * Uses Node.js built-in test runner (node:test) and assert (node:assert).
5
+ * Tests use real temp git repos with mocked pushAll to verify push integration.
6
+ */
7
+
8
+ const { describe, it, beforeEach, afterEach } = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { execSync } = require('child_process');
13
+
14
+ const { createTempProject } = require('./test-helpers.cjs');
15
+
16
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Capture JSON output from cmdCommit by intercepting console.log.
20
+ * cmdCommit calls output() which calls console.log(JSON.stringify(...)).
21
+ */
22
+ function captureCommitOutput(fn) {
23
+ const logs = [];
24
+ const origLog = console.log;
25
+ console.log = (...args) => logs.push(args.join(' '));
26
+ try {
27
+ fn();
28
+ } finally {
29
+ console.log = origLog;
30
+ }
31
+ // Parse the last JSON output line
32
+ for (let i = logs.length - 1; i >= 0; i--) {
33
+ try {
34
+ return JSON.parse(logs[i]);
35
+ } catch { /* not JSON, skip */ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Set up a temp project with git, write a file, and return the fixture.
42
+ */
43
+ function setupGitFixture(syncPush) {
44
+ const configOverrides = {};
45
+ if (syncPush) {
46
+ configOverrides.git = { sync_push: syncPush };
47
+ }
48
+ const fixture = createTempProject({
49
+ withGit: true,
50
+ withConfig: configOverrides,
51
+ });
52
+ return fixture;
53
+ }
54
+
55
+ /**
56
+ * Write a new file and stage it so there's something to commit.
57
+ */
58
+ function writeAndStageFile(cwd, name, content) {
59
+ const filePath = path.join(cwd, name);
60
+ fs.writeFileSync(filePath, content || `content-${Date.now()}`);
61
+ execSync(`git add "${name}"`, { cwd, stdio: 'pipe' });
62
+ }
63
+
64
+ /**
65
+ * Mock pushAll on the sync.cjs module exports.
66
+ * Since cmdCommit does a lazy require('./sync.cjs') and destructures pushAll,
67
+ * we need to replace it on the cached module BEFORE cmdCommit runs.
68
+ */
69
+ function mockPushAll(mockFn) {
70
+ const syncPath = require.resolve('./sync.cjs');
71
+ const syncModule = require(syncPath);
72
+ const original = syncModule.pushAll;
73
+ syncModule.pushAll = mockFn;
74
+ return () => { syncModule.pushAll = original; };
75
+ }
76
+
77
+ // ─── Tests ────────────────────────────────────────────────────────────────────
78
+
79
+ describe('cmdCommit with push=false (default behavior)', () => {
80
+ let fixture;
81
+ let commands;
82
+
83
+ beforeEach(() => {
84
+ fixture = setupGitFixture('auto');
85
+ commands = require('./commands.cjs');
86
+ });
87
+
88
+ afterEach(() => {
89
+ fixture.cleanup();
90
+ });
91
+
92
+ it('commits without push fields when push is false', () => {
93
+ writeAndStageFile(fixture.cwd, 'test-no-push.txt');
94
+ const result = captureCommitOutput(() => {
95
+ commands.cmdCommit(fixture.cwd, 'test commit no push', [], true, false, false);
96
+ });
97
+ assert.equal(result.committed, true);
98
+ assert.ok(result.hash);
99
+ assert.equal(result.reason, 'committed');
100
+ assert.equal(result.pushed, undefined, 'pushed should not be set');
101
+ assert.equal(result.push_result, undefined, 'push_result should not be set');
102
+ assert.equal(result.needs_push, undefined, 'needs_push should not be set');
103
+ });
104
+
105
+ it('commits without push fields when push is undefined', () => {
106
+ writeAndStageFile(fixture.cwd, 'test-no-push-undef.txt');
107
+ const result = captureCommitOutput(() => {
108
+ commands.cmdCommit(fixture.cwd, 'test commit no push undef', [], true, false);
109
+ });
110
+ assert.equal(result.committed, true);
111
+ assert.equal(result.pushed, undefined);
112
+ assert.equal(result.needs_push, undefined);
113
+ });
114
+ });
115
+
116
+ describe('cmdCommit with push=true, sync_push=auto, pushAll succeeds', () => {
117
+ let fixture;
118
+ let commands;
119
+ let restorePushAll;
120
+
121
+ beforeEach(() => {
122
+ fixture = setupGitFixture('auto');
123
+ restorePushAll = mockPushAll(() => ({
124
+ ok: true,
125
+ results: [{ repo: 'test', status: 'pushed', commits: 1, message: 'Pushed 1 commit' }],
126
+ summary: '1 pushed',
127
+ }));
128
+ commands = require('./commands.cjs');
129
+ });
130
+
131
+ afterEach(() => {
132
+ restorePushAll();
133
+ fixture.cleanup();
134
+ });
135
+
136
+ it('returns pushed=true and push_result with ok=true', () => {
137
+ writeAndStageFile(fixture.cwd, 'test-auto-push-ok.txt');
138
+ const result = captureCommitOutput(() => {
139
+ commands.cmdCommit(fixture.cwd, 'test commit auto push ok', [], true, false, true);
140
+ });
141
+ assert.equal(result.committed, true);
142
+ assert.ok(result.hash);
143
+ assert.equal(result.pushed, true);
144
+ assert.ok(result.push_result);
145
+ assert.equal(result.push_result.ok, true);
146
+ assert.equal(result.needs_push, undefined, 'needs_push should not be set in auto mode');
147
+ });
148
+ });
149
+
150
+ describe('cmdCommit with push=true, sync_push=auto, pushAll fails', () => {
151
+ let fixture;
152
+ let commands;
153
+ let restorePushAll;
154
+
155
+ beforeEach(() => {
156
+ fixture = setupGitFixture('auto');
157
+ restorePushAll = mockPushAll(() => ({
158
+ ok: false,
159
+ results: [{ repo: 'test', status: 'failed', commits: null, message: 'Push rejected' }],
160
+ summary: '1 failed',
161
+ }));
162
+ commands = require('./commands.cjs');
163
+ });
164
+
165
+ afterEach(() => {
166
+ restorePushAll();
167
+ fixture.cleanup();
168
+ });
169
+
170
+ it('returns committed=true, pushed=false, push_result with ok=false', () => {
171
+ writeAndStageFile(fixture.cwd, 'test-auto-push-fail.txt');
172
+ const result = captureCommitOutput(() => {
173
+ commands.cmdCommit(fixture.cwd, 'test commit auto push fail', [], true, false, true);
174
+ });
175
+ assert.equal(result.committed, true, 'commit must succeed even if push fails');
176
+ assert.ok(result.hash);
177
+ assert.equal(result.pushed, false);
178
+ assert.ok(result.push_result);
179
+ assert.equal(result.push_result.ok, false);
180
+ });
181
+ });
182
+
183
+ describe('cmdCommit with push=true, sync_push=auto, pushAll throws', () => {
184
+ let fixture;
185
+ let commands;
186
+ let restorePushAll;
187
+
188
+ beforeEach(() => {
189
+ fixture = setupGitFixture('auto');
190
+ restorePushAll = mockPushAll(() => {
191
+ throw new Error('Network unreachable');
192
+ });
193
+ commands = require('./commands.cjs');
194
+ });
195
+
196
+ afterEach(() => {
197
+ restorePushAll();
198
+ fixture.cleanup();
199
+ });
200
+
201
+ it('returns committed=true, pushed=false, push_result with error message', () => {
202
+ writeAndStageFile(fixture.cwd, 'test-auto-push-throw.txt');
203
+ const result = captureCommitOutput(() => {
204
+ commands.cmdCommit(fixture.cwd, 'test commit auto push throw', [], true, false, true);
205
+ });
206
+ assert.equal(result.committed, true, 'commit must succeed even if pushAll throws');
207
+ assert.ok(result.hash);
208
+ assert.equal(result.pushed, false);
209
+ assert.ok(result.push_result);
210
+ assert.equal(result.push_result.ok, false);
211
+ assert.ok(result.push_result.error.includes('Network unreachable'));
212
+ });
213
+ });
214
+
215
+ describe('cmdCommit with push=true, sync_push=prompt', () => {
216
+ let fixture;
217
+ let commands;
218
+
219
+ beforeEach(() => {
220
+ fixture = setupGitFixture('prompt');
221
+ commands = require('./commands.cjs');
222
+ });
223
+
224
+ afterEach(() => {
225
+ fixture.cleanup();
226
+ });
227
+
228
+ it('returns needs_push=true, no pushed or push_result fields', () => {
229
+ writeAndStageFile(fixture.cwd, 'test-prompt-push.txt');
230
+ const result = captureCommitOutput(() => {
231
+ commands.cmdCommit(fixture.cwd, 'test commit prompt push', [], true, false, true);
232
+ });
233
+ assert.equal(result.committed, true);
234
+ assert.ok(result.hash);
235
+ assert.equal(result.needs_push, true);
236
+ assert.equal(result.pushed, undefined, 'pushed should not be set in prompt mode');
237
+ assert.equal(result.push_result, undefined, 'push_result should not be set in prompt mode');
238
+ });
239
+ });
240
+
241
+ describe('cmdCommit with push=true, sync_push=off', () => {
242
+ let fixture;
243
+ let commands;
244
+
245
+ beforeEach(() => {
246
+ fixture = setupGitFixture('off');
247
+ commands = require('./commands.cjs');
248
+ });
249
+
250
+ afterEach(() => {
251
+ fixture.cleanup();
252
+ });
253
+
254
+ it('returns no push-related fields (same as push=false)', () => {
255
+ writeAndStageFile(fixture.cwd, 'test-off-push.txt');
256
+ const result = captureCommitOutput(() => {
257
+ commands.cmdCommit(fixture.cwd, 'test commit off push', [], true, false, true);
258
+ });
259
+ assert.equal(result.committed, true);
260
+ assert.ok(result.hash);
261
+ assert.equal(result.pushed, undefined, 'pushed should not be set when sync_push=off');
262
+ assert.equal(result.push_result, undefined, 'push_result should not be set when sync_push=off');
263
+ assert.equal(result.needs_push, undefined, 'needs_push should not be set when sync_push=off');
264
+ });
265
+ });
266
+
267
+ describe('cmdCommit with push=true but nothing to commit', () => {
268
+ let fixture;
269
+ let commands;
270
+ let pushAllCalled;
271
+ let restorePushAll;
272
+
273
+ beforeEach(() => {
274
+ fixture = setupGitFixture('auto');
275
+ pushAllCalled = false;
276
+ restorePushAll = mockPushAll(() => {
277
+ pushAllCalled = true;
278
+ return { ok: true, results: [], summary: '' };
279
+ });
280
+ commands = require('./commands.cjs');
281
+ });
282
+
283
+ afterEach(() => {
284
+ restorePushAll();
285
+ fixture.cleanup();
286
+ });
287
+
288
+ it('does not attempt push when commit has nothing to commit', () => {
289
+ // Don't write any new file -- nothing to commit
290
+ const result = captureCommitOutput(() => {
291
+ commands.cmdCommit(fixture.cwd, 'test nothing to commit', [], true, false, true);
292
+ });
293
+ assert.equal(result.committed, false);
294
+ assert.equal(result.reason, 'nothing_to_commit');
295
+ assert.equal(pushAllCalled, false, 'pushAll should not be called when nothing to commit');
296
+ assert.equal(result.pushed, undefined);
297
+ assert.equal(result.needs_push, undefined);
298
+ });
299
+ });
300
+
301
+ describe('cmdCommit with push=true, sync_push unset (defaults to off)', () => {
302
+ let fixture;
303
+ let commands;
304
+
305
+ beforeEach(() => {
306
+ // No sync_push in config -- should default to 'off'
307
+ fixture = setupGitFixture(undefined);
308
+ commands = require('./commands.cjs');
309
+ });
310
+
311
+ afterEach(() => {
312
+ fixture.cleanup();
313
+ });
314
+
315
+ it('returns no push-related fields when sync_push is not configured', () => {
316
+ writeAndStageFile(fixture.cwd, 'test-unset-push.txt');
317
+ const result = captureCommitOutput(() => {
318
+ commands.cmdCommit(fixture.cwd, 'test commit unset push', [], true, false, true);
319
+ });
320
+ assert.equal(result.committed, true);
321
+ assert.equal(result.pushed, undefined);
322
+ assert.equal(result.push_result, undefined);
323
+ assert.equal(result.needs_push, undefined);
324
+ });
325
+ });
@@ -9,7 +9,7 @@ const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInte
9
9
  const { requireGitIdentity, formatAuthorString } = require('./identity.cjs');
10
10
  const { getPlanningRoot, PROJECTS_DIR } = require('./paths.cjs');
11
11
  const { parseReposMd, validateReposMdEager } = require('./repos.cjs');
12
- const { getCadence } = require('./sync.cjs');
12
+ const { getCadence, pullAll } = require('./sync.cjs');
13
13
 
14
14
  /**
15
15
  * Safely resolve the current git author string.
@@ -25,6 +25,67 @@ function resolveAuthorSafe(cwd) {
25
25
  }
26
26
  }
27
27
 
28
+ // ─── Sync Pull Helper ────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Apply sync pull logic to an init result object.
32
+ *
33
+ * Enriches the result with `needs_pull` and optionally `pull_result` based on
34
+ * the sync_pull config and cadence_pull for this workflow. This moves pull
35
+ * orchestration into init itself so workflows get pull state in their JSON
36
+ * without needing to manually orchestrate sync commands.
37
+ *
38
+ * Modes:
39
+ * - cadence_pull=false OR sync_pull="off": sets needs_pull=false, no pull
40
+ * - sync_pull="prompt": sets needs_pull=true, no pull (workflow prompts user)
41
+ * - sync_pull="auto": executes pullAll, sets needs_pull=false, adds pull_result
42
+ *
43
+ * CRITICAL: Never throws — init must always return its JSON.
44
+ *
45
+ * @param {string} cwd - Working directory
46
+ * @param {string} workflowName - Workflow name for logging
47
+ * @param {Object} result - The init result object to enrich (mutated in place)
48
+ */
49
+ function applySyncPull(cwd, workflowName, result) {
50
+ const syncPull = result.sync_pull;
51
+ const cadencePull = result.cadence_pull;
52
+
53
+ // No pull: cadence says no, or mode is off
54
+ if (!cadencePull || syncPull === 'off') {
55
+ result.needs_pull = false;
56
+ return;
57
+ }
58
+
59
+ // Prompt mode: signal that pull is needed but don't execute
60
+ if (syncPull === 'prompt') {
61
+ result.needs_pull = true;
62
+ return;
63
+ }
64
+
65
+ // Auto mode: execute pull immediately
66
+ if (syncPull === 'auto') {
67
+ result.needs_pull = false;
68
+ try {
69
+ const pullResult = pullAll(cwd, { force: true });
70
+ result.pull_result = {
71
+ action: pullResult.ok ? 'pulled' : 'failed',
72
+ ok: pullResult.ok,
73
+ summary: pullResult.summary,
74
+ };
75
+ } catch (err) {
76
+ result.pull_result = {
77
+ action: 'failed',
78
+ ok: false,
79
+ summary: err.message,
80
+ };
81
+ }
82
+ return;
83
+ }
84
+
85
+ // Unknown mode: treat as off
86
+ result.needs_pull = false;
87
+ }
88
+
28
89
  // ─── v2 Project Context Resolution ──────────────────────────────────────────
29
90
 
30
91
  /**
@@ -232,6 +293,7 @@ function cmdInitExecutePhase(cwd, phase, raw) {
232
293
  v2_hint: ctx.v2_hint || null,
233
294
  };
234
295
 
296
+ applySyncPull(cwd, 'execute-phase', result);
235
297
  output(result, raw);
236
298
  }
237
299
 
@@ -327,6 +389,7 @@ function cmdInitPlanPhase(cwd, phase, raw) {
327
389
  } catch {}
328
390
  }
329
391
 
392
+ applySyncPull(cwd, 'plan-phase', result);
330
393
  output(result, raw);
331
394
  }
332
395
 
@@ -406,6 +469,7 @@ function cmdInitNewProject(cwd, raw) {
406
469
  v2_hint: ctx.v2_hint || null,
407
470
  };
408
471
 
472
+ applySyncPull(cwd, 'new-project', result);
409
473
  output(result, raw);
410
474
  }
411
475
 
@@ -454,6 +518,7 @@ function cmdInitNewMilestone(cwd, raw) {
454
518
  v2_hint: ctx.v2_hint || null,
455
519
  };
456
520
 
521
+ applySyncPull(cwd, 'new-milestone', result);
457
522
  output(result, raw);
458
523
  }
459
524
 
@@ -521,6 +586,7 @@ function cmdInitQuick(cwd, description, raw) {
521
586
  v2_hint: ctx.v2_hint || null,
522
587
  };
523
588
 
589
+ applySyncPull(cwd, 'quick', result);
524
590
  output(result, raw);
525
591
  }
526
592
 
@@ -569,6 +635,7 @@ function cmdInitResume(cwd, raw) {
569
635
  v2_hint: ctx.v2_hint || null,
570
636
  };
571
637
 
638
+ applySyncPull(cwd, 'resume-work', result);
572
639
  output(result, raw);
573
640
  }
574
641
 
@@ -612,6 +679,7 @@ function cmdInitVerifyWork(cwd, phase, raw) {
612
679
  guard: ctx.guard,
613
680
  };
614
681
 
682
+ applySyncPull(cwd, 'verify-work', result);
615
683
  output(result, raw);
616
684
  }
617
685
 
@@ -655,6 +723,7 @@ function cmdInitAuditPhase(cwd, phase, raw) {
655
723
  guard: ctx.guard,
656
724
  };
657
725
 
726
+ applySyncPull(cwd, 'audit-phase', result);
658
727
  output(result, raw);
659
728
  }
660
729
 
@@ -755,6 +824,7 @@ function cmdInitPhaseOp(cwd, phase, raw, workflow) {
755
824
  } catch {}
756
825
  }
757
826
 
827
+ applySyncPull(cwd, workflow || 'plan-phase', result);
758
828
  output(result, raw);
759
829
  }
760
830
 
@@ -834,6 +904,7 @@ function cmdInitTodos(cwd, area, raw, workflow) {
834
904
  v2_hint: ctx.v2_hint || null,
835
905
  };
836
906
 
907
+ applySyncPull(cwd, workflow || 'check-todos', result);
837
908
  output(result, raw);
838
909
  }
839
910
 
@@ -927,6 +998,7 @@ function cmdInitMilestoneOp(cwd, raw, workflow) {
927
998
  v2_hint: ctx.v2_hint || null,
928
999
  };
929
1000
 
1001
+ applySyncPull(cwd, workflow || 'complete-milestone', result);
930
1002
  output(result, raw);
931
1003
  }
932
1004
 
@@ -1033,6 +1105,7 @@ function cmdInitMapCodebase(cwd, onlyRepo, raw) {
1033
1105
  v2_hint: ctx.v2_hint || null,
1034
1106
  };
1035
1107
 
1108
+ applySyncPull(cwd, 'map-codebase', result);
1036
1109
  output(result, raw);
1037
1110
  }
1038
1111
 
@@ -1149,6 +1222,7 @@ function cmdInitProgress(cwd, raw) {
1149
1222
  v2_hint: ctx.v2_hint || null,
1150
1223
  };
1151
1224
 
1225
+ applySyncPull(cwd, 'progress', result);
1152
1226
  output(result, raw);
1153
1227
  }
1154
1228
 
@@ -1166,4 +1240,5 @@ module.exports = {
1166
1240
  cmdInitMilestoneOp,
1167
1241
  cmdInitMapCodebase,
1168
1242
  cmdInitProgress,
1243
+ applySyncPull,
1169
1244
  };
@@ -505,12 +505,12 @@ describe('v2 mode with project: init todos', () => {
505
505
  fixture.cleanup();
506
506
  });
507
507
 
508
- it('returns project-qualified pending_dir', () => {
509
- assert.equal(result.pending_dir, path.join('.planning', 'projects', 'test-project', 'todos', 'pending'));
508
+ it('returns product-level pending_dir', () => {
509
+ assert.equal(result.pending_dir, path.join('.planning', 'todos', 'pending'));
510
510
  });
511
511
 
512
- it('returns project-qualified completed_dir', () => {
513
- assert.equal(result.completed_dir, path.join('.planning', 'projects', 'test-project', 'todos', 'completed'));
512
+ it('returns product-level completed_dir', () => {
513
+ assert.equal(result.completed_dir, path.join('.planning', 'todos', 'completed'));
514
514
  });
515
515
 
516
516
  it('returns dgs_mode v2', () => {
@@ -1210,6 +1210,179 @@ describe('branch_name with {project} resolution', () => {
1210
1210
 
1211
1211
  // ─── Backward Compatibility ──────────────────────────────────────────────────
1212
1212
 
1213
+ // ─── Sync Pull: needs_pull field tests ────────────────────────────────────────
1214
+
1215
+ // Helper: v1 fixture with custom git.sync_pull setting
1216
+ function v1FixtureWithSyncPull(syncPull) {
1217
+ return createFixture({
1218
+ '.planning/config.json': JSON.stringify({ git: { sync_pull: syncPull } }),
1219
+ '.planning/STATE.md': '# State',
1220
+ '.planning/ROADMAP.md': '# Roadmap\n\n## Phases\n\n- [ ] **Phase 1: Test Phase** - A test\n',
1221
+ '.planning/REQUIREMENTS.md': '# Requirements',
1222
+ '.planning/PROJECT.md': '# Project',
1223
+ '.planning/phases/01-test-phase/01-CONTEXT.md': '# Context',
1224
+ '.planning/phases/01-test-phase/01-01-PLAN.md': '---\nphase: 01-test-phase\nplan: 01\n---\n# Plan',
1225
+ });
1226
+ }
1227
+
1228
+ describe('init commands include needs_pull field (sync_pull off)', () => {
1229
+ let fixture;
1230
+
1231
+ beforeEach(() => {
1232
+ fixture = v1Fixture();
1233
+ });
1234
+
1235
+ afterEach(() => {
1236
+ fixture.cleanup();
1237
+ });
1238
+
1239
+ it('init execute-phase returns needs_pull false when sync_pull is off', () => {
1240
+ const result = runInit(fixture.cwd, 'execute-phase 1');
1241
+ assert.equal(result.needs_pull, false);
1242
+ });
1243
+
1244
+ it('init plan-phase returns needs_pull false when sync_pull is off', () => {
1245
+ const result = runInit(fixture.cwd, 'plan-phase 1');
1246
+ assert.equal(result.needs_pull, false);
1247
+ });
1248
+
1249
+ it('init quick returns needs_pull false when sync_pull is off', () => {
1250
+ const result = runInit(fixture.cwd, 'quick "test task"');
1251
+ assert.equal(result.needs_pull, false);
1252
+ });
1253
+
1254
+ it('init progress returns needs_pull false when sync_pull is off', () => {
1255
+ const result = runInit(fixture.cwd, 'progress');
1256
+ assert.equal(result.needs_pull, false);
1257
+ });
1258
+
1259
+ it('init new-project returns needs_pull false when sync_pull is off', () => {
1260
+ const result = runInit(fixture.cwd, 'new-project');
1261
+ assert.equal(result.needs_pull, false);
1262
+ });
1263
+
1264
+ it('init resume returns needs_pull false when sync_pull is off', () => {
1265
+ const result = runInit(fixture.cwd, 'resume');
1266
+ assert.equal(result.needs_pull, false);
1267
+ });
1268
+
1269
+ it('init milestone-op returns needs_pull false when sync_pull is off', () => {
1270
+ const result = runInit(fixture.cwd, 'milestone-op');
1271
+ assert.equal(result.needs_pull, false);
1272
+ });
1273
+
1274
+ it('init map-codebase returns needs_pull false when sync_pull is off', () => {
1275
+ const result = runInit(fixture.cwd, 'map-codebase');
1276
+ assert.equal(result.needs_pull, false);
1277
+ });
1278
+
1279
+ it('no pull_result when sync_pull is off', () => {
1280
+ const result = runInit(fixture.cwd, 'execute-phase 1');
1281
+ assert.equal(result.pull_result, undefined);
1282
+ });
1283
+ });
1284
+
1285
+ describe('init with sync_pull prompt returns needs_pull true', () => {
1286
+ let fixture;
1287
+
1288
+ beforeEach(() => {
1289
+ fixture = v1FixtureWithSyncPull('prompt');
1290
+ });
1291
+
1292
+ afterEach(() => {
1293
+ fixture.cleanup();
1294
+ });
1295
+
1296
+ it('init execute-phase returns needs_pull true when sync_pull is prompt and cadence says pull', () => {
1297
+ const result = runInit(fixture.cwd, 'execute-phase 1');
1298
+ assert.equal(result.needs_pull, true);
1299
+ assert.equal(result.pull_result, undefined);
1300
+ });
1301
+
1302
+ it('init plan-phase returns needs_pull true when sync_pull is prompt', () => {
1303
+ const result = runInit(fixture.cwd, 'plan-phase 1');
1304
+ assert.equal(result.needs_pull, true);
1305
+ assert.equal(result.pull_result, undefined);
1306
+ });
1307
+
1308
+ it('init quick returns needs_pull true when sync_pull is prompt', () => {
1309
+ const result = runInit(fixture.cwd, 'quick "test task"');
1310
+ assert.equal(result.needs_pull, true);
1311
+ assert.equal(result.pull_result, undefined);
1312
+ });
1313
+
1314
+ it('init resume returns needs_pull true when sync_pull is prompt (cadence_pull is true for resume-work)', () => {
1315
+ const result = runInit(fixture.cwd, 'resume');
1316
+ assert.equal(result.needs_pull, true);
1317
+ assert.equal(result.pull_result, undefined);
1318
+ });
1319
+ });
1320
+
1321
+ describe('init with sync_pull auto but no remote returns pull_result', () => {
1322
+ let fixture;
1323
+
1324
+ beforeEach(() => {
1325
+ fixture = v1FixtureWithSyncPull('auto');
1326
+ });
1327
+
1328
+ afterEach(() => {
1329
+ fixture.cleanup();
1330
+ });
1331
+
1332
+ it('init execute-phase returns needs_pull false and pull_result when sync_pull is auto', () => {
1333
+ const result = runInit(fixture.cwd, 'execute-phase 1');
1334
+ assert.equal(result.needs_pull, false);
1335
+ assert.ok(result.pull_result, 'pull_result should be an object');
1336
+ assert.ok('action' in result.pull_result, 'pull_result should have action field');
1337
+ assert.ok('ok' in result.pull_result, 'pull_result should have ok field');
1338
+ assert.ok('summary' in result.pull_result, 'pull_result should have summary field');
1339
+ });
1340
+
1341
+ it('pull_result never causes init to throw', () => {
1342
+ // This should not throw even though the fixture has no git remote
1343
+ const result = runInit(fixture.cwd, 'execute-phase 1');
1344
+ assert.ok(result, 'init should return result even when pull has no remote');
1345
+ assert.equal(result.needs_pull, false);
1346
+ });
1347
+
1348
+ it('init plan-phase returns pull_result when sync_pull is auto', () => {
1349
+ const result = runInit(fixture.cwd, 'plan-phase 1');
1350
+ assert.equal(result.needs_pull, false);
1351
+ assert.ok(result.pull_result, 'pull_result should be an object');
1352
+ });
1353
+ });
1354
+
1355
+ describe('init with cadence_pull false skips pull regardless of sync mode', () => {
1356
+ let fixture;
1357
+
1358
+ beforeEach(() => {
1359
+ // map-codebase has cadence { pull: false, push: true }
1360
+ fixture = v1FixtureWithSyncPull('prompt');
1361
+ });
1362
+
1363
+ afterEach(() => {
1364
+ fixture.cleanup();
1365
+ });
1366
+
1367
+ it('init map-codebase returns needs_pull false because cadence_pull is false', () => {
1368
+ const result = runInit(fixture.cwd, 'map-codebase');
1369
+ assert.equal(result.cadence_pull, false, 'map-codebase cadence should have pull: false');
1370
+ assert.equal(result.needs_pull, false);
1371
+ });
1372
+
1373
+ it('init map-codebase returns no pull_result even with sync_pull auto', () => {
1374
+ // Change to auto mode
1375
+ const autoFixture = v1FixtureWithSyncPull('auto');
1376
+ try {
1377
+ const result = runInit(autoFixture.cwd, 'map-codebase');
1378
+ assert.equal(result.needs_pull, false);
1379
+ assert.equal(result.pull_result, undefined, 'No pull_result when cadence_pull is false');
1380
+ } finally {
1381
+ autoFixture.cleanup();
1382
+ }
1383
+ });
1384
+ });
1385
+
1213
1386
  describe('backward compatibility: v1 output is superset of old shape', () => {
1214
1387
  let fixture;
1215
1388
 
@@ -138,7 +138,7 @@ function createBackupTag(cwd) {
138
138
  // ─── Migration Planning ─────────────────────────────────────────────────────
139
139
 
140
140
  // Directories to move (project-level, order: directories first)
141
- const DIRS_TO_MOVE = ['phases', 'research', 'todos', 'quick', 'debug'];
141
+ const DIRS_TO_MOVE = ['phases', 'research', 'quick', 'debug'];
142
142
 
143
143
  // Files to move (project-level)
144
144
  const FILES_TO_MOVE = ['PROJECT.md', 'REQUIREMENTS.md', 'ROADMAP.md', 'STATE.md'];
@@ -327,13 +327,13 @@ describe('migrateV1ToV2', () => {
327
327
  assert.ok(log.includes('rename'));
328
328
  });
329
329
 
330
- it('moves todos/ directory if it exists', () => {
330
+ it('preserves todos/ at product level (not moved to project)', () => {
331
331
  writeFile(tmpDir, '.planning/todos/pending/task1.md', '# Todo 1\n');
332
332
  createV1Install(tmpDir, 'Test App');
333
333
  migrateV1ToV2(tmpDir, 'test-app');
334
334
 
335
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'todos', 'pending', 'task1.md')));
336
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'todos')));
335
+ assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'task1.md')));
336
+ assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'todos')));
337
337
  });
338
338
 
339
339
  it('moves quick/ directory if it exists', () => {
@@ -407,18 +407,15 @@ describe('collectMigrationMoves', () => {
407
407
  writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Test\n');
408
408
  writeFile(tmpDir, '.planning/phases/01-setup/01-01-PLAN.md', '---\n---\n');
409
409
  writeFile(tmpDir, '.planning/research/notes.md', '# Notes\n');
410
- writeFile(tmpDir, '.planning/todos/pending/task.md', '# Todo\n');
411
410
 
412
411
  const { moves } = collectMigrationMoves(tmpDir, 'test');
413
412
 
414
413
  const phasesMove = moves.find(m => m.relSource.endsWith('phases'));
415
414
  const researchMove = moves.find(m => m.relSource.endsWith('research'));
416
- const todosMove = moves.find(m => m.relSource.endsWith('todos'));
417
415
  const projectMove = moves.find(m => m.relSource.endsWith('PROJECT.md'));
418
416
 
419
417
  assert.strictEqual(phasesMove.isDir, true);
420
418
  assert.strictEqual(researchMove.isDir, true);
421
- assert.strictEqual(todosMove.isDir, true);
422
419
  assert.strictEqual(projectMove.isDir, false);
423
420
  });
424
421
  });
@@ -41,7 +41,7 @@ const COMPREHENSIVE_PATTERN = /\.planning\//;
41
41
  * multi-project install because they resolve to the root instead of
42
42
  * .planning/<project>/<path>.
43
43
  */
44
- const PROJECT_SCOPED_PATTERN = /\.planning\/(?:STATE|ROADMAP|PROJECT(?!S\.md)|REQUIREMENTS|phases|archive|quick|todos|debug|research)/;
44
+ const PROJECT_SCOPED_PATTERN = /\.planning\/(?:STATE|ROADMAP|PROJECT(?!S\.md)|REQUIREMENTS|phases|archive|quick|debug|research)/;
45
45
 
46
46
  /**
47
47
  * Workflow files allowlisted from comprehensive .planning/ scanning.
@@ -14,7 +14,7 @@ const { getPlanningRoot } = require('./paths.cjs');
14
14
 
15
15
  // ─── Constants ──────────────────────────────────────────────────────────────
16
16
 
17
- const STANDARD_DIRS = ['phases', 'research', 'todos', 'quick', 'debug'];
17
+ const STANDARD_DIRS = ['phases', 'research', 'quick', 'debug'];
18
18
 
19
19
  // ─── Project Subfolder Creation ─────────────────────────────────────────────
20
20
 
@@ -64,7 +64,7 @@ describe('createProjectSubfolder', () => {
64
64
 
65
65
  it('creates all required subdirectories', () => {
66
66
  createProjectSubfolder(tmpDir, 'auth-overhaul', 'Auth Overhaul');
67
- const expectedDirs = ['phases', 'research', 'todos', 'quick', 'debug'];
67
+ const expectedDirs = ['phases', 'research', 'quick', 'debug'];
68
68
  for (const dir of expectedDirs) {
69
69
  assert.ok(
70
70
  fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'auth-overhaul', dir)),
@@ -571,7 +571,7 @@ function cmdStateSnapshot(cwd, raw) {
571
571
  function getMilestonePhaseFilter(cwd) {
572
572
  const milestonePhaseNums = new Set();
573
573
  try {
574
- const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8');
574
+ const roadmap = fs.readFileSync(path.join(getPlanningRoot(cwd), 'ROADMAP.md'), 'utf-8');
575
575
  const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
576
576
  let m;
577
577
  while ((m = phasePattern.exec(roadmap)) !== null) {
@@ -632,7 +632,7 @@ function buildStateFrontmatter(bodyContent, cwd) {
632
632
 
633
633
  if (cwd) {
634
634
  try {
635
- const phasesDir = path.join(cwd, '.planning', 'phases');
635
+ const phasesDir = path.join(getPlanningRoot(cwd), 'phases');
636
636
  if (fs.existsSync(phasesDir)) {
637
637
  const isDirInMilestone = getMilestonePhaseFilter(cwd);
638
638
  const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
@@ -228,8 +228,8 @@ function createTempProject(options) {
228
228
  }
229
229
 
230
230
  if (withTodos) {
231
- fs.mkdirSync(path.join(cwd, '.planning', PROJECTS_DIR, project, 'todos', 'pending'), { recursive: true });
232
- fs.mkdirSync(path.join(cwd, '.planning', PROJECTS_DIR, project, 'todos', 'completed'), { recursive: true });
231
+ fs.mkdirSync(path.join(cwd, '.planning', 'todos', 'pending'), { recursive: true });
232
+ fs.mkdirSync(path.join(cwd, '.planning', 'todos', 'completed'), { recursive: true });
233
233
  }
234
234
 
235
235
  if (withResearch) {
@@ -527,8 +527,10 @@ The CLI handles:
527
527
  Extract from result: `next_phase`, `next_phase_name`, `is_last_phase`.
528
528
 
529
529
  ```bash
530
- node "$HOME/.claude/deliver-great-systems/bin/dgs-tools.cjs" commit "docs(phase-{X}): complete phase execution" --files ${roadmap_path} ${state_path} ${requirements_path} ${phase_dir}/*-VERIFICATION.md
530
+ node "$HOME/.claude/deliver-great-systems/bin/dgs-tools.cjs" commit "docs(phase-{X}): complete phase execution" --push --files ${roadmap_path} ${state_path} ${requirements_path} ${phase_dir}/*-VERIFICATION.md
531
531
  ```
532
+
533
+ Parse the commit result JSON. If `needs_push` is true (sync_push is "prompt" mode), handle push prompting via the existing sync_after step. If `pushed` is true, the push already happened atomically with the commit -- skip the sync_after push for this commit (avoid double-push). If `pushed` is false and `push_result` exists, log the push warning but do not halt.
532
534
  </step>
533
535
 
534
536
  <step name="sync_after">
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "bugs": {
5
5
  "url": "https://github.com/KT-Partners-Ltd/dgs-platform-docs/issues"
6
6
  },
7
- "version": "2.7.0",
7
+ "version": "2.7.2",
8
8
  "description": "Deliver Great Systems Platform — A meta-prompting, context engineering and spec-driven development system for Claude Code and Gemini by KT Partners.",
9
9
  "bin": {
10
10
  "dgs": "bin/install.js"