@ktpartners/dgs-platform 2.8.0 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +41 -13
  3. package/agents/dgs-plan-checker.md +29 -3
  4. package/agents/dgs-planner.md +10 -0
  5. package/commands/dgs/abandon-quick.md +28 -0
  6. package/commands/dgs/add-tests.md +2 -2
  7. package/commands/dgs/audit-milestone.md +2 -2
  8. package/commands/dgs/capture-principle.md +11 -11
  9. package/commands/dgs/cleanup.md +2 -2
  10. package/commands/dgs/complete-milestone.md +11 -11
  11. package/commands/dgs/complete-quick.md +28 -0
  12. package/commands/dgs/create-milestone-job.md +2 -2
  13. package/commands/dgs/debug.md +3 -3
  14. package/commands/dgs/develop-idea.md +1 -1
  15. package/commands/dgs/fast.md +3 -1
  16. package/commands/dgs/health.md +1 -1
  17. package/commands/dgs/map-codebase.md +6 -6
  18. package/commands/dgs/new-milestone.md +5 -5
  19. package/commands/dgs/new-project.md +6 -6
  20. package/commands/dgs/plan-milestone-gaps.md +1 -1
  21. package/commands/dgs/progress.md +3 -3
  22. package/commands/dgs/quick-abandon.md +8 -0
  23. package/commands/dgs/quick-complete.md +8 -0
  24. package/commands/dgs/quick.md +10 -3
  25. package/commands/dgs/research-idea.md +2 -2
  26. package/commands/dgs/research-phase.md +3 -3
  27. package/commands/dgs/switch-project.md +1 -1
  28. package/commands/dgs/write-spec.md +3 -3
  29. package/deliver-great-systems/bin/dgs-tools.cjs +284 -30
  30. package/deliver-great-systems/bin/lib/commands.cjs +316 -31
  31. package/deliver-great-systems/bin/lib/commands.test.cjs +336 -0
  32. package/deliver-great-systems/bin/lib/config.cjs +39 -6
  33. package/deliver-great-systems/bin/lib/context.cjs +120 -0
  34. package/deliver-great-systems/bin/lib/core.cjs +28 -11
  35. package/deliver-great-systems/bin/lib/execution.cjs +49 -17
  36. package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
  37. package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
  38. package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
  39. package/deliver-great-systems/bin/lib/init.cjs +306 -39
  40. package/deliver-great-systems/bin/lib/init.test.cjs +416 -6
  41. package/deliver-great-systems/bin/lib/jobs.cjs +124 -21
  42. package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
  43. package/deliver-great-systems/bin/lib/migration.cjs +409 -1
  44. package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
  45. package/deliver-great-systems/bin/lib/milestone.cjs +54 -29
  46. package/deliver-great-systems/bin/lib/phase.cjs +128 -2
  47. package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
  48. package/deliver-great-systems/bin/lib/projects.cjs +28 -8
  49. package/deliver-great-systems/bin/lib/projects.test.cjs +86 -0
  50. package/deliver-great-systems/bin/lib/quick.cjs +584 -0
  51. package/deliver-great-systems/bin/lib/quick.test.cjs +596 -0
  52. package/deliver-great-systems/bin/lib/repos.cjs +25 -1
  53. package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
  54. package/deliver-great-systems/bin/lib/specs.cjs +3 -81
  55. package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
  56. package/deliver-great-systems/bin/lib/state.cjs +142 -54
  57. package/deliver-great-systems/bin/lib/sync.cjs +75 -0
  58. package/deliver-great-systems/bin/lib/verify.cjs +80 -1
  59. package/deliver-great-systems/bin/lib/worktrees.cjs +764 -0
  60. package/deliver-great-systems/bin/lib/worktrees.test.cjs +887 -0
  61. package/deliver-great-systems/templates/claude-md.md +16 -0
  62. package/deliver-great-systems/workflows/abandon-quick.md +89 -0
  63. package/deliver-great-systems/workflows/add-idea.md +3 -3
  64. package/deliver-great-systems/workflows/add-tests.md +14 -0
  65. package/deliver-great-systems/workflows/add-todo.md +1 -0
  66. package/deliver-great-systems/workflows/approve-spec.md +25 -4
  67. package/deliver-great-systems/workflows/audit-phase.md +15 -5
  68. package/deliver-great-systems/workflows/cancel-job.md +1 -1
  69. package/deliver-great-systems/workflows/check-todos.md +2 -3
  70. package/deliver-great-systems/workflows/complete-milestone.md +197 -22
  71. package/deliver-great-systems/workflows/complete-quick.md +68 -0
  72. package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
  73. package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
  74. package/deliver-great-systems/workflows/develop-idea.md +11 -11
  75. package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
  76. package/deliver-great-systems/workflows/discuss-idea.md +1 -1
  77. package/deliver-great-systems/workflows/execute-phase.md +121 -32
  78. package/deliver-great-systems/workflows/execute-plan.md +12 -21
  79. package/deliver-great-systems/workflows/help.md +33 -29
  80. package/deliver-great-systems/workflows/init-product.md +2 -18
  81. package/deliver-great-systems/workflows/new-milestone.md +40 -24
  82. package/deliver-great-systems/workflows/new-project.md +22 -680
  83. package/deliver-great-systems/workflows/progress-all.md +133 -0
  84. package/deliver-great-systems/workflows/quick-abandon.md +89 -0
  85. package/deliver-great-systems/workflows/quick-complete.md +68 -0
  86. package/deliver-great-systems/workflows/quick.md +152 -23
  87. package/deliver-great-systems/workflows/refine-spec.md +1 -1
  88. package/deliver-great-systems/workflows/research-idea.md +8 -8
  89. package/deliver-great-systems/workflows/resume-project.md +2 -2
  90. package/deliver-great-systems/workflows/run-job.md +8 -8
  91. package/deliver-great-systems/workflows/validate-phase.md +39 -1
  92. package/deliver-great-systems/workflows/verify-work.md +14 -0
  93. package/deliver-great-systems/workflows/write-spec.md +2 -2
  94. package/package.json +1 -1
@@ -9,11 +9,15 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { execGit, safeReadFile, loadConfig, output, error } = require('./core.cjs');
12
- const { getPlanningRoot } = require('./paths.cjs');
12
+ const { getPlanningRoot, PROJECTS_DIR } = require('./paths.cjs');
13
13
  const { parseReposMd } = require('./repos.cjs');
14
14
  const { extractFrontmatter } = require('./frontmatter.cjs');
15
15
  const { scanProjectReposTags } = require('./projects.cjs');
16
16
 
17
+ // Lazy require to avoid circular dependency (context.cjs does not require execution.cjs,
18
+ // but keeping it lazy is a safety practice for future refactoring).
19
+ function _getResolveCodeContext() { return require('./context.cjs').resolveCodeContext; }
20
+
17
21
  // ─── Repo Path Resolution ───────────────────────────────────────────────────
18
22
 
19
23
  /**
@@ -24,7 +28,26 @@ const { scanProjectReposTags } = require('./projects.cjs');
24
28
  * @param {Array} repos - Parsed repos array (optional, loads from REPOS.md if not provided)
25
29
  * @returns {{ absPath: string, repo: Object } | null}
26
30
  */
27
- function resolveRepoPath(cwd, repoName, repos) {
31
+ function resolveRepoPath(cwd, repoName, repos, useActiveContext) {
32
+ // If active context requested, resolve to worktree directory
33
+ if (useActiveContext) {
34
+ try {
35
+ const ctx = _getResolveCodeContext()(cwd, repoName);
36
+ if (ctx && ctx.type !== 'main') {
37
+ // Resolve the repo object for metadata (still need repo.name etc.)
38
+ if (!repos) {
39
+ const parsed = parseReposMd(cwd);
40
+ if (parsed) repos = parsed.repos;
41
+ }
42
+ const repo = repos ? repos.find(r => r.name === repoName) : null;
43
+ return { absPath: ctx.directory, repo: repo || { name: repoName, path: ctx.directory } };
44
+ }
45
+ } catch {
46
+ // Fall through to standard resolution
47
+ }
48
+ }
49
+
50
+ // Standard resolution via REPOS.md
28
51
  if (!repos) {
29
52
  const parsed = parseReposMd(cwd);
30
53
  if (!parsed) return null;
@@ -48,8 +71,8 @@ function resolveRepoPath(cwd, repoName, repos) {
48
71
  * @param {Array} repos - Parsed repos array (optional, loads from REPOS.md if not provided)
49
72
  * @returns {{ absFilePath: string, repoAbsPath: string, repo: Object } | null}
50
73
  */
51
- function resolveRepoRelativePath(cwd, repoName, relativePath, repos) {
52
- const resolved = resolveRepoPath(cwd, repoName, repos);
74
+ function resolveRepoRelativePath(cwd, repoName, relativePath, repos, useActiveContext) {
75
+ const resolved = resolveRepoPath(cwd, repoName, repos, useActiveContext);
53
76
  if (!resolved) return null;
54
77
  const absFilePath = path.join(resolved.absPath, relativePath);
55
78
  return { absFilePath, repoAbsPath: resolved.absPath, repo: resolved.repo };
@@ -65,14 +88,14 @@ function resolveRepoRelativePath(cwd, repoName, relativePath, repos) {
65
88
  * @param {string} [phaseDir] - Optional phase directory to check for partial SUMMARY.md
66
89
  * @returns {{ passed: boolean, dirty_repos: Array, partial_execution: Object|null, missing_repos: string[] }}
67
90
  */
68
- function preflightCheck(cwd, repoNames, phaseDir) {
91
+ function preflightCheck(cwd, repoNames, phaseDir, useActiveContext) {
69
92
  const parsed = parseReposMd(cwd);
70
93
  const repos = parsed ? parsed.repos : [];
71
94
  const dirty_repos = [];
72
95
  const missing_repos = [];
73
96
 
74
97
  for (const name of repoNames) {
75
- const resolved = resolveRepoPath(cwd, name, repos);
98
+ const resolved = resolveRepoPath(cwd, name, repos, useActiveContext);
76
99
  if (!resolved) {
77
100
  missing_repos.push(name);
78
101
  continue;
@@ -316,13 +339,13 @@ function buildPlanningCommitBody(repoResults) {
316
339
  * @param {string[]} repoNames - Repo names to check
317
340
  * @returns {Array<{repoName: string, repoPath: string, files: string[]}>}
318
341
  */
319
- function detectRepoChanges(cwd, repoNames) {
342
+ function detectRepoChanges(cwd, repoNames, useActiveContext) {
320
343
  const parsed = parseReposMd(cwd);
321
344
  const repos = parsed ? parsed.repos : [];
322
345
  const changes = [];
323
346
 
324
347
  for (const name of repoNames) {
325
- const resolved = resolveRepoPath(cwd, name, repos);
348
+ const resolved = resolveRepoPath(cwd, name, repos, useActiveContext);
326
349
  if (!resolved) continue;
327
350
  if (!fs.existsSync(resolved.absPath)) continue;
328
351
 
@@ -347,7 +370,7 @@ function detectRepoChanges(cwd, repoNames) {
347
370
  if (files.length > 0) {
348
371
  changes.push({
349
372
  repoName: name,
350
- repoPath: resolved.repo.path,
373
+ repoPath: useActiveContext ? resolved.absPath : resolved.repo.path,
351
374
  files,
352
375
  });
353
376
  }
@@ -360,7 +383,6 @@ function detectRepoChanges(cwd, repoNames) {
360
383
 
361
384
  /**
362
385
  * Create branches in each repo for multi-repo execution.
363
- * Respects branching_strategy config — if 'none', skips branch creation.
364
386
  * Reuses existing branch if it already exists.
365
387
  * Detects branch prefix collisions and warns about potential ambiguity.
366
388
  *
@@ -375,15 +397,12 @@ function detectRepoChanges(cwd, repoNames) {
375
397
  * @param {string} cwd - Product root
376
398
  * @param {string[]} repoNames - Repo names
377
399
  * @param {string} branchName - Branch name (e.g., dgs/project/phase-slug)
378
- * @param {{ branching_strategy?: string }} config - Config object
400
+ * @param {object} [config] - Config object (legacy parameter, kept for backward compatibility)
379
401
  * @param {string|null} [baseBranch=null] - Base branch to checkout before creating new branch.
380
402
  * When null/undefined, creates branch from wherever HEAD is (backwards-compatible behavior).
381
403
  * @returns {{ created: boolean, reason?: string, error?: string, repo?: string, branches?: Array<{repo: string, branch: string, action: string}>, warnings?: Array }}
382
404
  */
383
405
  function createRepoBranches(cwd, repoNames, branchName, config, baseBranch) {
384
- if (config && config.branching_strategy === 'none') {
385
- return { created: false, reason: 'branching_disabled' };
386
- }
387
406
 
388
407
  const parsed = parseReposMd(cwd);
389
408
  const repos = parsed ? parsed.repos : [];
@@ -521,7 +540,7 @@ function updateRepoStatus(cwd, projectSlug, repoResults) {
521
540
  let statePath;
522
541
  const planRoot = getPlanningRoot(cwd);
523
542
  if (projectSlug) {
524
- statePath = path.join(planRoot, projectSlug, 'STATE.md');
543
+ statePath = path.join(planRoot, PROJECTS_DIR, projectSlug, 'STATE.md');
525
544
  }
526
545
  if (!statePath || !fs.existsSync(statePath)) {
527
546
  statePath = path.join(planRoot, 'STATE.md');
@@ -622,8 +641,21 @@ function cmdCommitMultiRepo(cwd, options, raw) {
622
641
 
623
642
  const warnings = [];
624
643
 
644
+ // Determine whether to resolve repos against active worktree
645
+ let useActiveContext = false;
646
+ try {
647
+ const { getLocalConfigPath } = require('./config.cjs');
648
+ const localPath = getLocalConfigPath(cwd);
649
+ if (fs.existsSync(localPath)) {
650
+ const localCfg = JSON.parse(fs.readFileSync(localPath, 'utf-8'));
651
+ if (localCfg.execution && localCfg.execution.active_context) {
652
+ useActiveContext = true;
653
+ }
654
+ }
655
+ } catch { /* fall back to standard resolution */ }
656
+
625
657
  // Run preflight
626
- const preflight = preflightCheck(cwd, repoNames, options.phaseDir || null);
658
+ const preflight = preflightCheck(cwd, repoNames, options.phaseDir || null, useActiveContext);
627
659
 
628
660
  // Missing repos is a hard error — block execution
629
661
  if (preflight.missing_repos && preflight.missing_repos.length > 0) {
@@ -649,7 +681,7 @@ function cmdCommitMultiRepo(cwd, options, raw) {
649
681
  }
650
682
 
651
683
  // Detect changes
652
- const changes = detectRepoChanges(cwd, repoNames);
684
+ const changes = detectRepoChanges(cwd, repoNames, useActiveContext);
653
685
  if (changes.length === 0) {
654
686
  output({ success: true, message: 'No changes detected', commits: [], warnings }, raw);
655
687
  return;
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Tests for migrateFlatStatus — flat status directory migration
3
+ *
4
+ * Phase 134 (v20.0): Migrates ideas/todos/jobs from state subdirectories
5
+ * to flat directories with frontmatter status fields.
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
+
13
+ const { createTempDir, cleanupDir, writeFile, initGitRepo } = require('./test-helpers.cjs');
14
+ const { migrateFlatStatus } = require('./migration.cjs');
15
+ const { extractFrontmatter } = require('./frontmatter.cjs');
16
+
17
+ // ─── Helpers ────────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Creates a temp directory with git init, returns realpathSync'd path
21
+ * (macOS /var -> /private/var symlink fix from Phase 118).
22
+ */
23
+ function makeGitDir() {
24
+ const tmpDir = createTempDir('flat-mig-test-');
25
+ initGitRepo(tmpDir);
26
+ return fs.realpathSync(tmpDir);
27
+ }
28
+
29
+ /**
30
+ * Creates a temp directory without git, returns realpathSync'd path.
31
+ */
32
+ function makePlainDir() {
33
+ const tmpDir = createTempDir('flat-mig-test-');
34
+ return fs.realpathSync(tmpDir);
35
+ }
36
+
37
+ function gitCommitAll(dir, msg) {
38
+ const { execSync } = require('child_process');
39
+ execSync('git add -A', { cwd: dir, stdio: 'pipe' });
40
+ execSync(`git commit -m "${msg}" --allow-empty`, { cwd: dir, stdio: 'pipe' });
41
+ }
42
+
43
+ function readFile(dir, relPath) {
44
+ return fs.readFileSync(path.join(dir, relPath), 'utf-8');
45
+ }
46
+
47
+ function fileExists(dir, relPath) {
48
+ return fs.existsSync(path.join(dir, relPath));
49
+ }
50
+
51
+ /**
52
+ * Create a minimal idea file with frontmatter
53
+ */
54
+ function makeIdea(id, title, opts = {}) {
55
+ const fm = [
56
+ '---',
57
+ `id: ${id}`,
58
+ `title: "${title}"`,
59
+ ];
60
+ if (opts.status) fm.push(`status: ${opts.status}`);
61
+ if (opts.consolidated_into) fm.push(`consolidated_into: "${opts.consolidated_into}"`);
62
+ if (opts.consolidated_from) {
63
+ fm.push('consolidated_from:');
64
+ for (const src of opts.consolidated_from) {
65
+ fm.push(` - "${src}"`);
66
+ }
67
+ }
68
+ fm.push(`created: "2026-01-01T00:00:00Z"`);
69
+ fm.push(`updated: "2026-01-01T00:00:00Z"`);
70
+ fm.push('---');
71
+ fm.push('');
72
+ fm.push(`# ${title}`);
73
+ fm.push('');
74
+ fm.push(opts.body || 'Some idea content.');
75
+ if (opts.researchLog) {
76
+ fm.push('');
77
+ fm.push('## Research Log');
78
+ fm.push('');
79
+ fm.push(opts.researchLog);
80
+ }
81
+ return fm.join('\n') + '\n';
82
+ }
83
+
84
+ /**
85
+ * Create a minimal todo file (no frontmatter — tests that frontmatter gets added)
86
+ */
87
+ function makeTodo(title) {
88
+ return `# ${title}\n\nSome todo content.\n`;
89
+ }
90
+
91
+ /**
92
+ * Create a minimal job file with bold-key Status header
93
+ */
94
+ function makeJob(title, status) {
95
+ return `---\ntitle: "${title}"\n---\n\n# ${title}\n\n**Status:** ${status}\n\nJob details.\n`;
96
+ }
97
+
98
+ // ─── Tests ──────────────────────────────────────────────────────────────────
99
+
100
+ describe('migrateFlatStatus', () => {
101
+ let tmpDir;
102
+
103
+ afterEach(() => {
104
+ if (tmpDir) cleanupDir(tmpDir);
105
+ });
106
+
107
+ describe('idempotency', () => {
108
+ it('returns early when flat_status_migration_done is true', () => {
109
+ tmpDir = makePlainDir();
110
+ writeFile(tmpDir, 'config.local.json', JSON.stringify({ flat_status_migration_done: true }));
111
+ const result = migrateFlatStatus(tmpDir);
112
+ assert.equal(result.migrated, false);
113
+ assert.equal(result.actions.length, 0);
114
+ });
115
+
116
+ it('sets flag and returns when no legacy files exist', () => {
117
+ tmpDir = makePlainDir();
118
+ writeFile(tmpDir, 'ideas/001-test.md', makeIdea(1, 'Test', { status: 'pending' }));
119
+ const result = migrateFlatStatus(tmpDir);
120
+ assert.equal(result.migrated, false);
121
+ assert.equal(result.actions.length, 0);
122
+ const config = JSON.parse(readFile(tmpDir, 'config.local.json'));
123
+ assert.equal(config.flat_status_migration_done, true);
124
+ });
125
+ });
126
+
127
+ describe('dry-run mode', () => {
128
+ it('returns actions without modifying files', () => {
129
+ tmpDir = makePlainDir();
130
+ writeFile(tmpDir, 'ideas/pending/001-test.md', makeIdea(1, 'Test'));
131
+
132
+ const result = migrateFlatStatus(tmpDir);
133
+
134
+ assert.equal(result.dryRun, true);
135
+ assert.equal(result.migrated, false);
136
+ assert.ok(result.actions.length > 0, 'should have actions');
137
+ assert.equal(result.filesMoved, 1);
138
+ // File should NOT have been moved
139
+ assert.ok(fileExists(tmpDir, 'ideas/pending/001-test.md'), 'original file should still exist');
140
+ assert.ok(!fileExists(tmpDir, 'ideas/001-test.md'), 'destination should not exist');
141
+ });
142
+
143
+ it('reports correct counts for multi-subsystem migration', () => {
144
+ tmpDir = makePlainDir();
145
+ writeFile(tmpDir, 'ideas/pending/001-idea.md', makeIdea(1, 'Idea'));
146
+ writeFile(tmpDir, 'ideas/done/002-done-idea.md', makeIdea(2, 'Done Idea'));
147
+ writeFile(tmpDir, 'todos/pending/todo-1.md', makeTodo('Todo 1'));
148
+ writeFile(tmpDir, 'jobs/completed/job-v1.md', makeJob('Job v1', 'completed'));
149
+
150
+ const result = migrateFlatStatus(tmpDir);
151
+
152
+ assert.equal(result.dryRun, true);
153
+ assert.equal(result.filesMoved, 4);
154
+ assert.equal(result.statusFieldsAdded, 4);
155
+ });
156
+ });
157
+
158
+ describe('apply mode', () => {
159
+ it('moves idea files and adds status frontmatter', () => {
160
+ tmpDir = makeGitDir();
161
+ writeFile(tmpDir, 'ideas/pending/001-test.md', makeIdea(1, 'Test'));
162
+ writeFile(tmpDir, 'ideas/done/002-shipped.md', makeIdea(2, 'Shipped'));
163
+ gitCommitAll(tmpDir, 'add ideas');
164
+
165
+ const result = migrateFlatStatus(tmpDir, { apply: true });
166
+
167
+ assert.equal(result.migrated, true);
168
+ assert.equal(result.dryRun, false);
169
+ assert.ok(result.commitHash, 'should have commit hash');
170
+
171
+ // Files moved to flat directory
172
+ assert.ok(fileExists(tmpDir, 'ideas/001-test.md'), 'idea should be in flat dir');
173
+ assert.ok(fileExists(tmpDir, 'ideas/002-shipped.md'), 'done idea should be in flat dir');
174
+ assert.ok(!fileExists(tmpDir, 'ideas/pending/001-test.md'), 'original should be gone');
175
+ assert.ok(!fileExists(tmpDir, 'ideas/done/002-shipped.md'), 'original should be gone');
176
+
177
+ // Status frontmatter added
178
+ const pendingContent = readFile(tmpDir, 'ideas/001-test.md');
179
+ const pendingFm = extractFrontmatter(pendingContent);
180
+ assert.equal(pendingFm.status, 'pending');
181
+
182
+ const doneContent = readFile(tmpDir, 'ideas/002-shipped.md');
183
+ const doneFm = extractFrontmatter(doneContent);
184
+ assert.equal(doneFm.status, 'done');
185
+ });
186
+
187
+ it('maps todo states correctly (completed -> done, resolved -> done)', () => {
188
+ tmpDir = makeGitDir();
189
+ writeFile(tmpDir, 'todos/pending/todo-1.md', makeTodo('Pending Todo'));
190
+ writeFile(tmpDir, 'todos/completed/todo-2.md', makeTodo('Completed Todo'));
191
+ writeFile(tmpDir, 'todos/done/todo-3.md', makeTodo('Done Todo'));
192
+ writeFile(tmpDir, 'todos/resolved/todo-4.md', makeTodo('Resolved Todo'));
193
+ gitCommitAll(tmpDir, 'add todos');
194
+
195
+ const result = migrateFlatStatus(tmpDir, { apply: true });
196
+
197
+ assert.equal(result.migrated, true);
198
+
199
+ // All files in flat dir
200
+ assert.ok(fileExists(tmpDir, 'todos/todo-1.md'));
201
+ assert.ok(fileExists(tmpDir, 'todos/todo-2.md'));
202
+ assert.ok(fileExists(tmpDir, 'todos/todo-3.md'));
203
+ assert.ok(fileExists(tmpDir, 'todos/todo-4.md'));
204
+
205
+ // Status mappings correct
206
+ const todo1 = extractFrontmatter(readFile(tmpDir, 'todos/todo-1.md'));
207
+ assert.equal(todo1.status, 'pending');
208
+ const todo2 = extractFrontmatter(readFile(tmpDir, 'todos/todo-2.md'));
209
+ assert.equal(todo2.status, 'done');
210
+ const todo3 = extractFrontmatter(readFile(tmpDir, 'todos/todo-3.md'));
211
+ assert.equal(todo3.status, 'done');
212
+ const todo4 = extractFrontmatter(readFile(tmpDir, 'todos/todo-4.md'));
213
+ assert.equal(todo4.status, 'done');
214
+ });
215
+
216
+ it('flattens research docs and updates path references in ideas', () => {
217
+ tmpDir = makeGitDir();
218
+ const ideaWithResearch = makeIdea(5, 'Research Idea', {
219
+ researchLog: '**Document:** docs/ideas/pending/research-idea-research.md\n**Date:** 2026-01-01'
220
+ });
221
+ writeFile(tmpDir, 'ideas/pending/005-research-idea.md', ideaWithResearch);
222
+ writeFile(tmpDir, 'docs/ideas/pending/research-idea-research.md', '# Research\n\nFindings here.\n');
223
+ gitCommitAll(tmpDir, 'add research');
224
+
225
+ const result = migrateFlatStatus(tmpDir, { apply: true });
226
+
227
+ assert.equal(result.migrated, true);
228
+
229
+ // Research doc flattened
230
+ assert.ok(fileExists(tmpDir, 'docs/ideas/research-idea-research.md'), 'research doc in flat dir');
231
+ assert.ok(!fileExists(tmpDir, 'docs/ideas/pending/research-idea-research.md'), 'research doc removed from state dir');
232
+
233
+ // Path reference updated in idea
234
+ const ideaContent = readFile(tmpDir, 'ideas/005-research-idea.md');
235
+ assert.ok(ideaContent.includes('docs/ideas/research-idea-research.md'), 'path should be flat');
236
+ assert.ok(!ideaContent.includes('docs/ideas/pending/research-idea-research.md'), 'state path should be gone');
237
+ });
238
+
239
+ it('normalizes consolidated_into and consolidated_from paths', () => {
240
+ tmpDir = makeGitDir();
241
+ const consolidatedIdea = makeIdea(3, 'Old Idea', {
242
+ consolidated_into: 'ideas/pending/010-merged.md'
243
+ });
244
+ const mergedIdea = makeIdea(10, 'Merged', {
245
+ consolidated_from: ['ideas/pending/003-old-idea.md', 'ideas/done/004-another.md']
246
+ });
247
+ writeFile(tmpDir, 'ideas/consolidated/003-old-idea.md', consolidatedIdea);
248
+ writeFile(tmpDir, 'ideas/pending/010-merged.md', mergedIdea);
249
+ gitCommitAll(tmpDir, 'add consolidated');
250
+
251
+ const result = migrateFlatStatus(tmpDir, { apply: true });
252
+
253
+ assert.equal(result.migrated, true);
254
+ assert.ok(result.pathsNormalized > 0, 'should have normalized paths');
255
+
256
+ // Check consolidated_into normalized
257
+ const oldContent = readFile(tmpDir, 'ideas/003-old-idea.md');
258
+ assert.ok(!oldContent.includes('ideas/pending/'), 'should not have state dir in path');
259
+ assert.ok(oldContent.includes('ideas/010-merged.md'), 'should have flat path');
260
+
261
+ // Check consolidated_from normalized
262
+ const mergedContent = readFile(tmpDir, 'ideas/010-merged.md');
263
+ assert.ok(!mergedContent.includes('ideas/pending/003'), 'should not have state dir');
264
+ assert.ok(!mergedContent.includes('ideas/done/004'), 'should not have state dir');
265
+ });
266
+
267
+ it('validates file count (no data loss)', () => {
268
+ tmpDir = makeGitDir();
269
+ writeFile(tmpDir, 'ideas/pending/001-a.md', makeIdea(1, 'A'));
270
+ writeFile(tmpDir, 'ideas/done/002-b.md', makeIdea(2, 'B'));
271
+ writeFile(tmpDir, 'ideas/rejected/003-c.md', makeIdea(3, 'C'));
272
+ gitCommitAll(tmpDir, 'add ideas');
273
+
274
+ const result = migrateFlatStatus(tmpDir, { apply: true });
275
+
276
+ assert.equal(result.migrated, true);
277
+ assert.equal(result.filesMoved, 3);
278
+
279
+ // Count files in flat dir (should be 3 ideas)
280
+ const flatFiles = fs.readdirSync(path.join(tmpDir, 'ideas')).filter(f => f.endsWith('.md'));
281
+ assert.equal(flatFiles.length, 3, 'all 3 ideas should be in flat dir');
282
+ });
283
+
284
+ it('handles jobs with dual-write status (frontmatter + bold-key header)', () => {
285
+ tmpDir = makeGitDir();
286
+ writeFile(tmpDir, 'jobs/completed/milestone-v5.md', makeJob('v5 Build', 'completed'));
287
+ writeFile(tmpDir, 'jobs/in-progress/milestone-v6.md', makeJob('v6 Build', 'in-progress'));
288
+ gitCommitAll(tmpDir, 'add jobs');
289
+
290
+ const result = migrateFlatStatus(tmpDir, { apply: true });
291
+
292
+ assert.equal(result.migrated, true);
293
+
294
+ // Check frontmatter status
295
+ const v5Content = readFile(tmpDir, 'jobs/milestone-v5.md');
296
+ const v5Fm = extractFrontmatter(v5Content);
297
+ assert.equal(v5Fm.status, 'completed');
298
+
299
+ // Check bold-key header still present
300
+ assert.ok(v5Content.includes('**Status:** completed'), 'bold-key header should be updated');
301
+
302
+ const v6Content = readFile(tmpDir, 'jobs/milestone-v6.md');
303
+ const v6Fm = extractFrontmatter(v6Content);
304
+ assert.equal(v6Fm.status, 'in-progress');
305
+ assert.ok(v6Content.includes('**Status:** in-progress'), 'bold-key header should be updated');
306
+ });
307
+ });
308
+
309
+ describe('realistic project fixtures', () => {
310
+ it('handles fc-like project (done ideas, research docs, pending todos, completed jobs)', () => {
311
+ tmpDir = makeGitDir();
312
+
313
+ // fc-like: 7 done ideas, 5 pending ideas, 11 pending todos, 1 resolved todo, 10 completed jobs, 1 research doc
314
+ for (let i = 1; i <= 7; i++) {
315
+ writeFile(tmpDir, `ideas/done/00${i}-done-idea-${i}.md`, makeIdea(i, `Done Idea ${i}`));
316
+ }
317
+ for (let i = 8; i <= 12; i++) {
318
+ writeFile(tmpDir, `ideas/pending/0${i}-pending-idea-${i}.md`, makeIdea(i, `Pending Idea ${i}`));
319
+ }
320
+
321
+ for (let i = 1; i <= 11; i++) {
322
+ writeFile(tmpDir, `todos/pending/todo-${i}.md`, makeTodo(`Todo ${i}`));
323
+ }
324
+ writeFile(tmpDir, 'todos/resolved/todo-resolved.md', makeTodo('Resolved Todo'));
325
+
326
+ for (let i = 1; i <= 10; i++) {
327
+ writeFile(tmpDir, `jobs/completed/milestone-v${i}.md`, makeJob(`v${i} Build`, 'completed'));
328
+ }
329
+
330
+ writeFile(tmpDir, 'docs/ideas/pending/research-doc.md', '# Research\n\nContent.\n');
331
+
332
+ gitCommitAll(tmpDir, 'init');
333
+
334
+ const result = migrateFlatStatus(tmpDir, { apply: true });
335
+
336
+ assert.equal(result.migrated, true);
337
+ // 7 done + 5 pending ideas + 12 todos + 10 jobs + 1 research = 35
338
+ assert.equal(result.filesMoved, 35);
339
+
340
+ // Spot check: done idea has status field
341
+ const doneIdea = readFile(tmpDir, 'ideas/001-done-idea-1.md');
342
+ assert.ok(doneIdea.includes('status: done'), 'done idea should have status: done');
343
+
344
+ // Resolved todo mapped to done
345
+ const resolvedTodo = readFile(tmpDir, 'todos/todo-resolved.md');
346
+ const resolvedFm = extractFrontmatter(resolvedTodo);
347
+ assert.equal(resolvedFm.status, 'done');
348
+
349
+ // Research doc flattened
350
+ assert.ok(fileExists(tmpDir, 'docs/ideas/research-doc.md'));
351
+ assert.ok(!fileExists(tmpDir, 'docs/ideas/pending/research-doc.md'));
352
+ });
353
+
354
+ it('handles gsd-like project (mixed todo states, in-progress job)', () => {
355
+ tmpDir = makeGitDir();
356
+
357
+ // gsd-like: 4 done ideas, 5 pending ideas, mixed todos, 1 in-progress job
358
+ for (let i = 1; i <= 4; i++) {
359
+ writeFile(tmpDir, `ideas/done/00${i}-done-${i}.md`, makeIdea(i, `Done ${i}`));
360
+ }
361
+ for (let i = 5; i <= 9; i++) {
362
+ writeFile(tmpDir, `ideas/pending/00${i}-pending-${i}.md`, makeIdea(i, `Pending ${i}`));
363
+ }
364
+
365
+ for (let i = 1; i <= 17; i++) {
366
+ writeFile(tmpDir, `todos/pending/todo-p${i}.md`, makeTodo(`Pending ${i}`));
367
+ }
368
+ for (let i = 1; i <= 4; i++) {
369
+ writeFile(tmpDir, `todos/completed/todo-c${i}.md`, makeTodo(`Completed ${i}`));
370
+ }
371
+ for (let i = 1; i <= 9; i++) {
372
+ writeFile(tmpDir, `todos/done/todo-d${i}.md`, makeTodo(`Done ${i}`));
373
+ }
374
+
375
+ writeFile(tmpDir, 'jobs/in-progress/milestone-v20.md', makeJob('v20 Build', 'in-progress'));
376
+ writeFile(tmpDir, 'jobs/completed/milestone-v19.md', makeJob('v19 Build', 'completed'));
377
+
378
+ gitCommitAll(tmpDir, 'init');
379
+
380
+ const result = migrateFlatStatus(tmpDir, { apply: true });
381
+
382
+ assert.equal(result.migrated, true);
383
+ // 4 done + 5 pending ideas + 17 pending + 4 completed + 9 done todos + 2 jobs = 41
384
+ assert.equal(result.filesMoved, 41);
385
+
386
+ // All todos in flat dir with correct status
387
+ const completedTodo = readFile(tmpDir, 'todos/todo-c1.md');
388
+ const completedFm = extractFrontmatter(completedTodo);
389
+ assert.equal(completedFm.status, 'done', 'completed -> done');
390
+
391
+ const doneTodo = readFile(tmpDir, 'todos/todo-d1.md');
392
+ const doneFm = extractFrontmatter(doneTodo);
393
+ assert.equal(doneFm.status, 'done', 'done -> done');
394
+ });
395
+ });
396
+ });