@ktpartners/dgs-platform 3.4.2 → 3.5.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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -0
  3. package/agents/dgs-codebase-cross-analyzer.md +1 -1
  4. package/agents/dgs-codebase-mapper.md +1 -1
  5. package/agents/dgs-codebase-synthesizer.md +1 -1
  6. package/agents/dgs-phase-researcher.md +1 -1
  7. package/bin/install.js +12 -2
  8. package/deliver-great-systems/bin/dgs-tools.cjs +7 -1
  9. package/deliver-great-systems/bin/lib/commands.cjs +66 -29
  10. package/deliver-great-systems/bin/lib/commands.test.cjs +221 -1
  11. package/deliver-great-systems/bin/lib/context.cjs +6 -6
  12. package/deliver-great-systems/bin/lib/context.test.cjs +9 -9
  13. package/deliver-great-systems/bin/lib/core.cjs +199 -9
  14. package/deliver-great-systems/bin/lib/core.test.cjs +242 -0
  15. package/deliver-great-systems/bin/lib/execution.cjs +7 -0
  16. package/deliver-great-systems/bin/lib/governance.cjs +7 -7
  17. package/deliver-great-systems/bin/lib/init.cjs +25 -17
  18. package/deliver-great-systems/bin/lib/init.test.cjs +69 -10
  19. package/deliver-great-systems/bin/lib/jobs.cjs +16 -10
  20. package/deliver-great-systems/bin/lib/jobs.test.cjs +17 -1
  21. package/deliver-great-systems/bin/lib/migration.test.cjs +8 -0
  22. package/deliver-great-systems/bin/lib/milestone-archival.test.cjs +186 -0
  23. package/deliver-great-systems/bin/lib/milestone.cjs +168 -37
  24. package/deliver-great-systems/bin/lib/milestone.test.cjs +113 -1
  25. package/deliver-great-systems/bin/lib/path-audit.test.cjs +128 -0
  26. package/deliver-great-systems/bin/lib/paths.cjs +1 -2
  27. package/deliver-great-systems/bin/lib/paths.test.cjs +3 -4
  28. package/deliver-great-systems/bin/lib/phase-versioned.test.cjs +134 -0
  29. package/deliver-great-systems/bin/lib/phase.cjs +60 -7
  30. package/deliver-great-systems/bin/lib/phase.test.cjs +168 -1
  31. package/deliver-great-systems/bin/lib/projects.test.cjs +38 -0
  32. package/deliver-great-systems/bin/lib/repos.cjs +8 -4
  33. package/deliver-great-systems/bin/lib/repos.test.cjs +6 -2
  34. package/deliver-great-systems/bin/lib/roadmap.cjs +9 -6
  35. package/deliver-great-systems/bin/lib/state-snapshot.test.cjs +134 -0
  36. package/deliver-great-systems/bin/lib/state.cjs +173 -26
  37. package/deliver-great-systems/templates/milestone-archive.md +1 -1
  38. package/deliver-great-systems/templates/roadmap.md +12 -10
  39. package/deliver-great-systems/workflows/abandon-milestone.md +8 -1
  40. package/deliver-great-systems/workflows/abandon-quick.md +1 -1
  41. package/deliver-great-systems/workflows/new-milestone.md +46 -12
  42. package/deliver-great-systems/workflows/quick-abandon.md +1 -1
  43. package/deliver-great-systems/workflows/quick.md +3 -3
  44. package/package.json +3 -2
@@ -5,7 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { execSync } = require('child_process');
8
- const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error, resolveProjectPath, getProjectRoot, isV2Install, getProjectFolders, getV2Hint, safeReadFile } = require('./core.cjs');
8
+ const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error, resolveProjectPath, getProjectRoot, phasesDir, isV2Install, getProjectFolders, getV2Hint, safeReadFile, isValidMilestoneVersion } = require('./core.cjs');
9
9
  const { requireGitIdentity, formatAuthorString } = require('./identity.cjs');
10
10
  const { getPlanningRoot, PROJECTS_DIR } = require('./paths.cjs');
11
11
  const { parseReposMd, validateReposMdEager } = require('./repos.cjs');
@@ -242,7 +242,7 @@ function cmdInitExecutePhase(cwd, phase, raw) {
242
242
  const config = loadConfig(cwd);
243
243
  const ctx = resolveProjectContext(cwd);
244
244
  let phaseInfo = findPhaseInternal(cwd, phase);
245
- if (phaseInfo?.archived) phaseInfo = null;
245
+ if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
246
246
  const milestone = getMilestoneInfo(cwd);
247
247
  const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
248
248
 
@@ -348,7 +348,7 @@ function cmdInitPlanPhase(cwd, phase, raw) {
348
348
  const config = loadConfig(cwd);
349
349
  const ctx = resolveProjectContext(cwd);
350
350
  let phaseInfo = findPhaseInternal(cwd, phase);
351
- if (phaseInfo?.archived) phaseInfo = null;
351
+ if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
352
352
  const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
353
353
 
354
354
  const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
@@ -556,8 +556,11 @@ function cmdInitNewMilestone(cwd, raw) {
556
556
  cadence_pull: getCadence('new-milestone').pull,
557
557
  cadence_push: getCadence('new-milestone').push,
558
558
 
559
- // Current milestone
560
- current_milestone: milestone.version,
559
+ // Current milestone — current_milestone is the authoritative structured
560
+ // version signal. new-milestone (this flow) and init.cjs are the SOLE
561
+ // setters. Emit only a grammar-valid value; surface a gap as null rather
562
+ // than persisting an out-of-grammar value (and NEVER coerce to v1.0).
563
+ current_milestone: isValidMilestoneVersion(milestone.version) ? milestone.version : null,
561
564
  current_milestone_name: milestone.name,
562
565
 
563
566
  // File existence (project-qualified)
@@ -747,7 +750,7 @@ function cmdInitVerifyWork(cwd, phase, raw) {
747
750
  const config = loadConfig(cwd);
748
751
  const ctx = resolveProjectContext(cwd);
749
752
  let phaseInfo = findPhaseInternal(cwd, phase);
750
- if (phaseInfo?.archived) phaseInfo = null;
753
+ if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
751
754
 
752
755
  const result = {
753
756
  // Models
@@ -792,7 +795,7 @@ function cmdInitAuditPhase(cwd, phase, raw) {
792
795
  const config = loadConfig(cwd);
793
796
  const ctx = resolveProjectContext(cwd);
794
797
  let phaseInfo = findPhaseInternal(cwd, phase);
795
- if (phaseInfo?.archived) phaseInfo = null;
798
+ if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
796
799
 
797
800
  const result = {
798
801
  // Models
@@ -834,7 +837,7 @@ function cmdInitPhaseOp(cwd, phase, raw, workflow) {
834
837
  const ctx = resolveProjectContext(cwd);
835
838
  const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
836
839
  let phaseInfo = findPhaseInternal(cwd, phase);
837
- if (phaseInfo?.archived) phaseInfo = null;
840
+ if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
838
841
  const cadence = getCadence(workflow || 'plan-phase');
839
842
 
840
843
  // Fallback to ROADMAP.md if no directory exists (e.g., Plans: TBD)
@@ -858,6 +861,9 @@ function cmdInitPhaseOp(cwd, phase, raw, workflow) {
858
861
  }
859
862
  }
860
863
 
864
+ let phasesDirRel;
865
+ try { phasesDirRel = phasesDir(cwd); } catch { phasesDirRel = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'); }
866
+
861
867
  const result = {
862
868
  // Config
863
869
  commit_docs: config.commit_docs,
@@ -891,7 +897,7 @@ function cmdInitPhaseOp(cwd, phase, raw, workflow) {
891
897
  roadmap_path: ctx.root ? path.join(ctx.root, 'ROADMAP.md') : path.join(planRootRel, 'ROADMAP.md'),
892
898
  requirements_path: ctx.root ? path.join(ctx.root, 'REQUIREMENTS.md') : path.join(planRootRel, 'REQUIREMENTS.md'),
893
899
  project_path: ctx.root ? path.join(ctx.root, 'PROJECT.md') : path.join(planRootRel, 'PROJECT.md'),
894
- phases_dir: ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'),
900
+ phases_dir: phasesDirRel,
895
901
 
896
902
  // Author
897
903
  author: resolveAuthorSafe(cwd),
@@ -1055,17 +1061,18 @@ function cmdInitMilestoneOp(cwd, raw, workflow) {
1055
1061
  // Count phases (project-qualified)
1056
1062
  let phaseCount = 0;
1057
1063
  let completedPhases = 0;
1058
- const phasesBase = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases');
1059
- const phasesDir = path.join(cwd, phasesBase);
1064
+ let phasesBase;
1065
+ try { phasesBase = phasesDir(cwd); } catch { phasesBase = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'); }
1066
+ const phasesAbs = path.join(cwd, phasesBase);
1060
1067
  try {
1061
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1068
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
1062
1069
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1063
1070
  phaseCount = dirs.length;
1064
1071
 
1065
1072
  // Count phases with summaries (completed)
1066
1073
  for (const dir of dirs) {
1067
1074
  try {
1068
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
1075
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, dir));
1069
1076
  const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1070
1077
  if (hasSummary) completedPhases++;
1071
1078
  } catch {}
@@ -1251,14 +1258,15 @@ function cmdInitProgress(cwd, raw) {
1251
1258
  const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
1252
1259
 
1253
1260
  // Analyze phases (project-qualified)
1254
- const phasesBase = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases');
1255
- const phasesDir = path.join(cwd, phasesBase);
1261
+ let phasesBase;
1262
+ try { phasesBase = phasesDir(cwd); } catch { phasesBase = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'); }
1263
+ const phasesAbs = path.join(cwd, phasesBase);
1256
1264
  const phases = [];
1257
1265
  let currentPhase = null;
1258
1266
  let nextPhase = null;
1259
1267
 
1260
1268
  try {
1261
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1269
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
1262
1270
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
1263
1271
 
1264
1272
  for (const dir of dirs) {
@@ -1266,7 +1274,7 @@ function cmdInitProgress(cwd, raw) {
1266
1274
  const phaseNumber = match ? match[1] : dir;
1267
1275
  const phaseName = match && match[2] ? match[2] : null;
1268
1276
 
1269
- const phasePath = path.join(phasesDir, dir);
1277
+ const phasePath = path.join(phasesAbs, dir);
1270
1278
  const phaseFiles = fs.readdirSync(phasePath);
1271
1279
 
1272
1280
  const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
@@ -1657,16 +1657,6 @@ describe('cmdInitQuick quick_dir resolution', () => {
1657
1657
  beforeEach(() => {
1658
1658
  fixture = createFixture({
1659
1659
  'config.json': JSON.stringify({ current_project: 'test-project' }),
1660
- 'config.local.json': JSON.stringify({
1661
- execution: { active_context: 'v19-git-worktrees' },
1662
- projects: {
1663
- 'test-project': {
1664
- worktrees: {
1665
- 'v19-git-worktrees': { type: 'milestone' }
1666
- }
1667
- }
1668
- }
1669
- }),
1670
1660
  'PROJECTS.md': '# Projects\n\n| Project | Status |\n|---------|--------|\n| test-project | Active |\n',
1671
1661
  'REPOS.md': '# Repos\n\n| Name | Path |\n|------|------|\n',
1672
1662
  'projects/test-project/STATE.md': '# State',
@@ -1676,6 +1666,35 @@ describe('cmdInitQuick quick_dir resolution', () => {
1676
1666
  'projects/test-project/phases/01-test-phase/01-CONTEXT.md': '# Context',
1677
1667
  'projects/test-project/phases/01-test-phase/01-01-PLAN.md': '---\nphase: 01-test-phase\nplan: 01\n---\n# Plan',
1678
1668
  });
1669
+
1670
+ // Establish a GENUINE milestone context. detectQuickMode treats a
1671
+ // milestone worktree entry as active only when its `repos` map points at
1672
+ // an on-disk directory; a bare { type: 'milestone' } entry with no live
1673
+ // repos is treated as stale and falls through to PRODUCT mode (the
1674
+ // documented stale-defence from bug 260507-pdp). The previous fixture
1675
+ // omitted `repos`, so it silently exercised product mode and the
1676
+ // project-scoped expectations below could never pass. Create a real
1677
+ // worktree dir inside the fixture (cleaned up with it) and point the
1678
+ // entry at it so milestone-context resolution actually fires.
1679
+ const worktreeDir = path.join(fixture.cwd, 'worktrees', 'v19-git-worktrees');
1680
+ fs.mkdirSync(worktreeDir, { recursive: true });
1681
+ fs.writeFileSync(
1682
+ path.join(fixture.cwd, 'config.local.json'),
1683
+ JSON.stringify({
1684
+ execution: { active_context: 'v19-git-worktrees' },
1685
+ projects: {
1686
+ 'test-project': {
1687
+ worktrees: {
1688
+ 'v19-git-worktrees': {
1689
+ type: 'milestone',
1690
+ repos: { 'deliver-great-systems': worktreeDir },
1691
+ },
1692
+ },
1693
+ },
1694
+ },
1695
+ }),
1696
+ 'utf-8'
1697
+ );
1679
1698
  });
1680
1699
 
1681
1700
  afterEach(() => {
@@ -2196,3 +2215,43 @@ describe('v2 mode multi-project: end-to-end', () => {
2196
2215
  }
2197
2216
  });
2198
2217
  });
2218
+
2219
+ // ─── LOOK-01: init coerces a cross-milestone-ambiguous bare number to not-found ─
2220
+
2221
+ describe('init execute-phase coerces ambiguous archive number to not-found (LOOK-01)', () => {
2222
+ // A v2 project where bare "03" is NOT an active phase but exists in TWO
2223
+ // milestone archives. findPhaseInternal returns a {found:false, ambiguous:true}
2224
+ // signal; the init.cjs coercion must treat it as not-found rather than silently
2225
+ // adopting one archive's directory (the pre-fix Group C bug).
2226
+ function ambiguousArchiveFixture() {
2227
+ return createFixture({
2228
+ 'config.json': JSON.stringify({}),
2229
+ 'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
2230
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n|---------|--------|\n| auth-overhaul | Active |\n',
2231
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n|------|------|\n',
2232
+ 'projects/auth-overhaul/STATE.md': '---\ncurrent_milestone: v25.0\n---\n# State',
2233
+ 'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
2234
+ 'projects/auth-overhaul/REQUIREMENTS.md': '# Requirements',
2235
+ 'projects/auth-overhaul/PROJECT.md': '# Project',
2236
+ 'projects/auth-overhaul/phases/': null,
2237
+ 'milestones/v3.0-phases/03-alpha/01-PLAN.md': '# Plan',
2238
+ 'milestones/v4.0-phases/03-beta/01-PLAN.md': '# Plan',
2239
+ });
2240
+ }
2241
+
2242
+ it('does NOT silently resolve to either archive directory', () => {
2243
+ const fixture = ambiguousArchiveFixture();
2244
+ try {
2245
+ const result = runInit(fixture.cwd, 'execute-phase 03');
2246
+ // Coerced to not-found: the ambiguity object was nulled out.
2247
+ assert.equal(result.phase_found, false, 'ambiguous number must be not-found');
2248
+ assert.equal(result.phase_dir, null, 'no archive directory adopted');
2249
+ // Belt-and-suspenders: neither archived dir leaked into the result.
2250
+ const blob = JSON.stringify(result);
2251
+ assert.ok(!blob.includes('03-alpha'), 'must not adopt v3.0 archive dir');
2252
+ assert.ok(!blob.includes('03-beta'), 'must not adopt v4.0 archive dir');
2253
+ } finally {
2254
+ fixture.cleanup();
2255
+ }
2256
+ });
2257
+ });
@@ -30,7 +30,7 @@
30
30
  const fs = require('fs');
31
31
  const path = require('path');
32
32
  const { execSync } = require('child_process');
33
- const { output, error, getProjectRoot } = require('./core.cjs');
33
+ const { output, error, getProjectRoot, phasesDir } = require('./core.cjs');
34
34
  const { getPlanningRoot } = require('./paths.cjs');
35
35
  const { requireGitIdentity, formatAuthorString } = require('./identity.cjs');
36
36
  const { extractFrontmatter, spliceFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
@@ -1128,7 +1128,9 @@ function analyzeMilestonePhases(cwd, version) {
1128
1128
  }
1129
1129
 
1130
1130
  const content = fs.readFileSync(roadmapPath, 'utf-8');
1131
- const phasesDir = path.join(projectRoot, 'phases');
1131
+ let phasesAbs;
1132
+ try { phasesAbs = path.join(cwd, phasesDir(cwd)); }
1133
+ catch { phasesAbs = path.join(projectRoot, 'phases'); }
1132
1134
 
1133
1135
  // Find the milestone section: look for heading containing the version + "In Progress" or "SHIPPED"
1134
1136
  // Also match the milestone in the summary list to find its phase range
@@ -1222,12 +1224,12 @@ function analyzeMilestonePhases(cwd, version) {
1222
1224
  let diskStatus = 'no_directory';
1223
1225
 
1224
1226
  try {
1225
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1227
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
1226
1228
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1227
1229
  const dirMatch = dirs.find(d => d.startsWith(padded + '-') || d === padded);
1228
1230
 
1229
1231
  if (dirMatch) {
1230
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch));
1232
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, dirMatch));
1231
1233
  const planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
1232
1234
  const summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
1233
1235
  const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
@@ -1708,7 +1710,9 @@ function generateJobSummary(cwd, version) {
1708
1710
  try {
1709
1711
  const milestonePhases = analyzeMilestonePhases(cwd, normalizedVersion);
1710
1712
  const projectRoot = resolveProjectRoot(cwd);
1711
- const phasesDir = path.join(projectRoot, 'phases');
1713
+ let phasesAbs;
1714
+ try { phasesAbs = path.join(cwd, phasesDir(cwd)); }
1715
+ catch { phasesAbs = path.join(projectRoot, 'phases'); }
1712
1716
 
1713
1717
  for (const phase of milestonePhases) {
1714
1718
  const phaseNum = phase.number;
@@ -1717,7 +1721,7 @@ function generateJobSummary(cwd, version) {
1717
1721
  // Find the phase directory on disk
1718
1722
  let phaseDir = null;
1719
1723
  try {
1720
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1724
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
1721
1725
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1722
1726
  phaseDir = dirs.find(d => d.startsWith(padded + '-') || d === padded);
1723
1727
  } catch {}
@@ -1725,7 +1729,7 @@ function generateJobSummary(cwd, version) {
1725
1729
  if (!phaseDir) continue;
1726
1730
 
1727
1731
  // Look for UAT file
1728
- const uatPath = path.join(phasesDir, phaseDir, `${padded}-UAT.md`);
1732
+ const uatPath = path.join(phasesAbs, phaseDir, `${padded}-UAT.md`);
1729
1733
  let uatContent = null;
1730
1734
  try {
1731
1735
  uatContent = fs.readFileSync(uatPath, 'utf-8');
@@ -1921,7 +1925,9 @@ function rollbackJob(cwd, version) {
1921
1925
 
1922
1926
  // Identify executed phases from completed steps
1923
1927
  const projectRoot = resolveProjectRoot(cwd);
1924
- const phasesDir = path.join(projectRoot, 'phases');
1928
+ let phasesAbs;
1929
+ try { phasesAbs = path.join(cwd, phasesDir(cwd)); }
1930
+ catch { phasesAbs = path.join(projectRoot, 'phases'); }
1925
1931
 
1926
1932
  for (const step of parsed.steps) {
1927
1933
  if (step.status === 'completed' && step.command === 'execute-phase') {
@@ -1932,12 +1938,12 @@ function rollbackJob(cwd, version) {
1932
1938
  const padded = phaseNum.replace(/^(\d+)/, (m) => m.padStart(2, '0'));
1933
1939
 
1934
1940
  try {
1935
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1941
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
1936
1942
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1937
1943
  const dirMatch = dirs.find(d => d.startsWith(padded + '-') || d === padded);
1938
1944
 
1939
1945
  if (dirMatch) {
1940
- const phaseDir = path.join(phasesDir, dirMatch);
1946
+ const phaseDir = path.join(phasesAbs, dirMatch);
1941
1947
  const phaseFiles = fs.readdirSync(phaseDir);
1942
1948
  const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1943
1949
 
@@ -2124,8 +2124,24 @@ Plans:
2124
2124
  });
2125
2125
 
2126
2126
  it('returns not_in_progress when job is in pending/', () => {
2127
+ // findJobFile's contract: frontmatter Status is authoritative, the
2128
+ // legacy directory is advisory only ("Frontmatter wins"). A genuinely
2129
+ // pending job therefore needs Status: pending in its frontmatter — using
2130
+ // a status:in-progress fixture here would (correctly) be cancellable.
2131
+ const PENDING_JOB = `# Milestone Job: v6.0
2132
+
2133
+ **Version:** v6.0
2134
+ **Created:** 2026-03-02T10:00:00Z
2135
+ **Status:** pending
2136
+ **Check:** true
2137
+
2138
+ ## Steps
2139
+
2140
+ - [ ] \`/dgs:plan-phase 41\`
2141
+ - [ ] \`/dgs:execute-phase 41\`
2142
+ `;
2127
2143
  fixture = createFixture({
2128
- 'jobs/pending/milestone-v6.0.md': WELL_FORMED_JOB,
2144
+ 'jobs/pending/milestone-v6.0.md': PENDING_JOB,
2129
2145
  });
2130
2146
  const result = cancelJob(fixture.cwd, 'v6.0');
2131
2147
 
@@ -423,6 +423,14 @@ describe('CLI: migrate command (MIG-08, OPT-02)', () => {
423
423
 
424
424
  beforeEach(() => {
425
425
  dir = makeGitDir();
426
+ // Real DGS repos gitignore config.local.json — the local half of the
427
+ // documented two-file config layout (config.json tracked / config.local.json
428
+ // gitignored). dgs-tools writes config.local.json on startup (e.g.
429
+ // { branching_migration_done: true }); without the .gitignore that real
430
+ // installs carry, that untracked write dirties the tree and trips migrate's
431
+ // (correct) clean-working-tree guard with exit 1. Mirror the real layout so
432
+ // the guard is exercised honestly rather than failing on a fixture artifact.
433
+ writeFile(dir, '.gitignore', 'config.local.json\n');
426
434
  });
427
435
 
428
436
  afterEach(() => {
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Tests for cmdMilestoneComplete — structural phase archival (Phase 167, NUM-03/NUM-04)
3
+ *
4
+ * Covers the versioned single-move archival path, the collision-abort guard,
5
+ * mixed-layout correctness, and flat-mode preservation.
6
+ *
7
+ * Uses Node.js built-in test runner (node:test) and assert (node:assert/strict).
8
+ * Each test creates an isolated temp directory fixture and cleans up after.
9
+ */
10
+
11
+ const { describe, it, afterEach } = require('node:test');
12
+ const assert = require('node:assert/strict');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const { createFixture } = require('./test-helpers.cjs');
17
+ const { cmdMilestoneComplete } = require('./milestone.cjs');
18
+
19
+ // Helper: capture stdout from cmdMilestoneComplete (which calls output()).
20
+ // Also stubs process.exit so error()-guarded paths return instead of exiting.
21
+ function captureResult(fn) {
22
+ const chunks = [];
23
+ const origWrite = process.stdout.write;
24
+ process.stdout.write = function (chunk) {
25
+ chunks.push(String(chunk));
26
+ };
27
+ const origStderrWrite = process.stderr.write;
28
+ process.stderr.write = function () {};
29
+ const origExit = process.exit;
30
+ process.exit = function () {};
31
+ try {
32
+ fn();
33
+ } finally {
34
+ process.stdout.write = origWrite;
35
+ process.stderr.write = origStderrWrite;
36
+ process.exit = origExit;
37
+ }
38
+ const raw = chunks.join('');
39
+ try {
40
+ return JSON.parse(raw);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Creates a root-layout fixture suitable for cmdMilestoneComplete.
48
+ * Pass `extras` to add/override structure entries (e.g. a versioned STATE.md
49
+ * with current_milestone frontmatter and phases/v26.0/ dirs).
50
+ */
51
+ function createMilestoneFixture(extras) {
52
+ const base = {
53
+ 'config.json': JSON.stringify({}),
54
+ 'config.local.json': JSON.stringify({ planningRoot: '.' }),
55
+ 'STATE.md': '# Project State\n\nPhase: 1\nStatus: Ready\nProgress: [----------] 0%\n',
56
+ 'ROADMAP.md': '# Roadmap\n\n## Phase 1: Test\n',
57
+ 'phases/': null,
58
+ };
59
+ if (extras) {
60
+ Object.assign(base, extras);
61
+ }
62
+ return createFixture(base);
63
+ }
64
+
65
+ // Versioned STATE.md with current_milestone frontmatter so resolveMilestoneVersion → v26.0.
66
+ const VERSIONED_STATE =
67
+ '---\ncurrent_milestone: v26.0\nmilestone: v26.0\n---\n\n# Project State\n\nPhase: 1\nStatus: Ready\n';
68
+
69
+ describe('cmdMilestoneComplete versioned structural archival', () => {
70
+ let fixture;
71
+
72
+ afterEach(() => {
73
+ if (fixture) fixture.cleanup();
74
+ fixture = undefined;
75
+ });
76
+
77
+ it('versioned single-move: archives phases/v26.0 to milestones/v26.0-phases via one rename', () => {
78
+ fixture = createMilestoneFixture({
79
+ 'STATE.md': VERSIONED_STATE,
80
+ 'phases/v26.0/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
81
+ 'phases/v26.0/02-bar/02-bar-SUMMARY.md': '---\none-liner: bar\n---\n## Task 1\n',
82
+ });
83
+ const cwd = fixture.cwd;
84
+
85
+ const result = captureResult(() => {
86
+ cmdMilestoneComplete(cwd, 'v26.0', { archivePhases: true }, false);
87
+ });
88
+
89
+ assert.ok(
90
+ fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '01-foo')),
91
+ '01-foo should be archived under milestones/v26.0-phases/'
92
+ );
93
+ assert.ok(
94
+ fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '02-bar')),
95
+ '02-bar should be archived under milestones/v26.0-phases/'
96
+ );
97
+ assert.ok(
98
+ !fs.existsSync(path.join(cwd, 'phases', 'v26.0')),
99
+ 'phases/v26.0 should be MOVED (not copied) — source must no longer exist'
100
+ );
101
+ assert.ok(result, 'Should return result JSON');
102
+ assert.equal(result.archived.phases, true, 'archived.phases should be true');
103
+ });
104
+
105
+ it('versioned collision abort: aborts when milestones/v26.0-phases already exists, leaves source intact', () => {
106
+ fixture = createMilestoneFixture({
107
+ 'STATE.md': VERSIONED_STATE,
108
+ 'phases/v26.0/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
109
+ 'milestones/v26.0-phases/old/keep.md': 'PRESERVE ME',
110
+ });
111
+ const cwd = fixture.cwd;
112
+
113
+ captureResult(() => {
114
+ cmdMilestoneComplete(cwd, 'v26.0', { archivePhases: true }, false);
115
+ });
116
+
117
+ // Source NOT moved on collision.
118
+ assert.ok(
119
+ fs.existsSync(path.join(cwd, 'phases', 'v26.0', '01-foo')),
120
+ 'source phases/v26.0/01-foo should still exist after collision abort'
121
+ );
122
+ // Pre-existing archive untouched — no overwrite, no merge.
123
+ const keepPath = path.join(cwd, 'milestones', 'v26.0-phases', 'old', 'keep.md');
124
+ assert.ok(fs.existsSync(keepPath), 'pre-existing archive entry should be untouched');
125
+ assert.equal(
126
+ fs.readFileSync(keepPath, 'utf-8'),
127
+ 'PRESERVE ME',
128
+ 'pre-existing archive content should not be overwritten'
129
+ );
130
+ // No source dir merged into the existing archive.
131
+ assert.ok(
132
+ !fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '01-foo')),
133
+ 'source dir should NOT be merged into the existing archive'
134
+ );
135
+ });
136
+
137
+ it('mixed-layout: archives only phases/v26.0, leaves sibling flat phases/NN-slug dirs untouched', () => {
138
+ fixture = createMilestoneFixture({
139
+ 'STATE.md': VERSIONED_STATE,
140
+ 'phases/v26.0/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
141
+ 'phases/90-legacy/PLAN.md': '# Legacy flat phase',
142
+ });
143
+ const cwd = fixture.cwd;
144
+
145
+ captureResult(() => {
146
+ cmdMilestoneComplete(cwd, 'v26.0', { archivePhases: true }, false);
147
+ });
148
+
149
+ assert.ok(
150
+ fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '01-foo')),
151
+ 'versioned phase 01-foo should be archived'
152
+ );
153
+ assert.ok(
154
+ fs.existsSync(path.join(cwd, 'phases', '90-legacy', 'PLAN.md')),
155
+ 'unrelated sibling flat phase phases/90-legacy/ must NOT be disturbed'
156
+ );
157
+ assert.ok(
158
+ !fs.existsSync(path.join(cwd, 'phases', 'v26.0')),
159
+ 'versioned subtree should be moved away'
160
+ );
161
+ });
162
+
163
+ it('flat mode preserved: per-directory filtered move still works (no frontmatter version)', () => {
164
+ fixture = createMilestoneFixture({
165
+ 'ROADMAP.md': '# Roadmap\n\n## Phase 1: Test\n',
166
+ 'phases/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
167
+ });
168
+ const cwd = fixture.cwd;
169
+
170
+ const result = captureResult(() => {
171
+ cmdMilestoneComplete(cwd, 'v1.0', { archivePhases: true }, false);
172
+ });
173
+
174
+ assert.ok(
175
+ fs.existsSync(path.join(cwd, 'milestones', 'v1.0-phases', '01-foo')),
176
+ 'flat per-dir move should archive 01-foo to milestones/v1.0-phases/'
177
+ );
178
+ // Flat phases/ is mkdir-archived FROM, not moved wholesale → it still exists as a dir.
179
+ assert.ok(
180
+ fs.existsSync(path.join(cwd, 'phases')),
181
+ 'flat phases/ directory should still exist (archived from, not moved wholesale)'
182
+ );
183
+ assert.ok(result, 'Should return result JSON');
184
+ assert.equal(result.archived.phases, true, 'archived.phases should be true in flat mode');
185
+ });
186
+ });