@ktpartners/dgs-platform 3.4.2 → 3.5.1

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 (49) hide show
  1. package/CHANGELOG.md +28 -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 +34 -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 +132 -67
  20. package/deliver-great-systems/bin/lib/jobs.test.cjs +157 -13
  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 +21 -11
  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/references/git-integration.md +1 -1
  38. package/deliver-great-systems/templates/milestone-archive.md +1 -1
  39. package/deliver-great-systems/templates/roadmap.md +12 -10
  40. package/deliver-great-systems/workflows/abandon-milestone.md +8 -1
  41. package/deliver-great-systems/workflows/abandon-quick.md +1 -1
  42. package/deliver-great-systems/workflows/codereview.md +1 -1
  43. package/deliver-great-systems/workflows/complete-milestone.md +1 -1
  44. package/deliver-great-systems/workflows/execute-phase.md +2 -2
  45. package/deliver-great-systems/workflows/execute-plan.md +2 -2
  46. package/deliver-great-systems/workflows/new-milestone.md +46 -12
  47. package/deliver-great-systems/workflows/quick-abandon.md +1 -1
  48. package/deliver-great-systems/workflows/quick.md +3 -3
  49. package/package.json +3 -2
@@ -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
@@ -1155,52 +1157,77 @@ function analyzeMilestonePhases(cwd, version) {
1155
1157
  );
1156
1158
  const sectionMatch = content.match(sectionHeadingPattern);
1157
1159
  if (!sectionMatch) {
1158
- throw new Error(`Milestone ${version} not found in ROADMAP.md`);
1159
- }
1160
+ // No ##-#### heading carries the version. Support the newer
1161
+ // single-milestone ROADMAP format where the version appears ONLY in the
1162
+ // `# Roadmap: ... (vX.Y)` title and a `**Milestone:** vX.Y` bold line.
1163
+ // Gate the whole-roadmap phase scan behind a single-milestone check so a
1164
+ // stray bold line on a multi-milestone roadmap does not mis-derive a range.
1165
+ const boldMilestonePattern = new RegExp(
1166
+ '^\\*\\*Milestone:\\*\\*\\s*' + escapedVersion + '\\b',
1167
+ 'im'
1168
+ );
1169
+ const versionHeadings = content.match(/^#{2,4}\s+.*v\d+\.\d+/gim) || [];
1170
+ const isSingleMilestone = versionHeadings.length <= 1;
1171
+ if (boldMilestonePattern.test(content) && isSingleMilestone) {
1172
+ const fullPhasePattern = /Phase\s+(\d+)/gi;
1173
+ const phaseNumbers = [];
1174
+ let fpMatch;
1175
+ while ((fpMatch = fullPhasePattern.exec(content)) !== null) {
1176
+ phaseNumbers.push(parseInt(fpMatch[1], 10));
1177
+ }
1178
+ if (phaseNumbers.length === 0) {
1179
+ throw new Error(`Milestone ${version} not found in ROADMAP.md`);
1180
+ }
1181
+ phaseStart = Math.min(...phaseNumbers);
1182
+ phaseEnd = Math.max(...phaseNumbers);
1183
+ } else {
1184
+ throw new Error(`Milestone ${version} not found in ROADMAP.md`);
1185
+ }
1186
+ } else {
1187
+ const headingLevel = sectionMatch[1].length;
1188
+ const sectionStart = sectionMatch.index + sectionMatch[0].length;
1160
1189
 
1161
- const headingLevel = sectionMatch[1].length;
1162
- const sectionStart = sectionMatch.index + sectionMatch[0].length;
1190
+ // Find the end of this section (next heading at same or higher level)
1191
+ const endPattern = new RegExp(
1192
+ `^#{2,${headingLevel}}\\s+`,
1193
+ 'im'
1194
+ );
1195
+ const restContent = content.slice(sectionStart);
1196
+ const endMatch = restContent.match(endPattern);
1197
+ const sectionContent = endMatch
1198
+ ? restContent.slice(0, endMatch.index)
1199
+ : restContent;
1200
+
1201
+ // Collect Phase N: headers within this section
1202
+ const phaseHeaderPattern = /Phase\s+(\d+)\s*:/gi;
1203
+ const phaseNumbers = [];
1204
+ let phMatch;
1205
+ while ((phMatch = phaseHeaderPattern.exec(sectionContent)) !== null) {
1206
+ phaseNumbers.push(parseInt(phMatch[1], 10));
1207
+ }
1163
1208
 
1164
- // Find the end of this section (next heading at same or higher level)
1165
- const endPattern = new RegExp(
1166
- `^#{2,${headingLevel}}\\s+`,
1167
- 'im'
1168
- );
1169
- const restContent = content.slice(sectionStart);
1170
- const endMatch = restContent.match(endPattern);
1171
- const sectionContent = endMatch
1172
- ? restContent.slice(0, endMatch.index)
1173
- : restContent;
1174
-
1175
- // Collect Phase N: headers within this section
1176
- const phaseHeaderPattern = /Phase\s+(\d+)\s*:/gi;
1177
- const phaseNumbers = [];
1178
- let phMatch;
1179
- while ((phMatch = phaseHeaderPattern.exec(sectionContent)) !== null) {
1180
- phaseNumbers.push(parseInt(phMatch[1], 10));
1181
- }
1209
+ // If section-based search found nothing (common when ROADMAP has peer-level
1210
+ // ## headings like ## v1.0, ## Overview, ## Phase Details), search the entire
1211
+ // ROADMAP for Phase N: headings. For single-milestone ROADMAPs all phases
1212
+ // belong to the milestone.
1213
+ if (phaseNumbers.length === 0) {
1214
+ const fullPhasePattern = new RegExp(
1215
+ `Phase\\s+(\\d+)`,
1216
+ 'gi'
1217
+ );
1218
+ let fpMatch;
1219
+ while ((fpMatch = fullPhasePattern.exec(content)) !== null) {
1220
+ phaseNumbers.push(parseInt(fpMatch[1], 10));
1221
+ }
1222
+ }
1182
1223
 
1183
- // If section-based search found nothing (common when ROADMAP has peer-level
1184
- // ## headings like ## v1.0, ## Overview, ## Phase Details), search the entire
1185
- // ROADMAP for Phase N: headings. For single-milestone ROADMAPs all phases
1186
- // belong to the milestone.
1187
- if (phaseNumbers.length === 0) {
1188
- const fullPhasePattern = new RegExp(
1189
- `Phase\\s+(\\d+)`,
1190
- 'gi'
1191
- );
1192
- let fpMatch;
1193
- while ((fpMatch = fullPhasePattern.exec(content)) !== null) {
1194
- phaseNumbers.push(parseInt(fpMatch[1], 10));
1224
+ if (phaseNumbers.length === 0) {
1225
+ throw new Error(`Milestone ${version} not found in ROADMAP.md`);
1195
1226
  }
1196
- }
1197
1227
 
1198
- if (phaseNumbers.length === 0) {
1199
- throw new Error(`Milestone ${version} not found in ROADMAP.md`);
1228
+ phaseStart = Math.min(...phaseNumbers);
1229
+ phaseEnd = Math.max(...phaseNumbers);
1200
1230
  }
1201
-
1202
- phaseStart = Math.min(...phaseNumbers);
1203
- phaseEnd = Math.max(...phaseNumbers);
1204
1231
  }
1205
1232
 
1206
1233
  // Extract all phase detail sections: ### Phase N: Name
@@ -1222,12 +1249,12 @@ function analyzeMilestonePhases(cwd, version) {
1222
1249
  let diskStatus = 'no_directory';
1223
1250
 
1224
1251
  try {
1225
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1252
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
1226
1253
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1227
1254
  const dirMatch = dirs.find(d => d.startsWith(padded + '-') || d === padded);
1228
1255
 
1229
1256
  if (dirMatch) {
1230
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch));
1257
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, dirMatch));
1231
1258
  const planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
1232
1259
  const summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
1233
1260
  const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
@@ -1270,7 +1297,13 @@ function detectActiveMilestone(content) {
1270
1297
  if (match) return match[1];
1271
1298
  // Fallback: milestone list item with (in progress)
1272
1299
  const listMatch = content.match(/-\s+.*?(v\d+\.\d+).*?\((?:in\s+progress)\)/i);
1273
- return listMatch ? listMatch[1] : null;
1300
+ if (listMatch) return listMatch[1];
1301
+ // Final fallback: newer single-milestone format with a `**Milestone:** vX.Y`
1302
+ // bold line. Runs LAST so legacy In-Progress heading/list signals win when
1303
+ // present on multi-milestone roadmaps.
1304
+ const boldMatch = content.match(/^\*\*Milestone:\*\*\s*(v\d+\.\d+)/im);
1305
+ if (boldMatch) return boldMatch[1];
1306
+ return null;
1274
1307
  }
1275
1308
 
1276
1309
  // ─── Milestone Job CLI Wrappers ─────────────────────────────────────────────
@@ -1517,37 +1550,63 @@ function cancelJob(cwd, version) {
1517
1550
  }
1518
1551
 
1519
1552
  /**
1520
- * Validate jobs directory structure. Auto-create missing directories.
1553
+ * Validate jobs directory structure (flat-first).
1554
+ *
1555
+ * Scans the flat `jobs/milestone-*.md` layout first. Legacy
1556
+ * `pending/in-progress/completed` subdirs are an existence-gated optional
1557
+ * fallback: they are counted + parse-validated when present, but are never
1558
+ * created and their absence is not an issue. Only genuine parse failures
1559
+ * flip `healthy` to false.
1521
1560
  *
1522
1561
  * @param {string} cwd - Working directory
1523
1562
  * @returns {{ healthy: boolean, directories: Array, job_count: number, issues?: Array }}
1524
1563
  */
1525
1564
  function healthCheck(cwd) {
1526
1565
  const jobsDir = path.join(getPlanningRoot(cwd), 'jobs');
1527
- const requiredDirs = ['pending', 'in-progress', 'completed'];
1528
1566
  const directories = [];
1529
1567
  const issues = [];
1530
1568
  let jobCount = 0;
1531
1569
 
1532
- for (const dirName of requiredDirs) {
1533
- const dirPath = path.join(jobsDir, dirName);
1534
- const exists = fs.existsSync(dirPath);
1535
- let created = false;
1570
+ // An absent/empty jobs dir is healthy. Ensure it exists, but do not flag it.
1571
+ let jobsCreated = false;
1572
+ if (!fs.existsSync(jobsDir)) {
1573
+ fs.mkdirSync(jobsDir, { recursive: true });
1574
+ jobsCreated = true;
1575
+ }
1536
1576
 
1537
- if (!exists) {
1538
- fs.mkdirSync(dirPath, { recursive: true });
1539
- created = true;
1540
- issues.push(`Missing directory auto-created: ${dirName}/`);
1577
+ // Shared flat-job predicate (identical to findJobFile/listJobs). Excludes
1578
+ // summary files (job-*-SUMMARY.md) since they do not start with 'milestone-'.
1579
+ const isJobFile = f => f.startsWith('milestone-') && f.endsWith('.md');
1580
+
1581
+ // Flat-first scan: jobs/milestone-*.md
1582
+ try {
1583
+ const flatFiles = fs.readdirSync(jobsDir).filter(isJobFile);
1584
+ jobCount += flatFiles.length;
1585
+ for (const file of flatFiles) {
1586
+ const filePath = path.join(jobsDir, file);
1587
+ try {
1588
+ parseJobFile(filePath);
1589
+ } catch (err) {
1590
+ issues.push(`Parse failure in ${file}: ${err.message}`);
1591
+ }
1541
1592
  }
1593
+ } catch {
1594
+ // jobs dir unreadable; treat as empty.
1595
+ }
1542
1596
 
1543
- directories.push({ name: dirName, exists: exists || created, created });
1597
+ directories.push({ name: 'jobs', exists: true, created: jobsCreated });
1598
+
1599
+ // Legacy subdirs: existence-gated optional fallback. Never created.
1600
+ const legacyDirs = ['pending', 'in-progress', 'completed'];
1601
+ for (const dirName of legacyDirs) {
1602
+ const dirPath = path.join(jobsDir, dirName);
1603
+ if (!fs.existsSync(dirPath)) continue;
1604
+
1605
+ directories.push({ name: dirName, exists: true, created: false });
1544
1606
 
1545
- // Scan for job files
1546
1607
  try {
1547
- const files = fs.readdirSync(dirPath).filter(f => f.startsWith('milestone-') && f.endsWith('.md'));
1608
+ const files = fs.readdirSync(dirPath).filter(isJobFile);
1548
1609
  jobCount += files.length;
1549
-
1550
- // Validate each file
1551
1610
  for (const file of files) {
1552
1611
  const filePath = path.join(dirPath, file);
1553
1612
  try {
@@ -1557,7 +1616,7 @@ function healthCheck(cwd) {
1557
1616
  }
1558
1617
  }
1559
1618
  } catch {
1560
- // Directory just created, no files
1619
+ // Subdir unreadable; skip.
1561
1620
  }
1562
1621
  }
1563
1622
 
@@ -1708,7 +1767,9 @@ function generateJobSummary(cwd, version) {
1708
1767
  try {
1709
1768
  const milestonePhases = analyzeMilestonePhases(cwd, normalizedVersion);
1710
1769
  const projectRoot = resolveProjectRoot(cwd);
1711
- const phasesDir = path.join(projectRoot, 'phases');
1770
+ let phasesAbs;
1771
+ try { phasesAbs = path.join(cwd, phasesDir(cwd)); }
1772
+ catch { phasesAbs = path.join(projectRoot, 'phases'); }
1712
1773
 
1713
1774
  for (const phase of milestonePhases) {
1714
1775
  const phaseNum = phase.number;
@@ -1717,7 +1778,7 @@ function generateJobSummary(cwd, version) {
1717
1778
  // Find the phase directory on disk
1718
1779
  let phaseDir = null;
1719
1780
  try {
1720
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1781
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
1721
1782
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1722
1783
  phaseDir = dirs.find(d => d.startsWith(padded + '-') || d === padded);
1723
1784
  } catch {}
@@ -1725,7 +1786,7 @@ function generateJobSummary(cwd, version) {
1725
1786
  if (!phaseDir) continue;
1726
1787
 
1727
1788
  // Look for UAT file
1728
- const uatPath = path.join(phasesDir, phaseDir, `${padded}-UAT.md`);
1789
+ const uatPath = path.join(phasesAbs, phaseDir, `${padded}-UAT.md`);
1729
1790
  let uatContent = null;
1730
1791
  try {
1731
1792
  uatContent = fs.readFileSync(uatPath, 'utf-8');
@@ -1921,7 +1982,9 @@ function rollbackJob(cwd, version) {
1921
1982
 
1922
1983
  // Identify executed phases from completed steps
1923
1984
  const projectRoot = resolveProjectRoot(cwd);
1924
- const phasesDir = path.join(projectRoot, 'phases');
1985
+ let phasesAbs;
1986
+ try { phasesAbs = path.join(cwd, phasesDir(cwd)); }
1987
+ catch { phasesAbs = path.join(projectRoot, 'phases'); }
1925
1988
 
1926
1989
  for (const step of parsed.steps) {
1927
1990
  if (step.status === 'completed' && step.command === 'execute-phase') {
@@ -1932,12 +1995,12 @@ function rollbackJob(cwd, version) {
1932
1995
  const padded = phaseNum.replace(/^(\d+)/, (m) => m.padStart(2, '0'));
1933
1996
 
1934
1997
  try {
1935
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1998
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
1936
1999
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1937
2000
  const dirMatch = dirs.find(d => d.startsWith(padded + '-') || d === padded);
1938
2001
 
1939
2002
  if (dirMatch) {
1940
- const phaseDir = path.join(phasesDir, dirMatch);
2003
+ const phaseDir = path.join(phasesAbs, dirMatch);
1941
2004
  const phaseFiles = fs.readdirSync(phaseDir);
1942
2005
  const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1943
2006
 
@@ -2111,6 +2174,8 @@ module.exports = {
2111
2174
  cmdJobsInsertPhaseGapFix,
2112
2175
  cmdJobsCreateMilestone,
2113
2176
  cmdJobsMilestonePreview,
2177
+ analyzeMilestonePhases,
2178
+ detectActiveMilestone,
2114
2179
  cmdJobsListJobs,
2115
2180
  cmdJobsCancelJob,
2116
2181
  cmdJobsRecordStartShas,
@@ -18,6 +18,7 @@ const {
18
18
  parseJobFile, updateJobStep, moveJobFile,
19
19
  generateMilestoneSteps, buildJobFileContent,
20
20
  cmdJobsCreateMilestone, cmdJobsMilestonePreview,
21
+ analyzeMilestonePhases, detectActiveMilestone,
21
22
  findJobFile, updateJobHeader, insertJobSteps,
22
23
  buildGapFixSteps, insertGapFixSection,
23
24
  listJobs, cancelJob, recordStartShas, rollbackJob,
@@ -1379,6 +1380,105 @@ Plans:
1379
1380
  });
1380
1381
  });
1381
1382
 
1383
+ // ─── Bold-line single-milestone format Tests ────────────────────────────
1384
+
1385
+ describe('bold-line single-milestone format', () => {
1386
+ let fixture;
1387
+
1388
+ afterEach(() => {
1389
+ if (fixture) fixture.cleanup();
1390
+ });
1391
+
1392
+ const BOLD_LINE_ROADMAP = `# Roadmap: Tenant data-quality metrics in Admin UI (v30.1)
1393
+
1394
+ **Milestone:** v30.1 — Tenant data-quality metrics in Admin UI
1395
+
1396
+ ## Overview
1397
+
1398
+ Single-milestone roadmap. Version is only in the title and the bold line.
1399
+
1400
+ ### Phase 1: Metrics schema
1401
+ **Goal**: Define metrics tables
1402
+ Plans:
1403
+ - [ ] 01-01-PLAN.md
1404
+
1405
+ ### Phase 2: Admin UI surface
1406
+ **Goal**: Surface metrics in admin
1407
+ Plans:
1408
+ - [ ] 02-01-PLAN.md
1409
+ `;
1410
+
1411
+ const MULTI_MILESTONE_WITH_BOLD = `# Roadmap (v9.9)
1412
+
1413
+ **Milestone:** v9.9 — stray bold line
1414
+
1415
+ ## v1.0 Foundation (SHIPPED)
1416
+ ### Phase 1: Base
1417
+ ### Phase 2: More base
1418
+
1419
+ ## v2.0 Next (In Progress)
1420
+ ### Phase 3: New
1421
+ ### Phase 4: Newer
1422
+ `;
1423
+
1424
+ it('detectActiveMilestone returns v30.1 from the bold line', () => {
1425
+ assert.equal(detectActiveMilestone(BOLD_LINE_ROADMAP), 'v30.1');
1426
+ });
1427
+
1428
+ it('analyzeMilestonePhases derives phases 1 and 2 from the bold-line roadmap', () => {
1429
+ fixture = createFixture({
1430
+ 'ROADMAP.md': BOLD_LINE_ROADMAP,
1431
+ 'phases/01-metrics-schema/01-01-PLAN.md': '',
1432
+ 'phases/02-admin-ui/02-01-PLAN.md': '',
1433
+ });
1434
+
1435
+ const phases = analyzeMilestonePhases(fixture.cwd, 'v30.1');
1436
+ assert.equal(phases.length, 2);
1437
+ assert.deepEqual(phases.map(p => p.number), ['1', '2']);
1438
+ });
1439
+
1440
+ it('cmdJobsMilestonePreview succeeds with an explicit version', () => {
1441
+ fixture = createFixture({
1442
+ 'ROADMAP.md': BOLD_LINE_ROADMAP,
1443
+ 'phases/01-metrics-schema/01-01-PLAN.md': '',
1444
+ 'phases/02-admin-ui/02-01-PLAN.md': '',
1445
+ });
1446
+
1447
+ const result = cmdJobsMilestonePreview(fixture.cwd, 'v30.1', true, false);
1448
+ assert.equal(result.preview, true);
1449
+ assert.equal(result.version, 'v30.1');
1450
+ assert.ok(result.step_count > 0, 'expected at least one step');
1451
+ });
1452
+
1453
+ it('cmdJobsMilestonePreview auto-detects the bold-line version', () => {
1454
+ fixture = createFixture({
1455
+ 'ROADMAP.md': BOLD_LINE_ROADMAP,
1456
+ 'phases/01-metrics-schema/01-01-PLAN.md': '',
1457
+ 'phases/02-admin-ui/02-01-PLAN.md': '',
1458
+ });
1459
+
1460
+ const result = cmdJobsMilestonePreview(fixture.cwd, null, true, false);
1461
+ assert.equal(result.version, 'v30.1');
1462
+ });
1463
+
1464
+ it('analyzeMilestonePhases throws on a multi-milestone roadmap with a stray bold line', () => {
1465
+ fixture = createFixture({
1466
+ 'ROADMAP.md': MULTI_MILESTONE_WITH_BOLD,
1467
+ 'phases/01-base/01-01-PLAN.md': '',
1468
+ 'phases/03-new/03-01-PLAN.md': '',
1469
+ });
1470
+
1471
+ assert.throws(
1472
+ () => analyzeMilestonePhases(fixture.cwd, 'v9.9'),
1473
+ (err) => /v9\.9|not found/i.test(err.message)
1474
+ );
1475
+ });
1476
+
1477
+ it('detectActiveMilestone prefers the In-Progress heading over the bold line', () => {
1478
+ assert.equal(detectActiveMilestone(MULTI_MILESTONE_WITH_BOLD), 'v2.0');
1479
+ });
1480
+ });
1481
+
1382
1482
  // ─── findJobFile Tests ──────────────────────────────────────────────────
1383
1483
 
1384
1484
  describe('findJobFile', () => {
@@ -2124,8 +2224,24 @@ Plans:
2124
2224
  });
2125
2225
 
2126
2226
  it('returns not_in_progress when job is in pending/', () => {
2227
+ // findJobFile's contract: frontmatter Status is authoritative, the
2228
+ // legacy directory is advisory only ("Frontmatter wins"). A genuinely
2229
+ // pending job therefore needs Status: pending in its frontmatter — using
2230
+ // a status:in-progress fixture here would (correctly) be cancellable.
2231
+ const PENDING_JOB = `# Milestone Job: v6.0
2232
+
2233
+ **Version:** v6.0
2234
+ **Created:** 2026-03-02T10:00:00Z
2235
+ **Status:** pending
2236
+ **Check:** true
2237
+
2238
+ ## Steps
2239
+
2240
+ - [ ] \`/dgs:plan-phase 41\`
2241
+ - [ ] \`/dgs:execute-phase 41\`
2242
+ `;
2127
2243
  fixture = createFixture({
2128
- 'jobs/pending/milestone-v6.0.md': WELL_FORMED_JOB,
2244
+ 'jobs/pending/milestone-v6.0.md': PENDING_JOB,
2129
2245
  });
2130
2246
  const result = cancelJob(fixture.cwd, 'v6.0');
2131
2247
 
@@ -2166,18 +2282,46 @@ Plans:
2166
2282
  assert.equal(result.job_count, 0);
2167
2283
  });
2168
2284
 
2169
- it('returns healthy: false listing issues for missing directories, then auto-creates them', () => {
2285
+ it('does not create legacy subdirs and stays healthy when they are absent', () => {
2170
2286
  fixture = createFixture({
2171
2287
  '': null,
2172
2288
  });
2173
2289
  const result = healthCheck(fixture.cwd);
2174
2290
 
2175
- // Should report missing directories but auto-create them
2291
+ // An empty project (no legacy subdirs) is healthy with no jobs.
2292
+ assert.equal(result.healthy, true);
2293
+ assert.equal(result.job_count, 0);
2176
2294
  assert.ok(Array.isArray(result.directories));
2177
- // After auto-create, directories should exist
2178
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')));
2179
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')));
2180
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')));
2295
+ // Legacy subdirs must NOT be auto-created.
2296
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')), false);
2297
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')), false);
2298
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')), false);
2299
+ });
2300
+
2301
+ it('reports healthy: true for a flat jobs layout and does not create legacy subdirs', () => {
2302
+ fixture = createFixture({
2303
+ 'jobs/milestone-v6.0.md': WELL_FORMED_JOB,
2304
+ });
2305
+ const result = healthCheck(fixture.cwd);
2306
+
2307
+ assert.equal(result.healthy, true);
2308
+ assert.equal(result.job_count, 1);
2309
+ assert.equal(result.issues.length, 0);
2310
+ // No legacy subdirs created for a flat layout.
2311
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')), false);
2312
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')), false);
2313
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')), false);
2314
+ });
2315
+
2316
+ it('excludes job-*-SUMMARY.md files from job_count in a flat layout', () => {
2317
+ fixture = createFixture({
2318
+ 'jobs/milestone-v6.0.md': WELL_FORMED_JOB,
2319
+ 'jobs/job-v6.0-SUMMARY.md': '# Summary',
2320
+ });
2321
+ const result = healthCheck(fixture.cwd);
2322
+
2323
+ assert.equal(result.job_count, 1);
2324
+ assert.equal(result.healthy, true);
2181
2325
  });
2182
2326
 
2183
2327
  it('validates each job file parses successfully; reports parse failures as issues', () => {
@@ -2530,14 +2674,14 @@ describe('jobs root-layout', () => {
2530
2674
 
2531
2675
  });
2532
2676
 
2533
- it('healthCheck creates jobs dirs at root layout', () => {
2677
+ it('healthCheck does not create legacy subdirs at root layout', () => {
2534
2678
  fixture = createTempProject({ layout: 'root' });
2535
2679
  const result = healthCheck(fixture.cwd);
2536
- assert.ok(result.directories.length >= 3);
2537
- // Verify dirs were created at root
2538
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')));
2539
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')));
2540
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')));
2680
+ assert.equal(result.healthy, true);
2681
+ // Legacy subdirs must NOT be auto-created.
2682
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')), false);
2683
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')), false);
2684
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')), false);
2541
2685
  });
2542
2686
 
2543
2687
  it('listJobs works in root layout', () => {
@@ -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(() => {