@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
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tests for roadmap.cjs::cmdRoadmapAnalyze milestone enumeration.
3
+ *
4
+ * Focus: the bold-line single-milestone fallback (`**Milestone:** vX.Y`) that
5
+ * fires ONLY when the `## vX.Y` heading scan yields zero milestones, gated by
6
+ * `milestones.length === 0` so it never double-counts a heading roadmap.
7
+ *
8
+ * Invokes the dgs-tools.cjs CLI via execSync against isolated temp fixtures
9
+ * (mirrors init.test.cjs's CLI-invocation style).
10
+ */
11
+
12
+ const { describe, it, afterEach } = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+ const { execSync } = require('child_process');
15
+ const path = require('path');
16
+
17
+ const { createFixture } = require('./test-helpers.cjs');
18
+
19
+ // Path to CLI entry point
20
+ const CLI = path.resolve(__dirname, '..', 'dgs-tools.cjs');
21
+
22
+ /**
23
+ * Run `dgs-tools roadmap analyze --raw` in a fixture and return parsed JSON.
24
+ */
25
+ function runAnalyze(cwd) {
26
+ const stdout = execSync(`node "${CLI}" roadmap analyze --raw`, {
27
+ cwd,
28
+ encoding: 'utf-8',
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ });
31
+ return JSON.parse(stdout.trim());
32
+ }
33
+
34
+ describe('cmdRoadmapAnalyze milestone enumeration', () => {
35
+ let fixture;
36
+
37
+ afterEach(() => {
38
+ if (fixture) fixture.cleanup();
39
+ });
40
+
41
+ // Single-milestone roadmap whose version lives only in the title + bold line —
42
+ // there is NO `## vX.Y` heading, so the heading scan yields [].
43
+ const BOLD_LINE_ROADMAP = `# Roadmap: Tenant data-quality metrics in Admin UI (v30.1)
44
+
45
+ **Milestone:** v30.1 — Tenant data-quality metrics in Admin UI
46
+
47
+ ## Overview
48
+
49
+ Single-milestone roadmap.
50
+
51
+ ### Phase 1: Metrics schema
52
+ **Goal:** Define metrics tables
53
+
54
+ ### Phase 2: Admin UI surface
55
+ **Goal:** Surface metrics in admin
56
+ `;
57
+
58
+ // Classic multi-milestone heading roadmap WITH a stray bold line: the heading
59
+ // scan enumerates the `## vX.Y` headings; the bold fallback must stay inert.
60
+ const HEADING_ROADMAP = `# Roadmap
61
+
62
+ **Milestone:** v9.9 — stray bold line that must NOT be enumerated
63
+
64
+ ## v1.0 Foundation
65
+ ### Phase 1: Base
66
+
67
+ ## v2.0 Next (In Progress)
68
+ ### Phase 2: New
69
+ `;
70
+
71
+ it('enumerates a bold-line-only roadmap (>=1 milestone, correct version)', () => {
72
+ fixture = createFixture({ 'config.json': JSON.stringify({}), 'ROADMAP.md': BOLD_LINE_ROADMAP });
73
+ const result = runAnalyze(fixture.cwd);
74
+ assert.ok(result.milestones.length >= 1, 'expected at least one milestone');
75
+ assert.equal(result.milestones[0].version, 'v30.1');
76
+ });
77
+
78
+ it('still enumerates a `## vX.Y` heading roadmap via headings (bold fallback inert, no double-count)', () => {
79
+ fixture = createFixture({ 'config.json': JSON.stringify({}), 'ROADMAP.md': HEADING_ROADMAP });
80
+ const result = runAnalyze(fixture.cwd);
81
+ const versions = result.milestones.map(m => m.version);
82
+ assert.deepEqual(versions, ['v1.0', 'v2.0']);
83
+ // The stray bold line (v9.9) was NOT enumerated — the length===0 gate held.
84
+ assert.ok(!versions.includes('v9.9'), 'stray bold line must not be enumerated');
85
+ });
86
+ });
@@ -161,8 +161,10 @@ function parseFrontmatter(content) {
161
161
  // ─── Content Scanners ───────────────────────────────────────────────────────
162
162
 
163
163
  /**
164
- * Scan ideas directories for searchable idea files.
165
- * Scans ideas/{pending,done,rejected}/ for .md files.
164
+ * Scan ideas for searchable idea files — flat-first.
165
+ * Reads flat ideas/*.md deriving `state` from frontmatter.status (mirror
166
+ * scanSpecs / cmdIdeasList). Legacy ideas/{pending,done,rejected}/ subdirs are
167
+ * an existence-gated dedup fallback so back-compat installs still surface.
166
168
  *
167
169
  * @param {string} cwd - Working directory
168
170
  * @param {object} options - { include_rejected, tags }
@@ -172,12 +174,60 @@ function scanIdeas(cwd, options) {
172
174
  const results = [];
173
175
  const planRoot = getPlanningRoot(cwd);
174
176
  const planRootRel = path.relative(cwd, planRoot) || '.';
175
- const states = options.include_rejected
177
+ const includeRejected = !!options.include_rejected;
178
+ const seenIds = new Set();
179
+
180
+ // Tag filter: if tags option set, idea must have at least one matching tag (OR logic).
181
+ function tagMatches(tags) {
182
+ if (!options.tags) return true;
183
+ const filterTags = options.tags.split(',').map(t => t.trim().toLowerCase());
184
+ const ideaTags = tags.map(t => t.toLowerCase());
185
+ return filterTags.some(ft => ideaTags.includes(ft));
186
+ }
187
+
188
+ // Flat-first scan: ideas/*.md (excluding subdirs), state from frontmatter.status.
189
+ const flatDir = path.join(planRoot, 'ideas');
190
+ let flatFiles;
191
+ try {
192
+ flatFiles = fs.readdirSync(flatDir).filter(f => f.endsWith('.md')
193
+ && !fs.statSync(path.join(flatDir, f)).isDirectory());
194
+ } catch {
195
+ flatFiles = [];
196
+ }
197
+
198
+ for (const file of flatFiles) {
199
+ const content = safeReadFile(path.join(flatDir, file));
200
+ if (!content) continue;
201
+
202
+ const { frontmatter, body } = parseFrontmatter(content);
203
+ const state = frontmatter.status || 'pending';
204
+ if (!includeRejected && state === 'rejected') continue;
205
+
206
+ const tags = frontmatter.tags || [];
207
+ if (!tagMatches(tags)) continue;
208
+
209
+ results.push({
210
+ type: 'idea',
211
+ id: frontmatter.id,
212
+ title: frontmatter.title || file,
213
+ filePath: path.join(planRootRel, 'ideas', file),
214
+ state,
215
+ tags,
216
+ author: frontmatter.author || '',
217
+ content: (frontmatter.title || '') + ' ' + body,
218
+ });
219
+ if (frontmatter.id !== undefined) seenIds.add(frontmatter.id);
220
+ }
221
+
222
+ // Legacy subdir fallback (existence-gated, deduped by id).
223
+ const legacyStates = includeRejected
176
224
  ? ['pending', 'done', 'rejected']
177
225
  : ['pending', 'done'];
178
226
 
179
- for (const state of states) {
180
- const dir = path.join(planRoot, 'ideas', state);
227
+ for (const dirState of legacyStates) {
228
+ const dir = path.join(planRoot, 'ideas', dirState);
229
+ if (!fs.existsSync(dir)) continue;
230
+
181
231
  let files;
182
232
  try {
183
233
  files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
@@ -191,26 +241,22 @@ function scanIdeas(cwd, options) {
191
241
  if (!content) continue;
192
242
 
193
243
  const { frontmatter, body } = parseFrontmatter(content);
194
- const tags = frontmatter.tags || [];
244
+ if (frontmatter.id !== undefined && seenIds.has(frontmatter.id)) continue;
195
245
 
196
- // Tag filter: if tags option set, idea must have at least one matching tag (OR logic)
197
- if (options.tags) {
198
- const filterTags = options.tags.split(',').map(t => t.trim().toLowerCase());
199
- const ideaTags = tags.map(t => t.toLowerCase());
200
- const hasMatch = filterTags.some(ft => ideaTags.includes(ft));
201
- if (!hasMatch) continue;
202
- }
246
+ const tags = frontmatter.tags || [];
247
+ if (!tagMatches(tags)) continue;
203
248
 
204
249
  results.push({
205
250
  type: 'idea',
206
251
  id: frontmatter.id,
207
252
  title: frontmatter.title || file,
208
- filePath: path.join(planRootRel, 'ideas', state, file),
209
- state,
253
+ filePath: path.join(planRootRel, 'ideas', dirState, file),
254
+ state: frontmatter.status || dirState,
210
255
  tags,
211
256
  author: frontmatter.author || '',
212
257
  content: (frontmatter.title || '') + ' ' + body,
213
258
  });
259
+ if (frontmatter.id !== undefined) seenIds.add(frontmatter.id);
214
260
  }
215
261
  }
216
262
 
@@ -553,6 +599,7 @@ function cmdSearch(cwd, query, options, raw) {
553
599
 
554
600
  module.exports = {
555
601
  cmdSearch,
602
+ scanIdeas,
556
603
  fuzzyMatch,
557
604
  levenshteinDistance,
558
605
  matchesQuery,
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for search.cjs scanIdeas — flat-first idea scanning.
3
+ *
4
+ * Verifies that scanIdeas reads the migrated flat layout (ideas/*.md with
5
+ * `status:` in frontmatter), honours include_rejected, and still surfaces
6
+ * legacy status-subdir ideas as an existence-gated fallback.
7
+ */
8
+
9
+ const { describe, it, afterEach } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const { createFixture } = require('./test-helpers.cjs');
12
+ const { resetPaths } = require('./paths.cjs');
13
+ const { scanIdeas } = require('./search.cjs');
14
+
15
+ function ideaContent(id, title, status, tags = []) {
16
+ const tagsStr = tags.length > 0 ? `[${tags.map(t => `"${t}"`).join(', ')}]` : '[]';
17
+ return `---\nid: ${id}\ntitle: "${title}"\nstatus: ${status}\ntags: ${tagsStr}\n---\n\n${title} body.\n`;
18
+ }
19
+
20
+ describe('search scanIdeas (flat-first)', () => {
21
+ let fixture;
22
+
23
+ afterEach(() => {
24
+ if (fixture) fixture.cleanup();
25
+ resetPaths();
26
+ });
27
+
28
+ it('finds flat-layout ideas with state derived from frontmatter.status', () => {
29
+ fixture = createFixture({
30
+ 'ideas/001-alpha.md': ideaContent(1, 'Alpha', 'pending', ['api']),
31
+ 'ideas/002-beta.md': ideaContent(2, 'Beta', 'done', ['ui']),
32
+ });
33
+
34
+ const results = scanIdeas(fixture.cwd, { include_rejected: false });
35
+ assert.equal(results.length, 2);
36
+
37
+ const byId = Object.fromEntries(results.map(r => [r.id, r]));
38
+ assert.equal(byId[1].state, 'pending');
39
+ assert.equal(byId[1].title, 'Alpha');
40
+ assert.equal(byId[2].state, 'done');
41
+ // filePath is the flat location — no status subdir segment.
42
+ assert.match(byId[1].filePath, /ideas[\\/]001-alpha\.md$/);
43
+ assert.ok(!byId[1].filePath.includes('pending'));
44
+ });
45
+
46
+ it('hides rejected flat ideas by default and includes them when requested', () => {
47
+ fixture = createFixture({
48
+ 'ideas/001-alpha.md': ideaContent(1, 'Alpha', 'pending'),
49
+ 'ideas/009-reject.md': ideaContent(9, 'Rejected idea', 'rejected'),
50
+ });
51
+
52
+ const hidden = scanIdeas(fixture.cwd, { include_rejected: false });
53
+ assert.equal(hidden.length, 1);
54
+ assert.equal(hidden[0].id, 1);
55
+
56
+ const shown = scanIdeas(fixture.cwd, { include_rejected: true });
57
+ assert.equal(shown.length, 2);
58
+ assert.ok(shown.some(r => r.id === 9 && r.state === 'rejected'));
59
+ });
60
+
61
+ it('still finds legacy subdir ideas (back-compat)', () => {
62
+ fixture = createFixture({
63
+ 'ideas/pending/003-gamma.md': ideaContent(3, 'Gamma', 'pending'),
64
+ });
65
+
66
+ const results = scanIdeas(fixture.cwd, { include_rejected: false });
67
+ assert.equal(results.length, 1);
68
+ assert.equal(results[0].id, 3);
69
+ assert.equal(results[0].state, 'pending');
70
+ assert.match(results[0].filePath, /ideas[\\/]pending[\\/]003-gamma\.md$/);
71
+ });
72
+
73
+ it('dedupes a flat idea against a legacy subdir copy (by id)', () => {
74
+ fixture = createFixture({
75
+ 'ideas/003-gamma.md': ideaContent(3, 'Gamma flat', 'pending'),
76
+ 'ideas/pending/003-gamma.md': ideaContent(3, 'Gamma legacy', 'pending'),
77
+ });
78
+
79
+ const results = scanIdeas(fixture.cwd, { include_rejected: false });
80
+ assert.equal(results.length, 1);
81
+ assert.equal(results[0].title, 'Gamma flat');
82
+ });
83
+
84
+ it('applies the OR tag filter', () => {
85
+ fixture = createFixture({
86
+ 'ideas/001-alpha.md': ideaContent(1, 'Alpha', 'pending', ['api']),
87
+ 'ideas/002-beta.md': ideaContent(2, 'Beta', 'pending', ['ui']),
88
+ });
89
+
90
+ const results = scanIdeas(fixture.cwd, { include_rejected: false, tags: 'api' });
91
+ assert.equal(results.length, 1);
92
+ assert.equal(results[0].id, 1);
93
+ });
94
+ });
@@ -5,7 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
- const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, output, error } = require('./core.cjs');
8
+ const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, output, error, phasesDir } = require('./core.cjs');
9
9
  const { getPlanningRoot } = require('./paths.cjs');
10
10
  const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
11
11
  const { parseReposMd, validateRepoPaths } = require('./repos.cjs');
@@ -399,7 +399,15 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
399
399
  function cmdValidateConsistency(cwd, raw) {
400
400
  const planRoot = getPlanningRoot(cwd);
401
401
  const roadmapPath = path.join(planRoot, 'ROADMAP.md');
402
- const phasesDir = path.join(planRoot, 'phases');
402
+ // Resolve phases directory via the canonical version-aware resolver; keep the
403
+ // soft flat-layout fallback (matches core.findPhaseInternal). Under a versioned
404
+ // milestone layout this descends phases/<version>/ so diskPhases is populated.
405
+ // NB: join to planRoot (the git-resolved planning root used everywhere else in
406
+ // this function), NOT cwd — on macOS git realpaths /var → /private/var, so a
407
+ // cwd-based join would diverge from planRoot and break downstream path.relative.
408
+ let phasesRel;
409
+ try { phasesRel = phasesDir(cwd); } catch { phasesRel = 'phases'; }
410
+ const phasesAbs = path.join(planRoot, phasesRel);
403
411
  const errors = [];
404
412
  const warnings = [];
405
413
 
@@ -423,7 +431,7 @@ function cmdValidateConsistency(cwd, raw) {
423
431
  // Get phases on disk
424
432
  const diskPhases = new Set();
425
433
  try {
426
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
434
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
427
435
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
428
436
  for (const dir of dirs) {
429
437
  const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)?)/i);
@@ -460,11 +468,11 @@ function cmdValidateConsistency(cwd, raw) {
460
468
 
461
469
  // Check: plan numbering within phases
462
470
  try {
463
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
471
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
464
472
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
465
473
 
466
474
  for (const dir of dirs) {
467
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
475
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, dir));
468
476
  const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort();
469
477
 
470
478
  // Extract plan numbers
@@ -495,15 +503,15 @@ function cmdValidateConsistency(cwd, raw) {
495
503
 
496
504
  // Check: frontmatter in plans has required fields
497
505
  try {
498
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
506
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
499
507
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
500
508
 
501
509
  for (const dir of dirs) {
502
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
510
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, dir));
503
511
  const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md'));
504
512
 
505
513
  for (const plan of plans) {
506
- const content = fs.readFileSync(path.join(phasesDir, dir, plan), 'utf-8');
514
+ const content = fs.readFileSync(path.join(phasesAbs, dir, plan), 'utf-8');
507
515
  const fm = extractFrontmatter(content);
508
516
 
509
517
  if (!fm.wave) {
@@ -523,7 +531,16 @@ function cmdValidateHealth(cwd, options, raw) {
523
531
  const roadmapPath = path.join(planningDir, 'ROADMAP.md');
524
532
  const statePath = path.join(planningDir, 'STATE.md');
525
533
  const configPath = path.join(planningDir, 'config.json');
526
- const phasesDir = path.join(planningDir, 'phases');
534
+ // Version-aware phases dir (descends phases/<version>/ when present) with the
535
+ // canonical soft flat-layout fallback. All checks below walk this root so the
536
+ // Check-12 W010 walker and consistency checks see versioned-layout phase dirs.
537
+ // NB: join to planningDir (the git-resolved root used by the W010 walker's
538
+ // path.relative + git ls-files), NOT cwd — on macOS git realpaths /var →
539
+ // /private/var, so a cwd-based join would diverge and mis-report tracked
540
+ // artifacts as untracked.
541
+ let phasesRel;
542
+ try { phasesRel = phasesDir(cwd); } catch { phasesRel = 'phases'; }
543
+ const phasesAbs = path.join(planningDir, phasesRel);
527
544
 
528
545
  const errors = [];
529
546
  const warnings = [];
@@ -577,7 +594,7 @@ function cmdValidateHealth(cwd, options, raw) {
577
594
  // Get disk phases
578
595
  const diskPhases = new Set();
579
596
  try {
580
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
597
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
581
598
  for (const e of entries) {
582
599
  if (e.isDirectory()) {
583
600
  const m = e.name.match(/^(\d+(?:\.\d+)?)/);
@@ -619,7 +636,7 @@ function cmdValidateHealth(cwd, options, raw) {
619
636
 
620
637
  // ─── Check 6: Phase directory naming (NN-name format) ─────────────────────
621
638
  try {
622
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
639
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
623
640
  for (const e of entries) {
624
641
  if (e.isDirectory() && !e.name.match(/^\d{2,}(?:\.\d+)*-[\w-]+$/)) {
625
642
  addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)');
@@ -629,10 +646,10 @@ function cmdValidateHealth(cwd, options, raw) {
629
646
 
630
647
  // ─── Check 7: Orphaned plans (PLAN without SUMMARY) ───────────────────────
631
648
  try {
632
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
649
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
633
650
  for (const e of entries) {
634
651
  if (!e.isDirectory()) continue;
635
- const phaseFiles = fs.readdirSync(path.join(phasesDir, e.name));
652
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, e.name));
636
653
  const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
637
654
  const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
638
655
  const summaryBases = new Set(summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')));
@@ -659,7 +676,7 @@ function cmdValidateHealth(cwd, options, raw) {
659
676
 
660
677
  const diskPhases = new Set();
661
678
  try {
662
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
679
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
663
680
  for (const e of entries) {
664
681
  if (e.isDirectory()) {
665
682
  const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)?)/i);
@@ -756,7 +773,7 @@ function cmdValidateHealth(cwd, options, raw) {
756
773
  const trackingVerif = [];
757
774
  if (completedPhases.size > 0) {
758
775
  try {
759
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
776
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
760
777
  for (const e of entries) {
761
778
  if (!e.isDirectory()) continue;
762
779
  const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
@@ -764,10 +781,10 @@ function cmdValidateHealth(cwd, options, raw) {
764
781
  const phaseNum = dm[1];
765
782
  const unpadded = String(parseInt(phaseNum, 10));
766
783
  if (!completedPhases.has(phaseNum) && !completedPhases.has(unpadded)) continue;
767
- const phaseFiles = fs.readdirSync(path.join(phasesDir, e.name));
784
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, e.name));
768
785
  for (const f of phaseFiles) {
769
786
  if (/-VERIFICATION\.md$/i.test(f)) {
770
- trackingVerif.push(path.relative(gitRoot, path.join(phasesDir, e.name, f)));
787
+ trackingVerif.push(path.relative(gitRoot, path.join(phasesAbs, e.name, f)));
771
788
  }
772
789
  }
773
790
  }
@@ -858,12 +875,12 @@ function cmdValidateHealth(cwd, options, raw) {
858
875
  // the dangling artifact on the next /dgs:health run.
859
876
  try {
860
877
  const untracked = [];
861
- if (fs.existsSync(phasesDir)) {
878
+ if (fs.existsSync(phasesAbs)) {
862
879
  const ARTIFACT_RE = /^[0-9].*-(PLAN|CONTEXT|RESEARCH|UAT|VERIFICATION)\.md$/;
863
- const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
880
+ const phaseEntries = fs.readdirSync(phasesAbs, { withFileTypes: true });
864
881
  for (const entry of phaseEntries) {
865
882
  if (!entry.isDirectory()) continue;
866
- const phaseDirAbs = path.join(phasesDir, entry.name);
883
+ const phaseDirAbs = path.join(phasesAbs, entry.name);
867
884
  let files;
868
885
  try { files = fs.readdirSync(phaseDirAbs); } catch { continue; }
869
886
  for (const f of files) {
@@ -80,3 +80,61 @@ test('REL-12: cmdValidateHealth does NOT flag tracked .gitkeep files', () => {
80
80
  });
81
81
 
82
82
  // REL-12 sentinel — flag this block as a Wave-0 RED scaffold for plan 04.
83
+
84
+ // ─── 260628-p59: versioned phases/<version>/ layout regression ──────────────
85
+ // cmdValidateConsistency must descend phases/<version>/ (via core.phasesDir) so a
86
+ // phase dir living under phases/v25.0/NN-slug is seen on disk and does NOT trigger
87
+ // a spurious "Phase N in ROADMAP.md but no directory on disk" warning.
88
+
89
+ function setupVersionedConsistencyFixture() {
90
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'p59-consist-'));
91
+ // getPlanningRoot requires a git repo (it process.exit(1)s otherwise, which would
92
+ // make the child shim emit empty stdout and falsely "pass"). Initialise one.
93
+ execSync('git init -q', { cwd: root });
94
+ execSync('git config user.email test@test', { cwd: root });
95
+ execSync('git config user.name test', { cwd: root });
96
+ fs.writeFileSync(path.join(root, 'STATE.md'), '---\nmilestone: v25.0\n---\n# State\n');
97
+ fs.writeFileSync(path.join(root, 'ROADMAP.md'), '# Roadmap\n\n### Phase 3: Foo\n');
98
+ const phaseDir = path.join(root, 'phases', 'v25.0', '03-foo');
99
+ fs.mkdirSync(phaseDir, { recursive: true });
100
+ fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'),
101
+ '---\nphase: 3\nplan: 01\nwave: 1\n---\n# Plan\n');
102
+ return root;
103
+ }
104
+
105
+ function captureConsistencyOutput(root) {
106
+ // cmdValidateConsistency calls output() which process.exit(0)s — run in a child
107
+ // process and capture stdout JSON (mirrors captureHealthOutput above).
108
+ const verifyPath = path.resolve(__dirname, 'verify.cjs');
109
+ // cmdValidateConsistency passes a status word as output()'s third arg, so it
110
+ // must be called with raw=false to emit the JSON payload (not the status word).
111
+ const shim = [
112
+ `const v = require(${JSON.stringify(verifyPath)});`,
113
+ `v.cmdValidateConsistency(${JSON.stringify(root)}, false);`,
114
+ ].join('\n');
115
+ const shimPath = path.join(os.tmpdir(), `p59-consist-shim-${process.pid}-${Date.now()}.cjs`);
116
+ fs.writeFileSync(shimPath, shim);
117
+ try {
118
+ const stdout = execSync(`node ${JSON.stringify(shimPath)}`, { encoding: 'utf-8' });
119
+ try { return JSON.parse(stdout); } catch { return { stdout, warnings: [] }; }
120
+ } catch (err) {
121
+ const out = err.stdout && err.stdout.toString();
122
+ try { return JSON.parse(out); } catch { return { stdout: out, warnings: [] }; }
123
+ } finally {
124
+ try { fs.unlinkSync(shimPath); } catch { /* ignore */ }
125
+ }
126
+ }
127
+
128
+ test('260628-p59: cmdValidateConsistency descends phases/<version>/ (no spurious missing-directory warning)', () => {
129
+ const root = setupVersionedConsistencyFixture();
130
+ const result = captureConsistencyOutput(root);
131
+ // Guard against a silent false-pass: confirm the JSON payload actually parsed
132
+ // (a crashed child would yield {warnings: []} and trivially pass the filter).
133
+ assert.ok(Object.prototype.hasOwnProperty.call(result, 'passed'),
134
+ 'expected a parsed consistency result with a `passed` field');
135
+ const missingDirWarnings = (result.warnings || []).filter(
136
+ w => /Phase 3 in ROADMAP.*no directory on disk/.test(w)
137
+ );
138
+ assert.strictEqual(missingDirWarnings.length, 0,
139
+ 'phases/v25.0/03-foo should be seen on disk; no missing-directory warning expected');
140
+ });
@@ -228,7 +228,7 @@ Each plan produces 2-4 commits (tasks + metadata). Clear, granular, bisectable.
228
228
 
229
229
  **Context engineering for AI:**
230
230
  - Git history becomes primary context source for future Claude sessions
231
- - `git log --grep="{phase}-{plan}"` shows all work for a plan
231
+ - `git log $(git merge-base main HEAD)..HEAD --grep="{phase}-{plan}"` shows all work for a plan on the current milestone branch
232
232
  - `git diff <hash>^..<hash>` shows exact changes per task
233
233
  - Less reliance on parsing SUMMARY.md = more context for actual work
234
234
 
@@ -149,7 +149,7 @@ For each phase's VERIFICATION.md, extract the expanded requirements table:
149
149
 
150
150
  For each phase's SUMMARY.md, extract `requirements-completed` from YAML frontmatter:
151
151
  ```bash
152
- for summary in ${project_root}/phases/*-*/*-SUMMARY.md; do
152
+ for summary in $(find ${project_root}/phases -name '*-SUMMARY.md' 2>/dev/null); do
153
153
  node "$HOME/.claude/deliver-great-systems/bin/dgs-tools.cjs" summary-extract "$summary" --fields requirements_completed | jq -r '.requirements_completed'
154
154
  done
155
155
  ```
@@ -66,7 +66,7 @@ Extract phase numbers and names from the archived roadmap (e.g., Phase 1: Founda
66
66
  Check which of those phase directories still exist in `${project_root}/phases/`:
67
67
 
68
68
  ```bash
69
- ls -d ${project_root}/phases/*/ 2>/dev/null
69
+ find ${project_root}/phases -mindepth 1 -maxdepth 2 -type d -name '[0-9]*-*' 2>/dev/null
70
70
  ```
71
71
 
72
72
  Match phase directories to milestone membership. Only include directories that still exist in `${project_root}/phases/`.
@@ -21,7 +21,7 @@ Multi-agent code review that runs 3 passes of 3 parallel agents each (9 total re
21
21
  Compute the diff from the plan's task commits.
22
22
 
23
23
  ```bash
24
- FIRST_TASK_COMMIT=$(git -C "${CODE_REPO_PATH}" log --oneline --grep="feat(${PHASE}-${PLAN}):" --grep="fix(${PHASE}-${PLAN}):" --grep="test(${PHASE}-${PLAN}):" --grep="refactor(${PHASE}-${PLAN}):" --reverse | head -1 | cut -d' ' -f1)
24
+ FIRST_TASK_COMMIT=$(git -C "${CODE_REPO_PATH}" log $(git -C "${CODE_REPO_PATH}" merge-base main HEAD)..HEAD --oneline --grep="feat(${PHASE}-${PLAN}):" --grep="fix(${PHASE}-${PLAN}):" --grep="test(${PHASE}-${PLAN}):" --grep="refactor(${PHASE}-${PLAN}):" --reverse | head -1 | cut -d' ' -f1)
25
25
  ```
26
26
 
27
27
  If FIRST_TASK_COMMIT is empty, exit with message: "No task commits found for ${PHASE}-${PLAN}, skipping code review."
@@ -433,7 +433,7 @@ If mark-milestone-complete fails, log a warning but continue to gather_stats (no
433
433
  Calculate milestone statistics:
434
434
 
435
435
  ```bash
436
- git log --oneline --grep="feat(" | head -20
436
+ git log --oneline FIRST_COMMIT..LAST_COMMIT --grep="feat(" | head -20
437
437
  git diff --stat FIRST_COMMIT..LAST_COMMIT | tail -1
438
438
  find . -name "*.swift" -o -name "*.ts" -o -name "*.py" | xargs wc -l 2>/dev/null
439
439
  git log --format="%ai" FIRST_COMMIT | tail -1
@@ -461,7 +461,7 @@ Extract one-liners from SUMMARY.md files using summary-extract:
461
461
 
462
462
  ```bash
463
463
  # For each phase in milestone, extract one-liner
464
- for summary in ${project_root}/phases/*-*/*-SUMMARY.md; do
464
+ for summary in ${phases_dir}/*/*-SUMMARY.md; do
465
465
  node "$HOME/.claude/deliver-great-systems/bin/dgs-tools.cjs" summary-extract "$summary" --fields one_liner | jq -r '.one_liner'
466
466
  done
467
467
  ```
@@ -494,7 +494,7 @@ Full PROJECT.md evolution review at milestone completion.
494
494
  Read all phase summaries:
495
495
 
496
496
  ```bash
497
- cat ${project_root}/phases/*-*/*-SUMMARY.md
497
+ cat ${phases_dir}/*/*-SUMMARY.md
498
498
  ```
499
499
 
500
500
  **Full review checklist:**
@@ -482,7 +482,11 @@ Use values from init: `phase_dir`, `phase_slug`, `padded_phase`.
482
482
 
483
483
  If `phase_dir` is null (phase exists in roadmap but no directory). Use `project_root` from init:
484
484
  ```bash
485
- mkdir -p "${project_root}/phases/${padded_phase}-${phase_slug}"
485
+ # Resolve version-aware phases base (mirror phasesDir(): versioned -> phases/<version>/, flat -> phases/)
486
+ PHASES_BASE="${project_root}/phases"
487
+ VERSION_DIR=$(ls -d ${project_root}/phases/v[0-9]*.[0-9]*/ 2>/dev/null | tail -1)
488
+ [ -n "$VERSION_DIR" ] && PHASES_BASE="${VERSION_DIR%/}"
489
+ mkdir -p "${PHASES_BASE}/${padded_phase}-${phase_slug}"
486
490
  ```
487
491
 
488
492
  **File location:** `${phase_dir}/${padded_phase}-CONTEXT.md`
@@ -377,7 +377,7 @@ Execute each wave in sequence. Within a wave: parallel if `PARALLELIZATION=true`
377
377
 
378
378
  For each SUMMARY.md:
379
379
  - Verify first 2 files from `key-files.created` exist on disk
380
- - Check `git log --oneline --all --grep="{phase}-{plan}"` returns ≥1 commit
380
+ - Check `git log --oneline $(git merge-base main HEAD)..HEAD --grep="{phase}-{plan}"` returns ≥1 commit
381
381
  - Check for `## Self-Check: FAILED` marker
382
382
 
383
383
  If ANY spot-check fails: report which plan failed, route to failure handler.
@@ -450,7 +450,7 @@ Execute each wave in sequence. Within a wave: parallel if `PARALLELIZATION=true`
450
450
 
451
451
  Compute diff reference for the plan's task commits:
452
452
  ```bash
453
- FIRST_TASK_COMMIT=$(git -C "${CODE_REPO_PATH}" log --oneline --grep="feat(${PHASE}-${PLAN}):" --grep="fix(${PHASE}-${PLAN}):" --grep="test(${PHASE}-${PLAN}):" --grep="refactor(${PHASE}-${PLAN}):" --reverse | head -1 | cut -d' ' -f1)
453
+ FIRST_TASK_COMMIT=$(git -C "${CODE_REPO_PATH}" log $(git -C "${CODE_REPO_PATH}" merge-base main HEAD)..HEAD --oneline --grep="feat(${PHASE}-${PLAN}):" --grep="fix(${PHASE}-${PLAN}):" --grep="test(${PHASE}-${PLAN}):" --grep="refactor(${PHASE}-${PLAN}):" --reverse | head -1 | cut -d' ' -f1)
454
454
  ```
455
455
 
456
456
  If FIRST_TASK_COMMIT is empty (no task commits found), skip codereview for this plan with message: "No task commits found for {phase}-{plan}, skipping code review."
@@ -114,7 +114,7 @@ Pattern B only (verify-only checkpoints). Skip for A/C.
114
114
  - Main route: execute tasks using standard flow (step name="execute")
115
115
  3. After ALL segments: aggregate files/deviations/decisions → create SUMMARY.md → commit → self-check:
116
116
  - Verify key-files.created exist on disk with `[ -f ]`
117
- - Check `git log --oneline --all --grep="{phase}-{plan}"` returns ≥1 commit
117
+ - Check `git log --oneline $(git merge-base main HEAD)..HEAD --grep="{phase}-{plan}"` returns ≥1 commit
118
118
  - Append `## Self-Check: PASSED` or `## Self-Check: FAILED` to SUMMARY
119
119
 
120
120
  **Known Claude Code bug (classifyHandoffIfNeeded):** If any segment agent reports "failed" with `classifyHandoffIfNeeded is not defined`, this is a Claude Code runtime bug — not a real failure. Run spot-checks; if they pass, treat as successful.
@@ -490,7 +490,7 @@ The plan name is auto-extracted from the PLAN.md `plan_name` frontmatter field (
490
490
  If ${project_root}/codebase/ doesn't exist: skip.
491
491
 
492
492
  ```bash
493
- FIRST_TASK=$(git log --oneline --grep="feat({phase}-{plan}):" --grep="fix({phase}-{plan}):" --grep="test({phase}-{plan}):" --reverse | head -1 | cut -d' ' -f1)
493
+ FIRST_TASK=$(git log $(git merge-base main HEAD)..HEAD --oneline --grep="feat({phase}-{plan}):" --grep="fix({phase}-{plan}):" --grep="test({phase}-{plan}):" --reverse | head -1 | cut -d' ' -f1)
494
494
  git diff --name-only ${FIRST_TASK}^..HEAD 2>/dev/null
495
495
  ```
496
496
 
@@ -25,7 +25,7 @@ Find current phase directory from most recently modified files:
25
25
 
26
26
  ```bash
27
27
  # Find most recent phase directory with work
28
- ls -lt ${project_root}/phases/*/PLAN.md 2>/dev/null | head -1 | grep -oP 'phases/\K[^/]+'
28
+ ls -t ${project_root}/phases/*/*-PLAN.md ${project_root}/phases/*/*/*-PLAN.md 2>/dev/null | head -1 | xargs -r dirname | xargs -r basename
29
29
  ```
30
30
 
31
31
  If no active phase detected, ask user which phase they're pausing work on.