@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
@@ -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;
@@ -265,17 +268,24 @@ function roadmapUpdatePlanProgressInternal(cwd, phaseNum) {
265
268
  }
266
269
 
267
270
  let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
268
- const phaseEscaped = escapeRegex(phaseNum);
269
-
270
- // Progress table row: update Plans column (summaries/plans) and Status column
271
+ // Derive a BARE numeric phase key by stripping any leading `vX.Y/` prefix.
272
+ // Checklist lines and detail headings are always bare, so the bare key drives
273
+ // the heading/checkbox regexes; the progress-row matcher allows an optional
274
+ // version prefix and Milestone column on top of the same bare key.
275
+ const bareKey = String(phaseNum).replace(/^v\d+\.\d+\//, '');
276
+ const phaseEscaped = escapeRegex(bareKey);
277
+
278
+ // Progress table row: update Plans column (summaries/plans) and Status column.
279
+ // Format-agnostic: optional `vX.Y/` prefix on the Phase cell and an optional
280
+ // Milestone column (a pure-version cell) between Phase and Plans, both preserved.
271
281
  const tablePattern = new RegExp(
272
- `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|)[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
282
+ `(\\|\\s*(?:v\\d+\\.\\d+/)?${phaseEscaped}\\.?\\s[^|]*\\|)(\\s*v\\d+\\.\\d+\\s*\\|)?[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
273
283
  'i'
274
284
  );
275
285
  const dateField = isComplete ? ` ${today} ` : ' ';
276
286
  roadmapContent = roadmapContent.replace(
277
287
  tablePattern,
278
- `$1 ${summaryCount}/${planCount} $2 ${status.padEnd(11)}$3${dateField}$4`
288
+ `$1$2 ${summaryCount}/${planCount} $3 ${status.padEnd(11)}$4${dateField}$5`
279
289
  );
280
290
 
281
291
  // Update plan count in phase detail section
@@ -0,0 +1,134 @@
1
+ /**
2
+ * State snapshot milestone-context test (LOOK-03).
3
+ *
4
+ * Dedicated file (no state test file existed before) proving that
5
+ * `cmdStateSnapshot` ("state-snapshot") surfaces `current_milestone` alongside
6
+ * the existing `current_phase` so a bare "phase 3" in the snapshot is
7
+ * unambiguous about which milestone it belongs to.
8
+ *
9
+ * The milestone value is read from the AUTHORITATIVE STATE.md frontmatter
10
+ * signal (Phase 163) with the pinned precedence `current_milestone` then
11
+ * `milestone`, grammar-validated via isValidMilestoneVersion, and surfaced as
12
+ * `null` (never coerced, never thrown) when absent/out-of-grammar — existing
13
+ * snapshot consumers keep working in the flat / pre-versioned case.
14
+ *
15
+ * Uses Node.js built-in test runner (node:test) + assert.
16
+ */
17
+
18
+ const { describe, it } = require('node:test');
19
+ const assert = require('node:assert');
20
+ const { createFixture } = require('./test-helpers.cjs');
21
+ const { cmdStateSnapshot } = require('./state.cjs');
22
+
23
+ // ─── Helpers ────────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Capture stdout from CLI commands that call output() (process.stdout.write +
27
+ * process.exit). Mirrors phase-versioned.test.cjs's captureStdout so multiple
28
+ * invocations can run in sequence. Returns { stdout, exitCode, json }.
29
+ */
30
+ function captureStdout(fn) {
31
+ const chunks = [];
32
+ const origWrite = process.stdout.write.bind(process.stdout);
33
+ const origExit = process.exit;
34
+ let exitCode = null;
35
+ process.stdout.write = (data) => { chunks.push(String(data)); return true; };
36
+ process.exit = (code) => {
37
+ exitCode = code == null ? 0 : code;
38
+ throw new Error('__EXIT__');
39
+ };
40
+ try {
41
+ fn();
42
+ } catch (e) {
43
+ if (e && e.message !== '__EXIT__') throw e;
44
+ } finally {
45
+ process.stdout.write = origWrite;
46
+ process.exit = origExit;
47
+ }
48
+ const stdout = chunks.join('');
49
+ let json = null;
50
+ try { json = JSON.parse(stdout); } catch { /* not JSON */ }
51
+ return { stdout, exitCode, json };
52
+ }
53
+
54
+ /**
55
+ * v2 project fixture whose STATE.md frontmatter + body the snapshot reads.
56
+ * `current_project` in config.local.json makes resolveStatePath resolve to
57
+ * projects/<slug>/STATE.md (mirrors phase-versioned.test.cjs's layout).
58
+ *
59
+ * @param {string} frontmatter - YAML lines between the `---` fences (no fences).
60
+ * @param {string} [body] - markdown body after the frontmatter (defaults to a
61
+ * minimal "**Current Phase:** 3" body so current_phase is populated).
62
+ */
63
+ function stateFixture(frontmatter, body = '\n# Project State\n\n## Current Position\n\n**Current Phase:** 3\n') {
64
+ const slug = 'demo';
65
+ const fm = frontmatter ? `---\n${frontmatter}\n---` : '';
66
+ return createFixture({
67
+ 'config.json': JSON.stringify({}),
68
+ 'config.local.json': JSON.stringify({ current_project: slug }),
69
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
70
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n',
71
+ [`projects/${slug}/PROJECT.md`]: '# Project',
72
+ [`projects/${slug}/STATE.md`]: `${fm}${body}`,
73
+ });
74
+ }
75
+
76
+ // ─── cmdStateSnapshot current_milestone (LOOK-03) ─────────────────────────────
77
+
78
+ describe('cmdStateSnapshot current_milestone (LOOK-03)', () => {
79
+ it('Test 1: surfaces current_milestone alongside current_phase (both present)', () => {
80
+ const fixture = stateFixture('current_milestone: v26.0');
81
+ try {
82
+ const { json } = captureStdout(() => cmdStateSnapshot(fixture.cwd, false));
83
+ assert.ok(json, 'snapshot should emit JSON');
84
+ // The milestone qualifies the phase: both surfaced, unambiguous together.
85
+ assert.equal(json.current_milestone, 'v26.0', 'current_milestone surfaced from frontmatter');
86
+ assert.equal(json.current_phase, '3', 'current_phase still surfaced from body');
87
+ } finally {
88
+ fixture.cleanup();
89
+ }
90
+ });
91
+
92
+ it('Test 2: falls back to milestone when current_milestone absent (real-repo shape)', () => {
93
+ // Mirrors the real repo: only the advisory `milestone:` field is present.
94
+ const fixture = stateFixture('milestone: v25.0');
95
+ try {
96
+ const { json } = captureStdout(() => cmdStateSnapshot(fixture.cwd, false));
97
+ assert.ok(json, 'snapshot should emit JSON');
98
+ // Phase-163 precedence: current_milestone first, milestone second.
99
+ assert.equal(json.current_milestone, 'v25.0', 'precedence fallback to milestone honoured');
100
+ } finally {
101
+ fixture.cleanup();
102
+ }
103
+ });
104
+
105
+ it('Test 3: absent milestone → null, no throw, rest of snapshot still populated', () => {
106
+ // Flat / pre-versioned STATE: no milestone frontmatter at all.
107
+ const fixture = stateFixture('');
108
+ try {
109
+ let result;
110
+ assert.doesNotThrow(() => {
111
+ result = captureStdout(() => cmdStateSnapshot(fixture.cwd, false));
112
+ }, 'snapshot must not throw when no milestone signal is present');
113
+ const json = result.json;
114
+ assert.ok(json, 'snapshot should emit JSON');
115
+ assert.equal(json.current_milestone, null, 'current_milestone is null when absent');
116
+ // Backward-compat: the rest of the snapshot is unaffected.
117
+ assert.equal(json.current_phase, '3', 'current_phase still populated (no regression)');
118
+ } finally {
119
+ fixture.cleanup();
120
+ }
121
+ });
122
+
123
+ it('Test 4: out-of-grammar current_milestone rejected (null), never coerced', () => {
124
+ const fixture = stateFixture('current_milestone: garbage');
125
+ try {
126
+ const { json } = captureStdout(() => cmdStateSnapshot(fixture.cwd, false));
127
+ assert.ok(json, 'snapshot should emit JSON');
128
+ // isValidMilestoneVersion rejects non-^v\d+\.\d+$ → null, never the raw string.
129
+ assert.equal(json.current_milestone, null, 'out-of-grammar value rejected, not coerced');
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 { loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error, getProjectRoot } = require('./core.cjs');
7
+ const { loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error, getProjectRoot, phasesDir, isValidMilestoneVersion, execGit } = require('./core.cjs');
8
8
  const { extractFrontmatter, reconstructFrontmatter, spliceFrontmatter } = require('./frontmatter.cjs');
9
9
  const { getPlanningRoot } = require('./paths.cjs');
10
10
 
@@ -296,15 +296,17 @@ function stateUpdateProgressInternal(cwd) {
296
296
  let content = fs.readFileSync(statePath, 'utf-8');
297
297
 
298
298
  // Count summaries across all phases
299
- const phasesDir = path.join(cwd, projectRoot, 'phases');
299
+ let phasesRel;
300
+ try { phasesRel = phasesDir(cwd); } catch { phasesRel = path.join(projectRoot, 'phases'); }
301
+ const phasesAbs = path.join(cwd, phasesRel);
300
302
  let totalPlans = 0;
301
303
  let totalSummaries = 0;
302
304
 
303
- if (fs.existsSync(phasesDir)) {
304
- const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
305
+ if (fs.existsSync(phasesAbs)) {
306
+ const phaseDirs = fs.readdirSync(phasesAbs, { withFileTypes: true })
305
307
  .filter(e => e.isDirectory()).map(e => e.name);
306
308
  for (const dir of phaseDirs) {
307
- const files = fs.readdirSync(path.join(phasesDir, dir));
309
+ const files = fs.readdirSync(path.join(phasesAbs, dir));
308
310
  totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length;
309
311
  totalSummaries += files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
310
312
  }
@@ -502,6 +504,17 @@ function cmdStateSnapshot(cwd, raw) {
502
504
 
503
505
  const content = fs.readFileSync(statePath, 'utf-8');
504
506
 
507
+ // Resolve the milestone signal that qualifies the current phase (LOOK-03).
508
+ // Read the AUTHORITATIVE structured frontmatter field (Phase 163) with the
509
+ // pinned precedence `current_milestone` then `milestone`, grammar-validated.
510
+ // Surface `null` (never coerced, never thrown) when absent/out-of-grammar so
511
+ // flat / pre-versioned STATE.md still produces a valid snapshot.
512
+ const fm = extractFrontmatter(content) || {};
513
+ let currentMilestone = null;
514
+ for (const v of [fm.current_milestone, fm.milestone]) {
515
+ if (isValidMilestoneVersion(v)) { currentMilestone = v; break; }
516
+ }
517
+
505
518
  // Extract basic fields
506
519
  const currentPhase = stateExtractField(content, 'Current Phase');
507
520
  const currentPhaseName = stateExtractField(content, 'Current Phase Name');
@@ -571,6 +584,7 @@ function cmdStateSnapshot(cwd, raw) {
571
584
  }
572
585
 
573
586
  const result = {
587
+ current_milestone: currentMilestone,
574
588
  current_phase: currentPhase,
575
589
  current_phase_name: currentPhaseName,
576
590
  total_phases: totalPhases,
@@ -596,7 +610,7 @@ function cmdStateSnapshot(cwd, raw) {
596
610
  * a YAML frontmatter object. Allows hooks and scripts to read state
597
611
  * reliably via `state json` instead of fragile regex parsing.
598
612
  */
599
- function buildStateFrontmatter(bodyContent, cwd) {
613
+ function buildStateFrontmatter(bodyContent, cwd, options) {
600
614
  const currentPhase = stateExtractField(bodyContent, 'Current Phase');
601
615
  const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name');
602
616
  const currentPlan = stateExtractField(bodyContent, 'Current Plan');
@@ -625,16 +639,15 @@ function buildStateFrontmatter(bodyContent, cwd) {
625
639
 
626
640
  if (cwd) {
627
641
  try {
628
- let phasesDir;
642
+ let phasesAbs;
629
643
  try {
630
- const projectRoot = getProjectRoot(cwd);
631
- phasesDir = path.join(cwd, projectRoot, 'phases');
644
+ phasesAbs = path.join(cwd, phasesDir(cwd));
632
645
  } catch {
633
- phasesDir = path.join(getPlanningRoot(cwd), 'phases');
646
+ phasesAbs = path.join(getPlanningRoot(cwd), 'phases');
634
647
  }
635
- if (fs.existsSync(phasesDir)) {
648
+ if (fs.existsSync(phasesAbs)) {
636
649
  const isDirInMilestone = getMilestonePhaseFilter(cwd);
637
- const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
650
+ const phaseDirs = fs.readdirSync(phasesAbs, { withFileTypes: true })
638
651
  .filter(e => e.isDirectory()).map(e => e.name)
639
652
  .filter(isDirInMilestone);
640
653
  let diskTotalPlans = 0;
@@ -642,7 +655,7 @@ function buildStateFrontmatter(bodyContent, cwd) {
642
655
  let diskCompletedPhases = 0;
643
656
 
644
657
  for (const dir of phaseDirs) {
645
- const files = fs.readdirSync(path.join(phasesDir, dir));
658
+ const files = fs.readdirSync(path.join(phasesAbs, dir));
646
659
  const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
647
660
  const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
648
661
  diskTotalPlans += plans;
@@ -689,6 +702,17 @@ function buildStateFrontmatter(bodyContent, cwd) {
689
702
 
690
703
  const fm = { dgs_state_version: '1.0' };
691
704
 
705
+ // Preserve the authoritative structured current_milestone signal through the
706
+ // rebuild. The STATE sync re-derives the advisory `milestone`/`milestone_name`
707
+ // fields from the ROADMAP prose scrape, but the structured `current_milestone`
708
+ // field is set only by the milestone-lifecycle writers (new-milestone flow +
709
+ // init.cjs). If the existing frontmatter carries a grammar-valid value, carry
710
+ // it forward verbatim; reject (omit) out-of-grammar values — never coerce.
711
+ const existingCM = options && options.existingFm ? options.existingFm.current_milestone : undefined;
712
+ if (isValidMilestoneVersion(existingCM)) {
713
+ fm.current_milestone = existingCM;
714
+ }
715
+
692
716
  if (milestone) fm.milestone = milestone;
693
717
  if (milestoneName) fm.milestone_name = milestoneName;
694
718
  if (currentPhase) fm.current_phase = currentPhase;
@@ -716,8 +740,12 @@ function stripFrontmatter(content) {
716
740
  }
717
741
 
718
742
  function syncStateFrontmatter(content, cwd) {
743
+ // Read the existing frontmatter BEFORE stripping so the structured
744
+ // current_milestone signal (set only by milestone-lifecycle writers) is
745
+ // carried through the rebuild rather than silently dropped.
746
+ const existingFm = extractFrontmatter(content);
719
747
  const body = stripFrontmatter(content);
720
- const fm = buildStateFrontmatter(body, cwd);
748
+ const fm = buildStateFrontmatter(body, cwd, { existingFm });
721
749
  const yamlStr = reconstructFrontmatter(fm);
722
750
  return `---\n${yamlStr}\n---\n\n${body}`;
723
751
  }
@@ -910,6 +938,37 @@ function cmdStateArchiveQuickTasks(cwd, raw) {
910
938
  * @param {string} cwd - Planning root directory
911
939
  * @returns {{ success: boolean, milestone: string, completed_date: string }}
912
940
  */
941
+ /**
942
+ * Shared STATE-body finalization for milestone completion.
943
+ *
944
+ * Performs all the "milestone shipped" body rewrites: the progress bar, the
945
+ * Current focus / Status / Last activity lines, AND the Current Position
946
+ * `Phase:` reset that keeps the dashboard from showing a frozen, mid-execution
947
+ * phase after a milestone ships (DASH-STALE-01).
948
+ *
949
+ * Used by both markMilestoneComplete (normal completion) and reconcileMilestone
950
+ * (self-heal) so the two paths produce byte-identical STATE bodies.
951
+ *
952
+ * @param {string} body - STATE.md body (frontmatter already stripped)
953
+ * @param {{ milestone: string, today: string, totalPhases: (number|string), totalPlans: (number|string) }} opts
954
+ * @returns {string} the rewritten body
955
+ */
956
+ function _finalizeMilestoneStateBody(body, { milestone, today, totalPhases, totalPlans }) {
957
+ // Update progress bar
958
+ body = body.replace(/Progress:\s*\[[^\]]*\]\s*\d+%/, 'Progress: [██████████] 100%');
959
+ // Update current focus
960
+ body = body.replace(/\*\*Current focus:\*\*\s*.+/, `**Current focus:** Milestone ${milestone} complete — shipped ${today}`);
961
+ // Update status line in Current Position
962
+ body = body.replace(/Status:\s*.+/, `Status: Milestone ${milestone} shipped ${today}`);
963
+ // Update last activity in Current Position
964
+ body = body.replace(/(Last activity:\s*).+/, `$1${today} -- Milestone ${milestone} shipped (${totalPhases} phases, ${totalPlans} plans)`);
965
+ // Reset the Current Position Phase: line so the dashboard no longer references
966
+ // an in-progress phase. Multiline-anchored so it targets the line-leading
967
+ // `Phase:` entry, not substrings like "Phases completed".
968
+ body = body.replace(/^Phase:\s*.+$/m, `Phase: — (between milestones; ${milestone} shipped ${today})`);
969
+ return body;
970
+ }
971
+
913
972
  function markMilestoneComplete(cwd) {
914
973
  const statePath = resolveStatePath(cwd);
915
974
  if (!fs.existsSync(statePath)) {
@@ -933,20 +992,10 @@ function markMilestoneComplete(cwd) {
933
992
  // Reconstruct frontmatter and preserve body
934
993
  let body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
935
994
 
936
- // Update markdown body to reflect completion
937
- const milestoneName = fm.milestone_name || fm.milestone || 'unknown';
995
+ // Update markdown body to reflect completion (shared with reconcileMilestone)
938
996
  const totalPhases = (fm.progress && fm.progress.total_phases) || '?';
939
997
  const totalPlans = (fm.progress && fm.progress.total_plans) || '?';
940
- const lastPhase = (fm.progress && fm.progress.completed_phases) || totalPhases;
941
-
942
- // Update progress bar
943
- body = body.replace(/Progress:\s*\[[^\]]*\]\s*\d+%/, 'Progress: [██████████] 100%');
944
- // Update current focus
945
- body = body.replace(/\*\*Current focus:\*\*\s*.+/, `**Current focus:** Milestone ${milestone} complete — shipped ${today}`);
946
- // Update status line in Current Position
947
- body = body.replace(/Status:\s*.+/, `Status: Milestone ${milestone} shipped ${today}`);
948
- // Update last activity in Current Position
949
- body = body.replace(/(Last activity:\s*).+/, `$1${today} -- Milestone ${milestone} shipped (${totalPhases} phases, ${totalPlans} plans)`);
998
+ body = _finalizeMilestoneStateBody(body, { milestone, today, totalPhases, totalPlans });
950
999
 
951
1000
  const yamlStr = reconstructFrontmatter(fm);
952
1001
  content = `---\n${yamlStr}\n---\n\n${body}`;
@@ -961,6 +1010,99 @@ function cmdMarkMilestoneComplete(cwd, raw) {
961
1010
  output(result, raw);
962
1011
  }
963
1012
 
1013
+ /**
1014
+ * Self-heal a shipped-but-not-flipped project (DASH-STALE-02).
1015
+ *
1016
+ * Completion's STATE flip is sometimes skipped, leaving a project that has a
1017
+ * MILESTONES.md entry AND a matching git tag (i.e. genuinely shipped) but whose
1018
+ * STATE.md still reports status `executing` and a mid-execution `Phase:` line.
1019
+ * This applies the SAME finalization as markMilestoneComplete to bring STATE in
1020
+ * line. It is idempotent — a no-op on an already-correct or not-yet-shipped
1021
+ * project. Scope is the CURRENT project (the resolved STATE.md) only.
1022
+ *
1023
+ * @param {string} cwd - Planning root directory
1024
+ * @returns {{ healed: boolean, milestone: (string|null), changes?: string[], reason?: string }}
1025
+ */
1026
+ function reconcileMilestone(cwd) {
1027
+ const statePath = resolveStatePath(cwd);
1028
+ if (!fs.existsSync(statePath)) {
1029
+ error('STATE.md not found');
1030
+ }
1031
+
1032
+ const content = fs.readFileSync(statePath, 'utf-8');
1033
+ const fm = extractFrontmatter(content) || {};
1034
+
1035
+ // Determine milestone version: structured fields first, then ROADMAP scrape.
1036
+ let version = null;
1037
+ for (const v of [fm.current_milestone, fm.milestone]) {
1038
+ if (isValidMilestoneVersion(v)) { version = v; break; }
1039
+ }
1040
+ if (!version) {
1041
+ try {
1042
+ const info = getMilestoneInfo(cwd);
1043
+ if (isValidMilestoneVersion(info.version)) version = info.version;
1044
+ } catch {}
1045
+ }
1046
+
1047
+ // Detect "shipped": MILESTONES.md heading AND a matching git tag — both required.
1048
+ let shipped = false;
1049
+ if (version) {
1050
+ let hasMilestoneEntry = false;
1051
+ try {
1052
+ const milestonesPath = path.join(getPlanningRoot(cwd), 'MILESTONES.md');
1053
+ const mContent = fs.readFileSync(milestonesPath, 'utf-8');
1054
+ const headingRe = new RegExp(`^##\\s+${escapeRegex(version)}\\b`, 'm');
1055
+ hasMilestoneEntry = headingRe.test(mContent);
1056
+ } catch {}
1057
+ const tagResult = execGit(cwd, ['tag', '-l', version]);
1058
+ const hasTag = tagResult.exitCode === 0 &&
1059
+ tagResult.stdout.split('\n').map((s) => s.trim()).includes(version);
1060
+ shipped = hasMilestoneEntry && hasTag;
1061
+ }
1062
+
1063
+ if (!shipped) {
1064
+ return { healed: false, milestone: version, reason: 'not_shipped' };
1065
+ }
1066
+
1067
+ // Detect "not flipped": status not milestone_shipped OR the Phase: line still
1068
+ // references an in-progress phase.
1069
+ const statusFlipped = fm.status === 'milestone_shipped';
1070
+ const body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
1071
+ const phaseLine = body.match(/^Phase:\s*(.+)$/m);
1072
+ const phaseInProgress = phaseLine ? /\d+\s+of\s+\d+/.test(phaseLine[1]) : false;
1073
+
1074
+ if (statusFlipped && !phaseInProgress) {
1075
+ return { healed: false, milestone: version, reason: 'already_correct' };
1076
+ }
1077
+
1078
+ // Heal: apply the same finalization as markMilestoneComplete.
1079
+ const today = new Date().toISOString().split('T')[0];
1080
+ const now = new Date().toISOString();
1081
+ const changes = [];
1082
+ if (!statusFlipped) changes.push('status');
1083
+ if (phaseInProgress) changes.push('phase');
1084
+
1085
+ fm.status = 'milestone_shipped';
1086
+ if (!fm.progress) fm.progress = {};
1087
+ fm.progress.percent = 100;
1088
+ fm.last_updated = now;
1089
+ fm.completed_date = today;
1090
+
1091
+ const totalPhases = (fm.progress && fm.progress.total_phases) || '?';
1092
+ const totalPlans = (fm.progress && fm.progress.total_plans) || '?';
1093
+ const newBody = _finalizeMilestoneStateBody(body, { milestone: version, today, totalPhases, totalPlans });
1094
+
1095
+ const yamlStr = reconstructFrontmatter(fm);
1096
+ fs.writeFileSync(statePath, `---\n${yamlStr}\n---\n\n${newBody}`, 'utf-8');
1097
+
1098
+ return { healed: true, milestone: version, changes };
1099
+ }
1100
+
1101
+ function cmdReconcileMilestone(cwd, raw) {
1102
+ const result = reconcileMilestone(cwd);
1103
+ output(result, raw, JSON.stringify(result));
1104
+ }
1105
+
964
1106
  // ─── Ad-hoc milestone marker (Phase 159, ADH-04 primary) ─────────────────────
965
1107
 
966
1108
  /**
@@ -1109,6 +1251,8 @@ function cmdStateAdhocReadiness(cwd, raw) {
1109
1251
  module.exports = {
1110
1252
  stateExtractField,
1111
1253
  stateReplaceField,
1254
+ buildStateFrontmatter,
1255
+ syncStateFrontmatter,
1112
1256
  writeStateMd,
1113
1257
  cmdStateLoad,
1114
1258
  cmdStateGet,
@@ -1125,8 +1269,11 @@ module.exports = {
1125
1269
  cmdStateSnapshot,
1126
1270
  cmdStateJson,
1127
1271
  cmdStateArchiveQuickTasks,
1272
+ _finalizeMilestoneStateBody,
1128
1273
  markMilestoneComplete,
1129
1274
  cmdMarkMilestoneComplete,
1275
+ reconcileMilestone,
1276
+ cmdReconcileMilestone,
1130
1277
  stateReadAdhoc,
1131
1278
  stateSetAdhoc,
1132
1279
  cmdStateReadAdhoc,
@@ -228,7 +228,7 @@ Each plan produces 2-4 commits (tasks + metadata). Clear, granular, bisectable.
228
228
 
229
229
  **Context engineering for AI:**
230
230
  - Git history becomes primary context source for future Claude sessions
231
- - `git log --grep="{phase}-{plan}"` shows all work for a plan
231
+ - `git log $(git merge-base main HEAD)..HEAD --grep="{phase}-{plan}"` shows all work for a plan on the current milestone branch
232
232
  - `git diff <hash>^..<hash>` shows exact changes per task
233
233
  - Less reliance on parsing SUMMARY.md = more context for actual work
234
234
 
@@ -121,5 +121,5 @@ _For current project status, see ROADMAP.md_
121
121
 
122
122
  - Update ROADMAP.md to collapse completed milestone in `<details>` tag
123
123
  - Update PROJECT.md to brownfield format with Current State section
124
- - Continue phase numbering in next milestone (never restart at 01)
124
+ - Restart phase numbering at `01` in the next milestone — numbering is per-milestone, with the milestone version disambiguating (active phases live under `phases/<version>/NN-slug/`). Already-archived milestones keep their original numbers; no renumbering.
125
125
  </guidelines>
@@ -95,7 +95,7 @@ Plans:
95
95
  ## Progress
96
96
 
97
97
  **Execution Order:**
98
- Phases execute in numeric order: 2 → 2.1 → 2.2 → 3 → 3.1 → 4
98
+ Phases execute in numeric order within a milestone: 2 → 2.1 → 2.2 → 3 → 3.1 → 4
99
99
 
100
100
  | Phase | Plans Complete | Status | Completed |
101
101
  |-------|----------------|--------|-----------|
@@ -125,7 +125,7 @@ Phases execute in numeric order: 2 → 2.1 → 2.2 → 3 → 3.1 → 4
125
125
  **After milestones ship:**
126
126
  - Collapse completed milestones in `<details>` tags
127
127
  - Add new milestone sections for upcoming work
128
- - Keep continuous phase numbering (never restart at 01)
128
+ - Restart phase numbering at `01` for each new milestone — numbering is per-milestone, scoped under `phases/<version>/NN-slug/` with the milestone version as the disambiguator (mirrors the `milestones/<version>-phases/` archive layout). Pre-existing flat-layout projects keep their existing global numbers — numbering is never rewritten.
129
129
  </guidelines>
130
130
 
131
131
  <status_values>
@@ -145,8 +145,8 @@ After completing first milestone, reorganize with milestone groupings:
145
145
  ## Milestones
146
146
 
147
147
  - ✅ **v1.0 MVP** - Phases 1-4 (shipped YYYY-MM-DD)
148
- - 🚧 **v1.1 [Name]** - Phases 5-6 (in progress)
149
- - 📋 **v2.0 [Name]** - Phases 7-10 (planned)
148
+ - 🚧 **v1.1 [Name]** - Phases 1-2 (in progress)
149
+ - 📋 **v2.0 [Name]** - Phases 1-4 (planned)
150
150
 
151
151
  ## Phases
152
152
 
@@ -170,14 +170,16 @@ Plans:
170
170
 
171
171
  **Milestone Goal:** [What v1.1 delivers]
172
172
 
173
- #### Phase 5: [Name]
173
+ **Phase directory:** `phases/v1.1/` (numbering restarts at `01` for this milestone)
174
+
175
+ #### Phase 1: [Name]
174
176
  **Goal**: [What this phase delivers]
175
- **Depends on**: Phase 4
177
+ **Depends on**: v1.0 milestone (first phase of v1.1 — numbering restarts at 01)
176
178
  **Plans**: 2 plans
177
179
 
178
180
  Plans:
179
- - [ ] 05-01: [Brief description]
180
- - [ ] 05-02: [Brief description]
181
+ - [ ] 01-01: [Brief description]
182
+ - [ ] 01-02: [Brief description]
181
183
 
182
184
  [... remaining v1.1 phases ...]
183
185
 
@@ -193,12 +195,12 @@ Plans:
193
195
  |-------|-----------|----------------|--------|-----------|
194
196
  | 1. Foundation | v1.0 | 3/3 | Complete | YYYY-MM-DD |
195
197
  | 2. Features | v1.0 | 2/2 | Complete | YYYY-MM-DD |
196
- | 5. Security | v1.1 | 0/2 | Not started | - |
198
+ | 1. Security | v1.1 | 0/2 | Not started | - |
197
199
  ```
198
200
 
199
201
  **Notes:**
200
202
  - Milestone emoji: ✅ shipped, 🚧 in progress, 📋 planned
201
203
  - Completed milestones collapsed in `<details>` for readability
202
204
  - Current/future milestones expanded
203
- - Continuous phase numbering (01-99)
205
+ - Per-milestone phase numbering: each milestone restarts at `01` (zero-padded), namespaced under `phases/<version>/`
204
206
  - Progress table includes milestone column