@ktpartners/dgs-platform 3.4.1 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +19 -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 +12 -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 +16 -10
  20. package/deliver-great-systems/bin/lib/jobs.test.cjs +17 -1
  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 +9 -6
  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/templates/milestone-archive.md +1 -1
  38. package/deliver-great-systems/templates/roadmap.md +12 -10
  39. package/deliver-great-systems/workflows/abandon-milestone.md +8 -1
  40. package/deliver-great-systems/workflows/abandon-quick.md +1 -1
  41. package/deliver-great-systems/workflows/execute-plan.md +1 -1
  42. package/deliver-great-systems/workflows/init-product.md +8 -8
  43. package/deliver-great-systems/workflows/new-milestone.md +46 -12
  44. package/deliver-great-systems/workflows/quick-abandon.md +1 -1
  45. package/deliver-great-systems/workflows/quick.md +3 -3
  46. 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 projectRoot;
17
+ let phasesRel;
18
18
  try {
19
- projectRoot = getProjectRoot(cwd);
19
+ phasesRel = phasesDir(cwd);
20
20
  } catch {
21
- projectRoot = '.';
21
+ phasesRel = 'phases'; // flat-layout fallback (== path.join('.', 'phases'))
22
22
  }
23
- return path.join(cwd, projectRoot, 'phases');
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
- if (!phaseInfo) {
771
- error(`Phase ${phaseNum} not found`);
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
- const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
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(phasesDir, phaseDir);
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 phasesDir = path.join(cwd, resolveProjectPath(cwd, 'phases'));
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(phasesDir, { withFileTypes: true });
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(phasesDir, dirMatch));
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
- if (!phaseInfo) {
241
- error(`Phase ${phaseNum} not found`);
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;