@ktpartners/dgs-platform 3.5.0 → 3.5.3

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 (36) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/bin/install.js +22 -0
  3. package/deliver-great-systems/bin/lib/core.cjs +21 -0
  4. package/deliver-great-systems/bin/lib/core.test.cjs +66 -0
  5. package/deliver-great-systems/bin/lib/ideas.cjs +39 -11
  6. package/deliver-great-systems/bin/lib/ideas.test.cjs +32 -3
  7. package/deliver-great-systems/bin/lib/init.cjs +23 -0
  8. package/deliver-great-systems/bin/lib/init.test.cjs +78 -0
  9. package/deliver-great-systems/bin/lib/jobs.cjs +194 -83
  10. package/deliver-great-systems/bin/lib/jobs.test.cjs +272 -15
  11. package/deliver-great-systems/bin/lib/overlap.cjs +14 -4
  12. package/deliver-great-systems/bin/lib/overlap.test.cjs +13 -0
  13. package/deliver-great-systems/bin/lib/package-scan-report.cjs +19 -0
  14. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +18 -0
  15. package/deliver-great-systems/bin/lib/phase.cjs +12 -4
  16. package/deliver-great-systems/bin/lib/phase.test.cjs +69 -0
  17. package/deliver-great-systems/bin/lib/projects.cjs +13 -3
  18. package/deliver-great-systems/bin/lib/projects.test.cjs +8 -0
  19. package/deliver-great-systems/bin/lib/roadmap.cjs +26 -5
  20. package/deliver-great-systems/bin/lib/roadmap.test.cjs +86 -0
  21. package/deliver-great-systems/bin/lib/search.cjs +62 -15
  22. package/deliver-great-systems/bin/lib/search.test.cjs +94 -0
  23. package/deliver-great-systems/bin/lib/verify.cjs +37 -20
  24. package/deliver-great-systems/bin/lib/verify.test.cjs +58 -0
  25. package/deliver-great-systems/references/git-integration.md +1 -1
  26. package/deliver-great-systems/workflows/audit-milestone.md +1 -1
  27. package/deliver-great-systems/workflows/cleanup.md +1 -1
  28. package/deliver-great-systems/workflows/codereview.md +1 -1
  29. package/deliver-great-systems/workflows/complete-milestone.md +3 -3
  30. package/deliver-great-systems/workflows/discuss-phase.md +5 -1
  31. package/deliver-great-systems/workflows/execute-phase.md +2 -2
  32. package/deliver-great-systems/workflows/execute-plan.md +2 -2
  33. package/deliver-great-systems/workflows/pause-work.md +1 -1
  34. package/deliver-great-systems/workflows/plan-phase.md +6 -2
  35. package/deliver-great-systems/workflows/resume-project.md +2 -2
  36. package/package.json +1 -1
@@ -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,199 @@ 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
+
1482
+ // ─── Ad-hoc container format ────────────────────────────────────────────
1483
+ describe('ad-hoc container format', () => {
1484
+ let fixture;
1485
+
1486
+ afterEach(() => {
1487
+ if (fixture) fixture.cleanup();
1488
+ });
1489
+
1490
+ // Bare seedAdhocRoadmapSection output: version on the heading, in-progress
1491
+ // on a SEPARATE **Status:** line, NO parenthetical on the heading, and NO
1492
+ // phase headings. Used only for the pure detectActiveMilestone assertions.
1493
+ const ADHOC_ROADMAP = `# Roadmap
1494
+
1495
+ ## Active Milestone: v25.1 add-prs
1496
+
1497
+ **Goal:** add-prs
1498
+ **Status:** In progress (ad-hoc container — phases added on demand via /dgs:add-phase)
1499
+
1500
+ Phases:
1501
+
1502
+ ---
1503
+ `;
1504
+
1505
+ // Ad-hoc container that has accrued a phase (heading + matching PLAN dir),
1506
+ // so analyzeMilestonePhases (called by cmdJobsMilestonePreview) resolves
1507
+ // rather than throwing "Milestone vX.Y not found".
1508
+ const ADHOC_ROADMAP_WITH_PHASE = `# Roadmap
1509
+
1510
+ ## Active Milestone: v25.1 add-prs
1511
+
1512
+ **Goal:** add-prs
1513
+ **Status:** In progress (ad-hoc container — phases added on demand via /dgs:add-phase)
1514
+
1515
+ Phases:
1516
+
1517
+ ### Phase 1: First ad-hoc phase
1518
+ **Goal**: Do the first thing
1519
+ Plans:
1520
+ - [ ] 01-01-PLAN.md
1521
+
1522
+ ---
1523
+ `;
1524
+
1525
+ // Local copies of the multi-milestone / bold-line fixtures (the originals
1526
+ // are scoped to the sibling describe block) for the over-match guard and
1527
+ // bold-line regression assertions.
1528
+ const MULTI_MILESTONE_WITH_BOLD = `# Roadmap (v9.9)
1529
+
1530
+ **Milestone:** v9.9 — stray bold line
1531
+
1532
+ ## v1.0 Foundation (SHIPPED)
1533
+ ### Phase 1: Base
1534
+ ### Phase 2: More base
1535
+
1536
+ ## v2.0 Next (In Progress)
1537
+ ### Phase 3: New
1538
+ ### Phase 4: Newer
1539
+ `;
1540
+
1541
+ const BOLD_LINE_ROADMAP = `# Roadmap: Tenant data-quality metrics in Admin UI (v30.1)
1542
+
1543
+ **Milestone:** v30.1 — Tenant data-quality metrics in Admin UI
1544
+
1545
+ ## Overview
1546
+
1547
+ Single-milestone roadmap. Version is only in the title and the bold line.
1548
+
1549
+ ### Phase 1: Metrics schema
1550
+ ### Phase 2: Admin UI surface
1551
+ `;
1552
+
1553
+ it('detectActiveMilestone resolves v25.1 from the ad-hoc heading', () => {
1554
+ assert.equal(detectActiveMilestone(ADHOC_ROADMAP), 'v25.1');
1555
+ });
1556
+
1557
+ it('detectActiveMilestone prefers the In-Progress heading over an ad-hoc branch (no hijack)', () => {
1558
+ assert.equal(detectActiveMilestone(MULTI_MILESTONE_WITH_BOLD), 'v2.0');
1559
+ });
1560
+
1561
+ it('detectActiveMilestone leaves the bold-line branch unchanged', () => {
1562
+ assert.equal(detectActiveMilestone(BOLD_LINE_ROADMAP), 'v30.1');
1563
+ });
1564
+
1565
+ it('cmdJobsMilestonePreview auto-detects v25.1 from an ad-hoc roadmap', () => {
1566
+ fixture = createFixture({
1567
+ 'ROADMAP.md': ADHOC_ROADMAP_WITH_PHASE,
1568
+ 'phases/01-first-ad-hoc-phase/01-01-PLAN.md': '',
1569
+ });
1570
+
1571
+ const result = cmdJobsMilestonePreview(fixture.cwd, null, true, false);
1572
+ assert.equal(result.version, 'v25.1');
1573
+ });
1574
+ });
1575
+
1382
1576
  // ─── findJobFile Tests ──────────────────────────────────────────────────
1383
1577
 
1384
1578
  describe('findJobFile', () => {
@@ -1986,10 +2180,11 @@ Plans:
1986
2180
  assert.deepEqual(result.completed, []);
1987
2181
  });
1988
2182
 
1989
- it('returns jobs grouped by status with correct fields', () => {
2183
+ it('groups FLAT jobs by frontmatter status with correct fields', () => {
2184
+ // Flat layout: jobs/milestone-*.md grouped by parsed status header.
1990
2185
  fixture = createFixture({
1991
- 'jobs/in-progress/milestone-v6.0.md': WELL_FORMED_JOB,
1992
- 'jobs/completed/milestone-v5.0.md': ALL_COMPLETED_JOB,
2186
+ 'jobs/milestone-v6.0.md': WELL_FORMED_JOB,
2187
+ 'jobs/milestone-v5.0.md': ALL_COMPLETED_JOB,
1993
2188
  });
1994
2189
  const result = listJobs(fixture.cwd);
1995
2190
 
@@ -2011,6 +2206,40 @@ Plans:
2011
2206
  assert.equal(comp.progress, '2/2');
2012
2207
  });
2013
2208
 
2209
+ it('still lists legacy-subdir jobs (back-compat)', () => {
2210
+ fixture = createFixture({
2211
+ 'jobs/in-progress/milestone-v6.0.md': WELL_FORMED_JOB,
2212
+ 'jobs/completed/milestone-v5.0.md': ALL_COMPLETED_JOB,
2213
+ });
2214
+ const result = listJobs(fixture.cwd);
2215
+
2216
+ assert.equal(result.in_progress.length, 1);
2217
+ assert.equal(result.completed.length, 1);
2218
+ assert.equal(result.in_progress[0].version, 'v6.0');
2219
+ assert.equal(result.completed[0].version, 'v5.0');
2220
+ });
2221
+
2222
+ it('dedupes a job present both flat and in a legacy subdir (by version)', () => {
2223
+ fixture = createFixture({
2224
+ 'jobs/milestone-v6.0.md': WELL_FORMED_JOB,
2225
+ 'jobs/in-progress/milestone-v6.0.md': WELL_FORMED_JOB,
2226
+ });
2227
+ const result = listJobs(fixture.cwd);
2228
+ assert.equal(result.in_progress.length, 1, 'flat + legacy same version should dedupe to 1');
2229
+ assert.equal(result.in_progress[0].version, 'v6.0');
2230
+ });
2231
+
2232
+ it('creates no legacy subdirs (read-only on the filesystem)', () => {
2233
+ fixture = createFixture({
2234
+ 'jobs/milestone-v6.0.md': WELL_FORMED_JOB,
2235
+ });
2236
+ listJobs(fixture.cwd);
2237
+
2238
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')), false);
2239
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')), false);
2240
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')), false);
2241
+ });
2242
+
2014
2243
  it('shows check flag false when --no-check was used', () => {
2015
2244
  fixture = createFixture({
2016
2245
  'jobs/completed/milestone-v5.0.md': ALL_COMPLETED_JOB,
@@ -2182,18 +2411,46 @@ Plans:
2182
2411
  assert.equal(result.job_count, 0);
2183
2412
  });
2184
2413
 
2185
- it('returns healthy: false listing issues for missing directories, then auto-creates them', () => {
2414
+ it('does not create legacy subdirs and stays healthy when they are absent', () => {
2186
2415
  fixture = createFixture({
2187
2416
  '': null,
2188
2417
  });
2189
2418
  const result = healthCheck(fixture.cwd);
2190
2419
 
2191
- // Should report missing directories but auto-create them
2420
+ // An empty project (no legacy subdirs) is healthy with no jobs.
2421
+ assert.equal(result.healthy, true);
2422
+ assert.equal(result.job_count, 0);
2192
2423
  assert.ok(Array.isArray(result.directories));
2193
- // After auto-create, directories should exist
2194
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')));
2195
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')));
2196
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')));
2424
+ // Legacy subdirs must NOT be auto-created.
2425
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')), false);
2426
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')), false);
2427
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')), false);
2428
+ });
2429
+
2430
+ it('reports healthy: true for a flat jobs layout and does not create legacy subdirs', () => {
2431
+ fixture = createFixture({
2432
+ 'jobs/milestone-v6.0.md': WELL_FORMED_JOB,
2433
+ });
2434
+ const result = healthCheck(fixture.cwd);
2435
+
2436
+ assert.equal(result.healthy, true);
2437
+ assert.equal(result.job_count, 1);
2438
+ assert.equal(result.issues.length, 0);
2439
+ // No legacy subdirs created for a flat layout.
2440
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')), false);
2441
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')), false);
2442
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')), false);
2443
+ });
2444
+
2445
+ it('excludes job-*-SUMMARY.md files from job_count in a flat layout', () => {
2446
+ fixture = createFixture({
2447
+ 'jobs/milestone-v6.0.md': WELL_FORMED_JOB,
2448
+ 'jobs/job-v6.0-SUMMARY.md': '# Summary',
2449
+ });
2450
+ const result = healthCheck(fixture.cwd);
2451
+
2452
+ assert.equal(result.job_count, 1);
2453
+ assert.equal(result.healthy, true);
2197
2454
  });
2198
2455
 
2199
2456
  it('validates each job file parses successfully; reports parse failures as issues', () => {
@@ -2546,14 +2803,14 @@ describe('jobs root-layout', () => {
2546
2803
 
2547
2804
  });
2548
2805
 
2549
- it('healthCheck creates jobs dirs at root layout', () => {
2806
+ it('healthCheck does not create legacy subdirs at root layout', () => {
2550
2807
  fixture = createTempProject({ layout: 'root' });
2551
2808
  const result = healthCheck(fixture.cwd);
2552
- assert.ok(result.directories.length >= 3);
2553
- // Verify dirs were created at root
2554
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')));
2555
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')));
2556
- assert.ok(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')));
2809
+ assert.equal(result.healthy, true);
2810
+ // Legacy subdirs must NOT be auto-created.
2811
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'pending')), false);
2812
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'in-progress')), false);
2813
+ assert.equal(fs.existsSync(path.join(fixture.cwd, 'jobs', 'completed')), false);
2557
2814
  });
2558
2815
 
2559
2816
  it('listJobs works in root layout', () => {
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { safeReadFile, getProjectFolders, output, error, getProjectDir } = require('./core.cjs');
11
+ const { safeReadFile, getProjectFolders, output, error, getProjectDir, isValidMilestoneVersion } = require('./core.cjs');
12
12
  const { parseProjectsMd, readProjectState } = require('./projects.cjs');
13
13
  const { parseReposMd, resolveFileToRepo } = require('./repos.cjs');
14
14
 
@@ -32,18 +32,28 @@ function scanProjectPlanFiles(cwd, slug) {
32
32
  const phasesDir = path.join(getProjectDir(cwd, slug), 'phases');
33
33
  const results = [];
34
34
 
35
+ // Per-milestone versioned layout: descend a single vN.N child dir if present
36
+ // (phases/<version>/NN-slug). Detect via core.isValidMilestoneVersion — no
37
+ // parallel version regex. No version child → stay flat (back-compat).
38
+ let scanRoot = phasesDir;
35
39
  try {
36
- const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
40
+ const kids = fs.readdirSync(scanRoot, { withFileTypes: true });
41
+ const vd = kids.find(e => e.isDirectory() && isValidMilestoneVersion(e.name));
42
+ if (vd) scanRoot = path.join(scanRoot, vd.name);
43
+ } catch {}
44
+
45
+ try {
46
+ const phaseDirs = fs.readdirSync(scanRoot, { withFileTypes: true })
37
47
  .filter(e => e.isDirectory())
38
48
  .map(e => e.name);
39
49
 
40
50
  // Exclude archived phase directories
41
51
  const activePhaseDirs = excludeArchivedPhases(
42
- phaseDirs.map(d => path.join(phasesDir, d))
52
+ phaseDirs.map(d => path.join(scanRoot, d))
43
53
  ).map(d => path.basename(d));
44
54
 
45
55
  for (const phaseDir of activePhaseDirs) {
46
- const fullPhaseDir = path.join(phasesDir, phaseDir);
56
+ const fullPhaseDir = path.join(scanRoot, phaseDir);
47
57
  let files;
48
58
  try {
49
59
  files = fs.readdirSync(fullPhaseDir)
@@ -85,6 +85,19 @@ describe('scanProjectPlanFiles', () => {
85
85
  assert.deepStrictEqual(result[0].files, ['web-app/src/app.js', 'web-app/src/index.js']);
86
86
  });
87
87
 
88
+ it('descends versioned phases/<version>/ layout (260628-p59)', () => {
89
+ createProject(tmpDir, 'project-a');
90
+ // Plan lives one level deeper, under phases/v25.0/01-setup/
91
+ createPlanFile(tmpDir, 'project-a', 'v25.0/01-setup', '01-01-PLAN.md', [
92
+ { name: 'Task 1', files: ['web-app/src/index.js'], repos: ['web-app'] },
93
+ ]);
94
+ const result = scanProjectPlanFiles(tmpDir, 'project-a');
95
+ assert.strictEqual(result.length, 1, 'plan under phases/v25.0/ must be found');
96
+ assert.deepStrictEqual(result[0].repos, ['web-app']);
97
+ assert.deepStrictEqual(result[0].files, ['web-app/src/index.js']);
98
+ assert.strictEqual(result[0].plan, '01-setup/01-01-PLAN.md');
99
+ });
100
+
88
101
  it('handles plans with multiple tasks', () => {
89
102
  createProject(tmpDir, 'project-a');
90
103
  createPlanFile(tmpDir, 'project-a', '01-setup', '01-01-PLAN.md', [
@@ -25,6 +25,7 @@ const fs = require('fs');
25
25
  const path = require('path');
26
26
  const { getPlanningRoot } = require('./paths.cjs');
27
27
  const { getLocalConfigPath } = require('./config.cjs');
28
+ const { isValidMilestoneVersion } = require('./core.cjs');
28
29
 
29
30
  // ─── Canonical frontmatter constants ─────────────────────────────────────────
30
31
  // Every findings[] entry emitted by this module carries these two constant
@@ -886,6 +887,24 @@ function _resolveReportPath(cwd, opts) {
886
887
  const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
887
888
  for (const phEnt of phaseEntries) {
888
889
  if (!phEnt.isDirectory()) continue;
890
+ // Per-milestone versioned layout: the immediate child is a vN.N dir, so
891
+ // the active phase lives one level deeper (phases/<version>/NN-slug).
892
+ // Detect via core.isValidMilestoneVersion (no parallel version regex)
893
+ // and match the active phase among the version dir's children.
894
+ if (isValidMilestoneVersion(phEnt.name)) {
895
+ const versionDir = path.join(phasesDir, phEnt.name);
896
+ let candidates;
897
+ try { candidates = fs.readdirSync(versionDir, { withFileTypes: true }); } catch { continue; }
898
+ for (const cand of candidates) {
899
+ if (!cand.isDirectory()) continue;
900
+ if (cand.name === activeContext) {
901
+ const cm = cand.name.match(/^(\d+)/);
902
+ const cprefix = cm ? cm[1] : cand.name;
903
+ return path.join(versionDir, cand.name, cprefix + '-PACKAGE-SCAN.md');
904
+ }
905
+ }
906
+ continue;
907
+ }
889
908
  if (phEnt.name === activeContext) {
890
909
  const m = phEnt.name.match(/^(\d+)/);
891
910
  const prefix = m ? m[1] : phEnt.name;
@@ -494,6 +494,24 @@ describe('_resolveReportPath — tier 1 (active phase)', () => {
494
494
  }
495
495
  });
496
496
 
497
+ test('active_context resolves under versioned phases/<version>/ layout (260628-p59)', () => {
498
+ tmpDir = setupPlanningRoot({
499
+ local: {
500
+ current_project: 'gsd',
501
+ execution: { active_context: '03-foo' },
502
+ },
503
+ });
504
+ // Phase dir lives one level deeper, under phases/v25.0/03-foo
505
+ const versionedPhaseDir = path.join(tmpDir, 'projects', 'gsd', 'phases', 'v25.0', '03-foo');
506
+ fs.mkdirSync(versionedPhaseDir, { recursive: true });
507
+ try {
508
+ const resolved = _resolveReportPath(tmpDir, {});
509
+ assert.equal(resolved, path.join(versionedPhaseDir, '03-PACKAGE-SCAN.md'));
510
+ } finally {
511
+ cleanup(tmpDir);
512
+ }
513
+ });
514
+
497
515
  test('active_context points at a phase that does NOT exist → falls through', () => {
498
516
  tmpDir = setupPlanningRoot({
499
517
  local: {
@@ -381,14 +381,22 @@ function cmdPhaseAdd(cwd, description, raw) {
381
381
  updatedContent = content + phaseEntry;
382
382
  }
383
383
 
384
- // Update milestone summary range (e.g., "Phases 124-128" -> "Phases 124-129")
385
- const rangePattern = /Phases\s+(\d+)-(\d+)\s*\(in progress\)/;
384
+ // Update milestone summary range. Handles both the legacy non-namespaced form
385
+ // ("Phases 124-128" -> "Phases 124-129") and the per-milestone-namespaced form
386
+ // ("Phases v25.1/1-9" -> "Phases v25.1/1-10"). In the live namespaced layout the
387
+ // START endpoint carries the `vX.Y/` prefix while the END endpoint is BARE
388
+ // (e.g. start "v25.1/1", end "9"), so the prefix is derived from whichever
389
+ // endpoint carries it; the START (rangeMatch[1]) is preserved verbatim and the
390
+ // rebuilt END reuses the END endpoint's own prefix (empty in the live form).
391
+ const rangePattern = /Phases\s+((?:v\d+\.\d+\/)?\d+)\s*-\s*((?:v\d+\.\d+\/)?\d+)\s*\(in progress\)/i;
386
392
  const rangeMatch = updatedContent.match(rangePattern);
387
393
  if (rangeMatch) {
388
- const rangeEnd = parseInt(rangeMatch[2], 10);
394
+ const endRaw = rangeMatch[2]; // e.g. "9" (bare) or "v25.1/9"
395
+ const prefix = (endRaw.match(/^v\d+\.\d+\//) || [''])[0];
396
+ const rangeEnd = parseInt(endRaw.replace(/^v\d+\.\d+\//, ''), 10);
389
397
  if (newPhaseNum > rangeEnd) {
390
398
  updatedContent = updatedContent.replace(rangePattern,
391
- `Phases ${rangeMatch[1]}-${newPhaseNum} (in progress)`);
399
+ `Phases ${rangeMatch[1]}-${prefix}${newPhaseNum} (in progress)`);
392
400
  }
393
401
  }
394
402
 
@@ -585,3 +585,72 @@ describe('cmdPhaseInitVersionedDir (NUM-02 versioned write path)', () => {
585
585
  }
586
586
  });
587
587
  });
588
+
589
+ describe('cmdPhaseAdd milestone-summary range bump', () => {
590
+ let phase;
591
+
592
+ beforeEach(() => {
593
+ resetPaths();
594
+ delete require.cache[require.resolve('./phase.cjs')];
595
+ phase = require('./phase.cjs');
596
+ });
597
+
598
+ afterEach(() => {
599
+ resetPaths();
600
+ });
601
+
602
+ it('bumps a namespaced "Phases v25.1/1-9 (in progress)" range to v25.1/1-10, preserving the prefix', () => {
603
+ // maxPhase resolves to 9 from the `### Phase 9:` heading → newPhaseNum=10.
604
+ const roadmap = `# Roadmap
605
+
606
+ - 🚧 **v25.1 add-prs** -- Phases v25.1/1-9 (in progress)
607
+
608
+ ## Phases
609
+
610
+ ### Phase 9: Ninth
611
+ **Goal:** done
612
+
613
+ ---
614
+ `;
615
+ const fixture = createFixture({ 'config.json': JSON.stringify({}), 'ROADMAP.md': roadmap });
616
+ try {
617
+ captureStdout(() => phase.cmdPhaseAdd(fixture.cwd, 'Tenth phase', false));
618
+ const updated = fs.readFileSync(path.join(fixture.cwd, 'ROADMAP.md'), 'utf-8');
619
+ assert.ok(
620
+ updated.includes('Phases v25.1/1-10 (in progress)'),
621
+ `expected namespaced bump to v25.1/1-10, got:\n${updated}`
622
+ );
623
+ // Prefix preserved on the START endpoint; END stays bare (no v25.1/ on the 10).
624
+ assert.ok(!/Phases v25\.1\/1-v25\.1\/10/.test(updated), 'END endpoint must stay bare');
625
+ assert.ok(!updated.includes('Phases v25.1/1-9 (in progress)'), 'old range must be gone');
626
+ } finally {
627
+ fixture.cleanup();
628
+ }
629
+ });
630
+
631
+ it('bumps a legacy non-namespaced "Phases 124-129 (in progress)" range to 124-130 (regression)', () => {
632
+ const roadmap = `# Roadmap
633
+
634
+ - 🚧 **v19.0 Git Worktrees** -- Phases 124-129 (in progress)
635
+
636
+ ## Phases
637
+
638
+ ### Phase 129: One-twenty-nine
639
+ **Goal:** done
640
+
641
+ ---
642
+ `;
643
+ const fixture = createFixture({ 'config.json': JSON.stringify({}), 'ROADMAP.md': roadmap });
644
+ try {
645
+ captureStdout(() => phase.cmdPhaseAdd(fixture.cwd, 'One thirty', false));
646
+ const updated = fs.readFileSync(path.join(fixture.cwd, 'ROADMAP.md'), 'utf-8');
647
+ assert.ok(
648
+ updated.includes('Phases 124-130 (in progress)'),
649
+ `expected legacy bump to 124-130, got:\n${updated}`
650
+ );
651
+ assert.ok(!updated.includes('Phases 124-129 (in progress)'), 'old range must be gone');
652
+ } finally {
653
+ fixture.cleanup();
654
+ }
655
+ });
656
+ });
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { safeReadFile, getProjectFolders, generateSlugInternal, isV2Install, output, error, loadConfig, getProjectDir } = require('./core.cjs');
11
+ const { safeReadFile, getProjectFolders, generateSlugInternal, isV2Install, output, error, loadConfig, getProjectDir, isValidMilestoneVersion } = require('./core.cjs');
12
12
  const { writeConfigField } = require('./config.cjs');
13
13
  const { getPlanningRoot } = require('./paths.cjs');
14
14
 
@@ -126,13 +126,23 @@ function scanProjectReposTags(cwd, slug) {
126
126
  const phasesDir = path.join(getProjectDir(cwd, slug), 'phases');
127
127
  const allRepos = new Set();
128
128
 
129
+ // Per-milestone versioned layout: descend a single vN.N child dir if present
130
+ // (phases/<version>/NN-slug). Detect via core.isValidMilestoneVersion — no
131
+ // parallel version regex. No version child → stay flat (back-compat).
132
+ let scanRoot = phasesDir;
129
133
  try {
130
- const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
134
+ const kids = fs.readdirSync(scanRoot, { withFileTypes: true });
135
+ const vd = kids.find(e => e.isDirectory() && isValidMilestoneVersion(e.name));
136
+ if (vd) scanRoot = path.join(scanRoot, vd.name);
137
+ } catch {}
138
+
139
+ try {
140
+ const phaseDirs = fs.readdirSync(scanRoot, { withFileTypes: true })
131
141
  .filter(e => e.isDirectory())
132
142
  .map(e => e.name);
133
143
 
134
144
  for (const phaseDir of phaseDirs) {
135
- const fullPhaseDir = path.join(phasesDir, phaseDir);
145
+ const fullPhaseDir = path.join(scanRoot, phaseDir);
136
146
  let planFiles;
137
147
  try {
138
148
  planFiles = fs.readdirSync(fullPhaseDir)
@@ -320,6 +320,14 @@ describe('scanProjectReposTags', () => {
320
320
  const repos = scanProjectReposTags(tmpDir, 'proj');
321
321
  assert.deepStrictEqual(repos, []);
322
322
  });
323
+
324
+ it('descends versioned phases/<version>/ layout (260628-p59)', () => {
325
+ createProjectManually(tmpDir, 'proj', '# State\n');
326
+ // Plan lives one level deeper, under phases/v25.0/01-setup/
327
+ createPlanFile(tmpDir, 'proj', 'v25.0/01-setup', '01-01-PLAN.md', '<repos>server</repos>\n');
328
+ const repos = scanProjectReposTags(tmpDir, 'proj');
329
+ assert.deepStrictEqual(repos, ['server']);
330
+ });
323
331
  });
324
332
 
325
333
  // ─── regenerateProjectsMd ───────────────────────────────────────────────────
@@ -188,6 +188,20 @@ function cmdRoadmapAnalyze(cwd, raw) {
188
188
  version: 'v' + mMatch[2],
189
189
  });
190
190
  }
191
+ // Bold-line single-milestone fallback: a `**Milestone:** vX.Y` roadmap has no
192
+ // `## vX.Y` heading, so the scan above yields []. Fire ONLY when the heading
193
+ // scan produced zero milestones — the `milestones.length === 0` guard is the
194
+ // over-match gate, so any roadmap that already has `##` version headings (incl.
195
+ // the `## Active Milestone:` ad-hoc heading, already matched above) ignores it.
196
+ if (milestones.length === 0) {
197
+ const bold = content.match(/^\*\*Milestone:\*\*\s*(v\d+\.\d+)([^\n]*)/im);
198
+ if (bold) {
199
+ milestones.push({
200
+ heading: ('Milestone: ' + bold[1] + bold[2]).trim(),
201
+ version: bold[1],
202
+ });
203
+ }
204
+ }
191
205
 
192
206
  // Find current and next phase
193
207
  const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
@@ -268,17 +282,24 @@ function roadmapUpdatePlanProgressInternal(cwd, phaseNum) {
268
282
  }
269
283
 
270
284
  let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
271
- const phaseEscaped = escapeRegex(phaseNum);
272
-
273
- // Progress table row: update Plans column (summaries/plans) and Status column
285
+ // Derive a BARE numeric phase key by stripping any leading `vX.Y/` prefix.
286
+ // Checklist lines and detail headings are always bare, so the bare key drives
287
+ // the heading/checkbox regexes; the progress-row matcher allows an optional
288
+ // version prefix and Milestone column on top of the same bare key.
289
+ const bareKey = String(phaseNum).replace(/^v\d+\.\d+\//, '');
290
+ const phaseEscaped = escapeRegex(bareKey);
291
+
292
+ // Progress table row: update Plans column (summaries/plans) and Status column.
293
+ // Format-agnostic: optional `vX.Y/` prefix on the Phase cell and an optional
294
+ // Milestone column (a pure-version cell) between Phase and Plans, both preserved.
274
295
  const tablePattern = new RegExp(
275
- `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|)[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
296
+ `(\\|\\s*(?:v\\d+\\.\\d+/)?${phaseEscaped}\\.?\\s[^|]*\\|)(\\s*v\\d+\\.\\d+\\s*\\|)?[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
276
297
  'i'
277
298
  );
278
299
  const dateField = isComplete ? ` ${today} ` : ' ';
279
300
  roadmapContent = roadmapContent.replace(
280
301
  tablePattern,
281
- `$1 ${summaryCount}/${planCount} $2 ${status.padEnd(11)}$3${dateField}$4`
302
+ `$1$2 ${summaryCount}/${planCount} $3 ${status.padEnd(11)}$4${dateField}$5`
282
303
  );
283
304
 
284
305
  // Update plan count in phase detail section