@ktpartners/dgs-platform 3.4.2 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +2 -0
  3. package/agents/dgs-codebase-cross-analyzer.md +1 -1
  4. package/agents/dgs-codebase-mapper.md +1 -1
  5. package/agents/dgs-codebase-synthesizer.md +1 -1
  6. package/agents/dgs-phase-researcher.md +1 -1
  7. package/bin/install.js +34 -2
  8. package/deliver-great-systems/bin/dgs-tools.cjs +7 -1
  9. package/deliver-great-systems/bin/lib/commands.cjs +66 -29
  10. package/deliver-great-systems/bin/lib/commands.test.cjs +221 -1
  11. package/deliver-great-systems/bin/lib/context.cjs +6 -6
  12. package/deliver-great-systems/bin/lib/context.test.cjs +9 -9
  13. package/deliver-great-systems/bin/lib/core.cjs +199 -9
  14. package/deliver-great-systems/bin/lib/core.test.cjs +242 -0
  15. package/deliver-great-systems/bin/lib/execution.cjs +7 -0
  16. package/deliver-great-systems/bin/lib/governance.cjs +7 -7
  17. package/deliver-great-systems/bin/lib/init.cjs +25 -17
  18. package/deliver-great-systems/bin/lib/init.test.cjs +69 -10
  19. package/deliver-great-systems/bin/lib/jobs.cjs +132 -67
  20. package/deliver-great-systems/bin/lib/jobs.test.cjs +157 -13
  21. package/deliver-great-systems/bin/lib/migration.test.cjs +8 -0
  22. package/deliver-great-systems/bin/lib/milestone-archival.test.cjs +186 -0
  23. package/deliver-great-systems/bin/lib/milestone.cjs +168 -37
  24. package/deliver-great-systems/bin/lib/milestone.test.cjs +113 -1
  25. package/deliver-great-systems/bin/lib/path-audit.test.cjs +128 -0
  26. package/deliver-great-systems/bin/lib/paths.cjs +1 -2
  27. package/deliver-great-systems/bin/lib/paths.test.cjs +3 -4
  28. package/deliver-great-systems/bin/lib/phase-versioned.test.cjs +134 -0
  29. package/deliver-great-systems/bin/lib/phase.cjs +60 -7
  30. package/deliver-great-systems/bin/lib/phase.test.cjs +168 -1
  31. package/deliver-great-systems/bin/lib/projects.test.cjs +38 -0
  32. package/deliver-great-systems/bin/lib/repos.cjs +8 -4
  33. package/deliver-great-systems/bin/lib/repos.test.cjs +6 -2
  34. package/deliver-great-systems/bin/lib/roadmap.cjs +21 -11
  35. package/deliver-great-systems/bin/lib/state-snapshot.test.cjs +134 -0
  36. package/deliver-great-systems/bin/lib/state.cjs +173 -26
  37. package/deliver-great-systems/references/git-integration.md +1 -1
  38. package/deliver-great-systems/templates/milestone-archive.md +1 -1
  39. package/deliver-great-systems/templates/roadmap.md +12 -10
  40. package/deliver-great-systems/workflows/abandon-milestone.md +8 -1
  41. package/deliver-great-systems/workflows/abandon-quick.md +1 -1
  42. package/deliver-great-systems/workflows/codereview.md +1 -1
  43. package/deliver-great-systems/workflows/complete-milestone.md +1 -1
  44. package/deliver-great-systems/workflows/execute-phase.md +2 -2
  45. package/deliver-great-systems/workflows/execute-plan.md +2 -2
  46. package/deliver-great-systems/workflows/new-milestone.md +46 -12
  47. package/deliver-great-systems/workflows/quick-abandon.md +1 -1
  48. package/deliver-great-systems/workflows/quick.md +3 -3
  49. package/package.json +3 -2
@@ -11,7 +11,7 @@ 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
15
 
16
16
  // ─── Helpers ──────────────────────────────────────────────────────────────────
17
17
 
@@ -774,3 +774,223 @@ describe('cmdListTodos flat-first scanning', () => {
774
774
  }
775
775
  });
776
776
  });
777
+
778
+ // ─── findPhaseInternal ambiguity surfacing (LOOK-01 callers) ──────────────────
779
+
780
+ describe('findPhaseInternal ambiguity surfacing (LOOK-01 callers)', () => {
781
+ // Captures stderr + the error()-driven process.exit so a guarded caller's
782
+ // milestone-qualified message is inspectable without killing the test worker.
783
+ // error() (core.cjs) writes to process.stderr then process.exit(1).
784
+ function captureError(fn) {
785
+ const chunks = [];
786
+ const origStderr = process.stderr.write.bind(process.stderr);
787
+ const origStdout = process.stdout.write.bind(process.stdout);
788
+ const origExit = process.exit;
789
+ let exitCode = null;
790
+ let threw = null;
791
+ process.stderr.write = (data) => { chunks.push(String(data)); return true; };
792
+ process.stdout.write = (data) => { chunks.push(String(data)); return true; };
793
+ process.exit = (code) => { exitCode = code == null ? 0 : code; throw new Error('__EXIT__'); };
794
+ try {
795
+ fn();
796
+ } catch (e) {
797
+ if (e && e.message !== '__EXIT__') threw = e;
798
+ } finally {
799
+ process.stderr.write = origStderr;
800
+ process.stdout.write = origStdout;
801
+ process.exit = origExit;
802
+ }
803
+ return { output: chunks.join(''), exitCode, threw };
804
+ }
805
+
806
+ // Build a v2 project where bare "03" is NOT an active phase but exists in TWO
807
+ // milestone archives — the cross-milestone collision case.
808
+ function ambiguousFixture() {
809
+ return createFixture({
810
+ 'config.json': JSON.stringify({}),
811
+ 'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
812
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
813
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n',
814
+ 'projects/auth-overhaul/PROJECT.md': '# Project',
815
+ 'projects/auth-overhaul/STATE.md': '---\ncurrent_milestone: v25.0\n---\n# State',
816
+ 'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
817
+ 'projects/auth-overhaul/phases/': null,
818
+ 'milestones/v3.0-phases/03-alpha/01-PLAN.md': '# Plan',
819
+ 'milestones/v4.0-phases/03-beta/01-PLAN.md': '# Plan',
820
+ });
821
+ }
822
+
823
+ it('cmdPlanFinalize (commands.cjs:623 path) surfaces the milestone-qualified message, not a TypeError or silent resolution', () => {
824
+ const commands = require('./commands.cjs');
825
+ const fixture = ambiguousFixture();
826
+ try {
827
+ const { output, exitCode, threw } = captureError(() =>
828
+ commands.cmdPlanFinalize(fixture.cwd, '03', '01', {}, true)
829
+ );
830
+ // Must NOT crash with a TypeError (the pre-hardening failure mode).
831
+ assert.equal(threw, null, threw ? `unexpected throw: ${threw.stack}` : 'no throw');
832
+ // The guard fired with error() → exit(1).
833
+ assert.equal(exitCode, 1, 'guarded caller exits via error()');
834
+ // The milestone-qualified ambiguity message surfaced, naming both versions.
835
+ assert.ok(output.includes('v3.0'), `output names v3.0: ${output}`);
836
+ assert.ok(output.includes('v4.0'), `output names v4.0: ${output}`);
837
+ assert.ok(/ambiguous/i.test(output), `output flags ambiguity: ${output}`);
838
+ // NOT silently resolved to one archive directory.
839
+ assert.ok(!output.includes('03-alpha'), 'must not silently resolve to v3.0 dir');
840
+ assert.ok(!output.includes('03-beta'), 'must not silently resolve to v4.0 dir');
841
+ } finally {
842
+ fixture.cleanup();
843
+ }
844
+ });
845
+ });
846
+
847
+ // ─── cmdHistoryDigest composite key (LOOK-02) ──────────────────────────────────
848
+ //
849
+ // Documented output shape (composite {milestone, phase} key, string encoding):
850
+ // - When an entry carries a milestone (archived dirs, or current-phase entries
851
+ // once the current milestone is resolvable via resolveMilestoneVersion), the
852
+ // bucket key is `"<milestone>/<phase>"` (e.g. `digest.phases['v4.0/03']`).
853
+ // - When an entry has no resolvable milestone (flat-layout repos), the key is the
854
+ // bare phase number (e.g. `digest.phases['03']`) — byte-identical to the
855
+ // pre-LOOK-02 shape, so flat installs see no data loss and no key change.
856
+ // - decisions[] entries carry both `phase` and `milestone` so two milestones'
857
+ // same-numbered decisions are never ambiguous.
858
+ describe('cmdHistoryDigest composite key (LOOK-02)', () => {
859
+ const { cmdHistoryDigest } = require('./commands.cjs');
860
+
861
+ // Build a fixture with TWO milestone archives that BOTH contain a phase "03",
862
+ // plus a current active phase. STATE.md carries current_milestone so the
863
+ // current-phase entry resolves to that milestone.
864
+ function twoMilestoneFixture(currentMilestone) {
865
+ const structure = {
866
+ 'config.json': JSON.stringify({}),
867
+ 'config.local.json': JSON.stringify({ planningRoot: '.' }),
868
+ 'PROJECT.md': '# Project\n',
869
+ 'ROADMAP.md': '# Roadmap\n',
870
+ 'PROJECTS.md': '# Projects\n',
871
+ 'REPOS.md': '# Repos\n',
872
+ 'STATE.md':
873
+ '---\n' +
874
+ 'dgs_state_version: 1.0\n' +
875
+ (currentMilestone ? `current_milestone: ${currentMilestone}\n` : '') +
876
+ 'milestone: v5.0\n' +
877
+ '---\n\n# Project State\n\nPhase: 7\n',
878
+ // v3.0 archive, phase 03 → provides A
879
+ 'milestones/v3.0-phases/v3.0-ROADMAP.md': '# v3.0\n',
880
+ 'milestones/v3.0-phases/03-alpha/03-SUMMARY.md':
881
+ '---\nphase: "03"\nname: "Alpha"\nprovides:\n - "A"\nkey-decisions:\n - "Decision Alpha"\n---\n',
882
+ // v4.0 archive, phase 03 → provides B
883
+ 'milestones/v4.0-phases/v4.0-ROADMAP.md': '# v4.0\n',
884
+ 'milestones/v4.0-phases/03-beta/03-SUMMARY.md':
885
+ '---\nphase: "03"\nname: "Beta"\nprovides:\n - "B"\nkey-decisions:\n - "Decision Beta"\n---\n',
886
+ // current active phase 07 → provides Current
887
+ 'phases/07-current/07-SUMMARY.md':
888
+ '---\nphase: "07"\nname: "Current"\nprovides:\n - "Current"\n---\n',
889
+ };
890
+ return createFixture(structure);
891
+ }
892
+
893
+ function runDigest(cwd) {
894
+ const { json } = captureStdout(() => cmdHistoryDigest(cwd, false));
895
+ assert.ok(json, 'digest JSON emitted');
896
+ return json;
897
+ }
898
+
899
+ it('Test 1 (no conflation): restarted "03" from two milestones lands in DISTINCT buckets', () => {
900
+ const fixture = twoMilestoneFixture('v5.0');
901
+ try {
902
+ const digest = runDigest(fixture.cwd);
903
+ const v3 = digest.phases['v3.0/03'];
904
+ const v4 = digest.phases['v4.0/03'];
905
+ assert.ok(v3, "v3.0/03 bucket exists");
906
+ assert.ok(v4, "v4.0/03 bucket exists");
907
+ // v3.0 bucket has A, NOT B; v4.0 bucket has B, NOT A.
908
+ assert.ok(v3.provides.includes('A'), `v3.0/03 provides A: ${JSON.stringify(v3.provides)}`);
909
+ assert.ok(!v3.provides.includes('B'), 'v3.0/03 must NOT contain B');
910
+ assert.ok(v4.provides.includes('B'), `v4.0/03 provides B: ${JSON.stringify(v4.provides)}`);
911
+ assert.ok(!v4.provides.includes('A'), 'v4.0/03 must NOT contain A');
912
+ // The old conflated bare bucket must NOT exist for these milestone entries.
913
+ assert.ok(!digest.phases['03'], 'no conflated bare "03" bucket for milestone entries');
914
+ } finally {
915
+ fixture.cleanup();
916
+ }
917
+ });
918
+
919
+ it('Test 2 (current phase carries milestone): current-phase entry keyed under current milestone', () => {
920
+ const fixture = twoMilestoneFixture('v5.0');
921
+ try {
922
+ const digest = runDigest(fixture.cwd);
923
+ assert.ok(digest.phases['v5.0/07'], 'current phase 07 keyed under v5.0');
924
+ assert.ok(
925
+ digest.phases['v5.0/07'].provides.includes('Current'),
926
+ 'current phase provides surfaced under its milestone bucket'
927
+ );
928
+ // Must NOT be keyed under bare "07" (would mean milestone was not assigned).
929
+ assert.ok(!digest.phases['07'], 'current phase must not land in a bare "07" bucket');
930
+ } finally {
931
+ fixture.cleanup();
932
+ }
933
+ });
934
+
935
+ it('Test 3 (documented shape): keys use the "<milestone>/<phase>" encoding', () => {
936
+ const fixture = twoMilestoneFixture('v5.0');
937
+ try {
938
+ const digest = runDigest(fixture.cwd);
939
+ const keys = Object.keys(digest.phases);
940
+ assert.ok(keys.includes('v3.0/03'), `keys include v3.0/03: ${keys}`);
941
+ assert.ok(keys.includes('v4.0/03'), `keys include v4.0/03: ${keys}`);
942
+ assert.ok(keys.includes('v5.0/07'), `keys include v5.0/07: ${keys}`);
943
+ // Every milestone-qualified key matches the documented grammar.
944
+ for (const k of keys) {
945
+ assert.ok(/^(v\d+\.\d+\/.+|[^/]+)$/.test(k), `key '${k}' matches documented shape`);
946
+ }
947
+ } finally {
948
+ fixture.cleanup();
949
+ }
950
+ });
951
+
952
+ it('Test 4 (decisions carry milestone): decisions tagged so two milestones are not ambiguous', () => {
953
+ const fixture = twoMilestoneFixture('v5.0');
954
+ try {
955
+ const digest = runDigest(fixture.cwd);
956
+ const alpha = digest.decisions.find(d => d.decision === 'Decision Alpha');
957
+ const beta = digest.decisions.find(d => d.decision === 'Decision Beta');
958
+ assert.ok(alpha, 'Decision Alpha present');
959
+ assert.ok(beta, 'Decision Beta present');
960
+ assert.strictEqual(alpha.milestone, 'v3.0', 'Decision Alpha tagged with v3.0');
961
+ assert.strictEqual(beta.milestone, 'v4.0', 'Decision Beta tagged with v4.0');
962
+ assert.strictEqual(alpha.phase, '03', 'Decision Alpha keeps readable phase');
963
+ } finally {
964
+ fixture.cleanup();
965
+ }
966
+ });
967
+
968
+ it('Test 5 (flat-layout backward-compat): no milestone → bare phase key preserved', () => {
969
+ // Flat repo: no milestones/ archives, no current_milestone. Current phases
970
+ // must keep the byte-identical bare-number key shape (information preserved).
971
+ const fixture = createFixture({
972
+ 'config.json': JSON.stringify({}),
973
+ 'config.local.json': JSON.stringify({ planningRoot: '.' }),
974
+ 'PROJECT.md': '# Project\n',
975
+ 'ROADMAP.md': '# Roadmap\n',
976
+ 'PROJECTS.md': '# Projects\n',
977
+ 'REPOS.md': '# Repos\n',
978
+ 'STATE.md': '# Project State\n\nPhase: 1\n',
979
+ 'phases/01-foundation/01-SUMMARY.md':
980
+ '---\nphase: "01"\nname: "Foundation"\nprovides:\n - "DB"\n---\n',
981
+ 'phases/02-api/02-SUMMARY.md':
982
+ '---\nphase: "02"\nname: "API"\nprovides:\n - "REST"\n---\n',
983
+ });
984
+ try {
985
+ const digest = runDigest(fixture.cwd);
986
+ assert.ok(digest.phases['01'], 'flat phase 01 keyed bare');
987
+ assert.ok(digest.phases['02'], 'flat phase 02 keyed bare');
988
+ assert.ok(digest.phases['01'].provides.includes('DB'), 'flat 01 provides preserved');
989
+ assert.ok(digest.phases['02'].provides.includes('REST'), 'flat 02 provides preserved');
990
+ // No spurious milestone-qualified keys in a flat repo.
991
+ assert.ok(!Object.keys(digest.phases).some(k => k.includes('/')), 'no milestone-qualified keys in flat repo');
992
+ } finally {
993
+ fixture.cleanup();
994
+ }
995
+ });
996
+ });
@@ -526,15 +526,15 @@ function getMilestoneSummaries(cwd, planningRoot, currentPhaseNum) {
526
526
  const isDirInMilestone = getMilestonePhaseFilter(cwd);
527
527
 
528
528
  // Scan phases directory for milestone phases with summaries
529
- let projectRoot;
529
+ const { phasesDir: resolvePhasesDir } = require('./core.cjs');
530
+ let phasesRel;
530
531
  try {
531
- const { getProjectRoot } = require('./core.cjs');
532
- projectRoot = getProjectRoot(cwd);
532
+ phasesRel = resolvePhasesDir(cwd);
533
533
  } catch {
534
- projectRoot = path.relative(cwd, planningRoot) || '.';
534
+ phasesRel = path.join(path.relative(cwd, planningRoot) || '.', 'phases');
535
535
  }
536
536
 
537
- const phasesDir = path.join(cwd, projectRoot, 'phases');
537
+ const phasesDir = path.join(cwd, phasesRel);
538
538
  if (!fs.existsSync(phasesDir)) return results;
539
539
 
540
540
  try {
@@ -561,7 +561,7 @@ function getMilestoneSummaries(cwd, planningRoot, currentPhaseNum) {
561
561
  const phaseFiles = fs.readdirSync(phasePath);
562
562
  const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
563
563
  for (const summary of summaries) {
564
- const relPath = toPosixPath(path.join(projectRoot, 'phases', dir, summary));
564
+ const relPath = toPosixPath(path.join(phasesRel, dir, summary));
565
565
  results.push({ path: relPath, category: 'milestone-summary' });
566
566
  }
567
567
  } catch {}
@@ -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 3 files (PROJECT.md, STATE.md, config.json)', () => {
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, 3, 'lite tier should have 3 files');
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 3 own files plus dynamic codebase glob', () => {
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, 3, 'planning tier should have 3 own files');
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, 3);
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 3, planning adds 3 more
684
- assert.equal(result.length, 6, 'planning should have 6 static files (3 lite + 3 planning)');
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 6
691
- assert.equal(result.length, 6, 'execution inherits all planning files');
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: project root or '.' fallback
293
- let projectRoot;
292
+ // Resolve phases directory via canonical resolver; keep soft '.' fallback
293
+ let phasesRel;
294
294
  try {
295
- projectRoot = getProjectRoot(cwd);
295
+ phasesRel = phasesDir(cwd);
296
296
  } catch {
297
- projectRoot = '.';
297
+ phasesRel = 'phases'; // flat-layout fallback (== path.join('.', 'phases'))
298
298
  }
299
- const phasesRel = path.join(projectRoot, 'phases');
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(phasesDir, phasesRel, normalized);
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
- return result;
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 based on ROADMAP.md phase headings.
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,