@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,352 +1,327 @@
1
1
  /**
2
- * Migration v1-to-v2 file structure migration
2
+ * Migration -- .planning/ to root layout migration engine
3
3
  *
4
- * Detects v1 DGS installs (single project at .planning/ root) and migrates
5
- * them to v2 multi-project structure (.planning/projects/<slug>/) using git mv
6
- * for history preservation.
7
- *
8
- * Entry point: cmdReposInitProduct detects v1 and offers migration.
9
- * The workflow handles the user prompt; this module handles the moves.
4
+ * Provides migrateDotPlanningToRoot() which moves all planning files from
5
+ * .planning/ to repo root. Handles conflict detection (abort on different
6
+ * content, resolve identical content), partial migration, .gitignore
7
+ * rewriting, config.local.json cleanup, dgs.config.json rename, git mv /
8
+ * fs.rename duality, atomic commit, rollback on failure, and dry-run
9
+ * data collection.
10
10
  */
11
11
 
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
- const { safeReadFile, execGit, isV2Install, generateSlugInternal, output, error } = require('./core.cjs');
15
- const { writeReposMd } = require('./repos.cjs');
16
- const { regenerateProjectsMd } = require('./projects.cjs');
17
- const { writeConfigField } = require('./config.cjs');
18
- const { getPlanningRoot, PROJECTS_DIR } = require('./paths.cjs');
14
+ const { execGit, safeReadFile, error } = require('./core.cjs');
19
15
 
20
- // ─── v1 Detection ────────────────────────────────────────────────────────────
16
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
21
17
 
22
18
  /**
23
- * Detect whether cwd is a v1 DGS install.
24
- *
25
- * v1 marker: .planning/PROJECT.md exists at root WITHOUT PROJECTS.md or REPOS.md.
26
- * If v1 detected, extracts project name from heading and generates slug.
19
+ * Recursively collects all file paths under `dir`, returning paths
20
+ * relative to `base`. Skips .git directories.
27
21
  *
28
- * @param {string} cwd - Working directory
29
- * @returns {{ isV1: boolean, projectName?: string, slug?: string }}
22
+ * @param {string} dir - Directory to walk
23
+ * @param {string} base - Base directory for relative path calculation
24
+ * @returns {string[]} Array of relative file paths
30
25
  */
31
- function detectV1Install(cwd) {
32
- // v1 always uses .planning/ — intentionally hardcoded for v1 source detection
33
- const projectMdPath = path.join(cwd, '.planning', 'PROJECT.md');
34
- const projectsMdPath = path.join(cwd, '.planning', 'PROJECTS.md');
35
- const reposMdPath = path.join(cwd, '.planning', 'REPOS.md');
36
-
37
- // Check if PROJECT.md exists
38
- const projectContent = safeReadFile(projectMdPath);
39
- if (!projectContent) {
40
- return { isV1: false };
41
- }
42
-
43
- // Check v2 markers — if either exists, it's already v2 (or partially migrated)
44
- const projectsMdContent = safeReadFile(projectsMdPath);
45
- if (projectsMdContent && projectsMdContent.startsWith('# Projects')) {
46
- return { isV1: false };
47
- }
48
-
49
- const reposMdContent = safeReadFile(reposMdPath);
50
- if (reposMdContent && reposMdContent.startsWith('# Repos')) {
51
- return { isV1: false };
52
- }
53
-
54
- // It's v1 — extract project name from heading
55
- let projectName = null;
56
-
57
- // Try "# Project: Name" format first
58
- const colonMatch = projectContent.match(/^#\s+Project:\s+(.+)$/m);
59
- if (colonMatch) {
60
- projectName = colonMatch[1].trim();
61
- }
62
-
63
- // Try "# Project Name" format (no colon)
64
- if (!projectName) {
65
- const headingMatch = projectContent.match(/^#\s+(.+)$/m);
66
- if (headingMatch) {
67
- projectName = headingMatch[1].trim();
26
+ function collectFiles(dir, base) {
27
+ const results = [];
28
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
29
+ for (const entry of entries) {
30
+ if (entry.name === '.git') continue;
31
+ const fullPath = path.join(dir, entry.name);
32
+ if (entry.isDirectory()) {
33
+ results.push(...collectFiles(fullPath, base));
34
+ } else {
35
+ results.push(path.relative(base, fullPath));
68
36
  }
69
37
  }
70
-
71
- // Fall back to directory name
72
- if (!projectName) {
73
- projectName = path.basename(cwd);
74
- }
75
-
76
- const slug = generateSlugInternal(projectName) || path.basename(cwd).toLowerCase().replace(/[^a-z0-9]+/g, '-');
77
-
78
- return { isV1: true, projectName, slug };
38
+ return results;
79
39
  }
80
40
 
81
- // ─── Precondition Validation ─────────────────────────────────────────────────
82
-
83
41
  /**
84
- * Validate that migration can proceed.
85
- *
86
- * Checks:
87
- * 1. Not already a v2 install
88
- * 2. .planning/PROJECT.md exists (v1 marker)
89
- * 3. Git working tree is clean (no uncommitted changes)
42
+ * Returns true if `cwd` is inside a git working tree.
90
43
  *
91
44
  * @param {string} cwd - Working directory
92
- * @returns {{ valid: boolean, reason?: string }}
45
+ * @returns {boolean}
93
46
  */
94
- function validateMigrationPreconditions(cwd) {
95
- // Check if already v2
96
- if (isV2Install(cwd)) {
97
- return { valid: false, reason: 'Already a v2 install. Migration not needed.' };
98
- }
99
-
100
- // Check if v1 marker exists — v1 always uses .planning/
101
- const projectMdPath = path.join(cwd, '.planning', 'PROJECT.md');
102
- if (!safeReadFile(projectMdPath)) {
103
- return { valid: false, reason: 'No .planning/PROJECT.md found. Not a v1 install.' };
104
- }
105
-
106
- // Check git working tree is clean
107
- const gitStatus = execGit(cwd, ['status', '--porcelain']);
108
- if (gitStatus.exitCode !== 0) {
109
- return { valid: false, reason: 'Not a git repository or git command failed.' };
110
- }
111
- if (gitStatus.stdout.trim().length > 0) {
112
- return { valid: false, reason: 'Working tree has uncommitted changes. Please commit or stash before migration.' };
113
- }
114
-
115
- return { valid: true };
47
+ function isGitRepo(cwd) {
48
+ const result = execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
49
+ return result.exitCode === 0;
116
50
  }
117
51
 
118
- // ─── Backup Tag ──────────────────────────────────────────────────────────────
119
-
120
52
  /**
121
- * Create a pre-migration backup tag for safe rollback.
53
+ * Returns true if the git working tree at `cwd` has no uncommitted changes.
122
54
  *
123
- * @param {string} cwd - Working directory
124
- * @returns {{ tagged: boolean, reason?: string }}
55
+ * @param {string} cwd - Working directory (should be repo root)
56
+ * @returns {boolean}
125
57
  */
126
- function createBackupTag(cwd) {
127
- const result = execGit(cwd, ['tag', 'dgs-pre-v2-migration']);
128
- if (result.exitCode !== 0) {
129
- // Check if tag already exists or no git
130
- if (result.stderr.includes('already exists')) {
131
- return { tagged: false, reason: 'Tag dgs-pre-v2-migration already exists.' };
132
- }
133
- return { tagged: false, reason: result.stderr || 'Failed to create tag.' };
134
- }
135
- return { tagged: true };
58
+ function isCleanWorkingTree(cwd) {
59
+ const result = execGit(cwd, ['status', '--porcelain']);
60
+ return result.stdout === '';
136
61
  }
137
62
 
138
- // ─── Migration Planning ─────────────────────────────────────────────────────
139
-
140
- // Directories to move (project-level, order: directories first)
141
- const DIRS_TO_MOVE = ['phases', 'research', 'quick', 'debug'];
142
-
143
- // Files to move (project-level)
144
- const FILES_TO_MOVE = ['PROJECT.md', 'REQUIREMENTS.md', 'ROADMAP.md', 'STATE.md'];
63
+ // ─── Main Migration Function ──────────────────────────────────────────────────
145
64
 
146
65
  /**
147
- * Collect all migration move operations as a plan (pure computation, no side effects).
148
- *
149
- * Returns an array of { relSource, relTarget, isDir } for all items that exist
150
- * and need moving. Directories are listed before files to maximize git rename detection.
66
+ * Migrates all planning files from .planning/ to repo root.
151
67
  *
152
- * @param {string} cwd - Working directory
153
- * @param {string} slug - Project slug for subfolder name
154
- * @returns {{ moves: Array<{relSource: string, relTarget: string, isDir: boolean}>, targetDir: string }}
68
+ * @param {string} [cwd] - Working directory (defaults to process.cwd())
69
+ * @param {Object} [options] - Migration options
70
+ * @param {boolean} [options.dryRun=false] - If true, return actions without modifying files
71
+ * @returns {{
72
+ * migrated: boolean,
73
+ * filesMoved: number,
74
+ * identicalResolved: number,
75
+ * commitHash: string|null,
76
+ * dryRun: boolean,
77
+ * actions: Array<{type: string, from: string, to: string}>
78
+ * }}
155
79
  */
156
- function collectMigrationMoves(cwd, slug) {
157
- // v1 always uses .planning/ — migration source is always the .planning directory
158
- const planningDir = path.join(cwd, '.planning');
159
- const targetDir = path.join(planningDir, PROJECTS_DIR, slug);
160
- const moves = [];
80
+ function migrateDotPlanningToRoot(cwd, options) {
81
+ const opts = options || {};
82
+ const dryRun = opts.dryRun || false;
83
+
84
+ // 1. Resolve paths
85
+ const inGit = isGitRepo(cwd || process.cwd());
86
+ let root;
87
+ if (inGit) {
88
+ root = execGit(cwd || process.cwd(), ['rev-parse', '--show-toplevel']).stdout;
89
+ } else {
90
+ root = path.resolve(cwd || process.cwd());
91
+ }
161
92
 
162
- // Directories first
163
- for (const dir of DIRS_TO_MOVE) {
164
- const sourcePath = path.join(planningDir, dir);
165
- if (fs.existsSync(sourcePath)) {
166
- moves.push({
167
- relSource: path.join('.planning', dir),
168
- relTarget: path.join('.planning', PROJECTS_DIR, slug, dir),
169
- isDir: true,
170
- });
171
- }
93
+ const dotPlanning = path.join(root, '.planning');
94
+
95
+ // 2. Idempotency check (MIG-07)
96
+ if (!fs.existsSync(dotPlanning) || !fs.statSync(dotPlanning).isDirectory()) {
97
+ return {
98
+ migrated: false,
99
+ filesMoved: 0,
100
+ identicalResolved: 0,
101
+ commitHash: null,
102
+ dryRun: dryRun,
103
+ actions: [],
104
+ };
172
105
  }
173
106
 
174
- // Then files
175
- for (const file of FILES_TO_MOVE) {
176
- const sourcePath = path.join(planningDir, file);
177
- if (fs.existsSync(sourcePath)) {
178
- moves.push({
179
- relSource: path.join('.planning', file),
180
- relTarget: path.join('.planning', PROJECTS_DIR, slug, file),
181
- isDir: false,
182
- });
183
- }
107
+ // 3. Clean working tree check (git only)
108
+ if (inGit && !isCleanWorkingTree(root)) {
109
+ error('Migration requires a clean working tree. Commit or stash your changes first, then re-run.');
184
110
  }
185
111
 
186
- return { moves, targetDir };
187
- }
112
+ // 4. Collect files in .planning/
113
+ const planningFiles = collectFiles(dotPlanning, dotPlanning);
188
114
 
189
- /**
190
- * Validate that all planned moves can succeed.
191
- *
192
- * Checks:
193
- * - Each source exists on disk
194
- * - Each target does NOT already exist (would cause git mv to fail)
195
- *
196
- * @param {string} cwd - Working directory
197
- * @param {Array<{relSource: string, relTarget: string, isDir: boolean}>} moves - Planned moves
198
- * @returns {{ valid: boolean, errors: string[] }}
199
- */
200
- function validateMoves(cwd, moves) {
201
- const errors = [];
115
+ // 5. Pre-flight conflict scan (MIG-02, MIG-03, MIG-04)
116
+ const moves = [];
117
+ const identical = [];
118
+ const conflicts = [];
202
119
 
203
- for (const move of moves) {
204
- const absSource = path.join(cwd, move.relSource);
205
- if (!fs.existsSync(absSource)) {
206
- errors.push('Source does not exist: ' + move.relSource);
207
- }
120
+ for (const relPath of planningFiles) {
121
+ const srcPath = path.join(dotPlanning, relPath);
208
122
 
209
- const absTarget = path.join(cwd, move.relTarget);
210
- if (fs.existsSync(absTarget)) {
211
- errors.push('Target already exists: ' + move.relTarget);
123
+ // 6. Handle dgs.config.json rename (MIG-10)
124
+ let destRelPath = relPath;
125
+ if (relPath === 'dgs.config.json') {
126
+ destRelPath = 'config.json';
212
127
  }
213
- }
214
128
 
215
- return { valid: errors.length === 0, errors };
216
- }
129
+ const destPath = path.join(root, destRelPath);
217
130
 
218
- // ─── Core Migration ──────────────────────────────────────────────────────────
131
+ if (fs.existsSync(destPath)) {
132
+ const srcContent = fs.readFileSync(srcPath, 'utf-8');
133
+ const destContent = fs.readFileSync(destPath, 'utf-8');
134
+ if (srcContent === destContent) {
135
+ identical.push({ relPath, destRelPath, srcPath, destPath });
136
+ } else {
137
+ conflicts.push({ relPath, destRelPath, srcPath, destPath });
138
+ }
139
+ } else {
140
+ moves.push({ relPath, destRelPath, srcPath, destPath });
141
+ }
142
+ }
219
143
 
220
- /**
221
- * Perform the v1-to-v2 migration.
222
- *
223
- * Uses a dry-run-then-execute pattern:
224
- * 1. Collect planned moves (collectMigrationMoves)
225
- * 2. Validate all moves can succeed (validateMoves)
226
- * 3. Execute moves with rollback on failure
227
- * 4. Commit with diff.renameLimit=1000 for large projects
228
- * 5. Create v2 marker files in a separate commit
229
- *
230
- * @param {string} cwd - Working directory
231
- * @param {string} slug - Project slug for subfolder name
232
- * @returns {{ migrated: boolean, slug: string, files_moved: string[], repos_created: boolean, project_created: boolean, error?: string, rollback?: boolean }}
233
- */
234
- function migrateV1ToV2(cwd, slug) {
235
- // v1 always uses .planning/ — migration operates on the .planning directory
236
- const planningDir = path.join(cwd, '.planning');
144
+ // Also check if dgs.config.json exists at root (not in .planning) and needs rename
145
+ const rootDgsConfig = path.join(root, 'dgs.config.json');
146
+ const rootConfig = path.join(root, 'config.json');
147
+ if (fs.existsSync(rootDgsConfig) && !fs.existsSync(rootConfig)) {
148
+ // Only if not already handled via .planning/dgs.config.json
149
+ const alreadyHandled = moves.some(m => m.relPath === 'dgs.config.json') ||
150
+ identical.some(m => m.relPath === 'dgs.config.json') ||
151
+ conflicts.some(m => m.relPath === 'dgs.config.json');
152
+ if (!alreadyHandled) {
153
+ moves.push({
154
+ relPath: 'dgs.config.json',
155
+ destRelPath: 'config.json',
156
+ srcPath: rootDgsConfig,
157
+ destPath: rootConfig,
158
+ isRootRename: true,
159
+ });
160
+ }
161
+ }
237
162
 
238
- // Step 1: Collect planned moves
239
- const { moves, targetDir } = collectMigrationMoves(cwd, slug);
163
+ // 7. Abort on conflicts (MIG-02)
164
+ if (conflicts.length > 0) {
165
+ const fileList = conflicts.map(c => ' - ' + c.relPath + ' (.planning/' + c.relPath + ' vs ./' + c.destRelPath + ')').join('\n');
166
+ error(
167
+ 'Migration aborted: ' + conflicts.length + ' file(s) have different content in .planning/ and root.\n' +
168
+ 'Resolve these conflicts manually, then re-run:\n' +
169
+ fileList
170
+ );
171
+ }
240
172
 
241
- // Create the target project directory (needed before validation and git mv)
242
- fs.mkdirSync(targetDir, { recursive: true });
173
+ // 8. Build actions array
174
+ const actions = [];
175
+ for (const m of moves) {
176
+ actions.push({ type: 'move', from: m.isRootRename ? 'dgs.config.json' : '.planning/' + m.relPath, to: m.destRelPath });
177
+ }
178
+ for (const i of identical) {
179
+ actions.push({ type: 'identical', from: '.planning/' + i.relPath, to: i.destRelPath });
180
+ }
243
181
 
244
- // Step 2: Validate all moves
245
- const validation = validateMoves(cwd, moves);
246
- if (!validation.valid) {
247
- // Clean up created target directory
248
- try { fs.rmdirSync(targetDir); } catch (_e) { /* ignore */ }
182
+ // 9. Dry-run exit (OPT-02)
183
+ if (dryRun) {
249
184
  return {
250
185
  migrated: false,
251
- slug,
252
- files_moved: [],
253
- repos_created: false,
254
- project_created: false,
255
- error: 'Validation failed: ' + validation.errors.join('; '),
186
+ filesMoved: moves.length,
187
+ identicalResolved: identical.length,
188
+ commitHash: null,
189
+ dryRun: true,
190
+ actions,
256
191
  };
257
192
  }
258
193
 
259
- // Step 3: Execute moves with rollback on failure
260
- const filesMoved = [];
261
-
262
- try {
263
- for (const move of moves) {
264
- const result = execGit(cwd, ['mv', move.relSource, move.relTarget]);
265
- if (result.exitCode !== 0) {
266
- throw new Error('git mv failed for ' + move.relSource + ': ' + result.stderr);
194
+ // 10. Execute file moves
195
+ for (const m of moves) {
196
+ if (m.isRootRename) {
197
+ // Root-level dgs.config.json -> config.json rename
198
+ if (inGit) {
199
+ execGit(root, ['mv', 'dgs.config.json', 'config.json']);
200
+ } else {
201
+ fs.renameSync(m.srcPath, m.destPath);
202
+ }
203
+ } else {
204
+ // .planning/ file -> root
205
+ fs.mkdirSync(path.dirname(m.destPath), { recursive: true });
206
+ if (inGit) {
207
+ execGit(root, ['-c', 'diff.renameLimit=1000', 'mv', '.planning/' + m.relPath, m.destRelPath]);
208
+ } else {
209
+ fs.renameSync(m.srcPath, m.destPath);
267
210
  }
268
- filesMoved.push(move.isDir ? path.basename(move.relSource) + '/' : path.basename(move.relSource));
269
211
  }
270
- } catch (err) {
271
- // Rollback: restore all files to pre-move state
272
- execGit(cwd, ['checkout', 'HEAD', '--', '.']);
273
- execGit(cwd, ['clean', '-fd', path.join('.planning', PROJECTS_DIR, slug)]);
274
- return {
275
- migrated: false,
276
- slug,
277
- files_moved: [],
278
- repos_created: false,
279
- project_created: false,
280
- error: 'Migration failed: ' + err.message,
281
- rollback: true,
282
- };
283
212
  }
284
213
 
285
- // Step 4: Commit the moves (git mv already stages files, no git add -A needed)
286
- // Use diff.renameLimit=1000 to handle projects with many files
287
- execGit(cwd, ['-c', 'diff.renameLimit=1000', 'commit', '-m', 'chore: migrate to DGS v2 multi-project structure']);
214
+ // 11. Resolve identical conflicts (MIG-03)
215
+ for (const i of identical) {
216
+ if (inGit) {
217
+ execGit(root, ['rm', '-f', '.planning/' + i.relPath]);
218
+ } else {
219
+ fs.unlinkSync(i.srcPath);
220
+ }
221
+ }
288
222
 
289
- // Step 5: Post-migration setup — Create REPOS.md
290
- const dirName = path.basename(cwd);
291
- writeReposMd(cwd, [{ name: dirName, path: '.', url: '', description: '' }]);
223
+ // 12. Rewrite .gitignore (MIG-05)
224
+ const gitignorePath = path.join(root, '.gitignore');
225
+ if (fs.existsSync(gitignorePath)) {
226
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
227
+ const rewritten = content
228
+ .split('\n')
229
+ .map(line => {
230
+ // Handle negation patterns: !.planning/X -> !X
231
+ if (line.startsWith('!.planning/')) {
232
+ return '!' + line.slice('!.planning/'.length);
233
+ }
234
+ // Handle normal patterns: .planning/X -> X
235
+ if (line.startsWith('.planning/')) {
236
+ return line.slice('.planning/'.length);
237
+ }
238
+ return line;
239
+ })
240
+ .join('\n');
241
+ fs.writeFileSync(gitignorePath, rewritten);
242
+ }
292
243
 
293
- // Post-migration setup: Set current_project in config.json
294
- writeConfigField(cwd, 'current_project', slug);
244
+ // 13. Clean config.local.json (MIG-06)
245
+ const configLocalPath = path.join(root, 'config.local.json');
246
+ if (fs.existsSync(configLocalPath)) {
247
+ try {
248
+ const configContent = fs.readFileSync(configLocalPath, 'utf-8');
249
+ const configObj = JSON.parse(configContent);
250
+ if ('planningRoot' in configObj) {
251
+ delete configObj.planningRoot;
252
+ fs.writeFileSync(configLocalPath, JSON.stringify(configObj, null, 2) + '\n');
253
+ }
254
+ } catch {
255
+ // If config.local.json is not valid JSON, skip cleanup
256
+ }
257
+ }
295
258
 
296
- // Post-migration setup: Create PROJECTS.md from migrated project state
297
- regenerateProjectsMd(cwd);
259
+ // 14. Remove empty .planning/ directory
260
+ if (fs.existsSync(dotPlanning)) {
261
+ fs.rmSync(dotPlanning, { recursive: true, force: true });
262
+ }
298
263
 
299
- // Commit post-migration setup (new files need git add -A)
300
- execGit(cwd, ['add', '-A']);
301
- execGit(cwd, ['commit', '-m', 'chore: create v2 marker files after migration']);
264
+ // 15. Git commit (MIG-11)
265
+ let commitHash = null;
266
+ if (inGit) {
267
+ execGit(root, ['add', '-A']);
268
+ const commitResult = execGit(root, ['commit', '-m', 'chore: migrate .planning/ to root layout']);
269
+ if (commitResult.exitCode !== 0) {
270
+ // Rollback (OPT-03): restore from git on commit failure
271
+ execGit(root, ['checkout', 'HEAD', '--', '.']);
272
+ error('Migration commit failed. Original .planning/ layout restored. Error: ' + commitResult.stderr);
273
+ }
274
+ const hashResult = execGit(root, ['rev-parse', '--short', 'HEAD']);
275
+ commitHash = hashResult.stdout;
276
+ }
302
277
 
278
+ // 16. Return result
303
279
  return {
304
280
  migrated: true,
305
- slug,
306
- files_moved: filesMoved,
307
- repos_created: true,
308
- project_created: true,
281
+ filesMoved: moves.length,
282
+ identicalResolved: identical.length,
283
+ commitHash: inGit ? commitHash : null,
284
+ dryRun: false,
285
+ actions,
309
286
  };
310
287
  }
311
288
 
312
- // ─── CLI Command ─────────────────────────────────────────────────────────────
289
+ // ─── V1 Rejection ────────────────────────────────────────────────────────────
313
290
 
314
291
  /**
315
- * CLI entry point for migration.
316
- * Called from repos migrate <slug> routing.
292
+ * Reject V1 single-repo layout installations.
293
+ * Detects .planning/PROJECT.md without PROJECTS.md or REPOS.md markers.
294
+ * Writes a persistent V1_UNSUPPORTED.md guidance file and exits with code 1.
317
295
  *
318
- * @param {string} cwd - Working directory
319
- * @param {string} slug - Project slug
320
- * @param {boolean} raw - Raw output mode
296
+ * Called from dgs-tools.cjs router -- single call site at CLI entry.
297
+ *
298
+ * @param {string} [cwd] - Working directory (defaults to process.cwd())
321
299
  */
322
- function cmdMigrateV1ToV2(cwd, slug, raw) {
323
- // Validate preconditions
324
- const preconditions = validateMigrationPreconditions(cwd);
325
- if (!preconditions.valid) {
326
- error(preconditions.reason);
300
+ function rejectV1Install(cwd) {
301
+ const resolved = path.resolve(cwd || process.cwd());
302
+ const dotPlanning = path.join(resolved, '.planning');
303
+ const v1Marker = path.join(dotPlanning, 'PROJECT.md');
304
+ if (!fs.existsSync(v1Marker)) return; // No V1 marker, nothing to do
305
+ const hasProjectsMd = fs.existsSync(path.join(resolved, 'PROJECTS.md')) ||
306
+ fs.existsSync(path.join(dotPlanning, 'PROJECTS.md'));
307
+ const hasReposMd = fs.existsSync(path.join(resolved, 'REPOS.md')) ||
308
+ fs.existsSync(path.join(dotPlanning, 'REPOS.md'));
309
+ if (!hasProjectsMd && !hasReposMd) {
310
+ const guidancePath = path.join(resolved, 'V1_UNSUPPORTED.md');
311
+ const guidanceContent = [
312
+ '# V1 Install Detected', '',
313
+ 'This repository has a DGS V1 (single-repo) layout which is no longer supported.', '',
314
+ 'DGS now requires the V2 multi-project layout. To continue using DGS,',
315
+ 'install an older version that supports V1, or set up a fresh V2 install',
316
+ 'with `/dgs:init-product`.', '',
317
+ 'See the DGS changelog for migration details.', '',
318
+ ].join('\n');
319
+ try { fs.writeFileSync(guidancePath, guidanceContent); } catch { /* best effort */ }
320
+ process.stderr.write('Error: V1 single-repo layout is no longer supported. Install an older DGS version or run /dgs:init-product for a fresh V2 setup. See V1_UNSUPPORTED.md for details.\n');
321
+ process.exit(1);
327
322
  }
328
-
329
- // Create backup tag
330
- const tag = createBackupTag(cwd);
331
-
332
- // Perform migration
333
- const result = migrateV1ToV2(cwd, slug);
334
-
335
- output({
336
- ...result,
337
- backup_tag: tag.tagged ? 'dgs-pre-v2-migration' : null,
338
- backup_tag_note: tag.tagged ? null : tag.reason,
339
- }, raw);
340
323
  }
341
324
 
342
- // ─── Exports ────────────────────────────────────────────────────────────────
343
-
344
- module.exports = {
345
- detectV1Install,
346
- validateMigrationPreconditions,
347
- createBackupTag,
348
- collectMigrationMoves,
349
- validateMoves,
350
- migrateV1ToV2,
351
- cmdMigrateV1ToV2,
352
- };
325
+ // ─── Exports ──────────────────────────────────────────────────────────────────
326
+
327
+ module.exports = { migrateDotPlanningToRoot, rejectV1Install };