@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.
- package/CHANGELOG.md +27 -0
- package/bin/install.js +22 -0
- package/deliver-great-systems/bin/lib/core.cjs +21 -0
- package/deliver-great-systems/bin/lib/core.test.cjs +66 -0
- package/deliver-great-systems/bin/lib/ideas.cjs +39 -11
- package/deliver-great-systems/bin/lib/ideas.test.cjs +32 -3
- package/deliver-great-systems/bin/lib/init.cjs +23 -0
- package/deliver-great-systems/bin/lib/init.test.cjs +78 -0
- package/deliver-great-systems/bin/lib/jobs.cjs +194 -83
- package/deliver-great-systems/bin/lib/jobs.test.cjs +272 -15
- package/deliver-great-systems/bin/lib/overlap.cjs +14 -4
- package/deliver-great-systems/bin/lib/overlap.test.cjs +13 -0
- package/deliver-great-systems/bin/lib/package-scan-report.cjs +19 -0
- package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +18 -0
- package/deliver-great-systems/bin/lib/phase.cjs +12 -4
- package/deliver-great-systems/bin/lib/phase.test.cjs +69 -0
- package/deliver-great-systems/bin/lib/projects.cjs +13 -3
- package/deliver-great-systems/bin/lib/projects.test.cjs +8 -0
- package/deliver-great-systems/bin/lib/roadmap.cjs +26 -5
- package/deliver-great-systems/bin/lib/roadmap.test.cjs +86 -0
- package/deliver-great-systems/bin/lib/search.cjs +62 -15
- package/deliver-great-systems/bin/lib/search.test.cjs +94 -0
- package/deliver-great-systems/bin/lib/verify.cjs +37 -20
- package/deliver-great-systems/bin/lib/verify.test.cjs +58 -0
- package/deliver-great-systems/references/git-integration.md +1 -1
- package/deliver-great-systems/workflows/audit-milestone.md +1 -1
- package/deliver-great-systems/workflows/cleanup.md +1 -1
- package/deliver-great-systems/workflows/codereview.md +1 -1
- package/deliver-great-systems/workflows/complete-milestone.md +3 -3
- package/deliver-great-systems/workflows/discuss-phase.md +5 -1
- package/deliver-great-systems/workflows/execute-phase.md +2 -2
- package/deliver-great-systems/workflows/execute-plan.md +2 -2
- package/deliver-great-systems/workflows/pause-work.md +1 -1
- package/deliver-great-systems/workflows/plan-phase.md +6 -2
- package/deliver-great-systems/workflows/resume-project.md +2 -2
- 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
|
|
165
|
-
*
|
|
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
|
|
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
|
|
180
|
-
const dir = path.join(planRoot, 'ideas',
|
|
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
|
-
|
|
244
|
+
if (frontmatter.id !== undefined && seenIds.has(frontmatter.id)) continue;
|
|
195
245
|
|
|
196
|
-
|
|
197
|
-
if (
|
|
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',
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
878
|
+
if (fs.existsSync(phasesAbs)) {
|
|
862
879
|
const ARTIFACT_RE = /^[0-9].*-(PLAN|CONTEXT|RESEARCH|UAT|VERIFICATION)\.md$/;
|
|
863
|
-
const phaseEntries = fs.readdirSync(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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
|
|
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 -
|
|
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.
|