@ktpartners/dgs-platform 2.7.5 → 2.9.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +15 -12
  3. package/agents/dgs-executor.md +0 -52
  4. package/deliver-great-systems/bin/dgs-tools.cjs +66 -10
  5. package/deliver-great-systems/bin/lib/commands.cjs +1 -8
  6. package/deliver-great-systems/bin/lib/config.cjs +9 -90
  7. package/deliver-great-systems/bin/lib/context.cjs +2 -2
  8. package/deliver-great-systems/bin/lib/context.test.cjs +100 -100
  9. package/deliver-great-systems/bin/lib/core.cjs +17 -57
  10. package/deliver-great-systems/bin/lib/core.test.cjs +166 -170
  11. package/deliver-great-systems/bin/lib/docs.cjs +3 -3
  12. package/deliver-great-systems/bin/lib/docs.test.cjs +14 -7
  13. package/deliver-great-systems/bin/lib/execution.cjs +2 -2
  14. package/deliver-great-systems/bin/lib/execution.test.cjs +65 -67
  15. package/deliver-great-systems/bin/lib/ideas.cjs +4 -4
  16. package/deliver-great-systems/bin/lib/ideas.test.cjs +45 -44
  17. package/deliver-great-systems/bin/lib/init.cjs +9 -4
  18. package/deliver-great-systems/bin/lib/init.test.cjs +242 -175
  19. package/deliver-great-systems/bin/lib/jobs.cjs +1 -1
  20. package/deliver-great-systems/bin/lib/jobs.test.cjs +203 -202
  21. package/deliver-great-systems/bin/lib/migration.cjs +256 -281
  22. package/deliver-great-systems/bin/lib/migration.test.cjs +385 -440
  23. package/deliver-great-systems/bin/lib/milestone.cjs +1 -1
  24. package/deliver-great-systems/bin/lib/overlap.cjs +4 -4
  25. package/deliver-great-systems/bin/lib/overlap.test.cjs +45 -44
  26. package/deliver-great-systems/bin/lib/path-audit.test.cjs +16 -22
  27. package/deliver-great-systems/bin/lib/paths.cjs +60 -59
  28. package/deliver-great-systems/bin/lib/paths.test.cjs +192 -225
  29. package/deliver-great-systems/bin/lib/phase.cjs +5 -4
  30. package/deliver-great-systems/bin/lib/projects.cjs +8 -8
  31. package/deliver-great-systems/bin/lib/projects.test.cjs +75 -74
  32. package/deliver-great-systems/bin/lib/repos.cjs +94 -230
  33. package/deliver-great-systems/bin/lib/repos.test.cjs +84 -75
  34. package/deliver-great-systems/bin/lib/search.cjs +4 -4
  35. package/deliver-great-systems/bin/lib/specs.cjs +2 -2
  36. package/deliver-great-systems/bin/lib/sync.cjs +1 -1
  37. package/deliver-great-systems/bin/lib/template.cjs +3 -3
  38. package/deliver-great-systems/bin/lib/test-helpers.cjs +59 -162
  39. package/deliver-great-systems/bin/lib/verify.cjs +3 -3
  40. package/deliver-great-systems/references/planning-config.md +7 -8
  41. package/deliver-great-systems/workflows/add-tests.md +1 -1
  42. package/deliver-great-systems/workflows/approve-spec.md +1 -11
  43. package/deliver-great-systems/workflows/complete-milestone.md +2 -2
  44. package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
  45. package/deliver-great-systems/workflows/create-milestone-job.md +2 -2
  46. package/deliver-great-systems/workflows/discuss-phase.md +2 -2
  47. package/deliver-great-systems/workflows/execute-phase.md +63 -4
  48. package/deliver-great-systems/workflows/execute-plan.md +0 -51
  49. package/deliver-great-systems/workflows/find-related-ideas.md +1 -1
  50. package/deliver-great-systems/workflows/help.md +55 -84
  51. package/deliver-great-systems/workflows/init-product.md +14 -451
  52. package/deliver-great-systems/workflows/map-codebase.md +109 -0
  53. package/deliver-great-systems/workflows/new-milestone.md +16 -6
  54. package/deliver-great-systems/workflows/new-project.md +22 -681
  55. package/deliver-great-systems/workflows/quick.md +2 -2
  56. package/deliver-great-systems/workflows/run-job.md +56 -0
  57. package/package.json +1 -1
@@ -1,580 +1,525 @@
1
1
  /**
2
- * Tests for migration.cjs — v1-to-v2 migration with git mv history preservation
2
+ * Tests for .planning/ to root layout migration engine.
3
+ *
4
+ * Covers all MIG requirements (01-11), OPT-02 (dry-run),
5
+ * and OPT-03 (rollback on commit failure).
3
6
  */
4
7
 
5
8
  const { describe, it, beforeEach, afterEach } = require('node:test');
6
9
  const assert = require('node:assert');
7
10
  const fs = require('fs');
8
11
  const path = require('path');
9
- const os = require('os');
10
12
  const { execSync } = require('child_process');
11
13
 
12
14
  const { createTempDir, cleanupDir, writeFile, initGitRepo } = require('./test-helpers.cjs');
15
+ const { resetPaths } = require('./paths.cjs');
16
+ const { migrateDotPlanningToRoot } = require('./migration.cjs');
13
17
 
14
- // Helper to create a v1 install structure and commit it
15
- function createV1Install(dir, projectName) {
16
- writeFile(dir, '.planning/PROJECT.md', `# Project: ${projectName}\n\nA test project.\n`);
17
- writeFile(dir, '.planning/REQUIREMENTS.md', `# Requirements\n\n## Functional\n\n- REQ-01: Do something\n`);
18
- writeFile(dir, '.planning/ROADMAP.md', `# Roadmap\n\n## Phase 1\n\nDo things.\n`);
19
- writeFile(dir, '.planning/STATE.md', `# Project State\n\nPhase: 1\nStatus: Ready\nProgress: [░░░░░░░░░░] 0%\n`);
20
- writeFile(dir, '.planning/config.json', '{"model_profile":"balanced"}');
21
- writeFile(dir, '.planning/phases/01-setup/01-01-PLAN.md', '---\nphase: 01\n---\n');
22
- writeFile(dir, '.planning/research/notes.md', '# Research Notes\n');
23
- execSync('git add .', { cwd: dir, stdio: 'pipe' });
24
- execSync('git commit -m "v1 install"', { cwd: dir, stdio: 'pipe' });
18
+ const libDir = path.resolve(__dirname);
19
+
20
+ // ─── Test Helpers ─────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Creates a temp directory with git init, returns realpathSync'd path
24
+ * (macOS /var -> /private/var symlink fix from Phase 118).
25
+ */
26
+ function makeGitDir() {
27
+ const tmpDir = createTempDir('dgs-mig-test-');
28
+ initGitRepo(tmpDir);
29
+ return fs.realpathSync(tmpDir);
25
30
  }
26
31
 
27
- const {
28
- detectV1Install,
29
- validateMigrationPreconditions,
30
- createBackupTag,
31
- collectMigrationMoves,
32
- validateMoves,
33
- migrateV1ToV2,
34
- } = require('./migration.cjs');
32
+ /**
33
+ * Creates a temp directory without git, returns realpathSync'd path.
34
+ */
35
+ function makePlainDir() {
36
+ const tmpDir = createTempDir('dgs-mig-test-');
37
+ return fs.realpathSync(tmpDir);
38
+ }
35
39
 
36
- // ─── detectV1Install ─────────────────────────────────────────────────────────
40
+ /**
41
+ * Spawns a subprocess to call migrateDotPlanningToRoot.
42
+ * Used for tests where error() calls process.exit(1).
43
+ *
44
+ * @param {string} cwd - Directory to pass
45
+ * @param {Object} [options] - Options to pass to migrateDotPlanningToRoot
46
+ * @returns {{ exitCode: number, stdout: string, stderr: string }}
47
+ */
48
+ function callMigrateSubprocess(cwd, options) {
49
+ const optsStr = options ? JSON.stringify(options) : '{}';
50
+ // Write script OUTSIDE the git repo to avoid dirtying the working tree
51
+ const scriptPath = path.join(require('os').tmpdir(), '_test_migrate_' + Date.now() + '.js');
52
+ const scriptContent = [
53
+ 'const { migrateDotPlanningToRoot } = require(' + JSON.stringify(path.join(libDir, 'migration.cjs')) + ');',
54
+ 'const result = migrateDotPlanningToRoot(' + JSON.stringify(cwd) + ', ' + optsStr + ');',
55
+ 'process.stdout.write(JSON.stringify(result));',
56
+ ].join('\n');
57
+ fs.writeFileSync(scriptPath, scriptContent);
58
+ try {
59
+ const stdout = execSync('node ' + JSON.stringify(scriptPath), {
60
+ encoding: 'utf-8',
61
+ stdio: ['pipe', 'pipe', 'pipe'],
62
+ timeout: 10000,
63
+ });
64
+ return { exitCode: 0, stdout: stdout, stderr: '' };
65
+ } catch (err) {
66
+ return {
67
+ exitCode: err.status ?? 1,
68
+ stdout: (err.stdout ?? '').toString(),
69
+ stderr: (err.stderr ?? '').toString(),
70
+ };
71
+ }
72
+ }
37
73
 
38
- describe('detectV1Install', () => {
39
- let tmpDir;
40
- beforeEach(() => { tmpDir = createTempDir(); });
41
- afterEach(() => { cleanupDir(tmpDir); });
74
+ // ─── Suite 1: Idempotency (MIG-07) ───────────────────────────────────────────
42
75
 
43
- it('returns isV1 true when PROJECT.md exists but no PROJECTS.md or REPOS.md', () => {
44
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: My Cool App\n');
45
- const result = detectV1Install(tmpDir);
46
- assert.strictEqual(result.isV1, true);
47
- assert.strictEqual(result.projectName, 'My Cool App');
48
- assert.strictEqual(result.slug, 'my-cool-app');
49
- });
76
+ describe('idempotency (MIG-07)', () => {
77
+ let dir;
50
78
 
51
- it('returns isV1 false when already v2 (PROJECTS.md exists)', () => {
52
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Test\n');
53
- writeFile(tmpDir, '.planning/PROJECTS.md', '# Projects\n');
54
- const result = detectV1Install(tmpDir);
55
- assert.strictEqual(result.isV1, false);
79
+ beforeEach(() => {
80
+ dir = makeGitDir();
56
81
  });
57
82
 
58
- it('returns isV1 false when already v2 (REPOS.md exists)', () => {
59
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Test\n');
60
- writeFile(tmpDir, '.planning/REPOS.md', '# Repos\n');
61
- const result = detectV1Install(tmpDir);
62
- assert.strictEqual(result.isV1, false);
83
+ afterEach(() => {
84
+ resetPaths();
85
+ cleanupDir(dir);
63
86
  });
64
87
 
65
- it('returns isV1 false when no .planning directory exists', () => {
66
- const result = detectV1Install(tmpDir);
67
- assert.strictEqual(result.isV1, false);
88
+ it('returns migrated:false when no .planning/ directory exists', () => {
89
+ const result = migrateDotPlanningToRoot(dir);
90
+ assert.strictEqual(result.migrated, false);
91
+ assert.strictEqual(result.filesMoved, 0);
92
+ assert.strictEqual(result.identicalResolved, 0);
93
+ assert.strictEqual(result.commitHash, null);
94
+ assert.deepStrictEqual(result.actions, []);
68
95
  });
69
96
 
70
- it('returns isV1 false when .planning exists but no PROJECT.md', () => {
71
- fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
72
- writeFile(tmpDir, '.planning/config.json', '{}');
73
- const result = detectV1Install(tmpDir);
74
- assert.strictEqual(result.isV1, false);
97
+ it('returns migrated:false when .planning is a file not a directory', () => {
98
+ fs.writeFileSync(path.join(dir, '.planning'), 'not a directory');
99
+ execSync('git add .planning && git commit -m "add file"', { cwd: dir, stdio: 'pipe' });
100
+ const result = migrateDotPlanningToRoot(dir);
101
+ assert.strictEqual(result.migrated, false);
102
+ assert.strictEqual(result.filesMoved, 0);
75
103
  });
104
+ });
105
+
106
+ // ─── Suite 2: Clean working tree (git only) ──────────────────────────────────
107
+
108
+ describe('clean working tree (git only)', () => {
109
+ let dir;
76
110
 
77
- it('extracts project name from "# Project: Name" heading', () => {
78
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Auth Overhaul\n\nDetails here.\n');
79
- const result = detectV1Install(tmpDir);
80
- assert.strictEqual(result.projectName, 'Auth Overhaul');
81
- assert.strictEqual(result.slug, 'auth-overhaul');
111
+ beforeEach(() => {
112
+ dir = makeGitDir();
82
113
  });
83
114
 
84
- it('falls back to directory name if no heading found', () => {
85
- writeFile(tmpDir, '.planning/PROJECT.md', 'Random content without heading\n');
86
- const result = detectV1Install(tmpDir);
87
- assert.strictEqual(result.isV1, true);
88
- // slug should be derived from the directory basename
89
- assert.ok(result.slug);
90
- assert.ok(result.slug.length > 0);
115
+ afterEach(() => {
116
+ resetPaths();
117
+ cleanupDir(dir);
91
118
  });
92
119
 
93
- it('handles PROJECT.md with "# Project Name" (no colon) heading', () => {
94
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project Name\n\nSome project.\n');
95
- const result = detectV1Install(tmpDir);
96
- assert.strictEqual(result.isV1, true);
97
- // Should parse the heading somehow
98
- assert.ok(result.slug);
120
+ it('aborts when git working tree has uncommitted changes', () => {
121
+ // Create .planning dir and commit it
122
+ writeFile(dir, '.planning/ROADMAP.md', '# Roadmap\n');
123
+ execSync('git add .planning && git commit -m "add planning"', { cwd: dir, stdio: 'pipe' });
124
+ // Create an uncommitted file to dirty the tree
125
+ fs.writeFileSync(path.join(dir, 'dirty.txt'), 'uncommitted');
126
+
127
+ const result = callMigrateSubprocess(dir);
128
+ assert.strictEqual(result.exitCode, 1, 'Should exit with code 1');
129
+ assert.ok(
130
+ result.stderr.includes('clean working tree'),
131
+ 'stderr should mention clean working tree, got: ' + result.stderr
132
+ );
99
133
  });
100
134
  });
101
135
 
102
- // ─── validateMigrationPreconditions ──────────────────────────────────────────
136
+ // ─── Suite 3: File moves (MIG-01, MIG-04) ────────────────────────────────────
137
+
138
+ describe('file moves (MIG-01, MIG-04)', () => {
139
+ let dir;
103
140
 
104
- describe('validateMigrationPreconditions', () => {
105
- let tmpDir;
106
141
  beforeEach(() => {
107
- tmpDir = createTempDir();
108
- initGitRepo(tmpDir);
142
+ dir = makeGitDir();
109
143
  });
110
- afterEach(() => { cleanupDir(tmpDir); });
111
144
 
112
- it('passes when v1 install with clean working tree', () => {
113
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Test\n');
114
- execSync('git add .', { cwd: tmpDir, stdio: 'pipe' });
115
- execSync('git commit -m "add planning"', { cwd: tmpDir, stdio: 'pipe' });
116
- const result = validateMigrationPreconditions(tmpDir);
117
- assert.strictEqual(result.valid, true);
145
+ afterEach(() => {
146
+ resetPaths();
147
+ cleanupDir(dir);
118
148
  });
119
149
 
120
- it('fails when working tree has uncommitted changes', () => {
121
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Test\n');
122
- // Don't commit — dirty tree
123
- const result = validateMigrationPreconditions(tmpDir);
124
- assert.strictEqual(result.valid, false);
125
- assert.ok(result.reason.toLowerCase().includes('uncommitted') || result.reason.toLowerCase().includes('dirty') || result.reason.toLowerCase().includes('clean'));
126
- });
150
+ it('moves all files from .planning/ to root', () => {
151
+ writeFile(dir, '.planning/ROADMAP.md', '# Roadmap\n');
152
+ writeFile(dir, '.planning/STATE.md', '# State\n');
153
+ writeFile(dir, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
154
+ execSync('git add .planning && git commit -m "add planning"', { cwd: dir, stdio: 'pipe' });
155
+
156
+ const result = migrateDotPlanningToRoot(dir);
127
157
 
128
- it('fails when already v2 install', () => {
129
- writeFile(tmpDir, '.planning/PROJECTS.md', '# Projects\n');
130
- writeFile(tmpDir, '.planning/REPOS.md', '# Repos\n');
131
- execSync('git add .', { cwd: tmpDir, stdio: 'pipe' });
132
- execSync('git commit -m "v2"', { cwd: tmpDir, stdio: 'pipe' });
133
- const result = validateMigrationPreconditions(tmpDir);
134
- assert.strictEqual(result.valid, false);
135
- assert.ok(result.reason.toLowerCase().includes('v2') || result.reason.toLowerCase().includes('already'));
158
+ assert.strictEqual(result.migrated, true);
159
+ assert.strictEqual(result.filesMoved, 3);
160
+ assert.ok(fs.existsSync(path.join(dir, 'ROADMAP.md')), 'ROADMAP.md should exist at root');
161
+ assert.ok(fs.existsSync(path.join(dir, 'STATE.md')), 'STATE.md should exist at root');
162
+ assert.ok(fs.existsSync(path.join(dir, 'phases/01-foo/01-01-PLAN.md')), 'Nested file should exist at root');
163
+ assert.ok(!fs.existsSync(path.join(dir, '.planning')), '.planning/ should be gone');
136
164
  });
137
165
 
138
- it('fails when no .planning/PROJECT.md exists', () => {
139
- const result = validateMigrationPreconditions(tmpDir);
140
- assert.strictEqual(result.valid, false);
166
+ it('handles partial migration (only files still in .planning/)', () => {
167
+ // ROADMAP.md already at root, STATE.md only in .planning
168
+ writeFile(dir, 'ROADMAP.md', '# Roadmap\n');
169
+ writeFile(dir, '.planning/STATE.md', '# State\n');
170
+ execSync('git add . && git commit -m "partial setup"', { cwd: dir, stdio: 'pipe' });
171
+
172
+ const result = migrateDotPlanningToRoot(dir);
173
+
174
+ assert.strictEqual(result.migrated, true);
175
+ assert.strictEqual(result.filesMoved, 1);
176
+ assert.ok(fs.existsSync(path.join(dir, 'STATE.md')), 'STATE.md should be at root');
177
+ assert.ok(fs.existsSync(path.join(dir, 'ROADMAP.md')), 'ROADMAP.md should still be at root');
141
178
  });
142
179
  });
143
180
 
144
- // ─── createBackupTag ─────────────────────────────────────────────────────────
181
+ // ─── Suite 4: Conflict detection (MIG-02, MIG-03) ────────────────────────────
182
+
183
+ describe('conflict detection (MIG-02, MIG-03)', () => {
184
+ let dir;
145
185
 
146
- describe('createBackupTag', () => {
147
- let tmpDir;
148
186
  beforeEach(() => {
149
- tmpDir = createTempDir();
150
- initGitRepo(tmpDir);
187
+ dir = makeGitDir();
151
188
  });
152
- afterEach(() => { cleanupDir(tmpDir); });
153
189
 
154
- it('creates dgs-pre-v2-migration tag successfully', () => {
155
- const result = createBackupTag(tmpDir);
156
- assert.strictEqual(result.tagged, true);
157
- // Verify tag actually exists
158
- const tags = execSync('git tag', { cwd: tmpDir, encoding: 'utf-8' }).trim();
159
- assert.ok(tags.includes('dgs-pre-v2-migration'));
190
+ afterEach(() => {
191
+ resetPaths();
192
+ cleanupDir(dir);
160
193
  });
161
194
 
162
- it('returns tagged false if tag already exists', () => {
163
- execSync('git tag dgs-pre-v2-migration', { cwd: tmpDir, stdio: 'pipe' });
164
- const result = createBackupTag(tmpDir);
165
- assert.strictEqual(result.tagged, false);
166
- assert.ok(result.reason);
167
- });
195
+ it('aborts when files have different content in both locations', () => {
196
+ writeFile(dir, '.planning/ROADMAP.md', 'old content');
197
+ writeFile(dir, 'ROADMAP.md', 'new content');
198
+ execSync('git add . && git commit -m "conflict setup"', { cwd: dir, stdio: 'pipe' });
168
199
 
169
- it('returns tagged false if not a git repo', () => {
170
- const noGitDir = createTempDir();
171
- try {
172
- const result = createBackupTag(noGitDir);
173
- assert.strictEqual(result.tagged, false);
174
- assert.ok(result.reason);
175
- } finally {
176
- cleanupDir(noGitDir);
177
- }
200
+ const result = callMigrateSubprocess(dir);
201
+ assert.strictEqual(result.exitCode, 1, 'Should exit with code 1');
202
+ assert.ok(result.stderr.includes('Migration aborted'), 'stderr should mention Migration aborted');
203
+ assert.ok(result.stderr.includes('ROADMAP.md'), 'stderr should list conflicting file');
178
204
  });
179
- });
180
205
 
181
- // ─── migrateV1ToV2 ──────────────────────────────────────────────────────────
206
+ it('resolves identical-content conflicts by keeping root copy', () => {
207
+ const content = '# Roadmap\nSame content\n';
208
+ writeFile(dir, '.planning/ROADMAP.md', content);
209
+ writeFile(dir, 'ROADMAP.md', content);
210
+ execSync('git add . && git commit -m "identical setup"', { cwd: dir, stdio: 'pipe' });
182
211
 
183
- describe('migrateV1ToV2', () => {
184
- let tmpDir;
185
- beforeEach(() => {
186
- tmpDir = createTempDir();
187
- initGitRepo(tmpDir);
188
- });
189
- afterEach(() => { cleanupDir(tmpDir); });
212
+ const result = migrateDotPlanningToRoot(dir);
190
213
 
191
- it('moves PROJECT.md, REQUIREMENTS.md, ROADMAP.md, STATE.md into project subfolder', () => {
192
- createV1Install(tmpDir, 'Test App');
193
- const result = migrateV1ToV2(tmpDir, 'test-app');
194
214
  assert.strictEqual(result.migrated, true);
195
- assert.strictEqual(result.slug, 'test-app');
215
+ assert.strictEqual(result.identicalResolved, 1);
216
+ assert.ok(!fs.existsSync(path.join(dir, '.planning')), '.planning/ should be gone');
217
+ const rootContent = fs.readFileSync(path.join(dir, 'ROADMAP.md'), 'utf-8');
218
+ assert.strictEqual(rootContent, content, 'Root copy should be unchanged');
219
+ });
220
+ });
196
221
 
197
- // Files should exist in new location
198
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'PROJECT.md')));
199
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'REQUIREMENTS.md')));
200
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'ROADMAP.md')));
201
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'STATE.md')));
222
+ // ─── Suite 5: .gitignore rewrite (MIG-05) ────────────────────────────────────
202
223
 
203
- // Files should NOT exist in old location
204
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'PROJECT.md')));
205
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md')));
206
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'ROADMAP.md')));
207
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'STATE.md')));
208
- });
224
+ describe('.gitignore rewrite (MIG-05)', () => {
225
+ let dir;
209
226
 
210
- it('moves phases/ directory into project subfolder', () => {
211
- createV1Install(tmpDir, 'Test App');
212
- const result = migrateV1ToV2(tmpDir, 'test-app');
213
- assert.strictEqual(result.migrated, true);
227
+ beforeEach(() => {
228
+ dir = makeGitDir();
229
+ });
214
230
 
215
- // phases/ should exist in new location
216
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'phases', '01-setup', '01-01-PLAN.md')));
217
- // phases/ should NOT exist in old location
218
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'phases')));
231
+ afterEach(() => {
232
+ resetPaths();
233
+ cleanupDir(dir);
219
234
  });
220
235
 
221
- it('moves research/ directory into project subfolder', () => {
222
- createV1Install(tmpDir, 'Test App');
223
- const result = migrateV1ToV2(tmpDir, 'test-app');
224
- assert.strictEqual(result.migrated, true);
236
+ it('rewrites .gitignore entries to strip .planning/ prefix', () => {
237
+ writeFile(dir, '.planning/ROADMAP.md', '# Roadmap\n');
238
+ writeFile(dir, '.gitignore', '.planning/config.local.json\n.planning/archive/\n!.planning/keep.txt\n');
239
+ execSync('git add . && git commit -m "gitignore setup"', { cwd: dir, stdio: 'pipe' });
225
240
 
226
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'research', 'notes.md')));
227
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'research')));
241
+ migrateDotPlanningToRoot(dir);
242
+
243
+ const gitignore = fs.readFileSync(path.join(dir, '.gitignore'), 'utf-8');
244
+ assert.ok(gitignore.includes('config.local.json'), 'Should contain config.local.json without .planning/ prefix');
245
+ assert.ok(gitignore.includes('archive/'), 'Should contain archive/ without .planning/ prefix');
246
+ assert.ok(gitignore.includes('!keep.txt'), 'Should contain !keep.txt without .planning/ prefix');
247
+ assert.ok(!gitignore.includes('.planning/'), 'Should not contain .planning/ prefix');
228
248
  });
249
+ });
229
250
 
230
- it('skips files/dirs that do not exist (partial v1 install)', () => {
231
- // Create minimal v1 install with only PROJECT.md and STATE.md
232
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Minimal\n');
233
- writeFile(tmpDir, '.planning/STATE.md', '# Project State\n\nPhase: 1\nStatus: Ready\nProgress: [░░░░░░░░░░] 0%\n');
234
- writeFile(tmpDir, '.planning/config.json', '{}');
235
- execSync('git add .', { cwd: tmpDir, stdio: 'pipe' });
236
- execSync('git commit -m "partial v1"', { cwd: tmpDir, stdio: 'pipe' });
251
+ // ─── Suite 6: config.local.json cleanup (MIG-06) ─────────────────────────────
237
252
 
238
- const result = migrateV1ToV2(tmpDir, 'minimal');
239
- assert.strictEqual(result.migrated, true);
240
- // Moved files should be in new location
241
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'minimal', 'PROJECT.md')));
242
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'minimal', 'STATE.md')));
243
- // Skipped files should not cause errors
244
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'minimal', 'REQUIREMENTS.md')));
245
- });
253
+ describe('config.local.json cleanup (MIG-06)', () => {
254
+ let dir;
246
255
 
247
- it('does NOT move config.json (product-level)', () => {
248
- createV1Install(tmpDir, 'Test App');
249
- migrateV1ToV2(tmpDir, 'test-app');
256
+ beforeEach(() => {
257
+ dir = makeGitDir();
258
+ });
250
259
 
251
- // config.json should remain at .planning/config.json
252
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'config.json')));
253
- // config.json should NOT be in project subfolder
254
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'config.json')));
260
+ afterEach(() => {
261
+ resetPaths();
262
+ cleanupDir(dir);
255
263
  });
256
264
 
257
- it('does NOT move codebase/ directory (product-level)', () => {
258
- writeFile(tmpDir, '.planning/codebase/overview.md', '# Codebase\n');
259
- createV1Install(tmpDir, 'Test App');
260
- migrateV1ToV2(tmpDir, 'test-app');
265
+ it('removes planningRoot key from config.local.json', () => {
266
+ writeFile(dir, '.planning/config.local.json', JSON.stringify({ planningRoot: '.', other: 'keep' }));
267
+ execSync('git add . && git commit -m "config setup"', { cwd: dir, stdio: 'pipe' });
268
+
269
+ migrateDotPlanningToRoot(dir);
261
270
 
262
- // codebase/ should remain at product level
263
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'codebase', 'overview.md')));
271
+ const configPath = path.join(dir, 'config.local.json');
272
+ assert.ok(fs.existsSync(configPath), 'config.local.json should exist at root');
273
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
274
+ assert.ok(!('planningRoot' in config), 'planningRoot key should be removed');
275
+ assert.strictEqual(config.other, 'keep', 'Other keys should be preserved');
264
276
  });
277
+ });
265
278
 
266
- it('does NOT move milestones/ directory (product-level)', () => {
267
- writeFile(tmpDir, '.planning/milestones/v1.0.md', '# Milestone v1.0\n');
268
- createV1Install(tmpDir, 'Test App');
269
- migrateV1ToV2(tmpDir, 'test-app');
279
+ // ─── Suite 7: dgs.config.json rename (MIG-10) ────────────────────────────────
270
280
 
271
- // milestones/ should remain at product level
272
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0.md')));
273
- });
281
+ describe('dgs.config.json rename (MIG-10)', () => {
282
+ let dir;
274
283
 
275
- it('creates REPOS.md with current directory as single repo', () => {
276
- createV1Install(tmpDir, 'Test App');
277
- migrateV1ToV2(tmpDir, 'test-app');
284
+ beforeEach(() => {
285
+ dir = makeGitDir();
286
+ });
278
287
 
279
- const reposMdPath = path.join(tmpDir, '.planning', 'REPOS.md');
280
- assert.ok(fs.existsSync(reposMdPath));
281
- const content = fs.readFileSync(reposMdPath, 'utf-8');
282
- assert.ok(content.startsWith('# Repos'));
283
- assert.ok(content.includes('.')); // path is '.'
288
+ afterEach(() => {
289
+ resetPaths();
290
+ cleanupDir(dir);
284
291
  });
285
292
 
286
- it('creates PROJECTS.md from migrated project', () => {
287
- createV1Install(tmpDir, 'Test App');
288
- migrateV1ToV2(tmpDir, 'test-app');
293
+ it('renames dgs.config.json to config.json during migration', () => {
294
+ writeFile(dir, '.planning/dgs.config.json', JSON.stringify({ model_profile: 'balanced' }));
295
+ execSync('git add . && git commit -m "dgs config setup"', { cwd: dir, stdio: 'pipe' });
289
296
 
290
- const projectsMdPath = path.join(tmpDir, '.planning', 'PROJECTS.md');
291
- assert.ok(fs.existsSync(projectsMdPath));
292
- const content = fs.readFileSync(projectsMdPath, 'utf-8');
293
- assert.ok(content.startsWith('# Projects'));
297
+ const result = migrateDotPlanningToRoot(dir);
298
+
299
+ assert.ok(fs.existsSync(path.join(dir, 'config.json')), 'config.json should exist at root');
300
+ assert.ok(!fs.existsSync(path.join(dir, 'dgs.config.json')), 'dgs.config.json should not exist');
301
+ const config = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf-8'));
302
+ assert.strictEqual(config.model_profile, 'balanced', 'Content should be preserved');
303
+ assert.strictEqual(result.migrated, true);
294
304
  });
305
+ });
295
306
 
296
- it('sets current_project in config.local.json', () => {
297
- createV1Install(tmpDir, 'Test App');
298
- migrateV1ToV2(tmpDir, 'test-app');
307
+ // ─── Suite 8: Atomic commit (MIG-11) ─────────────────────────────────────────
299
308
 
300
- const localConfigPath = path.join(tmpDir, '.planning', 'config.local.json');
301
- assert.ok(fs.existsSync(localConfigPath), 'config.local.json should exist');
302
- const config = JSON.parse(fs.readFileSync(localConfigPath, 'utf-8'));
303
- assert.strictEqual(config.current_project, 'test-app');
309
+ describe('atomic commit (MIG-11)', () => {
310
+ let dir;
311
+
312
+ beforeEach(() => {
313
+ dir = makeGitDir();
304
314
  });
305
315
 
306
- it('returns structured result with files_moved', () => {
307
- createV1Install(tmpDir, 'Test App');
308
- const result = migrateV1ToV2(tmpDir, 'test-app');
309
- assert.strictEqual(result.migrated, true);
310
- assert.strictEqual(result.slug, 'test-app');
311
- assert.ok(Array.isArray(result.files_moved));
312
- assert.ok(result.files_moved.length > 0);
313
- assert.strictEqual(result.repos_created, true);
314
- assert.strictEqual(result.project_created, true);
316
+ afterEach(() => {
317
+ resetPaths();
318
+ cleanupDir(dir);
315
319
  });
316
320
 
317
- it('uses git mv to preserve history', () => {
318
- createV1Install(tmpDir, 'Test App');
319
- migrateV1ToV2(tmpDir, 'test-app');
321
+ it('creates a single commit with migration message', () => {
322
+ writeFile(dir, '.planning/ROADMAP.md', '# Roadmap\n');
323
+ execSync('git add .planning && git commit -m "add planning"', { cwd: dir, stdio: 'pipe' });
320
324
 
321
- // Check git log for the moved files — git should show rename detection
322
- const log = execSync('git log --diff-filter=R --summary --format="" HEAD', {
323
- cwd: tmpDir,
324
- encoding: 'utf-8',
325
- }).trim();
326
- // Should have rename entries
327
- assert.ok(log.includes('rename'));
325
+ const result = migrateDotPlanningToRoot(dir);
326
+
327
+ assert.ok(result.commitHash, 'commitHash should be non-empty');
328
+ assert.strictEqual(typeof result.commitHash, 'string');
329
+ const log = execSync('git log --oneline -1', { cwd: dir, encoding: 'utf-8' });
330
+ assert.ok(log.includes('chore: migrate .planning/ to root layout'), 'Commit message should match');
328
331
  });
332
+ });
329
333
 
330
- it('preserves todos/ at product level (not moved to project)', () => {
331
- writeFile(tmpDir, '.planning/todos/pending/task1.md', '# Todo 1\n');
332
- createV1Install(tmpDir, 'Test App');
333
- migrateV1ToV2(tmpDir, 'test-app');
334
+ // ─── Suite 9: Non-git fallback (MIG-09) ──────────────────────────────────────
334
335
 
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
- });
336
+ describe('non-git fallback (MIG-09)', () => {
337
+ let dir;
338
338
 
339
- it('moves quick/ directory if it exists', () => {
340
- writeFile(tmpDir, '.planning/quick/fix1.md', '# Quick Fix\n');
341
- createV1Install(tmpDir, 'Test App');
342
- migrateV1ToV2(tmpDir, 'test-app');
339
+ beforeEach(() => {
340
+ dir = makePlainDir();
341
+ });
343
342
 
344
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'quick', 'fix1.md')));
345
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'quick')));
343
+ afterEach(() => {
344
+ resetPaths();
345
+ cleanupDir(dir);
346
346
  });
347
347
 
348
- it('moves debug/ directory if it exists', () => {
349
- writeFile(tmpDir, '.planning/debug/issue1.md', '# Debug Session\n');
350
- createV1Install(tmpDir, 'Test App');
351
- migrateV1ToV2(tmpDir, 'test-app');
348
+ it('moves files using fs.rename when not in a git repo', () => {
349
+ writeFile(dir, '.planning/ROADMAP.md', '# Roadmap\n');
350
+ writeFile(dir, '.planning/STATE.md', '# State\n');
352
351
 
353
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'projects', 'test-app', 'debug', 'issue1.md')));
354
- assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'debug')));
352
+ const result = migrateDotPlanningToRoot(dir);
353
+
354
+ assert.strictEqual(result.migrated, true);
355
+ assert.strictEqual(result.commitHash, null, 'commitHash should be null for non-git');
356
+ assert.ok(fs.existsSync(path.join(dir, 'ROADMAP.md')), 'ROADMAP.md should exist at root');
357
+ assert.ok(fs.existsSync(path.join(dir, 'STATE.md')), 'STATE.md should exist at root');
358
+ assert.ok(!fs.existsSync(path.join(dir, '.planning')), '.planning/ should be gone');
355
359
  });
356
360
  });
357
361
 
358
- // ─── collectMigrationMoves ──────────────────────────────────────────────────
362
+ // ─── Suite 10: Dry-run (OPT-02) ──────────────────────────────────────────────
359
363
 
360
- describe('collectMigrationMoves', () => {
361
- let tmpDir;
362
- beforeEach(() => { tmpDir = createTempDir(); });
363
- afterEach(() => { cleanupDir(tmpDir); });
364
+ describe('dry-run (OPT-02)', () => {
365
+ let dir;
364
366
 
365
- it('returns correct move entries for a full v1 install', () => {
366
- // Create a full v1 install (without git — just files on disk)
367
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Test\n');
368
- writeFile(tmpDir, '.planning/REQUIREMENTS.md', '# Requirements\n');
369
- writeFile(tmpDir, '.planning/ROADMAP.md', '# Roadmap\n');
370
- writeFile(tmpDir, '.planning/STATE.md', '# State\n');
371
- writeFile(tmpDir, '.planning/phases/01-setup/01-01-PLAN.md', '---\n---\n');
372
- writeFile(tmpDir, '.planning/research/notes.md', '# Notes\n');
367
+ beforeEach(() => {
368
+ dir = makeGitDir();
369
+ });
373
370
 
374
- const { moves, targetDir } = collectMigrationMoves(tmpDir, 'test-app');
371
+ afterEach(() => {
372
+ resetPaths();
373
+ cleanupDir(dir);
374
+ });
375
375
 
376
- // Should have 2 directories + 4 files = 6 moves
377
- assert.strictEqual(moves.length, 6);
378
- assert.ok(targetDir.endsWith(path.join('.planning', 'projects', 'test-app')));
376
+ it('dry-run returns actions without modifying files', () => {
377
+ writeFile(dir, '.planning/ROADMAP.md', '# Roadmap\n');
378
+ execSync('git add .planning && git commit -m "add planning"', { cwd: dir, stdio: 'pipe' });
379
379
 
380
- // Directories should come first
381
- const dirMoves = moves.filter(m => m.isDir);
382
- const fileMoves = moves.filter(m => !m.isDir);
383
- assert.strictEqual(dirMoves.length, 2); // phases, research
384
- assert.strictEqual(fileMoves.length, 4); // PROJECT.md, REQUIREMENTS.md, ROADMAP.md, STATE.md
380
+ const result = migrateDotPlanningToRoot(dir, { dryRun: true });
385
381
 
386
- // First moves should be dirs
387
- assert.strictEqual(moves[0].isDir, true);
388
- assert.strictEqual(moves[1].isDir, true);
382
+ assert.strictEqual(result.dryRun, true);
383
+ assert.strictEqual(result.migrated, false, 'migrated should be false in dry-run');
384
+ assert.ok(result.actions.length > 0, 'Should have actions');
385
+ assert.strictEqual(result.filesMoved, 1, 'filesMoved should count planned moves');
386
+ assert.strictEqual(result.commitHash, null, 'No commit in dry-run');
387
+ // Files should NOT have moved
388
+ assert.ok(fs.existsSync(path.join(dir, '.planning/ROADMAP.md')), '.planning/ROADMAP.md should still exist');
389
+ assert.ok(!fs.existsSync(path.join(dir, 'ROADMAP.md')), 'ROADMAP.md should not exist at root yet');
389
390
  });
391
+ });
390
392
 
391
- it('omits entries for non-existent items (partial v1 install)', () => {
392
- // Create only PROJECT.md and STATE.md
393
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Partial\n');
394
- writeFile(tmpDir, '.planning/STATE.md', '# State\n');
393
+ // ─── CLI Helper ──────────────────────────────────────────────────────────────
395
394
 
396
- const { moves } = collectMigrationMoves(tmpDir, 'partial');
395
+ const { spawnSync } = require('child_process');
397
396
 
398
- // Should have only 2 moves (the 2 files that exist)
399
- assert.strictEqual(moves.length, 2);
400
- assert.ok(moves.every(m => !m.isDir));
401
- const sources = moves.map(m => path.basename(m.relSource));
402
- assert.ok(sources.includes('PROJECT.md'));
403
- assert.ok(sources.includes('STATE.md'));
404
- });
397
+ /**
398
+ * Spawns dgs-tools.cjs in a subprocess with given args.
399
+ * Uses spawnSync to capture both stdout and stderr on success.
400
+ *
401
+ * @param {string} cwd - Working directory for the subprocess
402
+ * @param {string} args - CLI arguments string
403
+ * @returns {{ exitCode: number, stdout: string, stderr: string }}
404
+ */
405
+ function callDgsTools(cwd, args) {
406
+ const dgsToolsPath = path.resolve(__dirname, '..', 'dgs-tools.cjs');
407
+ const result = spawnSync('node', [dgsToolsPath, ...args.split(' ')], {
408
+ cwd,
409
+ encoding: 'utf-8',
410
+ timeout: 10000,
411
+ });
412
+ return {
413
+ exitCode: result.status ?? 1,
414
+ stdout: (result.stdout ?? '').toString(),
415
+ stderr: (result.stderr ?? '').toString(),
416
+ };
417
+ }
405
418
 
406
- it('sets isDir flag correctly for directories and files', () => {
407
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Test\n');
408
- writeFile(tmpDir, '.planning/phases/01-setup/01-01-PLAN.md', '---\n---\n');
409
- writeFile(tmpDir, '.planning/research/notes.md', '# Notes\n');
419
+ // ─── Suite 11: CLI: migrate command (MIG-08, OPT-02) ─────────────────────────
410
420
 
411
- const { moves } = collectMigrationMoves(tmpDir, 'test');
421
+ describe('CLI: migrate command (MIG-08, OPT-02)', () => {
422
+ let dir;
412
423
 
413
- const phasesMove = moves.find(m => m.relSource.endsWith('phases'));
414
- const researchMove = moves.find(m => m.relSource.endsWith('research'));
415
- const projectMove = moves.find(m => m.relSource.endsWith('PROJECT.md'));
424
+ beforeEach(() => {
425
+ dir = makeGitDir();
426
+ });
416
427
 
417
- assert.strictEqual(phasesMove.isDir, true);
418
- assert.strictEqual(researchMove.isDir, true);
419
- assert.strictEqual(projectMove.isDir, false);
428
+ afterEach(() => {
429
+ resetPaths();
430
+ cleanupDir(dir);
420
431
  });
421
- });
422
432
 
423
- // ─── validateMoves ──────────────────────────────────────────────────────────
433
+ it('migrate --layout root migrates .planning/ directory', () => {
434
+ writeFile(dir, '.planning/ROADMAP.md', '# Roadmap\n');
435
+ writeFile(dir, 'PROJECTS.md', '# Projects\n');
436
+ writeFile(dir, 'REPOS.md', '# Repos\n');
437
+ execSync('git add . && git commit -m "setup"', { cwd: dir, stdio: 'pipe' });
424
438
 
425
- describe('validateMoves', () => {
426
- let tmpDir;
427
- beforeEach(() => { tmpDir = createTempDir(); });
428
- afterEach(() => { cleanupDir(tmpDir); });
439
+ const result = callDgsTools(dir, 'migrate --layout root');
429
440
 
430
- it('returns valid when all sources exist and no targets exist', () => {
431
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Test\n');
432
- writeFile(tmpDir, '.planning/STATE.md', '# State\n');
441
+ assert.strictEqual(result.exitCode, 0, 'Should exit with code 0, stderr: ' + result.stderr);
442
+ assert.ok(fs.existsSync(path.join(dir, 'ROADMAP.md')), 'ROADMAP.md should exist at root');
443
+ assert.ok(!fs.existsSync(path.join(dir, '.planning')), '.planning/ should be gone');
444
+ });
433
445
 
434
- const moves = [
435
- { relSource: '.planning/PROJECT.md', relTarget: '.planning/test/PROJECT.md', isDir: false },
436
- { relSource: '.planning/STATE.md', relTarget: '.planning/test/STATE.md', isDir: false },
437
- ];
446
+ it('migrate --layout root --dry-run shows actions without moving files', () => {
447
+ writeFile(dir, '.planning/STATE.md', '# State\n');
448
+ writeFile(dir, 'PROJECTS.md', '# Projects\n');
449
+ writeFile(dir, 'REPOS.md', '# Repos\n');
450
+ execSync('git add . && git commit -m "setup"', { cwd: dir, stdio: 'pipe' });
438
451
 
439
- const result = validateMoves(tmpDir, moves);
440
- assert.strictEqual(result.valid, true);
441
- assert.strictEqual(result.errors.length, 0);
452
+ const result = callDgsTools(dir, 'migrate --layout root --dry-run');
453
+
454
+ assert.strictEqual(result.exitCode, 0, 'Should exit with code 0, stderr: ' + result.stderr);
455
+ assert.ok(fs.existsSync(path.join(dir, '.planning/STATE.md')), '.planning/STATE.md should still exist');
456
+ assert.ok(result.stderr.includes('Dry run') || result.stderr.includes('would be made'),
457
+ 'stderr should mention dry run, got: ' + result.stderr);
442
458
  });
443
459
 
444
- it('returns invalid when target already exists', () => {
445
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Test\n');
446
- // Pre-create a target that would collide
447
- writeFile(tmpDir, '.planning/test/PROJECT.md', '# Already here\n');
460
+ it('migrate --layout root --raw returns JSON', () => {
461
+ writeFile(dir, '.planning/ROADMAP.md', '# Roadmap\n');
462
+ writeFile(dir, 'PROJECTS.md', '# Projects\n');
463
+ writeFile(dir, 'REPOS.md', '# Repos\n');
464
+ execSync('git add . && git commit -m "setup"', { cwd: dir, stdio: 'pipe' });
448
465
 
449
- const moves = [
450
- { relSource: '.planning/PROJECT.md', relTarget: '.planning/test/PROJECT.md', isDir: false },
451
- ];
466
+ const result = callDgsTools(dir, 'migrate --layout root --raw');
452
467
 
453
- const result = validateMoves(tmpDir, moves);
454
- assert.strictEqual(result.valid, false);
455
- assert.ok(result.errors.length > 0);
456
- assert.ok(result.errors[0].includes('Target already exists'));
468
+ assert.strictEqual(result.exitCode, 0, 'Should exit with code 0, stderr: ' + result.stderr);
469
+ const parsed = JSON.parse(result.stdout);
470
+ assert.strictEqual(parsed.migrated, true, 'JSON should indicate migrated:true');
471
+ assert.ok(typeof parsed.filesMoved === 'number', 'filesMoved should be a number');
472
+ assert.ok(typeof parsed.commitHash === 'string', 'commitHash should be a string');
457
473
  });
458
- });
459
474
 
460
- // ─── Rollback on git mv failure ─────────────────────────────────────────────
475
+ it('migrate without --layout root shows usage error', () => {
476
+ const result = callDgsTools(dir, 'migrate');
461
477
 
462
- describe('migrateV1ToV2 rollback', () => {
463
- let tmpDir;
464
- beforeEach(() => {
465
- tmpDir = createTempDir();
466
- initGitRepo(tmpDir);
478
+ assert.strictEqual(result.exitCode, 1, 'Should exit with code 1');
479
+ assert.ok(result.stderr.includes('Usage'), 'stderr should mention Usage, got: ' + result.stderr);
467
480
  });
468
- afterEach(() => { cleanupDir(tmpDir); });
469
-
470
- it('returns migrated false and rolls back when git mv fails mid-migration', () => {
471
- createV1Install(tmpDir, 'Test App');
481
+ });
472
482
 
473
- // Create a file at the target path for phases/ to force git mv to fail.
474
- // The phases/ dir move will fail because the target .planning/bad-slug/phases
475
- // already exists as a FILE, preventing the directory move.
476
- const targetPhasesPath = path.join(tmpDir, '.planning', 'projects', 'bad-slug', 'phases');
477
- fs.mkdirSync(path.join(tmpDir, '.planning', 'projects', 'bad-slug'), { recursive: true });
478
- fs.writeFileSync(targetPhasesPath, 'blocker file');
479
- execSync('git add .', { cwd: tmpDir, stdio: 'pipe' });
480
- execSync('git commit -m "add blocker"', { cwd: tmpDir, stdio: 'pipe' });
483
+ // ─── Suite 12: CLI: auto-trigger migration ────────────────────────────────────
481
484
 
482
- const result = migrateV1ToV2(tmpDir, 'bad-slug');
485
+ describe('CLI: auto-trigger migration', () => {
486
+ let dir;
483
487
 
484
- // Should report validation failure (target already exists)
485
- assert.strictEqual(result.migrated, false);
486
- assert.ok(result.error);
488
+ beforeEach(() => {
489
+ dir = makeGitDir();
487
490
  });
488
491
 
489
- it('restores original files after rollback from git mv failure', () => {
490
- createV1Install(tmpDir, 'Test App');
492
+ afterEach(() => {
493
+ resetPaths();
494
+ cleanupDir(dir);
495
+ });
491
496
 
492
- // Force a validation failure by creating a conflicting target
493
- const targetProjectPath = path.join(tmpDir, '.planning', 'projects', 'fail-slug', 'PROJECT.md');
494
- fs.mkdirSync(path.join(tmpDir, '.planning', 'projects', 'fail-slug'), { recursive: true });
495
- fs.writeFileSync(targetProjectPath, 'conflict');
496
- execSync('git add .', { cwd: tmpDir, stdio: 'pipe' });
497
- execSync('git commit -m "add conflict"', { cwd: tmpDir, stdio: 'pipe' });
497
+ it('auto-triggers migration and prints banner on normal command', () => {
498
+ writeFile(dir, '.planning/ROADMAP.md', '# Roadmap\n');
499
+ writeFile(dir, 'PROJECTS.md', '# Projects\n');
500
+ writeFile(dir, 'REPOS.md', '# Repos\n');
501
+ writeFile(dir, 'config.json', '{}');
502
+ execSync('git add . && git commit -m "setup"', { cwd: dir, stdio: 'pipe' });
498
503
 
499
- migrateV1ToV2(tmpDir, 'fail-slug');
504
+ const result = callDgsTools(dir, 'current-timestamp');
500
505
 
501
- // Original files should still exist (validation catches it before any moves)
502
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'PROJECT.md')));
503
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'STATE.md')));
504
- assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01-setup', '01-01-PLAN.md')));
506
+ assert.strictEqual(result.exitCode, 0, 'Should exit with code 0, stderr: ' + result.stderr);
507
+ assert.ok(result.stderr.includes('DGS migrated .planning/ to root layout'),
508
+ 'stderr should contain migration banner, got: ' + result.stderr);
509
+ assert.ok(fs.existsSync(path.join(dir, 'ROADMAP.md')), 'ROADMAP.md should exist at root');
510
+ assert.ok(!fs.existsSync(path.join(dir, '.planning')), '.planning/ should be gone');
505
511
  });
506
- });
507
512
 
508
- // ─── Scale test: 50+ files with rename detection ────────────────────────────
513
+ it('auto-trigger is silent when no .planning/ directory', () => {
514
+ writeFile(dir, 'PROJECTS.md', '# Projects\n');
515
+ writeFile(dir, 'REPOS.md', '# Repos\n');
516
+ writeFile(dir, 'config.json', '{}');
517
+ execSync('git add . && git commit -m "setup"', { cwd: dir, stdio: 'pipe' });
509
518
 
510
- describe('migrateV1ToV2 scale test', () => {
511
- let tmpDir;
512
- beforeEach(() => {
513
- tmpDir = createTempDir();
514
- initGitRepo(tmpDir);
515
- });
516
- afterEach(() => { cleanupDir(tmpDir); });
517
-
518
- it('migrates 60+ phase files and git detects renames with renameLimit=1000', () => {
519
- // Create base v1 install files
520
- writeFile(tmpDir, '.planning/PROJECT.md', '# Project: Scale Test\n\nA large project.\n');
521
- writeFile(tmpDir, '.planning/REQUIREMENTS.md', '# Requirements\n\n- REQ-01\n');
522
- writeFile(tmpDir, '.planning/ROADMAP.md', '# Roadmap\n\n## Phase 1\n');
523
- writeFile(tmpDir, '.planning/STATE.md', '# Project State\n\nPhase: 1\nStatus: Active\nProgress: [░░░░░░░░░░] 0%\n');
524
- writeFile(tmpDir, '.planning/config.json', '{"model_profile":"balanced"}');
525
-
526
- // Create 60 plan files across 10 phases (6 files each)
527
- for (let phase = 1; phase <= 10; phase++) {
528
- const phaseNum = String(phase).padStart(2, '0');
529
- const phaseName = phaseNum + '-phase-' + phase;
530
- for (let plan = 1; plan <= 6; plan++) {
531
- const planNum = String(plan).padStart(2, '0');
532
- const filename = phaseNum + '-' + planNum + '-PLAN.md';
533
- writeFile(
534
- tmpDir,
535
- '.planning/phases/' + phaseName + '/' + filename,
536
- '---\nphase: ' + phaseNum + '\nplan: ' + planNum + '\n---\n\n# Plan ' + phaseNum + '-' + planNum + '\n\nContent for plan ' + phase + '.' + plan + '\n'
537
- );
538
- }
539
- }
540
-
541
- // Commit the v1 install
542
- execSync('git add .', { cwd: tmpDir, stdio: 'pipe' });
543
- execSync('git commit -m "v1 install with 60 plan files"', { cwd: tmpDir, stdio: 'pipe' });
544
-
545
- // Run migration
546
- const result = migrateV1ToV2(tmpDir, 'scale-test');
547
- assert.strictEqual(result.migrated, true);
519
+ const result = callDgsTools(dir, 'current-timestamp');
548
520
 
549
- // Verify all 60 plan files exist in new location
550
- let foundCount = 0;
551
- for (let phase = 1; phase <= 10; phase++) {
552
- const phaseNum = String(phase).padStart(2, '0');
553
- const phaseName = phaseNum + '-phase-' + phase;
554
- for (let plan = 1; plan <= 6; plan++) {
555
- const planNum = String(plan).padStart(2, '0');
556
- const filename = phaseNum + '-' + planNum + '-PLAN.md';
557
- const filePath = path.join(tmpDir, '.planning', 'projects', 'scale-test', 'phases', phaseName, filename);
558
- if (fs.existsSync(filePath)) {
559
- foundCount++;
560
- }
561
- }
562
- }
563
- assert.strictEqual(foundCount, 60, 'All 60 plan files should be in new location');
564
-
565
- // Verify git detects renames (not delete+add)
566
- // Look at the migration commit (HEAD~1 is the move commit, HEAD is the markers commit)
567
- const renameLog = execSync(
568
- 'git log --diff-filter=R --summary --format="" HEAD~1',
569
- { cwd: tmpDir, encoding: 'utf-8' }
570
- ).trim();
571
- assert.ok(renameLog.includes('rename'), 'Git should detect renames in the migration commit');
572
-
573
- // Count rename entries — should be at least 50 (60 plan files + 4 markdown files + directories)
574
- const renameLines = renameLog.split('\n').filter(line => line.includes('rename'));
575
- assert.ok(
576
- renameLines.length >= 50,
577
- 'Should detect at least 50 renames, got ' + renameLines.length
578
- );
521
+ assert.strictEqual(result.exitCode, 0, 'Should exit with code 0, stderr: ' + result.stderr);
522
+ assert.ok(!result.stderr.includes('migrated'),
523
+ 'stderr should NOT contain migration message, got: ' + result.stderr);
579
524
  });
580
525
  });