@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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restart-at-1 behaviour test (NUM-01).
|
|
3
|
+
*
|
|
4
|
+
* Dedicated file (separate from phase.test.cjs, which plan 02 owns) proving that a
|
|
5
|
+
* fresh versioned milestone's FIRST add-phase is numbered `01` (zero-padded) and
|
|
6
|
+
* lands UNDER the milestone's own phases/<version>/ directory — i.e. phase numbering
|
|
7
|
+
* restarts per milestone with NO arithmetic change (empty roadmap → maxPhase=0 →
|
|
8
|
+
* newPhaseNum=1 → '01'), and no global / MILESTONES.md continuation leaks into the number.
|
|
9
|
+
*
|
|
10
|
+
* Uses Node.js built-in test runner (node:test) + assert.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { describe, it } = require('node:test');
|
|
14
|
+
const assert = require('node:assert');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const { createFixture } = require('./test-helpers.cjs');
|
|
18
|
+
const { cmdPhaseInitVersionedDir, cmdPhaseAdd } = require('./phase.cjs');
|
|
19
|
+
|
|
20
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Capture stdout from CLI commands that call output() (process.stdout.write +
|
|
24
|
+
* process.exit). Mirrors phase.test.cjs's captureStdout so multiple invocations
|
|
25
|
+
* can run in sequence. Returns { stdout, exitCode, json }.
|
|
26
|
+
*/
|
|
27
|
+
function captureStdout(fn) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
30
|
+
const origExit = process.exit;
|
|
31
|
+
let exitCode = null;
|
|
32
|
+
process.stdout.write = (data) => { chunks.push(String(data)); return true; };
|
|
33
|
+
process.exit = (code) => {
|
|
34
|
+
exitCode = code == null ? 0 : code;
|
|
35
|
+
throw new Error('__EXIT__');
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
fn();
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e && e.message !== '__EXIT__') throw e;
|
|
41
|
+
} finally {
|
|
42
|
+
process.stdout.write = origWrite;
|
|
43
|
+
process.exit = origExit;
|
|
44
|
+
}
|
|
45
|
+
const stdout = chunks.join('');
|
|
46
|
+
let json = null;
|
|
47
|
+
try { json = JSON.parse(stdout); } catch { /* not JSON */ }
|
|
48
|
+
return { stdout, exitCode, json };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fresh-milestone v2 fixture. STATE.md frontmatter `current_milestone` is what
|
|
53
|
+
* resolveMilestoneVersion(required) reads. The ROADMAP has NO `### Phase` heading
|
|
54
|
+
* (fresh milestone) plus a `---` trailer so cmdPhaseAdd's insertion logic has a
|
|
55
|
+
* separator. `roadmapBody` lets a test seed a high-number context to prove
|
|
56
|
+
* milestone-local numbering.
|
|
57
|
+
*/
|
|
58
|
+
function freshMilestoneFixture({ slug = 'auth-overhaul', milestone = 'v26.0', roadmapBody } = {}) {
|
|
59
|
+
return createFixture({
|
|
60
|
+
'config.json': JSON.stringify({}),
|
|
61
|
+
'config.local.json': JSON.stringify({ current_project: slug }),
|
|
62
|
+
'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
|
|
63
|
+
'REPOS.md': '# Repos\n\n| Name | Path |\n',
|
|
64
|
+
[`projects/${slug}/PROJECT.md`]: '# Project',
|
|
65
|
+
[`projects/${slug}/STATE.md`]: `---\ncurrent_milestone: ${milestone}\n---\n# State`,
|
|
66
|
+
[`projects/${slug}/ROADMAP.md`]: roadmapBody || '# Roadmap\n\n## Phases\n\n---\n',
|
|
67
|
+
[`projects/${slug}/phases/`]: null,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Restart-at-1 (NUM-01) ────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe('restart-at-1 in a fresh versioned milestone (NUM-01)', () => {
|
|
74
|
+
it('Test 1: first add-phase is 01 and lands under phases/<version>/', () => {
|
|
75
|
+
const fixture = freshMilestoneFixture({ slug: 'auth-overhaul', milestone: 'v26.0' });
|
|
76
|
+
try {
|
|
77
|
+
// new-milestone flow materialises phases/v26.0/ (NUM-02 command).
|
|
78
|
+
captureStdout(() => cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
79
|
+
|
|
80
|
+
// First add-phase in the fresh versioned milestone.
|
|
81
|
+
const { json } = captureStdout(() =>
|
|
82
|
+
cmdPhaseAdd(fixture.cwd, 'first thing', false)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
assert.ok(json, 'add-phase should emit JSON');
|
|
86
|
+
// Restart-at-1: empty versioned roadmap → maxPhase=0 → newPhaseNum=1 → '01'.
|
|
87
|
+
assert.equal(json.padded, '01', 'first phase is zero-padded 01');
|
|
88
|
+
assert.equal(json.phase_number, 1, 'first phase_number is 1');
|
|
89
|
+
|
|
90
|
+
// The created dir is UNAMBIGUOUS evidence: phases/v26.0/01-first-thing.
|
|
91
|
+
const expectedDir = path.join('projects', 'auth-overhaul', 'phases', 'v26.0', '01-first-thing');
|
|
92
|
+
assert.equal(json.directory, expectedDir, 'phase dir is numbered 01 under the versioned dir');
|
|
93
|
+
assert.ok(
|
|
94
|
+
fs.existsSync(path.join(fixture.cwd, expectedDir)),
|
|
95
|
+
'phases/v26.0/01-first-thing exists on disk'
|
|
96
|
+
);
|
|
97
|
+
} finally {
|
|
98
|
+
fixture.cleanup();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('Test 2: numbers stay milestone-local (no global continuation leaks into 01)', () => {
|
|
103
|
+
// Seed a ROADMAP with prose that mentions high phase numbers but NO `### Phase`
|
|
104
|
+
// heading. A global / MILESTONES.md continuation scheme would have produced a
|
|
105
|
+
// high number; the milestone-local maxPhase scan sees zero `### Phase` headings,
|
|
106
|
+
// so the first add-phase is still 01.
|
|
107
|
+
const roadmapBody =
|
|
108
|
+
'# Roadmap\n\n' +
|
|
109
|
+
'Continuation note: the previous milestone ended at phase 162.\n\n' +
|
|
110
|
+
'## Phases\n\n' +
|
|
111
|
+
'---\n';
|
|
112
|
+
const fixture = freshMilestoneFixture({
|
|
113
|
+
slug: 'auth-overhaul',
|
|
114
|
+
milestone: 'v26.0',
|
|
115
|
+
roadmapBody,
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
captureStdout(() => cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
119
|
+
const { json } = captureStdout(() =>
|
|
120
|
+
cmdPhaseAdd(fixture.cwd, 'first thing', false)
|
|
121
|
+
);
|
|
122
|
+
assert.ok(json, 'add-phase should emit JSON');
|
|
123
|
+
// Still 01 — proves no MILESTONES.md / global 163-continuation leaks into the number.
|
|
124
|
+
assert.equal(json.padded, '01', 'milestone-local: first phase is 01 despite prose mentioning 162');
|
|
125
|
+
assert.equal(json.phase_number, 1, 'milestone-local: phase_number is 1');
|
|
126
|
+
assert.ok(
|
|
127
|
+
json.directory.startsWith(path.join('projects', 'auth-overhaul', 'phases', 'v26.0')),
|
|
128
|
+
`phase dir must be under the versioned dir: ${json.directory}`
|
|
129
|
+
);
|
|
130
|
+
} finally {
|
|
131
|
+
fixture.cleanup();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, toPosixPath, output, error, getProjectRoot, loadConfig, execGit } = require('./core.cjs');
|
|
7
|
+
const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, toPosixPath, output, error, getProjectRoot, phasesDir, resolveProjectPath, resolveMilestoneVersion, loadConfig, execGit } = require('./core.cjs');
|
|
8
8
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
9
9
|
const { writeStateMd } = require('./state.cjs');
|
|
10
10
|
|
|
@@ -14,13 +14,13 @@ const { writeStateMd } = require('./state.cjs');
|
|
|
14
14
|
* from getProjectRoot() or '.' as fallback.
|
|
15
15
|
*/
|
|
16
16
|
function resolvePhasesDir(cwd) {
|
|
17
|
-
let
|
|
17
|
+
let phasesRel;
|
|
18
18
|
try {
|
|
19
|
-
|
|
19
|
+
phasesRel = phasesDir(cwd);
|
|
20
20
|
} catch {
|
|
21
|
-
|
|
21
|
+
phasesRel = 'phases'; // flat-layout fallback (== path.join('.', 'phases'))
|
|
22
22
|
}
|
|
23
|
-
return path.join(cwd,
|
|
23
|
+
return path.join(cwd, phasesRel);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -406,6 +406,54 @@ function cmdPhaseAdd(cwd, description, raw) {
|
|
|
406
406
|
output(result, raw, paddedNum);
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Create the current milestone's versioned phases directory: phases/<version>/.
|
|
411
|
+
* This is the SOLE creator of a phases/<version> directory (NUM-02). It requires a
|
|
412
|
+
* determinable milestone version (fail-loud) so a phase is never written to phases/v1.0/.
|
|
413
|
+
* Idempotent: recursive mkdir is a no-op when the directory already exists.
|
|
414
|
+
* cmdPhaseAdd never calls this — it routes through the version-aware phasesDir resolver,
|
|
415
|
+
* which returns this directory once it exists.
|
|
416
|
+
*/
|
|
417
|
+
/**
|
|
418
|
+
* Internal, output-free creator of the current milestone's versioned phases dir.
|
|
419
|
+
* Shared by the CLI wrapper (cmdPhaseInitVersionedDir) AND create-adhoc seeding
|
|
420
|
+
* (milestone.cjs) so there is exactly ONE versioned-dir creator (Decision C / NUM-02).
|
|
421
|
+
*
|
|
422
|
+
* required:true → throws the Phase-163 remediation Error when the version is
|
|
423
|
+
* undeterminable; never writes phases/v1.0/. Idempotent: recursive mkdir is a
|
|
424
|
+
* no-op when the directory already exists, and `created` reflects whether THIS
|
|
425
|
+
* call materialized the directory (probed via existsSync BEFORE mkdir).
|
|
426
|
+
*
|
|
427
|
+
* @param {string} cwd - planning root
|
|
428
|
+
* @returns {{ version: string, directory: string, created: boolean }}
|
|
429
|
+
* `directory` is relative to cwd.
|
|
430
|
+
*/
|
|
431
|
+
function phaseInitVersionedDirInternal(cwd) {
|
|
432
|
+
// required:true → throws the Phase-163 remediation Error when undeterminable; never v1.0.
|
|
433
|
+
const version = resolveMilestoneVersion(cwd, { required: true });
|
|
434
|
+
// resolveProjectPath returns the FLAT relative phases base (NOT version-aware), so joining
|
|
435
|
+
// the version is correct and idempotent on re-run. It is a function call — not a
|
|
436
|
+
// path.join(..., 'phases') literal — so the 164-05 RSLV-03 phases-join gate stays GREEN.
|
|
437
|
+
const flatBase = resolveProjectPath(cwd, 'phases'); // relative: projects/<slug>/phases or phases
|
|
438
|
+
const versionedDir = path.join(cwd, flatBase, version); // absolute phases/<version>
|
|
439
|
+
const created = !fs.existsSync(versionedDir); // did THIS call create it?
|
|
440
|
+
fs.mkdirSync(versionedDir, { recursive: true }); // idempotent
|
|
441
|
+
return { version, directory: path.relative(cwd, versionedDir), created };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Create the current milestone's versioned phases directory: phases/<version>/.
|
|
446
|
+
* This is the SOLE creator of a phases/<version> directory (NUM-02). It requires a
|
|
447
|
+
* determinable milestone version (fail-loud) so a phase is never written to phases/v1.0/.
|
|
448
|
+
* Idempotent: recursive mkdir is a no-op when the directory already exists.
|
|
449
|
+
* cmdPhaseAdd never calls this — it routes through the version-aware phasesDir resolver,
|
|
450
|
+
* which returns this directory once it exists.
|
|
451
|
+
*/
|
|
452
|
+
function cmdPhaseInitVersionedDir(cwd, raw) {
|
|
453
|
+
const { version, directory, created } = phaseInitVersionedDirInternal(cwd);
|
|
454
|
+
output({ version, directory, created }, raw, directory);
|
|
455
|
+
}
|
|
456
|
+
|
|
409
457
|
function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
410
458
|
if (!afterPhase || !description) {
|
|
411
459
|
error('after-phase and description required for phase insert');
|
|
@@ -767,8 +815,11 @@ function phaseCompleteInternal(cwd, phaseNum) {
|
|
|
767
815
|
|
|
768
816
|
// Verify phase info
|
|
769
817
|
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
770
|
-
|
|
771
|
-
|
|
818
|
+
// Guard on `found`: a LOOK-01 ambiguity object is truthy but has no
|
|
819
|
+
// .directory/.plans, so neither is reached for an ambiguity object. Surface
|
|
820
|
+
// its milestone-qualified message instead of throwing.
|
|
821
|
+
if (!phaseInfo || !phaseInfo.found) {
|
|
822
|
+
error(phaseInfo?.message || `Phase ${phaseNum} not found`);
|
|
772
823
|
}
|
|
773
824
|
|
|
774
825
|
// Absolute phase directory (phaseInfo.directory is relative to cwd)
|
|
@@ -1046,6 +1097,8 @@ module.exports = {
|
|
|
1046
1097
|
cmdFindPhase,
|
|
1047
1098
|
cmdPhasePlanIndex,
|
|
1048
1099
|
cmdPhaseAdd,
|
|
1100
|
+
cmdPhaseInitVersionedDir,
|
|
1101
|
+
phaseInitVersionedDirInternal,
|
|
1049
1102
|
cmdPhaseInsert,
|
|
1050
1103
|
cmdPhaseRemove,
|
|
1051
1104
|
cmdPhaseComplete,
|
|
@@ -11,7 +11,8 @@ const fs = require('fs');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { execSync } = require('child_process');
|
|
13
13
|
|
|
14
|
-
const { createTempProject } = require('./test-helpers.cjs');
|
|
14
|
+
const { createTempProject, createFixture } = require('./test-helpers.cjs');
|
|
15
|
+
const { initPaths, resetPaths } = require('./paths.cjs');
|
|
15
16
|
|
|
16
17
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
17
18
|
|
|
@@ -418,3 +419,169 @@ describe('cmdPlanFinalize', () => {
|
|
|
418
419
|
assert.equal(gitLastMessage(fixture.cwd), 'docs(04-01): complete execution plan');
|
|
419
420
|
});
|
|
420
421
|
});
|
|
422
|
+
|
|
423
|
+
// ─── cmdPhaseInitVersionedDir (NUM-02 versioned write path) ────────────────────
|
|
424
|
+
|
|
425
|
+
describe('cmdPhaseInitVersionedDir (NUM-02 versioned write path)', () => {
|
|
426
|
+
let phase;
|
|
427
|
+
|
|
428
|
+
beforeEach(() => {
|
|
429
|
+
// Reload phase.cjs fresh so each fixture's config/paths are re-read.
|
|
430
|
+
delete require.cache[require.resolve('./phase.cjs')];
|
|
431
|
+
phase = require('./phase.cjs');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Shared v2 project-context builder. STATE.md frontmatter `current_milestone`
|
|
435
|
+
// is what resolveMilestoneVersion(required) reads. `roadmapBody`/`extraDirs`
|
|
436
|
+
// let individual tests scaffold a versioned ROADMAP context or pre-create dirs.
|
|
437
|
+
function v2Fixture({ slug = 'auth-overhaul', stateBody, roadmapBody, extraDirs } = {}) {
|
|
438
|
+
const tree = {
|
|
439
|
+
'config.json': JSON.stringify({}),
|
|
440
|
+
'config.local.json': JSON.stringify({ current_project: slug }),
|
|
441
|
+
'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
|
|
442
|
+
'REPOS.md': '# Repos\n\n| Name | Path |\n',
|
|
443
|
+
[`projects/${slug}/PROJECT.md`]: '# Project',
|
|
444
|
+
[`projects/${slug}/STATE.md`]: stateBody,
|
|
445
|
+
[`projects/${slug}/ROADMAP.md`]: roadmapBody || '# Roadmap',
|
|
446
|
+
[`projects/${slug}/phases/`]: null,
|
|
447
|
+
};
|
|
448
|
+
for (const d of (extraDirs || [])) tree[d] = null;
|
|
449
|
+
return createFixture(tree);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
it('Test 1: creates the current milestone versioned dir phases/<version>/', () => {
|
|
453
|
+
const fixture = v2Fixture({ stateBody: '---\ncurrent_milestone: v26.0\n---\n# State' });
|
|
454
|
+
try {
|
|
455
|
+
// raw=false → output() emits structured JSON (raw=true would print the human relDir line).
|
|
456
|
+
const { json } = captureStdout(() =>
|
|
457
|
+
phase.cmdPhaseInitVersionedDir(fixture.cwd, false)
|
|
458
|
+
);
|
|
459
|
+
const versioned = path.join(fixture.cwd, 'projects', 'auth-overhaul', 'phases', 'v26.0');
|
|
460
|
+
assert.ok(fs.existsSync(versioned), 'phases/v26.0/ must be created');
|
|
461
|
+
assert.ok(fs.statSync(versioned).isDirectory(), 'phases/v26.0/ must be a directory');
|
|
462
|
+
// Output reports the version + relative directory.
|
|
463
|
+
assert.ok(json, 'should emit JSON output');
|
|
464
|
+
assert.equal(json.version, 'v26.0');
|
|
465
|
+
assert.equal(json.created, true);
|
|
466
|
+
assert.equal(
|
|
467
|
+
json.directory,
|
|
468
|
+
path.join('projects', 'auth-overhaul', 'phases', 'v26.0')
|
|
469
|
+
);
|
|
470
|
+
} finally {
|
|
471
|
+
fixture.cleanup();
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('Test 2: idempotent — re-run does NOT throw and creates NO nested phases/<version>/<version>/', () => {
|
|
476
|
+
const fixture = v2Fixture({ stateBody: '---\ncurrent_milestone: v26.0\n---\n# State' });
|
|
477
|
+
try {
|
|
478
|
+
// First invocation creates phases/v26.0/. Now phasesDir(cwd) is version-aware
|
|
479
|
+
// and would return phases/v26.0 — so a wrong (version-aware-base) construction
|
|
480
|
+
// would yield phases/v26.0/v26.0 on the SECOND run. This asserts it does NOT.
|
|
481
|
+
assert.doesNotThrow(() => {
|
|
482
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
483
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
484
|
+
});
|
|
485
|
+
const versioned = path.join(fixture.cwd, 'projects', 'auth-overhaul', 'phases', 'v26.0');
|
|
486
|
+
const nested = path.join(versioned, 'v26.0');
|
|
487
|
+
assert.ok(fs.existsSync(versioned), 'phases/v26.0/ exists exactly once');
|
|
488
|
+
assert.ok(!fs.existsSync(nested), 'NO nested phases/v26.0/v26.0/ (flat-base idempotency)');
|
|
489
|
+
} finally {
|
|
490
|
+
fixture.cleanup();
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('Test 3: fail-loud — undeterminable version throws (current_milestone remediation), writes NO phases/v1.0/', () => {
|
|
495
|
+
const fixture = v2Fixture({ stateBody: '# State (no milestone frontmatter)' });
|
|
496
|
+
try {
|
|
497
|
+
let thrown = null;
|
|
498
|
+
try {
|
|
499
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
500
|
+
} catch (e) {
|
|
501
|
+
thrown = e;
|
|
502
|
+
}
|
|
503
|
+
assert.ok(thrown, 'must throw on undeterminable version');
|
|
504
|
+
assert.match(thrown.message, /current_milestone/, 'remediation references current_milestone');
|
|
505
|
+
assert.match(thrown.message, /STATE\.md/, 'remediation references STATE.md');
|
|
506
|
+
assert.ok(!/v1\.0/.test(thrown.message), 'message must NOT mention v1.0');
|
|
507
|
+
// Critical: no phases/v1.0/ and no phases/undefined/ were written.
|
|
508
|
+
const phasesBase = path.join(fixture.cwd, 'projects', 'auth-overhaul', 'phases');
|
|
509
|
+
assert.ok(!fs.existsSync(path.join(phasesBase, 'v1.0')), 'NO phases/v1.0/ created');
|
|
510
|
+
assert.ok(!fs.existsSync(path.join(phasesBase, 'undefined')), 'NO phases/undefined/ created');
|
|
511
|
+
} finally {
|
|
512
|
+
fixture.cleanup();
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('Test 4: after create, cmdPhaseAdd lands the new phase under phases/<version>/', () => {
|
|
517
|
+
const fixture = v2Fixture({
|
|
518
|
+
stateBody: '---\ncurrent_milestone: v26.0\n---\n# State',
|
|
519
|
+
// Versioned ROADMAP context so maxPhase resolves (empty here → maxPhase=0 → newPhaseNum=1).
|
|
520
|
+
roadmapBody: '# Roadmap\n\n## Phases\n\n',
|
|
521
|
+
});
|
|
522
|
+
try {
|
|
523
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
|
|
524
|
+
const { json } = captureStdout(() =>
|
|
525
|
+
phase.cmdPhaseAdd(fixture.cwd, 'First versioned phase', false)
|
|
526
|
+
);
|
|
527
|
+
assert.ok(json, 'add-phase should emit JSON');
|
|
528
|
+
// New phase dir lands UNDER phases/v26.0/ because version-aware phasesDir returns it.
|
|
529
|
+
assert.ok(
|
|
530
|
+
json.directory.startsWith(path.join('projects', 'auth-overhaul', 'phases', 'v26.0')),
|
|
531
|
+
`phase dir must be under versioned dir: ${json.directory}`
|
|
532
|
+
);
|
|
533
|
+
assert.equal(json.padded, '01', 'restart-at-1: empty versioned roadmap numbers from 01');
|
|
534
|
+
assert.ok(fs.existsSync(path.join(fixture.cwd, json.directory)), 'new phase dir exists on disk');
|
|
535
|
+
} finally {
|
|
536
|
+
fixture.cleanup();
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('Test 5: two active milestones — each add-phase lands in its own phases/<version>/', () => {
|
|
541
|
+
const roadmap = '# Roadmap\n\n## Phases\n\n';
|
|
542
|
+
const fixtureA = v2Fixture({
|
|
543
|
+
slug: 'proj-a',
|
|
544
|
+
stateBody: '---\ncurrent_milestone: v26.0\n---\n# State',
|
|
545
|
+
roadmapBody: roadmap,
|
|
546
|
+
});
|
|
547
|
+
const fixtureB = v2Fixture({
|
|
548
|
+
slug: 'proj-b',
|
|
549
|
+
stateBody: '---\ncurrent_milestone: v27.0\n---\n# State',
|
|
550
|
+
roadmapBody: roadmap,
|
|
551
|
+
});
|
|
552
|
+
try {
|
|
553
|
+
// Create each milestone's versioned dir, then add-phase in each context.
|
|
554
|
+
// Both fixtures live simultaneously; the planning-root cache is a single
|
|
555
|
+
// per-process value (createFixture primed it for whichever was built last),
|
|
556
|
+
// so reset + re-prime for each project before operating on it, otherwise
|
|
557
|
+
// operations on the other would read a stale root / wrong current_project.
|
|
558
|
+
resetPaths();
|
|
559
|
+
initPaths(fixtureA.cwd);
|
|
560
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixtureA.cwd, true));
|
|
561
|
+
const { json: jsonA } = captureStdout(() =>
|
|
562
|
+
phase.cmdPhaseAdd(fixtureA.cwd, 'A first phase', false)
|
|
563
|
+
);
|
|
564
|
+
resetPaths();
|
|
565
|
+
initPaths(fixtureB.cwd);
|
|
566
|
+
captureStdout(() => phase.cmdPhaseInitVersionedDir(fixtureB.cwd, true));
|
|
567
|
+
const { json: jsonB } = captureStdout(() =>
|
|
568
|
+
phase.cmdPhaseAdd(fixtureB.cwd, 'B first phase', false)
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
assert.ok(
|
|
572
|
+
jsonA.directory.startsWith(path.join('projects', 'proj-a', 'phases', 'v26.0')),
|
|
573
|
+
`A lands in v26.0: ${jsonA.directory}`
|
|
574
|
+
);
|
|
575
|
+
assert.ok(
|
|
576
|
+
jsonB.directory.startsWith(path.join('projects', 'proj-b', 'phases', 'v27.0')),
|
|
577
|
+
`B lands in v27.0: ${jsonB.directory}`
|
|
578
|
+
);
|
|
579
|
+
// Each numbered relative to its own (initially-empty) versioned roadmap.
|
|
580
|
+
assert.equal(jsonA.padded, '01');
|
|
581
|
+
assert.equal(jsonB.padded, '01');
|
|
582
|
+
} finally {
|
|
583
|
+
fixtureA.cleanup();
|
|
584
|
+
fixtureB.cleanup();
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
});
|
|
@@ -225,6 +225,44 @@ describe('readProjectState', () => {
|
|
|
225
225
|
assert.ok(state !== null);
|
|
226
226
|
assert.strictEqual(state.phase, 'Unknown');
|
|
227
227
|
});
|
|
228
|
+
|
|
229
|
+
it('returns a non-stale phase for a shipped, production-shaped STATE.md', () => {
|
|
230
|
+
// Regression (DASH-STALE-01): after a milestone ships, markMilestoneComplete
|
|
231
|
+
// resets the Current Position Phase: line to the between-milestones form. This
|
|
232
|
+
// proves readProjectState — whose Phase: read regex is UNANCHORED — yields a
|
|
233
|
+
// non-stale phase when given a realistic, fully-synced production STATE.md
|
|
234
|
+
// (full frontmatter block, NOT a bare `# Project State` body).
|
|
235
|
+
const shippedState = `---
|
|
236
|
+
dgs_state_version: 1.0
|
|
237
|
+
milestone: v1.0
|
|
238
|
+
milestone_name: milestone
|
|
239
|
+
status: milestone_shipped
|
|
240
|
+
last_updated: "2026-06-27T00:00:00.000Z"
|
|
241
|
+
completed_date: 2026-06-27
|
|
242
|
+
progress:
|
|
243
|
+
total_phases: 8
|
|
244
|
+
completed_phases: 8
|
|
245
|
+
total_plans: 20
|
|
246
|
+
completed_plans: 20
|
|
247
|
+
percent: 100
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
# Project State
|
|
251
|
+
|
|
252
|
+
## Current Position
|
|
253
|
+
|
|
254
|
+
Phase: — (between milestones; v1.0 shipped 2026-06-27)
|
|
255
|
+
Status: Milestone v1.0 shipped 2026-06-27
|
|
256
|
+
Progress: [██████████] 100%
|
|
257
|
+
`;
|
|
258
|
+
createProjectManually(tmpDir, 'proj', shippedState);
|
|
259
|
+
const state = readProjectState(tmpDir, 'proj');
|
|
260
|
+
assert.ok(
|
|
261
|
+
!/\d+\s+of\s+\d+/.test(state.phase),
|
|
262
|
+
`Shipped STATE should yield a non-stale phase, got: ${state.phase}`
|
|
263
|
+
);
|
|
264
|
+
assert.ok(/between milestones/.test(state.phase), 'phase reflects between-milestones form');
|
|
265
|
+
});
|
|
228
266
|
});
|
|
229
267
|
|
|
230
268
|
// ─── scanProjectReposTags ───────────────────────────────────────────────────
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { safeReadFile, execGit, isV2Install, output, error } = require('./core.cjs');
|
|
11
|
+
const { safeReadFile, execGit, isV2Install, output, error, phasesDir } = require('./core.cjs');
|
|
12
12
|
const { writeConfigField } = require('./config.cjs');
|
|
13
13
|
const { getPlanningRoot, resetPaths } = require('./paths.cjs');
|
|
14
14
|
|
|
@@ -997,15 +997,19 @@ function cmdReposRemove(cwd, repoName, options, raw) {
|
|
|
997
997
|
*/
|
|
998
998
|
function scanRepoReferences(cwd, repoName) {
|
|
999
999
|
const references = [];
|
|
1000
|
-
const phasesDir = path.join(getPlanningRoot(cwd), 'phases');
|
|
1001
1000
|
|
|
1002
1001
|
try {
|
|
1003
|
-
|
|
1002
|
+
// RSLV-03 residue (Phase 170): resolve the project + version scoped phases dir
|
|
1003
|
+
// via the canonical resolver so `repos repo show` finds phase hits under a
|
|
1004
|
+
// versioned layout. phasesDir(cwd) is fail-loud; the throw degrades to empty
|
|
1005
|
+
// references through this same catch, matching the prior readdirSync-throws path.
|
|
1006
|
+
const phasesAbs = path.join(cwd, phasesDir(cwd));
|
|
1007
|
+
const phaseDirs = fs.readdirSync(phasesAbs, { withFileTypes: true })
|
|
1004
1008
|
.filter(e => e.isDirectory())
|
|
1005
1009
|
.map(e => e.name);
|
|
1006
1010
|
|
|
1007
1011
|
for (const phaseDir of phaseDirs) {
|
|
1008
|
-
const fullPhaseDir = path.join(
|
|
1012
|
+
const fullPhaseDir = path.join(phasesAbs, phaseDir);
|
|
1009
1013
|
const planFiles = fs.readdirSync(fullPhaseDir)
|
|
1010
1014
|
.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
1011
1015
|
|
|
@@ -618,9 +618,13 @@ describe('writeReposMd -> parseReposMd roundtrip', () => {
|
|
|
618
618
|
afterEach(() => { resetPaths(); cleanupDir(tmpDir); });
|
|
619
619
|
|
|
620
620
|
it('write then parse produces identical data', () => {
|
|
621
|
+
// parseReposMd always returns a `setup` field (defensive 6th-column read);
|
|
622
|
+
// writeReposMd's standard 4-column table omits an empty setup, so the
|
|
623
|
+
// roundtrip normalizes setup to '' for every row. Expected objects must
|
|
624
|
+
// include it to match shipped parser behaviour.
|
|
621
625
|
const repos = [
|
|
622
|
-
{ name: 'web', path: './web', url: 'https://github.com/org/web', description: 'Frontend' },
|
|
623
|
-
{ name: 'api', path: './api', url: '', description: 'Backend API' },
|
|
626
|
+
{ name: 'web', path: './web', url: 'https://github.com/org/web', description: 'Frontend', setup: '' },
|
|
627
|
+
{ name: 'api', path: './api', url: '', description: 'Backend API', setup: '' },
|
|
624
628
|
];
|
|
625
629
|
writeReposMd(tmpDir, repos);
|
|
626
630
|
const parsed = parseReposMd(tmpDir);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal, resolveProjectPath } = require('./core.cjs');
|
|
7
|
+
const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal, resolveProjectPath, phasesDir } = require('./core.cjs');
|
|
8
8
|
|
|
9
9
|
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
|
|
10
10
|
const roadmapPath = path.join(cwd, resolveProjectPath(cwd, 'ROADMAP.md'));
|
|
@@ -99,7 +99,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
102
|
-
const
|
|
102
|
+
const phasesAbs = path.join(cwd, phasesDir(cwd));
|
|
103
103
|
|
|
104
104
|
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
|
|
105
105
|
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
|
@@ -132,12 +132,12 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
132
132
|
let hasResearch = false;
|
|
133
133
|
|
|
134
134
|
try {
|
|
135
|
-
const entries = fs.readdirSync(
|
|
135
|
+
const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
|
|
136
136
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
137
137
|
const dirMatch = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
|
|
138
138
|
|
|
139
139
|
if (dirMatch) {
|
|
140
|
-
const phaseFiles = fs.readdirSync(path.join(
|
|
140
|
+
const phaseFiles = fs.readdirSync(path.join(phasesAbs, dirMatch));
|
|
141
141
|
planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
|
142
142
|
summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
|
143
143
|
hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
|
|
@@ -237,8 +237,11 @@ function roadmapUpdatePlanProgressInternal(cwd, phaseNum) {
|
|
|
237
237
|
const roadmapPath = path.join(cwd, resolveProjectPath(cwd, 'ROADMAP.md'));
|
|
238
238
|
|
|
239
239
|
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
// Guard on `found`: a LOOK-01 ambiguity object is truthy but has no .plans,
|
|
241
|
+
// so `phaseInfo.plans.length` is not reached for an ambiguity object. Surface
|
|
242
|
+
// its milestone-qualified message instead of throwing.
|
|
243
|
+
if (!phaseInfo || !phaseInfo.found) {
|
|
244
|
+
error(phaseInfo?.message || `Phase ${phaseNum} not found`);
|
|
242
245
|
}
|
|
243
246
|
|
|
244
247
|
const planCount = phaseInfo.plans.length;
|