@ktpartners/dgs-platform 3.4.2 → 3.5.0
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 +14 -0
- package/README.md +2 -0
- package/agents/dgs-codebase-cross-analyzer.md +1 -1
- package/agents/dgs-codebase-mapper.md +1 -1
- package/agents/dgs-codebase-synthesizer.md +1 -1
- package/agents/dgs-phase-researcher.md +1 -1
- package/bin/install.js +12 -2
- package/deliver-great-systems/bin/dgs-tools.cjs +7 -1
- package/deliver-great-systems/bin/lib/commands.cjs +66 -29
- package/deliver-great-systems/bin/lib/commands.test.cjs +221 -1
- package/deliver-great-systems/bin/lib/context.cjs +6 -6
- package/deliver-great-systems/bin/lib/context.test.cjs +9 -9
- package/deliver-great-systems/bin/lib/core.cjs +199 -9
- package/deliver-great-systems/bin/lib/core.test.cjs +242 -0
- package/deliver-great-systems/bin/lib/execution.cjs +7 -0
- package/deliver-great-systems/bin/lib/governance.cjs +7 -7
- package/deliver-great-systems/bin/lib/init.cjs +25 -17
- package/deliver-great-systems/bin/lib/init.test.cjs +69 -10
- package/deliver-great-systems/bin/lib/jobs.cjs +16 -10
- package/deliver-great-systems/bin/lib/jobs.test.cjs +17 -1
- package/deliver-great-systems/bin/lib/migration.test.cjs +8 -0
- package/deliver-great-systems/bin/lib/milestone-archival.test.cjs +186 -0
- package/deliver-great-systems/bin/lib/milestone.cjs +168 -37
- package/deliver-great-systems/bin/lib/milestone.test.cjs +113 -1
- package/deliver-great-systems/bin/lib/path-audit.test.cjs +128 -0
- package/deliver-great-systems/bin/lib/paths.cjs +1 -2
- package/deliver-great-systems/bin/lib/paths.test.cjs +3 -4
- package/deliver-great-systems/bin/lib/phase-versioned.test.cjs +134 -0
- package/deliver-great-systems/bin/lib/phase.cjs +60 -7
- package/deliver-great-systems/bin/lib/phase.test.cjs +168 -1
- package/deliver-great-systems/bin/lib/projects.test.cjs +38 -0
- package/deliver-great-systems/bin/lib/repos.cjs +8 -4
- package/deliver-great-systems/bin/lib/repos.test.cjs +6 -2
- package/deliver-great-systems/bin/lib/roadmap.cjs +9 -6
- package/deliver-great-systems/bin/lib/state-snapshot.test.cjs +134 -0
- package/deliver-great-systems/bin/lib/state.cjs +173 -26
- package/deliver-great-systems/templates/milestone-archive.md +1 -1
- package/deliver-great-systems/templates/roadmap.md +12 -10
- package/deliver-great-systems/workflows/abandon-milestone.md +8 -1
- package/deliver-great-systems/workflows/abandon-quick.md +1 -1
- package/deliver-great-systems/workflows/new-milestone.md +46 -12
- package/deliver-great-systems/workflows/quick-abandon.md +1 -1
- package/deliver-great-systems/workflows/quick.md +3 -3
- package/package.json +3 -2
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { execSync } = require('child_process');
|
|
8
|
-
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error, resolveProjectPath, getProjectRoot, isV2Install, getProjectFolders, getV2Hint, safeReadFile } = require('./core.cjs');
|
|
8
|
+
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error, resolveProjectPath, getProjectRoot, phasesDir, isV2Install, getProjectFolders, getV2Hint, safeReadFile, isValidMilestoneVersion } = require('./core.cjs');
|
|
9
9
|
const { requireGitIdentity, formatAuthorString } = require('./identity.cjs');
|
|
10
10
|
const { getPlanningRoot, PROJECTS_DIR } = require('./paths.cjs');
|
|
11
11
|
const { parseReposMd, validateReposMdEager } = require('./repos.cjs');
|
|
@@ -242,7 +242,7 @@ function cmdInitExecutePhase(cwd, phase, raw) {
|
|
|
242
242
|
const config = loadConfig(cwd);
|
|
243
243
|
const ctx = resolveProjectContext(cwd);
|
|
244
244
|
let phaseInfo = findPhaseInternal(cwd, phase);
|
|
245
|
-
if (phaseInfo?.archived) phaseInfo = null;
|
|
245
|
+
if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
|
|
246
246
|
const milestone = getMilestoneInfo(cwd);
|
|
247
247
|
const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
|
|
248
248
|
|
|
@@ -348,7 +348,7 @@ function cmdInitPlanPhase(cwd, phase, raw) {
|
|
|
348
348
|
const config = loadConfig(cwd);
|
|
349
349
|
const ctx = resolveProjectContext(cwd);
|
|
350
350
|
let phaseInfo = findPhaseInternal(cwd, phase);
|
|
351
|
-
if (phaseInfo?.archived) phaseInfo = null;
|
|
351
|
+
if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
|
|
352
352
|
const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
|
|
353
353
|
|
|
354
354
|
const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
|
|
@@ -556,8 +556,11 @@ function cmdInitNewMilestone(cwd, raw) {
|
|
|
556
556
|
cadence_pull: getCadence('new-milestone').pull,
|
|
557
557
|
cadence_push: getCadence('new-milestone').push,
|
|
558
558
|
|
|
559
|
-
// Current milestone
|
|
560
|
-
|
|
559
|
+
// Current milestone — current_milestone is the authoritative structured
|
|
560
|
+
// version signal. new-milestone (this flow) and init.cjs are the SOLE
|
|
561
|
+
// setters. Emit only a grammar-valid value; surface a gap as null rather
|
|
562
|
+
// than persisting an out-of-grammar value (and NEVER coerce to v1.0).
|
|
563
|
+
current_milestone: isValidMilestoneVersion(milestone.version) ? milestone.version : null,
|
|
561
564
|
current_milestone_name: milestone.name,
|
|
562
565
|
|
|
563
566
|
// File existence (project-qualified)
|
|
@@ -747,7 +750,7 @@ function cmdInitVerifyWork(cwd, phase, raw) {
|
|
|
747
750
|
const config = loadConfig(cwd);
|
|
748
751
|
const ctx = resolveProjectContext(cwd);
|
|
749
752
|
let phaseInfo = findPhaseInternal(cwd, phase);
|
|
750
|
-
if (phaseInfo?.archived) phaseInfo = null;
|
|
753
|
+
if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
|
|
751
754
|
|
|
752
755
|
const result = {
|
|
753
756
|
// Models
|
|
@@ -792,7 +795,7 @@ function cmdInitAuditPhase(cwd, phase, raw) {
|
|
|
792
795
|
const config = loadConfig(cwd);
|
|
793
796
|
const ctx = resolveProjectContext(cwd);
|
|
794
797
|
let phaseInfo = findPhaseInternal(cwd, phase);
|
|
795
|
-
if (phaseInfo?.archived) phaseInfo = null;
|
|
798
|
+
if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
|
|
796
799
|
|
|
797
800
|
const result = {
|
|
798
801
|
// Models
|
|
@@ -834,7 +837,7 @@ function cmdInitPhaseOp(cwd, phase, raw, workflow) {
|
|
|
834
837
|
const ctx = resolveProjectContext(cwd);
|
|
835
838
|
const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
|
|
836
839
|
let phaseInfo = findPhaseInternal(cwd, phase);
|
|
837
|
-
if (phaseInfo?.archived) phaseInfo = null;
|
|
840
|
+
if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
|
|
838
841
|
const cadence = getCadence(workflow || 'plan-phase');
|
|
839
842
|
|
|
840
843
|
// Fallback to ROADMAP.md if no directory exists (e.g., Plans: TBD)
|
|
@@ -858,6 +861,9 @@ function cmdInitPhaseOp(cwd, phase, raw, workflow) {
|
|
|
858
861
|
}
|
|
859
862
|
}
|
|
860
863
|
|
|
864
|
+
let phasesDirRel;
|
|
865
|
+
try { phasesDirRel = phasesDir(cwd); } catch { phasesDirRel = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'); }
|
|
866
|
+
|
|
861
867
|
const result = {
|
|
862
868
|
// Config
|
|
863
869
|
commit_docs: config.commit_docs,
|
|
@@ -891,7 +897,7 @@ function cmdInitPhaseOp(cwd, phase, raw, workflow) {
|
|
|
891
897
|
roadmap_path: ctx.root ? path.join(ctx.root, 'ROADMAP.md') : path.join(planRootRel, 'ROADMAP.md'),
|
|
892
898
|
requirements_path: ctx.root ? path.join(ctx.root, 'REQUIREMENTS.md') : path.join(planRootRel, 'REQUIREMENTS.md'),
|
|
893
899
|
project_path: ctx.root ? path.join(ctx.root, 'PROJECT.md') : path.join(planRootRel, 'PROJECT.md'),
|
|
894
|
-
phases_dir:
|
|
900
|
+
phases_dir: phasesDirRel,
|
|
895
901
|
|
|
896
902
|
// Author
|
|
897
903
|
author: resolveAuthorSafe(cwd),
|
|
@@ -1055,17 +1061,18 @@ function cmdInitMilestoneOp(cwd, raw, workflow) {
|
|
|
1055
1061
|
// Count phases (project-qualified)
|
|
1056
1062
|
let phaseCount = 0;
|
|
1057
1063
|
let completedPhases = 0;
|
|
1058
|
-
|
|
1059
|
-
|
|
1064
|
+
let phasesBase;
|
|
1065
|
+
try { phasesBase = phasesDir(cwd); } catch { phasesBase = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'); }
|
|
1066
|
+
const phasesAbs = path.join(cwd, phasesBase);
|
|
1060
1067
|
try {
|
|
1061
|
-
const entries = fs.readdirSync(
|
|
1068
|
+
const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
|
|
1062
1069
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
1063
1070
|
phaseCount = dirs.length;
|
|
1064
1071
|
|
|
1065
1072
|
// Count phases with summaries (completed)
|
|
1066
1073
|
for (const dir of dirs) {
|
|
1067
1074
|
try {
|
|
1068
|
-
const phaseFiles = fs.readdirSync(path.join(
|
|
1075
|
+
const phaseFiles = fs.readdirSync(path.join(phasesAbs, dir));
|
|
1069
1076
|
const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
1070
1077
|
if (hasSummary) completedPhases++;
|
|
1071
1078
|
} catch {}
|
|
@@ -1251,14 +1258,15 @@ function cmdInitProgress(cwd, raw) {
|
|
|
1251
1258
|
const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
|
|
1252
1259
|
|
|
1253
1260
|
// Analyze phases (project-qualified)
|
|
1254
|
-
|
|
1255
|
-
|
|
1261
|
+
let phasesBase;
|
|
1262
|
+
try { phasesBase = phasesDir(cwd); } catch { phasesBase = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'); }
|
|
1263
|
+
const phasesAbs = path.join(cwd, phasesBase);
|
|
1256
1264
|
const phases = [];
|
|
1257
1265
|
let currentPhase = null;
|
|
1258
1266
|
let nextPhase = null;
|
|
1259
1267
|
|
|
1260
1268
|
try {
|
|
1261
|
-
const entries = fs.readdirSync(
|
|
1269
|
+
const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
|
|
1262
1270
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
1263
1271
|
|
|
1264
1272
|
for (const dir of dirs) {
|
|
@@ -1266,7 +1274,7 @@ function cmdInitProgress(cwd, raw) {
|
|
|
1266
1274
|
const phaseNumber = match ? match[1] : dir;
|
|
1267
1275
|
const phaseName = match && match[2] ? match[2] : null;
|
|
1268
1276
|
|
|
1269
|
-
const phasePath = path.join(
|
|
1277
|
+
const phasePath = path.join(phasesAbs, dir);
|
|
1270
1278
|
const phaseFiles = fs.readdirSync(phasePath);
|
|
1271
1279
|
|
|
1272
1280
|
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
@@ -1657,16 +1657,6 @@ describe('cmdInitQuick quick_dir resolution', () => {
|
|
|
1657
1657
|
beforeEach(() => {
|
|
1658
1658
|
fixture = createFixture({
|
|
1659
1659
|
'config.json': JSON.stringify({ current_project: 'test-project' }),
|
|
1660
|
-
'config.local.json': JSON.stringify({
|
|
1661
|
-
execution: { active_context: 'v19-git-worktrees' },
|
|
1662
|
-
projects: {
|
|
1663
|
-
'test-project': {
|
|
1664
|
-
worktrees: {
|
|
1665
|
-
'v19-git-worktrees': { type: 'milestone' }
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
}
|
|
1669
|
-
}),
|
|
1670
1660
|
'PROJECTS.md': '# Projects\n\n| Project | Status |\n|---------|--------|\n| test-project | Active |\n',
|
|
1671
1661
|
'REPOS.md': '# Repos\n\n| Name | Path |\n|------|------|\n',
|
|
1672
1662
|
'projects/test-project/STATE.md': '# State',
|
|
@@ -1676,6 +1666,35 @@ describe('cmdInitQuick quick_dir resolution', () => {
|
|
|
1676
1666
|
'projects/test-project/phases/01-test-phase/01-CONTEXT.md': '# Context',
|
|
1677
1667
|
'projects/test-project/phases/01-test-phase/01-01-PLAN.md': '---\nphase: 01-test-phase\nplan: 01\n---\n# Plan',
|
|
1678
1668
|
});
|
|
1669
|
+
|
|
1670
|
+
// Establish a GENUINE milestone context. detectQuickMode treats a
|
|
1671
|
+
// milestone worktree entry as active only when its `repos` map points at
|
|
1672
|
+
// an on-disk directory; a bare { type: 'milestone' } entry with no live
|
|
1673
|
+
// repos is treated as stale and falls through to PRODUCT mode (the
|
|
1674
|
+
// documented stale-defence from bug 260507-pdp). The previous fixture
|
|
1675
|
+
// omitted `repos`, so it silently exercised product mode and the
|
|
1676
|
+
// project-scoped expectations below could never pass. Create a real
|
|
1677
|
+
// worktree dir inside the fixture (cleaned up with it) and point the
|
|
1678
|
+
// entry at it so milestone-context resolution actually fires.
|
|
1679
|
+
const worktreeDir = path.join(fixture.cwd, 'worktrees', 'v19-git-worktrees');
|
|
1680
|
+
fs.mkdirSync(worktreeDir, { recursive: true });
|
|
1681
|
+
fs.writeFileSync(
|
|
1682
|
+
path.join(fixture.cwd, 'config.local.json'),
|
|
1683
|
+
JSON.stringify({
|
|
1684
|
+
execution: { active_context: 'v19-git-worktrees' },
|
|
1685
|
+
projects: {
|
|
1686
|
+
'test-project': {
|
|
1687
|
+
worktrees: {
|
|
1688
|
+
'v19-git-worktrees': {
|
|
1689
|
+
type: 'milestone',
|
|
1690
|
+
repos: { 'deliver-great-systems': worktreeDir },
|
|
1691
|
+
},
|
|
1692
|
+
},
|
|
1693
|
+
},
|
|
1694
|
+
},
|
|
1695
|
+
}),
|
|
1696
|
+
'utf-8'
|
|
1697
|
+
);
|
|
1679
1698
|
});
|
|
1680
1699
|
|
|
1681
1700
|
afterEach(() => {
|
|
@@ -2196,3 +2215,43 @@ describe('v2 mode multi-project: end-to-end', () => {
|
|
|
2196
2215
|
}
|
|
2197
2216
|
});
|
|
2198
2217
|
});
|
|
2218
|
+
|
|
2219
|
+
// ─── LOOK-01: init coerces a cross-milestone-ambiguous bare number to not-found ─
|
|
2220
|
+
|
|
2221
|
+
describe('init execute-phase coerces ambiguous archive number to not-found (LOOK-01)', () => {
|
|
2222
|
+
// A v2 project where bare "03" is NOT an active phase but exists in TWO
|
|
2223
|
+
// milestone archives. findPhaseInternal returns a {found:false, ambiguous:true}
|
|
2224
|
+
// signal; the init.cjs coercion must treat it as not-found rather than silently
|
|
2225
|
+
// adopting one archive's directory (the pre-fix Group C bug).
|
|
2226
|
+
function ambiguousArchiveFixture() {
|
|
2227
|
+
return createFixture({
|
|
2228
|
+
'config.json': JSON.stringify({}),
|
|
2229
|
+
'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
|
|
2230
|
+
'PROJECTS.md': '# Projects\n\n| Project | Status |\n|---------|--------|\n| auth-overhaul | Active |\n',
|
|
2231
|
+
'REPOS.md': '# Repos\n\n| Name | Path |\n|------|------|\n',
|
|
2232
|
+
'projects/auth-overhaul/STATE.md': '---\ncurrent_milestone: v25.0\n---\n# State',
|
|
2233
|
+
'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
|
|
2234
|
+
'projects/auth-overhaul/REQUIREMENTS.md': '# Requirements',
|
|
2235
|
+
'projects/auth-overhaul/PROJECT.md': '# Project',
|
|
2236
|
+
'projects/auth-overhaul/phases/': null,
|
|
2237
|
+
'milestones/v3.0-phases/03-alpha/01-PLAN.md': '# Plan',
|
|
2238
|
+
'milestones/v4.0-phases/03-beta/01-PLAN.md': '# Plan',
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
it('does NOT silently resolve to either archive directory', () => {
|
|
2243
|
+
const fixture = ambiguousArchiveFixture();
|
|
2244
|
+
try {
|
|
2245
|
+
const result = runInit(fixture.cwd, 'execute-phase 03');
|
|
2246
|
+
// Coerced to not-found: the ambiguity object was nulled out.
|
|
2247
|
+
assert.equal(result.phase_found, false, 'ambiguous number must be not-found');
|
|
2248
|
+
assert.equal(result.phase_dir, null, 'no archive directory adopted');
|
|
2249
|
+
// Belt-and-suspenders: neither archived dir leaked into the result.
|
|
2250
|
+
const blob = JSON.stringify(result);
|
|
2251
|
+
assert.ok(!blob.includes('03-alpha'), 'must not adopt v3.0 archive dir');
|
|
2252
|
+
assert.ok(!blob.includes('03-beta'), 'must not adopt v4.0 archive dir');
|
|
2253
|
+
} finally {
|
|
2254
|
+
fixture.cleanup();
|
|
2255
|
+
}
|
|
2256
|
+
});
|
|
2257
|
+
});
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
const fs = require('fs');
|
|
31
31
|
const path = require('path');
|
|
32
32
|
const { execSync } = require('child_process');
|
|
33
|
-
const { output, error, getProjectRoot } = require('./core.cjs');
|
|
33
|
+
const { output, error, getProjectRoot, phasesDir } = require('./core.cjs');
|
|
34
34
|
const { getPlanningRoot } = require('./paths.cjs');
|
|
35
35
|
const { requireGitIdentity, formatAuthorString } = require('./identity.cjs');
|
|
36
36
|
const { extractFrontmatter, spliceFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
|
|
@@ -1128,7 +1128,9 @@ function analyzeMilestonePhases(cwd, version) {
|
|
|
1128
1128
|
}
|
|
1129
1129
|
|
|
1130
1130
|
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
1131
|
-
|
|
1131
|
+
let phasesAbs;
|
|
1132
|
+
try { phasesAbs = path.join(cwd, phasesDir(cwd)); }
|
|
1133
|
+
catch { phasesAbs = path.join(projectRoot, 'phases'); }
|
|
1132
1134
|
|
|
1133
1135
|
// Find the milestone section: look for heading containing the version + "In Progress" or "SHIPPED"
|
|
1134
1136
|
// Also match the milestone in the summary list to find its phase range
|
|
@@ -1222,12 +1224,12 @@ function analyzeMilestonePhases(cwd, version) {
|
|
|
1222
1224
|
let diskStatus = 'no_directory';
|
|
1223
1225
|
|
|
1224
1226
|
try {
|
|
1225
|
-
const entries = fs.readdirSync(
|
|
1227
|
+
const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
|
|
1226
1228
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
1227
1229
|
const dirMatch = dirs.find(d => d.startsWith(padded + '-') || d === padded);
|
|
1228
1230
|
|
|
1229
1231
|
if (dirMatch) {
|
|
1230
|
-
const phaseFiles = fs.readdirSync(path.join(
|
|
1232
|
+
const phaseFiles = fs.readdirSync(path.join(phasesAbs, dirMatch));
|
|
1231
1233
|
const planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
|
1232
1234
|
const summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
|
1233
1235
|
const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
|
|
@@ -1708,7 +1710,9 @@ function generateJobSummary(cwd, version) {
|
|
|
1708
1710
|
try {
|
|
1709
1711
|
const milestonePhases = analyzeMilestonePhases(cwd, normalizedVersion);
|
|
1710
1712
|
const projectRoot = resolveProjectRoot(cwd);
|
|
1711
|
-
|
|
1713
|
+
let phasesAbs;
|
|
1714
|
+
try { phasesAbs = path.join(cwd, phasesDir(cwd)); }
|
|
1715
|
+
catch { phasesAbs = path.join(projectRoot, 'phases'); }
|
|
1712
1716
|
|
|
1713
1717
|
for (const phase of milestonePhases) {
|
|
1714
1718
|
const phaseNum = phase.number;
|
|
@@ -1717,7 +1721,7 @@ function generateJobSummary(cwd, version) {
|
|
|
1717
1721
|
// Find the phase directory on disk
|
|
1718
1722
|
let phaseDir = null;
|
|
1719
1723
|
try {
|
|
1720
|
-
const entries = fs.readdirSync(
|
|
1724
|
+
const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
|
|
1721
1725
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
1722
1726
|
phaseDir = dirs.find(d => d.startsWith(padded + '-') || d === padded);
|
|
1723
1727
|
} catch {}
|
|
@@ -1725,7 +1729,7 @@ function generateJobSummary(cwd, version) {
|
|
|
1725
1729
|
if (!phaseDir) continue;
|
|
1726
1730
|
|
|
1727
1731
|
// Look for UAT file
|
|
1728
|
-
const uatPath = path.join(
|
|
1732
|
+
const uatPath = path.join(phasesAbs, phaseDir, `${padded}-UAT.md`);
|
|
1729
1733
|
let uatContent = null;
|
|
1730
1734
|
try {
|
|
1731
1735
|
uatContent = fs.readFileSync(uatPath, 'utf-8');
|
|
@@ -1921,7 +1925,9 @@ function rollbackJob(cwd, version) {
|
|
|
1921
1925
|
|
|
1922
1926
|
// Identify executed phases from completed steps
|
|
1923
1927
|
const projectRoot = resolveProjectRoot(cwd);
|
|
1924
|
-
|
|
1928
|
+
let phasesAbs;
|
|
1929
|
+
try { phasesAbs = path.join(cwd, phasesDir(cwd)); }
|
|
1930
|
+
catch { phasesAbs = path.join(projectRoot, 'phases'); }
|
|
1925
1931
|
|
|
1926
1932
|
for (const step of parsed.steps) {
|
|
1927
1933
|
if (step.status === 'completed' && step.command === 'execute-phase') {
|
|
@@ -1932,12 +1938,12 @@ function rollbackJob(cwd, version) {
|
|
|
1932
1938
|
const padded = phaseNum.replace(/^(\d+)/, (m) => m.padStart(2, '0'));
|
|
1933
1939
|
|
|
1934
1940
|
try {
|
|
1935
|
-
const entries = fs.readdirSync(
|
|
1941
|
+
const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
|
|
1936
1942
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
1937
1943
|
const dirMatch = dirs.find(d => d.startsWith(padded + '-') || d === padded);
|
|
1938
1944
|
|
|
1939
1945
|
if (dirMatch) {
|
|
1940
|
-
const phaseDir = path.join(
|
|
1946
|
+
const phaseDir = path.join(phasesAbs, dirMatch);
|
|
1941
1947
|
const phaseFiles = fs.readdirSync(phaseDir);
|
|
1942
1948
|
const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
1943
1949
|
|
|
@@ -2124,8 +2124,24 @@ Plans:
|
|
|
2124
2124
|
});
|
|
2125
2125
|
|
|
2126
2126
|
it('returns not_in_progress when job is in pending/', () => {
|
|
2127
|
+
// findJobFile's contract: frontmatter Status is authoritative, the
|
|
2128
|
+
// legacy directory is advisory only ("Frontmatter wins"). A genuinely
|
|
2129
|
+
// pending job therefore needs Status: pending in its frontmatter — using
|
|
2130
|
+
// a status:in-progress fixture here would (correctly) be cancellable.
|
|
2131
|
+
const PENDING_JOB = `# Milestone Job: v6.0
|
|
2132
|
+
|
|
2133
|
+
**Version:** v6.0
|
|
2134
|
+
**Created:** 2026-03-02T10:00:00Z
|
|
2135
|
+
**Status:** pending
|
|
2136
|
+
**Check:** true
|
|
2137
|
+
|
|
2138
|
+
## Steps
|
|
2139
|
+
|
|
2140
|
+
- [ ] \`/dgs:plan-phase 41\`
|
|
2141
|
+
- [ ] \`/dgs:execute-phase 41\`
|
|
2142
|
+
`;
|
|
2127
2143
|
fixture = createFixture({
|
|
2128
|
-
'jobs/pending/milestone-v6.0.md':
|
|
2144
|
+
'jobs/pending/milestone-v6.0.md': PENDING_JOB,
|
|
2129
2145
|
});
|
|
2130
2146
|
const result = cancelJob(fixture.cwd, 'v6.0');
|
|
2131
2147
|
|
|
@@ -423,6 +423,14 @@ describe('CLI: migrate command (MIG-08, OPT-02)', () => {
|
|
|
423
423
|
|
|
424
424
|
beforeEach(() => {
|
|
425
425
|
dir = makeGitDir();
|
|
426
|
+
// Real DGS repos gitignore config.local.json — the local half of the
|
|
427
|
+
// documented two-file config layout (config.json tracked / config.local.json
|
|
428
|
+
// gitignored). dgs-tools writes config.local.json on startup (e.g.
|
|
429
|
+
// { branching_migration_done: true }); without the .gitignore that real
|
|
430
|
+
// installs carry, that untracked write dirties the tree and trips migrate's
|
|
431
|
+
// (correct) clean-working-tree guard with exit 1. Mirror the real layout so
|
|
432
|
+
// the guard is exercised honestly rather than failing on a fixture artifact.
|
|
433
|
+
writeFile(dir, '.gitignore', 'config.local.json\n');
|
|
426
434
|
});
|
|
427
435
|
|
|
428
436
|
afterEach(() => {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for cmdMilestoneComplete — structural phase archival (Phase 167, NUM-03/NUM-04)
|
|
3
|
+
*
|
|
4
|
+
* Covers the versioned single-move archival path, the collision-abort guard,
|
|
5
|
+
* mixed-layout correctness, and flat-mode preservation.
|
|
6
|
+
*
|
|
7
|
+
* Uses Node.js built-in test runner (node:test) and assert (node:assert/strict).
|
|
8
|
+
* Each test creates an isolated temp directory fixture and cleans up after.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { describe, it, afterEach } = require('node:test');
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const { createFixture } = require('./test-helpers.cjs');
|
|
17
|
+
const { cmdMilestoneComplete } = require('./milestone.cjs');
|
|
18
|
+
|
|
19
|
+
// Helper: capture stdout from cmdMilestoneComplete (which calls output()).
|
|
20
|
+
// Also stubs process.exit so error()-guarded paths return instead of exiting.
|
|
21
|
+
function captureResult(fn) {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
const origWrite = process.stdout.write;
|
|
24
|
+
process.stdout.write = function (chunk) {
|
|
25
|
+
chunks.push(String(chunk));
|
|
26
|
+
};
|
|
27
|
+
const origStderrWrite = process.stderr.write;
|
|
28
|
+
process.stderr.write = function () {};
|
|
29
|
+
const origExit = process.exit;
|
|
30
|
+
process.exit = function () {};
|
|
31
|
+
try {
|
|
32
|
+
fn();
|
|
33
|
+
} finally {
|
|
34
|
+
process.stdout.write = origWrite;
|
|
35
|
+
process.stderr.write = origStderrWrite;
|
|
36
|
+
process.exit = origExit;
|
|
37
|
+
}
|
|
38
|
+
const raw = chunks.join('');
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates a root-layout fixture suitable for cmdMilestoneComplete.
|
|
48
|
+
* Pass `extras` to add/override structure entries (e.g. a versioned STATE.md
|
|
49
|
+
* with current_milestone frontmatter and phases/v26.0/ dirs).
|
|
50
|
+
*/
|
|
51
|
+
function createMilestoneFixture(extras) {
|
|
52
|
+
const base = {
|
|
53
|
+
'config.json': JSON.stringify({}),
|
|
54
|
+
'config.local.json': JSON.stringify({ planningRoot: '.' }),
|
|
55
|
+
'STATE.md': '# Project State\n\nPhase: 1\nStatus: Ready\nProgress: [----------] 0%\n',
|
|
56
|
+
'ROADMAP.md': '# Roadmap\n\n## Phase 1: Test\n',
|
|
57
|
+
'phases/': null,
|
|
58
|
+
};
|
|
59
|
+
if (extras) {
|
|
60
|
+
Object.assign(base, extras);
|
|
61
|
+
}
|
|
62
|
+
return createFixture(base);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Versioned STATE.md with current_milestone frontmatter so resolveMilestoneVersion → v26.0.
|
|
66
|
+
const VERSIONED_STATE =
|
|
67
|
+
'---\ncurrent_milestone: v26.0\nmilestone: v26.0\n---\n\n# Project State\n\nPhase: 1\nStatus: Ready\n';
|
|
68
|
+
|
|
69
|
+
describe('cmdMilestoneComplete versioned structural archival', () => {
|
|
70
|
+
let fixture;
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
if (fixture) fixture.cleanup();
|
|
74
|
+
fixture = undefined;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('versioned single-move: archives phases/v26.0 to milestones/v26.0-phases via one rename', () => {
|
|
78
|
+
fixture = createMilestoneFixture({
|
|
79
|
+
'STATE.md': VERSIONED_STATE,
|
|
80
|
+
'phases/v26.0/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
|
|
81
|
+
'phases/v26.0/02-bar/02-bar-SUMMARY.md': '---\none-liner: bar\n---\n## Task 1\n',
|
|
82
|
+
});
|
|
83
|
+
const cwd = fixture.cwd;
|
|
84
|
+
|
|
85
|
+
const result = captureResult(() => {
|
|
86
|
+
cmdMilestoneComplete(cwd, 'v26.0', { archivePhases: true }, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
assert.ok(
|
|
90
|
+
fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '01-foo')),
|
|
91
|
+
'01-foo should be archived under milestones/v26.0-phases/'
|
|
92
|
+
);
|
|
93
|
+
assert.ok(
|
|
94
|
+
fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '02-bar')),
|
|
95
|
+
'02-bar should be archived under milestones/v26.0-phases/'
|
|
96
|
+
);
|
|
97
|
+
assert.ok(
|
|
98
|
+
!fs.existsSync(path.join(cwd, 'phases', 'v26.0')),
|
|
99
|
+
'phases/v26.0 should be MOVED (not copied) — source must no longer exist'
|
|
100
|
+
);
|
|
101
|
+
assert.ok(result, 'Should return result JSON');
|
|
102
|
+
assert.equal(result.archived.phases, true, 'archived.phases should be true');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('versioned collision abort: aborts when milestones/v26.0-phases already exists, leaves source intact', () => {
|
|
106
|
+
fixture = createMilestoneFixture({
|
|
107
|
+
'STATE.md': VERSIONED_STATE,
|
|
108
|
+
'phases/v26.0/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
|
|
109
|
+
'milestones/v26.0-phases/old/keep.md': 'PRESERVE ME',
|
|
110
|
+
});
|
|
111
|
+
const cwd = fixture.cwd;
|
|
112
|
+
|
|
113
|
+
captureResult(() => {
|
|
114
|
+
cmdMilestoneComplete(cwd, 'v26.0', { archivePhases: true }, false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Source NOT moved on collision.
|
|
118
|
+
assert.ok(
|
|
119
|
+
fs.existsSync(path.join(cwd, 'phases', 'v26.0', '01-foo')),
|
|
120
|
+
'source phases/v26.0/01-foo should still exist after collision abort'
|
|
121
|
+
);
|
|
122
|
+
// Pre-existing archive untouched — no overwrite, no merge.
|
|
123
|
+
const keepPath = path.join(cwd, 'milestones', 'v26.0-phases', 'old', 'keep.md');
|
|
124
|
+
assert.ok(fs.existsSync(keepPath), 'pre-existing archive entry should be untouched');
|
|
125
|
+
assert.equal(
|
|
126
|
+
fs.readFileSync(keepPath, 'utf-8'),
|
|
127
|
+
'PRESERVE ME',
|
|
128
|
+
'pre-existing archive content should not be overwritten'
|
|
129
|
+
);
|
|
130
|
+
// No source dir merged into the existing archive.
|
|
131
|
+
assert.ok(
|
|
132
|
+
!fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '01-foo')),
|
|
133
|
+
'source dir should NOT be merged into the existing archive'
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('mixed-layout: archives only phases/v26.0, leaves sibling flat phases/NN-slug dirs untouched', () => {
|
|
138
|
+
fixture = createMilestoneFixture({
|
|
139
|
+
'STATE.md': VERSIONED_STATE,
|
|
140
|
+
'phases/v26.0/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
|
|
141
|
+
'phases/90-legacy/PLAN.md': '# Legacy flat phase',
|
|
142
|
+
});
|
|
143
|
+
const cwd = fixture.cwd;
|
|
144
|
+
|
|
145
|
+
captureResult(() => {
|
|
146
|
+
cmdMilestoneComplete(cwd, 'v26.0', { archivePhases: true }, false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.ok(
|
|
150
|
+
fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '01-foo')),
|
|
151
|
+
'versioned phase 01-foo should be archived'
|
|
152
|
+
);
|
|
153
|
+
assert.ok(
|
|
154
|
+
fs.existsSync(path.join(cwd, 'phases', '90-legacy', 'PLAN.md')),
|
|
155
|
+
'unrelated sibling flat phase phases/90-legacy/ must NOT be disturbed'
|
|
156
|
+
);
|
|
157
|
+
assert.ok(
|
|
158
|
+
!fs.existsSync(path.join(cwd, 'phases', 'v26.0')),
|
|
159
|
+
'versioned subtree should be moved away'
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('flat mode preserved: per-directory filtered move still works (no frontmatter version)', () => {
|
|
164
|
+
fixture = createMilestoneFixture({
|
|
165
|
+
'ROADMAP.md': '# Roadmap\n\n## Phase 1: Test\n',
|
|
166
|
+
'phases/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
|
|
167
|
+
});
|
|
168
|
+
const cwd = fixture.cwd;
|
|
169
|
+
|
|
170
|
+
const result = captureResult(() => {
|
|
171
|
+
cmdMilestoneComplete(cwd, 'v1.0', { archivePhases: true }, false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
assert.ok(
|
|
175
|
+
fs.existsSync(path.join(cwd, 'milestones', 'v1.0-phases', '01-foo')),
|
|
176
|
+
'flat per-dir move should archive 01-foo to milestones/v1.0-phases/'
|
|
177
|
+
);
|
|
178
|
+
// Flat phases/ is mkdir-archived FROM, not moved wholesale → it still exists as a dir.
|
|
179
|
+
assert.ok(
|
|
180
|
+
fs.existsSync(path.join(cwd, 'phases')),
|
|
181
|
+
'flat phases/ directory should still exist (archived from, not moved wholesale)'
|
|
182
|
+
);
|
|
183
|
+
assert.ok(result, 'Should return result JSON');
|
|
184
|
+
assert.equal(result.archived.phases, true, 'archived.phases should be true in flat mode');
|
|
185
|
+
});
|
|
186
|
+
});
|