@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
|
@@ -99,22 +99,22 @@ describe('parseTierDefinitions', () => {
|
|
|
99
99
|
assert.equal(none.files.length, 0, 'none tier files should be empty');
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
it('lite tier has
|
|
102
|
+
it('lite tier has 4 files (PROJECT.md, PRODUCT-SUMMARY.md, STATE.md, config.json)', () => {
|
|
103
103
|
const tiers = parseTierDefinitions();
|
|
104
104
|
const lite = tiers.get('lite');
|
|
105
105
|
assert.ok(Array.isArray(lite.files), 'lite tier files should be array');
|
|
106
|
-
assert.equal(lite.files.length,
|
|
106
|
+
assert.equal(lite.files.length, 4, 'lite tier should have 4 files');
|
|
107
107
|
const paths = lite.files.map(f => f.path);
|
|
108
108
|
assert.ok(paths.includes('PROJECT.md'), 'should include PROJECT.md');
|
|
109
109
|
assert.ok(paths.includes('STATE.md'), 'should include STATE.md');
|
|
110
110
|
assert.ok(paths.includes('config.json'), 'should include config.json');
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
it('planning tier has
|
|
113
|
+
it('planning tier has 4 own files plus dynamic codebase glob', () => {
|
|
114
114
|
const tiers = parseTierDefinitions();
|
|
115
115
|
const planning = tiers.get('planning');
|
|
116
116
|
assert.ok(Array.isArray(planning.files), 'planning tier files should be array');
|
|
117
|
-
assert.equal(planning.files.length,
|
|
117
|
+
assert.equal(planning.files.length, 4, 'planning tier should have 4 own files');
|
|
118
118
|
const paths = planning.files.map(f => f.path);
|
|
119
119
|
assert.ok(paths.includes('ROADMAP.md'), 'should include ROADMAP.md');
|
|
120
120
|
assert.ok(paths.includes('REQUIREMENTS.md'), 'should include REQUIREMENTS.md');
|
|
@@ -674,20 +674,20 @@ describe('resolveTierFiles', () => {
|
|
|
674
674
|
it('returns lite files for lite tier', () => {
|
|
675
675
|
const tiers = parseTierDefinitions();
|
|
676
676
|
const result = resolveTierFiles('lite', tiers);
|
|
677
|
-
assert.equal(result.length,
|
|
677
|
+
assert.equal(result.length, 4);
|
|
678
678
|
});
|
|
679
679
|
|
|
680
680
|
it('planning tier inherits lite files', () => {
|
|
681
681
|
const tiers = parseTierDefinitions();
|
|
682
682
|
const result = resolveTierFiles('planning', tiers);
|
|
683
|
-
// lite has
|
|
684
|
-
assert.equal(result.length,
|
|
683
|
+
// lite has 4, planning adds 4 more
|
|
684
|
+
assert.equal(result.length, 8, 'planning should have 8 static files (4 lite + 4 planning)');
|
|
685
685
|
});
|
|
686
686
|
|
|
687
687
|
it('execution tier inherits planning+lite files', () => {
|
|
688
688
|
const tiers = parseTierDefinitions();
|
|
689
689
|
const result = resolveTierFiles('execution', tiers);
|
|
690
|
-
// execution has files: [] itself but inherits from planning which has
|
|
691
|
-
assert.equal(result.length,
|
|
690
|
+
// execution has files: [] itself but inherits from planning which has 8
|
|
691
|
+
assert.equal(result.length, 8, 'execution inherits all planning files');
|
|
692
692
|
});
|
|
693
693
|
});
|
|
@@ -289,18 +289,17 @@ function findPhaseInternal(cwd, phase) {
|
|
|
289
289
|
|
|
290
290
|
const normalized = normalizePhaseName(phase);
|
|
291
291
|
|
|
292
|
-
// Resolve phases directory
|
|
293
|
-
let
|
|
292
|
+
// Resolve phases directory via canonical resolver; keep soft '.' fallback
|
|
293
|
+
let phasesRel;
|
|
294
294
|
try {
|
|
295
|
-
|
|
295
|
+
phasesRel = phasesDir(cwd);
|
|
296
296
|
} catch {
|
|
297
|
-
|
|
297
|
+
phasesRel = 'phases'; // flat-layout fallback (== path.join('.', 'phases'))
|
|
298
298
|
}
|
|
299
|
-
const
|
|
300
|
-
const phasesDir = path.join(cwd, phasesRel);
|
|
299
|
+
const phasesAbs = path.join(cwd, phasesRel);
|
|
301
300
|
|
|
302
301
|
// Search current phases first
|
|
303
|
-
const current = searchPhaseInDir(
|
|
302
|
+
const current = searchPhaseInDir(phasesAbs, phasesRel, normalized);
|
|
304
303
|
if (current) return current;
|
|
305
304
|
|
|
306
305
|
// Search archived milestone phases (newest first)
|
|
@@ -318,6 +317,12 @@ function findPhaseInternal(cwd, phase) {
|
|
|
318
317
|
.reverse();
|
|
319
318
|
|
|
320
319
|
const planRootRel = path.relative(cwd, planRoot);
|
|
320
|
+
|
|
321
|
+
// LOOK-01: collect ALL archive matches before deciding, instead of
|
|
322
|
+
// returning the first newest-first hit. Once phase numbers restart per
|
|
323
|
+
// milestone (Phase 165), a bare number can collide across several milestone
|
|
324
|
+
// archives; silently returning the newest is a correctness bug.
|
|
325
|
+
const collected = [];
|
|
321
326
|
for (const archiveName of archiveDirs) {
|
|
322
327
|
const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
|
|
323
328
|
const archivePath = path.join(milestonesDir, archiveName);
|
|
@@ -325,9 +330,35 @@ function findPhaseInternal(cwd, phase) {
|
|
|
325
330
|
const result = searchPhaseInDir(archivePath, relBase, normalized);
|
|
326
331
|
if (result) {
|
|
327
332
|
result.archived = version;
|
|
328
|
-
|
|
333
|
+
collected.push({ version, result });
|
|
329
334
|
}
|
|
330
335
|
}
|
|
336
|
+
|
|
337
|
+
// Branch on the collected count:
|
|
338
|
+
// - 0 matches → null (unchanged)
|
|
339
|
+
// - 1 match → return that match with .archived set — BYTE-IDENTICAL to
|
|
340
|
+
// the previous single-archive path. Flat-layout / globally-
|
|
341
|
+
// unique numbers always land here, so their behaviour is
|
|
342
|
+
// preserved exactly.
|
|
343
|
+
// - 2+ matches → return a PINNED, NON-throwing structured ambiguity signal:
|
|
344
|
+
// { found:false, ambiguous:true, phase, matches:[{milestone,
|
|
345
|
+
// directory}...], message }. It carries NO top-level
|
|
346
|
+
// `directory` and NO single `archived` tag, so callers that
|
|
347
|
+
// guard on `!phaseInfo.found` treat it as not-found, while
|
|
348
|
+
// hardened callers surface its `message`. This object shape
|
|
349
|
+
// is part of findPhaseInternal's return contract.
|
|
350
|
+
if (collected.length === 0) return null;
|
|
351
|
+
if (collected.length === 1) return collected[0].result;
|
|
352
|
+
|
|
353
|
+
const versions = collected.map(c => c.version);
|
|
354
|
+
const newestVersion = [...versions].sort().reverse()[0];
|
|
355
|
+
return {
|
|
356
|
+
found: false,
|
|
357
|
+
ambiguous: true,
|
|
358
|
+
phase: normalized,
|
|
359
|
+
matches: collected.map(c => ({ milestone: c.version, directory: c.result.directory })),
|
|
360
|
+
message: `Phase ${normalized} is ambiguous across milestones ${versions.join(', ')} — use a milestone-qualified reference (e.g. ${newestVersion}/${normalized})`,
|
|
361
|
+
};
|
|
331
362
|
} catch {}
|
|
332
363
|
|
|
333
364
|
return null;
|
|
@@ -514,12 +545,122 @@ function getMilestoneInfo(cwd) {
|
|
|
514
545
|
}
|
|
515
546
|
}
|
|
516
547
|
|
|
548
|
+
// ─── Authoritative milestone-version signal (Phase 163-01) ─────────────────────
|
|
549
|
+
//
|
|
550
|
+
// Grounding (verified 2026-06-25) — the canonical structured field name:
|
|
551
|
+
// - The field persisted in STATE.md frontmatter TODAY is `milestone:`
|
|
552
|
+
// (written by state.cjs buildStateFrontmatter, derived from the getMilestoneInfo
|
|
553
|
+
// ROADMAP prose scrape). `current_milestone` exists only in init.cjs JSON output
|
|
554
|
+
// (init.cjs:560), NOT as a persisted STATE.md field.
|
|
555
|
+
// - The spec/CONTEXT name the authoritative field `current_milestone`.
|
|
556
|
+
// - To honour the locked name WHILE staying compatible with what exists today,
|
|
557
|
+
// resolveMilestoneVersion reads `current_milestone` FIRST and falls back to
|
|
558
|
+
// `milestone` SECOND. Plan 02 standardises the writer on `current_milestone`.
|
|
559
|
+
//
|
|
560
|
+
// This is the structured-first, grammar-validated, FAIL-LOUD signal. It deliberately
|
|
561
|
+
// does NOT trust getMilestoneInfo's `v1.0` default — that scrape is advisory only and
|
|
562
|
+
// is consulted here solely for an optional mismatch warning.
|
|
563
|
+
|
|
564
|
+
/** Canonical milestone-version grammar (RSLV-05): exactly `vN.N`, no trim, no flags. */
|
|
565
|
+
const MILESTONE_VERSION_RE = /^v\d+\.\d+$/;
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Validate a milestone-version string against the canonical grammar `^v\d+\.\d+$`.
|
|
569
|
+
* Out-of-grammar values are rejected, never coerced. Does NOT trim — trimming is the
|
|
570
|
+
* caller's choice, so malformed (whitespace-padded) input is not silently masked.
|
|
571
|
+
*
|
|
572
|
+
* @param {*} value
|
|
573
|
+
* @returns {boolean}
|
|
574
|
+
*/
|
|
575
|
+
function isValidMilestoneVersion(value) {
|
|
576
|
+
return typeof value === 'string' && MILESTONE_VERSION_RE.test(value);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Resolve the current milestone version from the structured STATE.md frontmatter
|
|
581
|
+
* field (authoritative), validate it against the canonical grammar, and FAIL LOUD
|
|
582
|
+
* when it is required but undeterminable. NEVER defaults to or returns 'v1.0'.
|
|
583
|
+
*
|
|
584
|
+
* Read precedence within STATE.md frontmatter: `current_milestone` then `milestone`.
|
|
585
|
+
* On a STATE-vs-ROADMAP mismatch, warns to stderr and trusts STATE.
|
|
586
|
+
*
|
|
587
|
+
* @param {string} cwd - Working directory
|
|
588
|
+
* @param {{ required?: boolean }} [opts]
|
|
589
|
+
* @returns {string|null} The validated `vN.N` version, or null when undeterminable
|
|
590
|
+
* and not required.
|
|
591
|
+
* @throws {Error} when required is true and the version is undeterminable/invalid.
|
|
592
|
+
*/
|
|
593
|
+
function resolveMilestoneVersion(cwd, { required = false } = {}) {
|
|
594
|
+
const remediation =
|
|
595
|
+
"Cannot determine current milestone version — set 'current_milestone' " +
|
|
596
|
+
'(e.g. v25.0) in STATE.md frontmatter.';
|
|
597
|
+
|
|
598
|
+
// 1. Read STATE.md (structured, authoritative). Mirror getMilestoneInfo's
|
|
599
|
+
// project-scoped read: project STATE.md first, then planning-root STATE.md.
|
|
600
|
+
let stateContent = null;
|
|
601
|
+
try {
|
|
602
|
+
const projectRoot = getProjectRoot(cwd);
|
|
603
|
+
stateContent = safeReadFile(path.join(cwd, projectRoot, 'STATE.md'));
|
|
604
|
+
} catch {
|
|
605
|
+
stateContent = null;
|
|
606
|
+
}
|
|
607
|
+
if (stateContent === null) {
|
|
608
|
+
stateContent = safeReadFile(path.join(getPlanningRoot(cwd), 'STATE.md'));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 2. Local minimal frontmatter parse. Intentionally does NOT import the
|
|
612
|
+
// frontmatter parser module to avoid a require cycle (that module requires this one).
|
|
613
|
+
let raw;
|
|
614
|
+
if (stateContent) {
|
|
615
|
+
const fmMatch = stateContent.match(/^---\n([\s\S]+?)\n---/);
|
|
616
|
+
if (fmMatch) {
|
|
617
|
+
const block = fmMatch[1];
|
|
618
|
+
const currentMatch = block.match(/^current_milestone:\s*["']?(\S+?)["']?\s*$/m);
|
|
619
|
+
const fallbackMatch = block.match(/^milestone:\s*["']?(\S+?)["']?\s*$/m);
|
|
620
|
+
raw = (currentMatch && currentMatch[1]) || (fallbackMatch && fallbackMatch[1]);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// 3-4. Valid grammar → optional advisory mismatch warning, then return.
|
|
625
|
+
if (isValidMilestoneVersion(raw)) {
|
|
626
|
+
try {
|
|
627
|
+
const advisory = getMilestoneInfo(cwd);
|
|
628
|
+
if (advisory && advisory.version && advisory.version !== raw) {
|
|
629
|
+
process.stderr.write(
|
|
630
|
+
`Warning: milestone version mismatch — STATE.md says ${raw} but ROADMAP ` +
|
|
631
|
+
`says ${advisory.version}. Trusting STATE.md (${raw}).\n`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
} catch { /* advisory only — never block on the prose scrape */ }
|
|
635
|
+
return raw;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 5-6. Present-but-invalid or absent → fail loud if required, else null.
|
|
639
|
+
// The abandoned getMilestoneInfo default is deliberately NOT used here.
|
|
640
|
+
if (required) {
|
|
641
|
+
throw new Error(remediation);
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
|
|
517
646
|
/**
|
|
518
647
|
* Returns a filter function that checks whether a phase directory belongs
|
|
519
|
-
* to the current milestone
|
|
648
|
+
* to the current milestone.
|
|
649
|
+
* Under versioned layout (phases/<version>/) membership is structural — every
|
|
650
|
+
* entry already belongs to the milestone — so this returns a pass-all predicate.
|
|
651
|
+
* Under flat layout (shared phases/) it returns a ROADMAP number-set predicate.
|
|
520
652
|
* If no ROADMAP exists or no phases are listed, returns a pass-all filter.
|
|
521
653
|
*/
|
|
522
654
|
function getMilestonePhaseFilter(cwd) {
|
|
655
|
+
// Versioned layout: phases/<version>/ is already milestone-scoped — every entry belongs to the milestone, so number-set filtering is redundant (NUM-04).
|
|
656
|
+
let versioned = false;
|
|
657
|
+
try { versioned = /^v\d+\.\d+$/.test(path.basename(phasesDir(cwd))); } catch { versioned = false; }
|
|
658
|
+
if (versioned) {
|
|
659
|
+
const passAll = () => true;
|
|
660
|
+
passAll.phaseCount = 0;
|
|
661
|
+
return passAll;
|
|
662
|
+
}
|
|
663
|
+
|
|
523
664
|
const milestonePhaseNums = new Set();
|
|
524
665
|
try {
|
|
525
666
|
let roadmap;
|
|
@@ -662,6 +803,52 @@ function resolveProjectPath(cwd, relativePath) {
|
|
|
662
803
|
return relativePath ? path.join(root, relativePath) : root;
|
|
663
804
|
}
|
|
664
805
|
|
|
806
|
+
/**
|
|
807
|
+
* Canonical resolver for the active-project phases directory.
|
|
808
|
+
*
|
|
809
|
+
* Returns the RELATIVE project-scoped phases path:
|
|
810
|
+
* - v2 layout: 'projects/<slug>/phases'
|
|
811
|
+
* - v1 / flat layout: 'phases'
|
|
812
|
+
*
|
|
813
|
+
* FAIL-LOUD: delegates to resolveProjectPath -> requireProjectRoot, which
|
|
814
|
+
* throws when there is no project root. Callers that want a soft fallback
|
|
815
|
+
* (e.g. flat-layout tooling) keep their own try/catch around this call —
|
|
816
|
+
* the fallback policy stays local to each call site, never buried here.
|
|
817
|
+
*
|
|
818
|
+
* This is the SINGLE seam for phases-path resolution, and it IS version-aware
|
|
819
|
+
* (Phase 165 / RSLV-04): when a 'phases/<version>' directory exists on disk under
|
|
820
|
+
* the active project — where <version> is the validated `vN.N` milestone signal
|
|
821
|
+
* (resolveMilestoneVersion, Phase 163) — it returns the RELATIVE versioned path
|
|
822
|
+
* 'phases/<version>'. Otherwise it falls back to flat 'phases'. The fallback is
|
|
823
|
+
* permanent and intentional (dual-mode cutover): pre-existing flat layouts and the
|
|
824
|
+
* in-flight milestone (no 'phases/<version>' dir on disk) resolve FLAT by design —
|
|
825
|
+
* versioning is never applied retroactively. The read path NEVER defaults to 'v1.0'
|
|
826
|
+
* and NEVER throws on an undeterminable version (it just resolves flat).
|
|
827
|
+
*
|
|
828
|
+
* Do not reintroduce ad-hoc path.join(...,'phases') sites — path-audit.test.cjs
|
|
829
|
+
* gates against that. The only phases-bearing joins below are path.join(base, version)
|
|
830
|
+
* (relative return) and the existsSync probe path.join(cwd, base, version); neither is
|
|
831
|
+
* a bare path.join(..., 'phases') literal, so the 164-05 gate stays satisfied.
|
|
832
|
+
*
|
|
833
|
+
* @param {string} cwd - Working directory
|
|
834
|
+
* @returns {string} Relative project-scoped phases path ('phases', 'projects/<slug>/phases',
|
|
835
|
+
* or the versioned '…/phases/<version>')
|
|
836
|
+
* @throws {Error} NO_CURRENT_PROJECT_V2 | PROJECT_NOT_FOUND | INVALID_PROJECT_NAME | PROJECT_COMPLETED
|
|
837
|
+
*/
|
|
838
|
+
function phasesDir(cwd) {
|
|
839
|
+
const base = resolveProjectPath(cwd, 'phases'); // RELATIVE, fail-loud (unchanged)
|
|
840
|
+
let version = null;
|
|
841
|
+
try {
|
|
842
|
+
version = resolveMilestoneVersion(cwd); // non-required: validated vN.N or null, never throws, never v1.0
|
|
843
|
+
} catch {
|
|
844
|
+
version = null; // read path never blocks on an undeterminable version
|
|
845
|
+
}
|
|
846
|
+
if (version && fs.existsSync(path.join(cwd, base, version))) {
|
|
847
|
+
return path.join(base, version); // versioned dir present → return RELATIVE versioned path
|
|
848
|
+
}
|
|
849
|
+
return base; // dual-mode fallback: flat phases/ (incl. the in-flight v25.0)
|
|
850
|
+
}
|
|
851
|
+
|
|
665
852
|
/**
|
|
666
853
|
* Check if a project is completed by reading its STATE.md directly.
|
|
667
854
|
*
|
|
@@ -749,9 +936,12 @@ module.exports = {
|
|
|
749
936
|
pathExistsInternal,
|
|
750
937
|
generateSlugInternal,
|
|
751
938
|
getMilestoneInfo,
|
|
939
|
+
isValidMilestoneVersion,
|
|
940
|
+
resolveMilestoneVersion,
|
|
752
941
|
getMilestonePhaseFilter,
|
|
753
942
|
toPosixPath,
|
|
754
943
|
resolveProjectPath,
|
|
944
|
+
phasesDir,
|
|
755
945
|
getProjectRoot,
|
|
756
946
|
requireProjectRoot,
|
|
757
947
|
isV2Install,
|
|
@@ -16,6 +16,7 @@ const { createFixture, createTempProject } = require('./test-helpers.cjs');
|
|
|
16
16
|
// Import the functions under test
|
|
17
17
|
const {
|
|
18
18
|
resolveProjectPath,
|
|
19
|
+
phasesDir,
|
|
19
20
|
getProjectRoot,
|
|
20
21
|
requireProjectRoot,
|
|
21
22
|
isV2Install,
|
|
@@ -26,6 +27,7 @@ const {
|
|
|
26
27
|
getProjectDir,
|
|
27
28
|
resolveModelInternal,
|
|
28
29
|
MODEL_PROFILES,
|
|
30
|
+
findPhaseInternal,
|
|
29
31
|
} = require('./core.cjs');
|
|
30
32
|
|
|
31
33
|
// ─── Root layout (no v2 markers) Tests ───────────────────────────────────────
|
|
@@ -145,6 +147,246 @@ describe('v2 mode without current_project (guard trigger)', () => {
|
|
|
145
147
|
});
|
|
146
148
|
});
|
|
147
149
|
|
|
150
|
+
// ─── phasesDir canonical resolver contract Tests ──────────────────────────────
|
|
151
|
+
|
|
152
|
+
describe('phasesDir canonical resolver', () => {
|
|
153
|
+
it('v1/flat layout returns phases', () => {
|
|
154
|
+
const fixture = createFixture({
|
|
155
|
+
'config.json': JSON.stringify({}),
|
|
156
|
+
'STATE.md': '# State',
|
|
157
|
+
'ROADMAP.md': '# Roadmap',
|
|
158
|
+
'phases/': null,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const result = phasesDir(fixture.cwd);
|
|
163
|
+
assert.equal(result, path.join('.', 'phases'));
|
|
164
|
+
assert.equal(result, 'phases');
|
|
165
|
+
} finally {
|
|
166
|
+
fixture.cleanup();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('v2 layout returns projects/<slug>/phases', () => {
|
|
171
|
+
const fixture = createFixture({
|
|
172
|
+
'config.json': JSON.stringify({}),
|
|
173
|
+
'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
|
|
174
|
+
'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
|
|
175
|
+
'REPOS.md': '# Repos\n\n| Name | Path |\n',
|
|
176
|
+
'projects/auth-overhaul/STATE.md': '# State',
|
|
177
|
+
'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
|
|
178
|
+
'projects/auth-overhaul/phases/': null,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const result = phasesDir(fixture.cwd);
|
|
183
|
+
assert.equal(result, path.join('projects', 'auth-overhaul', 'phases'));
|
|
184
|
+
} finally {
|
|
185
|
+
fixture.cleanup();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('v2 install with NO current_project throws (fail-loud, no fallback)', () => {
|
|
190
|
+
const fixture = createFixture({
|
|
191
|
+
'config.json': JSON.stringify({}),
|
|
192
|
+
'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
assert.throws(
|
|
197
|
+
() => phasesDir(fixture.cwd),
|
|
198
|
+
(err) => err.message === 'NO_CURRENT_PROJECT_V2'
|
|
199
|
+
);
|
|
200
|
+
} finally {
|
|
201
|
+
fixture.cleanup();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ─── Version-aware (dual-mode) resolver Tests (Phase 165, RSLV-04) ─────────────
|
|
207
|
+
|
|
208
|
+
describe('phasesDir version-aware (dual-mode)', () => {
|
|
209
|
+
// Shared v2 project context builder. STATE.md frontmatter `current_milestone`
|
|
210
|
+
// is what resolveMilestoneVersion reads; `phasesExtra` lets each test add the
|
|
211
|
+
// versioned sub-directory (or not) to exercise the existsSync branch.
|
|
212
|
+
function v2Fixture(stateBody, phasesExtra) {
|
|
213
|
+
const tree = {
|
|
214
|
+
'config.json': JSON.stringify({}),
|
|
215
|
+
'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
|
|
216
|
+
'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
|
|
217
|
+
'REPOS.md': '# Repos\n\n| Name | Path |\n',
|
|
218
|
+
'projects/auth-overhaul/PROJECT.md': '# Project',
|
|
219
|
+
'projects/auth-overhaul/STATE.md': stateBody,
|
|
220
|
+
'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
|
|
221
|
+
'projects/auth-overhaul/phases/': null,
|
|
222
|
+
};
|
|
223
|
+
if (phasesExtra) tree[phasesExtra] = null;
|
|
224
|
+
return createFixture(tree);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
it('Test A: versioned dir present → returns projects/<slug>/phases/<version>', () => {
|
|
228
|
+
const fixture = v2Fixture(
|
|
229
|
+
'---\ncurrent_milestone: v26.0\n---\n# State',
|
|
230
|
+
'projects/auth-overhaul/phases/v26.0/'
|
|
231
|
+
);
|
|
232
|
+
try {
|
|
233
|
+
const result = phasesDir(fixture.cwd);
|
|
234
|
+
assert.equal(
|
|
235
|
+
result,
|
|
236
|
+
path.join('projects', 'auth-overhaul', 'phases', 'v26.0')
|
|
237
|
+
);
|
|
238
|
+
} finally {
|
|
239
|
+
fixture.cleanup();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('Test B: version present but NO versioned dir → flat fallback (v25.0 invariant)', () => {
|
|
244
|
+
const fixture = v2Fixture(
|
|
245
|
+
'---\ncurrent_milestone: v25.0\n---\n# State',
|
|
246
|
+
null
|
|
247
|
+
);
|
|
248
|
+
try {
|
|
249
|
+
const result = phasesDir(fixture.cwd);
|
|
250
|
+
assert.equal(result, path.join('projects', 'auth-overhaul', 'phases'));
|
|
251
|
+
assert.ok(!result.includes('v25.0'), `must not resolve versioned: ${result}`);
|
|
252
|
+
} finally {
|
|
253
|
+
fixture.cleanup();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('Test C: version undeterminable → flat fallback, no throw, never v1.0', () => {
|
|
258
|
+
const fixture = v2Fixture('# State (no milestone frontmatter)', null);
|
|
259
|
+
try {
|
|
260
|
+
let result;
|
|
261
|
+
assert.doesNotThrow(() => { result = phasesDir(fixture.cwd); });
|
|
262
|
+
assert.equal(result, path.join('projects', 'auth-overhaul', 'phases'));
|
|
263
|
+
assert.ok(!result.includes('v1.0'), `must never default to v1.0: ${result}`);
|
|
264
|
+
} finally {
|
|
265
|
+
fixture.cleanup();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('Test D: flat/v1 layout unchanged (regression guard)', () => {
|
|
270
|
+
const fixture = createFixture({
|
|
271
|
+
'config.json': JSON.stringify({}),
|
|
272
|
+
'STATE.md': '# State',
|
|
273
|
+
'ROADMAP.md': '# Roadmap',
|
|
274
|
+
'phases/': null,
|
|
275
|
+
});
|
|
276
|
+
try {
|
|
277
|
+
const result = phasesDir(fixture.cwd);
|
|
278
|
+
assert.equal(result, 'phases');
|
|
279
|
+
} finally {
|
|
280
|
+
fixture.cleanup();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ─── findPhaseInternal version-aware archive (LOOK-01) Tests ──────────────────
|
|
286
|
+
|
|
287
|
+
describe('findPhaseInternal version-aware archive (LOOK-01)', () => {
|
|
288
|
+
// Build a v2 project with active phases plus milestone archives. Active phases
|
|
289
|
+
// live (flat, v25.0 invariant) at projects/<slug>/phases/<NN>-slug/; archives
|
|
290
|
+
// live product-level at milestones/<v>-phases/<NN>-slug/. Each phase dir needs
|
|
291
|
+
// at least one *-PLAN.md so searchPhaseInDir matches it.
|
|
292
|
+
function look01Fixture(activePhaseDirs, archives) {
|
|
293
|
+
const tree = {
|
|
294
|
+
'config.json': JSON.stringify({}),
|
|
295
|
+
'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
|
|
296
|
+
'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
|
|
297
|
+
'REPOS.md': '# Repos\n\n| Name | Path |\n',
|
|
298
|
+
'projects/auth-overhaul/PROJECT.md': '# Project',
|
|
299
|
+
'projects/auth-overhaul/STATE.md': '---\ncurrent_milestone: v25.0\n---\n# State',
|
|
300
|
+
'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
|
|
301
|
+
'projects/auth-overhaul/phases/': null,
|
|
302
|
+
};
|
|
303
|
+
for (const dir of activePhaseDirs || []) {
|
|
304
|
+
tree[`projects/auth-overhaul/phases/${dir}/01-PLAN.md`] = '# Plan';
|
|
305
|
+
}
|
|
306
|
+
// archives: { 'v3.0': ['07-foo'], 'v4.0': ['03-b'], ... }
|
|
307
|
+
for (const [version, dirs] of Object.entries(archives || {})) {
|
|
308
|
+
for (const dir of dirs) {
|
|
309
|
+
tree[`milestones/${version}-phases/${dir}/01-PLAN.md`] = '# Plan';
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return createFixture(tree);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
it('Test 1: active phase wins (unchanged) — bare number in active phases', () => {
|
|
316
|
+
const fixture = look01Fixture(
|
|
317
|
+
['03-active-feature'],
|
|
318
|
+
{ 'v3.0': ['03-archived-feature'] }
|
|
319
|
+
);
|
|
320
|
+
try {
|
|
321
|
+
const result = findPhaseInternal(fixture.cwd, '03');
|
|
322
|
+
assert.ok(result, 'expected an active match');
|
|
323
|
+
assert.equal(result.found, true);
|
|
324
|
+
assert.equal(result.archived, undefined, 'active match must not carry .archived');
|
|
325
|
+
assert.equal(result.ambiguous, undefined, 'active match must not be ambiguous');
|
|
326
|
+
assert.ok(result.directory.includes('03-active-feature'));
|
|
327
|
+
} finally {
|
|
328
|
+
fixture.cleanup();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('Test 2: unique archive (flat-layout preserved) — bare number in exactly ONE archive', () => {
|
|
333
|
+
const fixture = look01Fixture(
|
|
334
|
+
[],
|
|
335
|
+
{ 'v3.0': ['07-foo'] }
|
|
336
|
+
);
|
|
337
|
+
try {
|
|
338
|
+
const result = findPhaseInternal(fixture.cwd, '07');
|
|
339
|
+
assert.ok(result, 'expected a single archived match');
|
|
340
|
+
assert.equal(result.found, true, 'single-archive match preserves found:true');
|
|
341
|
+
assert.equal(result.archived, 'v3.0', 'single-archive match tagged with its version');
|
|
342
|
+
assert.equal(result.ambiguous, undefined, 'single archive is not ambiguous');
|
|
343
|
+
assert.ok(result.directory.includes('07-foo'));
|
|
344
|
+
} finally {
|
|
345
|
+
fixture.cleanup();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('Test 3: cross-milestone collision → structured ambiguity, NOT silent newest', () => {
|
|
350
|
+
const fixture = look01Fixture(
|
|
351
|
+
[],
|
|
352
|
+
{ 'v3.0': ['03-a'], 'v4.0': ['03-b'] }
|
|
353
|
+
);
|
|
354
|
+
try {
|
|
355
|
+
const result = findPhaseInternal(fixture.cwd, '03');
|
|
356
|
+
assert.ok(result, 'expected a structured ambiguity object, not null');
|
|
357
|
+
assert.equal(result.found, false, 'ambiguity is found:false');
|
|
358
|
+
assert.equal(result.ambiguous, true, 'ambiguity flag set');
|
|
359
|
+
assert.equal(result.directory, undefined, 'ambiguity must NOT point at either archive dir');
|
|
360
|
+
assert.equal(result.archived, undefined, 'ambiguity carries no single archived tag');
|
|
361
|
+
assert.ok(Array.isArray(result.matches), 'matches is an array');
|
|
362
|
+
assert.equal(result.matches.length, 2, 'both archives listed');
|
|
363
|
+
const versions = result.matches.map(m => m.milestone).sort();
|
|
364
|
+
assert.deepEqual(versions, ['v3.0', 'v4.0'], 'both milestone versions named');
|
|
365
|
+
assert.ok(typeof result.message === 'string' && result.message.length > 0);
|
|
366
|
+
assert.ok(result.message.includes('v3.0'), 'message names v3.0');
|
|
367
|
+
assert.ok(result.message.includes('v4.0'), 'message names v4.0');
|
|
368
|
+
} finally {
|
|
369
|
+
fixture.cleanup();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('Test 4: collision with an active number is irrelevant — active wins', () => {
|
|
374
|
+
const fixture = look01Fixture(
|
|
375
|
+
['01-active'],
|
|
376
|
+
{ 'v3.0': ['01-a'], 'v4.0': ['01-b'] }
|
|
377
|
+
);
|
|
378
|
+
try {
|
|
379
|
+
const result = findPhaseInternal(fixture.cwd, '01');
|
|
380
|
+
assert.ok(result, 'expected the active match');
|
|
381
|
+
assert.equal(result.found, true);
|
|
382
|
+
assert.equal(result.ambiguous, undefined, 'active match short-circuits ambiguity');
|
|
383
|
+
assert.ok(result.directory.includes('01-active'));
|
|
384
|
+
} finally {
|
|
385
|
+
fixture.cleanup();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
148
390
|
// ─── Strict v2 Marker Validation Tests ────────────────────────────────────────
|
|
149
391
|
|
|
150
392
|
describe('strict v2 marker validation', () => {
|
|
@@ -404,6 +404,13 @@ function detectRepoChanges(cwd, repoNames, useActiveContext) {
|
|
|
404
404
|
*/
|
|
405
405
|
function createRepoBranches(cwd, repoNames, branchName, config, baseBranch) {
|
|
406
406
|
|
|
407
|
+
// Honour the branching strategy: 'none' disables branch creation entirely.
|
|
408
|
+
// The function accepts config.branching_strategy and must respect it — a
|
|
409
|
+
// 'none' strategy is an explicit signal to create no branches.
|
|
410
|
+
if (config && config.branching_strategy === 'none') {
|
|
411
|
+
return { created: false, reason: 'branching_disabled' };
|
|
412
|
+
}
|
|
413
|
+
|
|
407
414
|
const parsed = parseReposMd(cwd);
|
|
408
415
|
const repos = parsed ? parsed.repos : [];
|
|
409
416
|
const branches = [];
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { extractNameFromAuthor } = require('./identity.cjs');
|
|
13
|
-
const { getMilestonePhaseFilter, getProjectRoot } = require('./core.cjs');
|
|
13
|
+
const { getMilestonePhaseFilter, getProjectRoot, phasesDir } = require('./core.cjs');
|
|
14
14
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
15
15
|
const { getPlanningRoot } = require('./paths.cjs');
|
|
16
16
|
|
|
@@ -58,23 +58,23 @@ function getContributors(cwd) {
|
|
|
58
58
|
const planRoot = getPlanningRoot(cwd);
|
|
59
59
|
const projectRootRel = getProjectRoot(cwd);
|
|
60
60
|
const projectRoot = path.join(planRoot, projectRootRel);
|
|
61
|
-
const
|
|
61
|
+
const phasesAbs = path.join(cwd, phasesDir(cwd));
|
|
62
62
|
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
63
63
|
|
|
64
|
-
if (fs.existsSync(
|
|
65
|
-
const entries = fs.readdirSync(
|
|
64
|
+
if (fs.existsSync(phasesAbs)) {
|
|
65
|
+
const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
|
|
66
66
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
67
67
|
|
|
68
68
|
for (const dir of dirs) {
|
|
69
69
|
if (!isDirInMilestone(dir)) continue;
|
|
70
70
|
|
|
71
|
-
const phaseFiles = fs.readdirSync(path.join(
|
|
71
|
+
const phaseFiles = fs.readdirSync(path.join(phasesAbs, dir));
|
|
72
72
|
|
|
73
73
|
// Read SUMMARY.md executed_by
|
|
74
74
|
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
75
75
|
for (const s of summaries) {
|
|
76
76
|
try {
|
|
77
|
-
const content = fs.readFileSync(path.join(
|
|
77
|
+
const content = fs.readFileSync(path.join(phasesAbs, dir, s), 'utf-8');
|
|
78
78
|
const fm = extractFrontmatter(content);
|
|
79
79
|
addContributor(fm.executed_by);
|
|
80
80
|
} catch { /* skip unreadable files */ }
|
|
@@ -84,7 +84,7 @@ function getContributors(cwd) {
|
|
|
84
84
|
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
85
85
|
for (const p of plans) {
|
|
86
86
|
try {
|
|
87
|
-
const content = fs.readFileSync(path.join(
|
|
87
|
+
const content = fs.readFileSync(path.join(phasesAbs, dir, p), 'utf-8');
|
|
88
88
|
const fm = extractFrontmatter(content);
|
|
89
89
|
addContributor(fm.created_by);
|
|
90
90
|
} catch { /* skip unreadable files */ }
|