@ktpartners/dgs-platform 3.5.1 → 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 (31) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/deliver-great-systems/bin/lib/core.cjs +21 -0
  3. package/deliver-great-systems/bin/lib/core.test.cjs +66 -0
  4. package/deliver-great-systems/bin/lib/ideas.cjs +39 -11
  5. package/deliver-great-systems/bin/lib/ideas.test.cjs +32 -3
  6. package/deliver-great-systems/bin/lib/init.cjs +23 -0
  7. package/deliver-great-systems/bin/lib/init.test.cjs +78 -0
  8. package/deliver-great-systems/bin/lib/jobs.cjs +78 -26
  9. package/deliver-great-systems/bin/lib/jobs.test.cjs +132 -3
  10. package/deliver-great-systems/bin/lib/overlap.cjs +14 -4
  11. package/deliver-great-systems/bin/lib/overlap.test.cjs +13 -0
  12. package/deliver-great-systems/bin/lib/package-scan-report.cjs +19 -0
  13. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +18 -0
  14. package/deliver-great-systems/bin/lib/phase.cjs +12 -4
  15. package/deliver-great-systems/bin/lib/phase.test.cjs +69 -0
  16. package/deliver-great-systems/bin/lib/projects.cjs +13 -3
  17. package/deliver-great-systems/bin/lib/projects.test.cjs +8 -0
  18. package/deliver-great-systems/bin/lib/roadmap.cjs +14 -0
  19. package/deliver-great-systems/bin/lib/roadmap.test.cjs +86 -0
  20. package/deliver-great-systems/bin/lib/search.cjs +62 -15
  21. package/deliver-great-systems/bin/lib/search.test.cjs +94 -0
  22. package/deliver-great-systems/bin/lib/verify.cjs +37 -20
  23. package/deliver-great-systems/bin/lib/verify.test.cjs +58 -0
  24. package/deliver-great-systems/workflows/audit-milestone.md +1 -1
  25. package/deliver-great-systems/workflows/cleanup.md +1 -1
  26. package/deliver-great-systems/workflows/complete-milestone.md +2 -2
  27. package/deliver-great-systems/workflows/discuss-phase.md +5 -1
  28. package/deliver-great-systems/workflows/pause-work.md +1 -1
  29. package/deliver-great-systems/workflows/plan-phase.md +6 -2
  30. package/deliver-great-systems/workflows/resume-project.md +2 -2
  31. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [3.5.3] - 2026-06-28
12
+
13
+ ### Fixed
14
+ - **Flat-first jobs tests (quick-260628-r5i)** — aligned the root `tests/jobs.test.cjs` with the flat-first `listJobs` behavior introduced in 3.5.2 (which had updated `bin/lib/jobs.test.cjs` but left the root copy stale, leaving the suite 61/2 on that file). The grouped-jobs test now writes flat `jobs/milestone-*.md` files grouped by frontmatter status, and the directory test asserts the legacy `jobs/pending|in-progress|completed/` subdirs are NOT auto-created. Suite back to green.
15
+
16
+ ## [3.5.2] - 2026-06-28
17
+
18
+ ### Fixed
19
+ - **Flat-store readers flat-first (quick-260628-otc)** — `search.cjs scanIdeas`, `jobs.cjs listJobs`, and `ideas.cjs cmdIdeasFindRelated` now read the migrated flat frontmatter-status layout (`ideas/*.md`, `jobs/milestone-*.md`) first, so `/dgs:search`, `/dgs:list-jobs`, `/dgs:diff-report`, and `/dgs:find-related-ideas` no longer silently return nothing on flat installs. `listJobs` is now read-only (stops recreating abandoned `jobs/{pending,in-progress,completed}` subdirs) and `cmdIdeasUpdate` returns the file's real flat path. Legacy status-subdirs remain an existence-gated fallback.
20
+ - **Versioned phases-dir scanners (quick-260628-p59)** — `verify.cjs` (consistency + health W010), `overlap.cjs`, `projects.cjs`, and `package-scan-report.cjs` now descend `phases/<version>/NN-slug/` (via `core.phasesDir` / `isValidMilestoneVersion`) instead of treating the `vN.N` version dir as a phase dir, so consistency validation, `/dgs:overlap-check`, repos-touched listings, and package-scan active-phase reports work under per-milestone numbering. Flat layout still supported.
21
+ - **Versioned phases-dir workflow globs (quick-260628-pn3)** — 9 phases-dir globs/heuristics across 7 workflows (`complete-milestone`, `audit-milestone`, `plan-phase`, `discuss-phase`, `pause-work`, `resume-project`, `cleanup`) are now dual-layout (flat + `phases/<version>/`), so milestone-completion accomplishment extraction, SUMMARY cross-checks, prior-phase continuity, pause/resume detection, and cleanup no longer come up empty on versioned milestones. Also drops a GNU-only `grep -oP`.
22
+ - **Roadmap milestone parsers recognize all formats (quick-260628-pxx)** — `detectActiveMilestone`, `getMilestoneInfo`, `parseProjectMilestone`, `cmdPhaseAdd` range-bump, and `cmdRoadmapAnalyze` now recognize the ad-hoc `## Active Milestone: vX.Y`, single-milestone bold-line `**Milestone:** vX.Y`, and per-milestone-namespaced `Phases vX.Y/N-M` formats (additively, milestone-name-preserving), gated against multi-milestone over-match. Fixes blank dashboard milestone, `create-milestone` "No active milestone found", and stale `add-phase` ranges on the newer roadmap formats.
23
+
11
24
  ## [3.5.1] - 2026-06-28
12
25
 
13
26
  ### Added
@@ -534,6 +534,27 @@ function getMilestoneInfo(cwd) {
534
534
  name: headingMatch[2].trim(),
535
535
  };
536
536
  }
537
+ // Ad-hoc container format: "## Active Milestone: v25.1 add-prs" — version
538
+ // and real NAME live on the heading. Runs after the heading branch so a
539
+ // legacy "### vX.Y Name" heading still wins; the literal "Active Milestone:"
540
+ // prefix is specific enough to run un-gated.
541
+ const adhoc = cleaned.match(/^##\s+Active Milestone:\s*v(\d+\.\d+)\s+(.+?)\s*$/im);
542
+ if (adhoc) {
543
+ return { version: 'v' + adhoc[1], name: adhoc[2].trim() };
544
+ }
545
+
546
+ // Bold-line single-milestone format: "**Milestone:** v30.1 — Name".
547
+ // GATED single-milestone (mirror analyzeMilestonePhases): only when at most
548
+ // one version-bearing heading exists, so a stray bold line never hijacks a
549
+ // multi-milestone heading roadmap.
550
+ const versionHeadings = cleaned.match(/^#{2,4}\s+.*v\d+\.\d+/gim) || [];
551
+ if (versionHeadings.length <= 1) {
552
+ const bold = cleaned.match(/^\*\*Milestone:\*\*\s*v(\d+\.\d+)\s*(?:[—-]\s*)?(.*)$/im);
553
+ if (bold) {
554
+ return { version: 'v' + bold[1], name: bold[2].trim() || 'milestone' };
555
+ }
556
+ }
557
+
537
558
  // Fallback: try bare version match
538
559
  const versionMatch = cleaned.match(/v(\d+\.\d+)/);
539
560
  return {
@@ -28,6 +28,7 @@ const {
28
28
  resolveModelInternal,
29
29
  MODEL_PROFILES,
30
30
  findPhaseInternal,
31
+ getMilestoneInfo,
31
32
  } = require('./core.cjs');
32
33
 
33
34
  // ─── Root layout (no v2 markers) Tests ───────────────────────────────────────
@@ -1157,3 +1158,68 @@ describe('MODEL_PROFILES dgs-idea-researcher tiering', () => {
1157
1158
  });
1158
1159
 
1159
1160
  });
1161
+
1162
+ // ─── getMilestoneInfo format coverage ───────────────────────────────────────
1163
+ describe('getMilestoneInfo all-format coverage', () => {
1164
+ let fixture;
1165
+
1166
+ afterEach(() => {
1167
+ if (fixture) fixture.cleanup();
1168
+ });
1169
+
1170
+ const ADHOC_ROADMAP = `# Roadmap
1171
+
1172
+ ## Active Milestone: v25.1 add-prs
1173
+
1174
+ **Goal:** add-prs
1175
+ **Status:** In progress (ad-hoc container — phases added on demand via /dgs:add-phase)
1176
+
1177
+ Phases:
1178
+
1179
+ ---
1180
+ `;
1181
+
1182
+ const BOLD_LINE_ROADMAP = `# Roadmap: Tenant data-quality metrics in Admin UI (v30.1)
1183
+
1184
+ **Milestone:** v30.1 — Tenant data-quality metrics in Admin UI
1185
+
1186
+ ## Overview
1187
+
1188
+ Single-milestone roadmap. Version is only in the title and the bold line.
1189
+ `;
1190
+
1191
+ // Multi-milestone heading roadmap WITH a stray bold line: the heading branch
1192
+ // must win, the gated bold-line branch must stay inert.
1193
+ const MULTI_MILESTONE_WITH_STRAY_BOLD = `# Roadmap
1194
+
1195
+ **Milestone:** v9.9 — stray bold line
1196
+
1197
+ ## v1.0 Foundation
1198
+ Some shipped work.
1199
+
1200
+ ## v2.0 Next
1201
+ Current work here.
1202
+ `;
1203
+
1204
+ it('returns the real name for an ad-hoc roadmap (not generic milestone)', () => {
1205
+ fixture = createFixture({ 'ROADMAP.md': ADHOC_ROADMAP });
1206
+ assert.deepEqual(getMilestoneInfo(fixture.cwd), { version: 'v25.1', name: 'add-prs' });
1207
+ });
1208
+
1209
+ it('returns the real name for a bold-line single-milestone roadmap', () => {
1210
+ fixture = createFixture({ 'ROADMAP.md': BOLD_LINE_ROADMAP });
1211
+ assert.deepEqual(getMilestoneInfo(fixture.cwd), {
1212
+ version: 'v30.1',
1213
+ name: 'Tenant data-quality metrics in Admin UI',
1214
+ });
1215
+ });
1216
+
1217
+ it('resolves a multi-milestone heading roadmap via the heading branch (bold line does not hijack)', () => {
1218
+ fixture = createFixture({ 'ROADMAP.md': MULTI_MILESTONE_WITH_STRAY_BOLD });
1219
+ const info = getMilestoneInfo(fixture.cwd);
1220
+ // Heading branch wins → version is the first heading's (v1.0), NOT the stray
1221
+ // bold line's v9.9. The key assertion is that the bold line did not hijack.
1222
+ assert.notEqual(info.version, 'v9.9');
1223
+ assert.equal(info.version, 'v1.0');
1224
+ });
1225
+ });
@@ -475,11 +475,12 @@ function cmdIdeasUpdate(cwd, idOrFilename, field, value, raw, author) {
475
475
  }
476
476
  }
477
477
 
478
- const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
479
478
  const result = {
480
479
  id: fm.id,
481
480
  filename: newFilename,
482
- path: path.join(planRootRel, 'ideas', idea.state, newFilename),
481
+ // Derive the returned path from the file's REAL location so it is correct
482
+ // for both flat (dirname is ideas/) and legacy (dirname is ideas/<state>/).
483
+ path: path.relative(cwd, path.join(path.dirname(idea.path), newFilename)),
483
484
  field,
484
485
  old_value: oldValue,
485
486
  new_value: value,
@@ -1294,23 +1295,50 @@ function cmdIdeasFindRelated(cwd, options, raw) {
1294
1295
 
1295
1296
  const anchorTags = anchor.frontmatter.tags || [];
1296
1297
 
1297
- // 2. Load all other pending ideas
1298
- const pendingDir = path.join(getPlanningRoot(cwd), 'ideas', 'pending');
1299
- let pendingFiles;
1298
+ // 2. Load all other pending ideas — flat-first (mirror cmdIdeasList's flat
1299
+ // scan), with legacy ideas/pending/ as an existence-gated dedup fallback.
1300
+ const candidateContents = [];
1301
+ const seenIds = new Set();
1302
+
1303
+ const flatDir = path.join(getPlanningRoot(cwd), 'ideas');
1300
1304
  try {
1301
- pendingFiles = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
1305
+ const flatFiles = fs.readdirSync(flatDir).filter(f => f.endsWith('.md') && !fs.statSync(path.join(flatDir, f)).isDirectory());
1306
+ for (const file of flatFiles) {
1307
+ const content = safeReadFile(path.join(flatDir, file));
1308
+ if (!content) continue;
1309
+ const parsed = parseIdeaFrontmatter(content);
1310
+ if ((parsed.frontmatter.status || 'pending') !== 'pending') continue;
1311
+ candidateContents.push(content);
1312
+ seenIds.add(parsed.frontmatter.id);
1313
+ }
1302
1314
  } catch {
1303
- pendingFiles = [];
1315
+ // Flat directory may not exist — continue to legacy scan
1316
+ }
1317
+
1318
+ // Legacy fallback: ideas/pending/ (existence-gated, deduped by id)
1319
+ const pendingDir = path.join(getPlanningRoot(cwd), 'ideas', 'pending');
1320
+ if (fs.existsSync(pendingDir)) {
1321
+ let pendingFiles;
1322
+ try {
1323
+ pendingFiles = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
1324
+ } catch {
1325
+ pendingFiles = [];
1326
+ }
1327
+ for (const file of pendingFiles) {
1328
+ const content = safeReadFile(path.join(pendingDir, file));
1329
+ if (!content) continue;
1330
+ const parsed = parseIdeaFrontmatter(content);
1331
+ if (seenIds.has(parsed.frontmatter.id)) continue;
1332
+ candidateContents.push(content);
1333
+ seenIds.add(parsed.frontmatter.id);
1334
+ }
1304
1335
  }
1305
1336
 
1306
1337
  // 3. Score each candidate for tag overlap
1307
1338
  const matchLevelOrder = { HIGH: 0, MEDIUM: 1, LOW: 2, NONE: 3 };
1308
1339
  const candidates = [];
1309
1340
 
1310
- for (const file of pendingFiles) {
1311
- const content = safeReadFile(path.join(pendingDir, file));
1312
- if (!content) continue;
1313
-
1341
+ for (const content of candidateContents) {
1314
1342
  const parsed = parseIdeaFrontmatter(content);
1315
1343
  const candidateId = parsed.frontmatter.id;
1316
1344
 
@@ -10,7 +10,7 @@ const path = require('path');
10
10
  const { execSync } = require('child_process');
11
11
  const { createTempDir, cleanupDir, createTempProject, initGitRepo } = require('./test-helpers.cjs');
12
12
  const { resetPaths } = require('./paths.cjs');
13
- const { findIdeaFile, parseIdeaFrontmatter, ensureIdeasDirs, cmdIdeasCreate, cmdIdeasList, loadManifest, IDEA_STATES, IDEA_STATUSES, setIdeaStatus, buildIdeaContent, cmdIdeasConsolidate, cmdIdeasFindRelated, cmdIdeasUndoConsolidation } = require('./ideas.cjs');
13
+ const { findIdeaFile, parseIdeaFrontmatter, ensureIdeasDirs, cmdIdeasCreate, cmdIdeasList, cmdIdeasUpdate, loadManifest, IDEA_STATES, IDEA_STATUSES, setIdeaStatus, buildIdeaContent, cmdIdeasConsolidate, cmdIdeasFindRelated, cmdIdeasUndoConsolidation } = require('./ideas.cjs');
14
14
 
15
15
  // ─── Helpers ──────────────────────────────────────────────────────────────────
16
16
 
@@ -949,8 +949,9 @@ describe('cmdIdeasFindRelated', () => {
949
949
  const filename = `${paddedId}-${slug}.md`;
950
950
  const now = new Date().toISOString();
951
951
  const tagsStr = tags.length > 0 ? `[${tags.map(t => `"${t}"`).join(', ')}]` : '[]';
952
- const content = `---\nid: ${id}\ntitle: "${title}"\ntags: ${tagsStr}\ncreated: ${now}\nupdated: ${now}\n---\n\n${body || title + ' body.'}\n`;
953
- const dir2 = path.join(dir, 'ideas', 'pending');
952
+ const content = `---\nid: ${id}\ntitle: "${title}"\nstatus: pending\ntags: ${tagsStr}\ncreated: ${now}\nupdated: ${now}\n---\n\n${body || title + ' body.'}\n`;
953
+ // Flat layout: ideas/<paddedId>-<slug>.md (status in frontmatter)
954
+ const dir2 = path.join(dir, 'ideas');
954
955
  fs.mkdirSync(dir2, { recursive: true });
955
956
  fs.writeFileSync(path.join(dir2, filename), content, 'utf-8');
956
957
  // Update manifest so allocateId returns next ID correctly
@@ -1168,6 +1169,34 @@ describe('cmdIdeasFindRelated', () => {
1168
1169
  `match_level should be HIGH, MEDIUM, or LOW, got: ${r.match_level}`);
1169
1170
  }
1170
1171
  });
1172
+
1173
+ it('finds flat-layout pending candidates', () => {
1174
+ // createPendingIdea now seeds FLAT ideas/*.md with status: pending.
1175
+ createPendingIdea(tmpDir, 1, 'Anchor idea', ['api', 'reliability']);
1176
+ createPendingIdea(tmpDir, 2, 'Flat candidate', ['api', 'reliability']);
1177
+ const output = captureOutput(() => {
1178
+ cmdIdeasFindRelated(tmpDir, { id: '1', threshold: 'low' }, true);
1179
+ });
1180
+ const result = JSON.parse(output);
1181
+ assert.ok(result.results.length >= 1, 'should find at least one flat candidate');
1182
+ assert.strictEqual(result.results[0].id, 2);
1183
+ assert.strictEqual(result.counts.total, 1);
1184
+ });
1185
+
1186
+ it('ideas update returns the real flat path (no state subdir segment)', () => {
1187
+ // Seed a FLAT pending idea, then rename via title update.
1188
+ createPendingIdea(tmpDir, 1, 'Original title', ['api']);
1189
+ const output = captureOutput(() => {
1190
+ cmdIdeasUpdate(tmpDir, '1', 'title', 'Renamed title', true);
1191
+ });
1192
+ const result = JSON.parse(output);
1193
+ // Path must point to the flat location ideas/<file>, never ideas/pending/<file>.
1194
+ assert.ok(!result.path.includes(`${path.sep}pending${path.sep}`),
1195
+ `path should not contain a pending/ segment, got: ${result.path}`);
1196
+ assert.match(result.path, /^ideas[\\/]001-renamed-title\.md$/);
1197
+ assert.ok(fs.existsSync(path.join(tmpDir, result.path)),
1198
+ `renamed file should exist at ${result.path}`);
1199
+ });
1171
1200
  });
1172
1201
 
1173
1202
  // ─── cmdIdeasUndoConsolidation ────────────────────────────────────────────────
@@ -1472,6 +1472,29 @@ function parseProjectMilestone(roadmapPath) {
1472
1472
  return { milestone_version: 'v' + headingMatch[1], milestone_name: headingMatch[2].trim() };
1473
1473
  }
1474
1474
 
1475
+ // Ad-hoc container: "## Active Milestone: v25.1 add-prs" (mirror getMilestoneInfo)
1476
+ const adhoc = cleaned.match(/^##\s+Active Milestone:\s*v(\d+\.\d+)\s+(.+?)\s*$/im);
1477
+ if (adhoc) {
1478
+ return { milestone_version: 'v' + adhoc[1], milestone_name: adhoc[2].trim() };
1479
+ }
1480
+
1481
+ // Bold-line single-milestone: "**Milestone:** v30.1 — Name", GATED to <=1
1482
+ // version-bearing heading so a stray bold line never hijacks a multi-milestone
1483
+ // roadmap (mirror getMilestoneInfo / analyzeMilestonePhases).
1484
+ const versionHeadings = cleaned.match(/^#{2,4}\s+.*v\d+\.\d+/gim) || [];
1485
+ if (versionHeadings.length <= 1) {
1486
+ const bold = cleaned.match(/^\*\*Milestone:\*\*\s*v(\d+\.\d+)\s*(?:[—-]\s*)?(.*)$/im);
1487
+ if (bold) {
1488
+ return { milestone_version: 'v' + bold[1], milestone_name: bold[2].trim() || 'milestone' };
1489
+ }
1490
+ }
1491
+
1492
+ // Bare-version fallback (mirror getMilestoneInfo): any vX.Y rather than {null,null}.
1493
+ const versionMatch = cleaned.match(/v(\d+\.\d+)/);
1494
+ if (versionMatch) {
1495
+ return { milestone_version: versionMatch[0], milestone_name: 'milestone' };
1496
+ }
1497
+
1475
1498
  return { milestone_version: null, milestone_name: null };
1476
1499
  }
1477
1500
 
@@ -2255,3 +2255,81 @@ describe('init execute-phase coerces ambiguous archive number to not-found (LOOK
2255
2255
  }
2256
2256
  });
2257
2257
  });
2258
+
2259
+ // ─── progress-all milestone parsing (parseProjectMilestone) ─────────────────
2260
+ describe('init progress-all: parseProjectMilestone format coverage', () => {
2261
+ let fixture;
2262
+
2263
+ afterEach(() => {
2264
+ if (fixture) fixture.cleanup();
2265
+ });
2266
+
2267
+ // Build a v2 fixture whose single active project's ROADMAP.md carries the
2268
+ // supplied content, so progress-all → parseProjectMilestone exercises it.
2269
+ function v2FixtureWithRoadmap(roadmapContent) {
2270
+ return createFixture({
2271
+ 'config.json': JSON.stringify({ current_project: 'test-project' }),
2272
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n|---------|--------|\n| test-project | Active |\n',
2273
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n|------|------|\n',
2274
+ 'projects/test-project/STATE.md': '# State',
2275
+ 'projects/test-project/ROADMAP.md': roadmapContent,
2276
+ 'projects/test-project/REQUIREMENTS.md': '# Requirements',
2277
+ 'projects/test-project/PROJECT.md': '# Project',
2278
+ });
2279
+ }
2280
+
2281
+ const ADHOC_ROADMAP = `# Roadmap
2282
+
2283
+ ## Active Milestone: v25.1 add-prs
2284
+
2285
+ **Goal:** add-prs
2286
+ **Status:** In progress (ad-hoc container — phases added on demand via /dgs:add-phase)
2287
+
2288
+ Phases:
2289
+
2290
+ ---
2291
+ `;
2292
+
2293
+ const BOLD_LINE_ROADMAP = `# Roadmap: Tenant data-quality metrics in Admin UI (v30.1)
2294
+
2295
+ **Milestone:** v30.1 — Tenant data-quality metrics in Admin UI
2296
+
2297
+ ## Overview
2298
+
2299
+ Single-milestone roadmap.
2300
+ `;
2301
+
2302
+ // Only a bare vX.Y appears anywhere — exercises the new bare-version fallback.
2303
+ const BARE_VERSION_ROADMAP = `# Roadmap
2304
+
2305
+ Some prose mentioning v42.7 without any heading or bold milestone line.
2306
+ `;
2307
+
2308
+ function projectEntry(result) {
2309
+ return (result.projects || []).find(p => p.name === 'test-project');
2310
+ }
2311
+
2312
+ it('populates milestone_version + milestone_name for an ad-hoc project roadmap', () => {
2313
+ fixture = v2FixtureWithRoadmap(ADHOC_ROADMAP);
2314
+ const p = projectEntry(runInit(fixture.cwd, 'progress-all --raw'));
2315
+ assert.ok(p, 'expected the active project in progress-all output');
2316
+ assert.equal(p.milestone_version, 'v25.1');
2317
+ assert.equal(p.milestone_name, 'add-prs');
2318
+ });
2319
+
2320
+ it('populates milestone_version + milestone_name for a bold-line project roadmap', () => {
2321
+ fixture = v2FixtureWithRoadmap(BOLD_LINE_ROADMAP);
2322
+ const p = projectEntry(runInit(fixture.cwd, 'progress-all --raw'));
2323
+ assert.ok(p, 'expected the active project in progress-all output');
2324
+ assert.equal(p.milestone_version, 'v30.1');
2325
+ assert.equal(p.milestone_name, 'Tenant data-quality metrics in Admin UI');
2326
+ });
2327
+
2328
+ it('bare-version fallback returns the version (no longer null) when only vX.Y appears', () => {
2329
+ fixture = v2FixtureWithRoadmap(BARE_VERSION_ROADMAP);
2330
+ const p = projectEntry(runInit(fixture.cwd, 'progress-all --raw'));
2331
+ assert.ok(p, 'expected the active project in progress-all output');
2332
+ assert.equal(p.milestone_version, 'v42.7');
2333
+ assert.notEqual(p.milestone_version, null);
2334
+ });
2335
+ });
@@ -1298,6 +1298,12 @@ function detectActiveMilestone(content) {
1298
1298
  // Fallback: milestone list item with (in progress)
1299
1299
  const listMatch = content.match(/-\s+.*?(v\d+\.\d+).*?\((?:in\s+progress)\)/i);
1300
1300
  if (listMatch) return listMatch[1];
1301
+ // Ad-hoc container format: version on the heading, in-progress on a separate
1302
+ // **Status:** line (NO parenthetical on the heading). Runs after the legacy
1303
+ // In-Progress heading/list signals so a real multi-milestone In-Progress
1304
+ // heading still wins on multi-milestone roadmaps.
1305
+ const adhocMatch = content.match(/^##\s+Active Milestone:\s*(v\d+\.\d+)/im);
1306
+ if (adhocMatch) return adhocMatch[1];
1301
1307
  // Final fallback: newer single-milestone format with a `**Milestone:** vX.Y`
1302
1308
  // bold line. Runs LAST so legacy In-Progress heading/list signals win when
1303
1309
  // present on multi-milestone roadmaps.
@@ -1333,7 +1339,14 @@ function cmdJobsCreateMilestone(cwd, version, check, raw) {
1333
1339
  throw new Error('No active milestone found in ROADMAP.md');
1334
1340
  }
1335
1341
  } else {
1336
- // Validate the version exists in ROADMAP
1342
+ // Validate the version exists in ROADMAP.
1343
+ // Confirmed against every real generator: each emits whitespace immediately
1344
+ // after the FIRST occurrence of the version — a space in the ad-hoc /
1345
+ // bullet headings (`v25.1 add-prs`, `Active Milestone: v25.1 add-prs`) and a
1346
+ // newline after the bold line (`**Milestone:** v25.1\n`). The namespaced
1347
+ // `Phases v25.1/1-9` form has a non-whitespace `/` delimiter, but it is
1348
+ // never the FIRST occurrence (the bold bullet `v25.1 add-prs` precedes it),
1349
+ // so `\s+` always matches the first occurrence. No change needed here.
1337
1350
  const escapedV = resolvedVersion.replace(/\./g, '\\.');
1338
1351
  const versionPattern = new RegExp(escapedV + '\\s+', 'i');
1339
1352
  if (!versionPattern.test(roadmapContent)) {
@@ -1452,47 +1465,86 @@ function cmdJobsMilestonePreview(cwd, version, check, raw) {
1452
1465
  function listJobs(cwd) {
1453
1466
  const jobsDir = path.join(getPlanningRoot(cwd), 'jobs');
1454
1467
  const groups = { in_progress: [], pending: [], completed: [] };
1455
- const dirMap = { 'in-progress': 'in_progress', 'pending': 'pending', 'completed': 'completed' };
1468
+ const statusToGroup = { 'in-progress': 'in_progress', 'pending': 'pending', 'completed': 'completed' };
1456
1469
 
1457
- for (const [dirName, groupKey] of Object.entries(dirMap)) {
1458
- const dirPath = path.join(jobsDir, dirName);
1459
- if (!fs.existsSync(dirPath)) {
1460
- fs.mkdirSync(dirPath, { recursive: true });
1470
+ // Shared flat-job predicate (identical to findJobFile/healthCheck reused,
1471
+ // not reinvented). Excludes summary files (job-*-SUMMARY.md).
1472
+ const isJobFile = f => f.startsWith('milestone-') && f.endsWith('.md');
1473
+ const seenVersions = new Set();
1474
+
1475
+ // Stage entries with their mtime so each group sorts most-recent-first.
1476
+ const staged = { in_progress: [], pending: [], completed: [] };
1477
+
1478
+ function stageEntry(groupKey, fullPath, parsed) {
1479
+ let mtime = 0;
1480
+ try { mtime = fs.statSync(fullPath).mtimeMs; } catch {}
1481
+ staged[groupKey].push({
1482
+ mtime,
1483
+ entry: {
1484
+ version: parsed.version,
1485
+ status: parsed.status,
1486
+ check: parsed.check,
1487
+ created_by: parsed.created_by,
1488
+ progress: `${parsed.completedCount}/${parsed.stepCount}`,
1489
+ file: fullPath,
1490
+ },
1491
+ });
1492
+ }
1493
+
1494
+ // Flat-first scan: jobs/milestone-*.md grouped by parsed frontmatter status.
1495
+ let flatFiles = [];
1496
+ try {
1497
+ flatFiles = fs.readdirSync(jobsDir).filter(isJobFile);
1498
+ } catch {
1499
+ flatFiles = [];
1500
+ }
1501
+ for (const file of flatFiles) {
1502
+ const fullPath = path.join(jobsDir, file);
1503
+ try {
1504
+ const parsed = parseJobFile(fullPath);
1505
+ const groupKey = statusToGroup[parsed.status];
1506
+ if (!groupKey) continue; // guard unknown/missing status
1507
+ stageEntry(groupKey, fullPath, parsed);
1508
+ if (parsed.version) seenVersions.add(parsed.version);
1509
+ } catch {
1510
+ // Skip unparseable files
1461
1511
  }
1512
+ }
1513
+
1514
+ // Legacy subdir fallback — existence-gated, NEVER created (read-only).
1515
+ const legacyDirs = ['in-progress', 'pending', 'completed'];
1516
+ for (const dirName of legacyDirs) {
1517
+ const dirPath = path.join(jobsDir, dirName);
1518
+ if (!fs.existsSync(dirPath)) continue;
1462
1519
 
1463
1520
  let files;
1464
1521
  try {
1465
- files = fs.readdirSync(dirPath).filter(f => f.startsWith('milestone-') && f.endsWith('.md'));
1522
+ files = fs.readdirSync(dirPath).filter(isJobFile);
1466
1523
  } catch {
1467
1524
  continue;
1468
1525
  }
1469
-
1470
- // Sort by modification time, most recent first
1471
- const fileStats = files.map(f => {
1472
- const fullPath = path.join(dirPath, f);
1473
- let mtime = 0;
1474
- try { mtime = fs.statSync(fullPath).mtimeMs; } catch {}
1475
- return { file: f, fullPath, mtime };
1476
- });
1477
- fileStats.sort((a, b) => b.mtime - a.mtime);
1478
-
1479
- for (const { file, fullPath } of fileStats) {
1526
+ for (const file of files) {
1527
+ const fullPath = path.join(dirPath, file);
1480
1528
  try {
1481
1529
  const parsed = parseJobFile(fullPath);
1482
- groups[groupKey].push({
1483
- version: parsed.version,
1484
- status: parsed.status,
1485
- check: parsed.check,
1486
- created_by: parsed.created_by,
1487
- progress: `${parsed.completedCount}/${parsed.stepCount}`,
1488
- file: fullPath,
1489
- });
1530
+ if (parsed.version && seenVersions.has(parsed.version)) continue;
1531
+ // Group by parsed status, falling back to the dir name if status missing.
1532
+ const groupKey = statusToGroup[parsed.status] || statusToGroup[dirName];
1533
+ if (!groupKey) continue;
1534
+ stageEntry(groupKey, fullPath, parsed);
1535
+ if (parsed.version) seenVersions.add(parsed.version);
1490
1536
  } catch {
1491
1537
  // Skip unparseable files
1492
1538
  }
1493
1539
  }
1494
1540
  }
1495
1541
 
1542
+ // Sort each group most-recent-first and unwrap to entry objects.
1543
+ for (const key of Object.keys(groups)) {
1544
+ staged[key].sort((a, b) => b.mtime - a.mtime);
1545
+ groups[key] = staged[key].map(s => s.entry);
1546
+ }
1547
+
1496
1548
  return groups;
1497
1549
  }
1498
1550
 
@@ -1479,6 +1479,100 @@ Plans:
1479
1479
  });
1480
1480
  });
1481
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
+
1482
1576
  // ─── findJobFile Tests ──────────────────────────────────────────────────
1483
1577
 
1484
1578
  describe('findJobFile', () => {
@@ -2086,10 +2180,11 @@ Plans:
2086
2180
  assert.deepEqual(result.completed, []);
2087
2181
  });
2088
2182
 
2089
- 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.
2090
2185
  fixture = createFixture({
2091
- 'jobs/in-progress/milestone-v6.0.md': WELL_FORMED_JOB,
2092
- '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,
2093
2188
  });
2094
2189
  const result = listJobs(fixture.cwd);
2095
2190
 
@@ -2111,6 +2206,40 @@ Plans:
2111
2206
  assert.equal(comp.progress, '2/2');
2112
2207
  });
2113
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
+
2114
2243
  it('shows check flag false when --no-check was used', () => {
2115
2244
  fixture = createFixture({
2116
2245
  'jobs/completed/milestone-v5.0.md': ALL_COMPLETED_JOB,