@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.
- package/CHANGELOG.md +28 -0
- package/README.md +2 -0
- package/agents/dgs-codebase-cross-analyzer.md +1 -1
- package/agents/dgs-codebase-mapper.md +1 -1
- package/agents/dgs-codebase-synthesizer.md +1 -1
- package/agents/dgs-phase-researcher.md +1 -1
- package/bin/install.js +34 -2
- package/deliver-great-systems/bin/dgs-tools.cjs +7 -1
- package/deliver-great-systems/bin/lib/commands.cjs +66 -29
- package/deliver-great-systems/bin/lib/commands.test.cjs +221 -1
- package/deliver-great-systems/bin/lib/context.cjs +6 -6
- package/deliver-great-systems/bin/lib/context.test.cjs +9 -9
- package/deliver-great-systems/bin/lib/core.cjs +199 -9
- package/deliver-great-systems/bin/lib/core.test.cjs +242 -0
- package/deliver-great-systems/bin/lib/execution.cjs +7 -0
- package/deliver-great-systems/bin/lib/governance.cjs +7 -7
- package/deliver-great-systems/bin/lib/init.cjs +25 -17
- package/deliver-great-systems/bin/lib/init.test.cjs +69 -10
- package/deliver-great-systems/bin/lib/jobs.cjs +132 -67
- package/deliver-great-systems/bin/lib/jobs.test.cjs +157 -13
- package/deliver-great-systems/bin/lib/migration.test.cjs +8 -0
- package/deliver-great-systems/bin/lib/milestone-archival.test.cjs +186 -0
- package/deliver-great-systems/bin/lib/milestone.cjs +168 -37
- package/deliver-great-systems/bin/lib/milestone.test.cjs +113 -1
- package/deliver-great-systems/bin/lib/path-audit.test.cjs +128 -0
- package/deliver-great-systems/bin/lib/paths.cjs +1 -2
- package/deliver-great-systems/bin/lib/paths.test.cjs +3 -4
- package/deliver-great-systems/bin/lib/phase-versioned.test.cjs +134 -0
- package/deliver-great-systems/bin/lib/phase.cjs +60 -7
- package/deliver-great-systems/bin/lib/phase.test.cjs +168 -1
- package/deliver-great-systems/bin/lib/projects.test.cjs +38 -0
- package/deliver-great-systems/bin/lib/repos.cjs +8 -4
- package/deliver-great-systems/bin/lib/repos.test.cjs +6 -2
- package/deliver-great-systems/bin/lib/roadmap.cjs +21 -11
- package/deliver-great-systems/bin/lib/state-snapshot.test.cjs +134 -0
- package/deliver-great-systems/bin/lib/state.cjs +173 -26
- package/deliver-great-systems/references/git-integration.md +1 -1
- package/deliver-great-systems/templates/milestone-archive.md +1 -1
- package/deliver-great-systems/templates/roadmap.md +12 -10
- package/deliver-great-systems/workflows/abandon-milestone.md +8 -1
- package/deliver-great-systems/workflows/abandon-quick.md +1 -1
- package/deliver-great-systems/workflows/codereview.md +1 -1
- package/deliver-great-systems/workflows/complete-milestone.md +1 -1
- package/deliver-great-systems/workflows/execute-phase.md +2 -2
- package/deliver-great-systems/workflows/execute-plan.md +2 -2
- package/deliver-great-systems/workflows/new-milestone.md +46 -12
- package/deliver-great-systems/workflows/quick-abandon.md +1 -1
- package/deliver-great-systems/workflows/quick.md +3 -3
- package/package.json +3 -2
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { safeReadFile, execGit, isV2Install, output, error } = require('./core.cjs');
|
|
11
|
+
const { safeReadFile, execGit, isV2Install, output, error, phasesDir } = require('./core.cjs');
|
|
12
12
|
const { writeConfigField } = require('./config.cjs');
|
|
13
13
|
const { getPlanningRoot, resetPaths } = require('./paths.cjs');
|
|
14
14
|
|
|
@@ -997,15 +997,19 @@ function cmdReposRemove(cwd, repoName, options, raw) {
|
|
|
997
997
|
*/
|
|
998
998
|
function scanRepoReferences(cwd, repoName) {
|
|
999
999
|
const references = [];
|
|
1000
|
-
const phasesDir = path.join(getPlanningRoot(cwd), 'phases');
|
|
1001
1000
|
|
|
1002
1001
|
try {
|
|
1003
|
-
|
|
1002
|
+
// RSLV-03 residue (Phase 170): resolve the project + version scoped phases dir
|
|
1003
|
+
// via the canonical resolver so `repos repo show` finds phase hits under a
|
|
1004
|
+
// versioned layout. phasesDir(cwd) is fail-loud; the throw degrades to empty
|
|
1005
|
+
// references through this same catch, matching the prior readdirSync-throws path.
|
|
1006
|
+
const phasesAbs = path.join(cwd, phasesDir(cwd));
|
|
1007
|
+
const phaseDirs = fs.readdirSync(phasesAbs, { withFileTypes: true })
|
|
1004
1008
|
.filter(e => e.isDirectory())
|
|
1005
1009
|
.map(e => e.name);
|
|
1006
1010
|
|
|
1007
1011
|
for (const phaseDir of phaseDirs) {
|
|
1008
|
-
const fullPhaseDir = path.join(
|
|
1012
|
+
const fullPhaseDir = path.join(phasesAbs, phaseDir);
|
|
1009
1013
|
const planFiles = fs.readdirSync(fullPhaseDir)
|
|
1010
1014
|
.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
1011
1015
|
|
|
@@ -618,9 +618,13 @@ describe('writeReposMd -> parseReposMd roundtrip', () => {
|
|
|
618
618
|
afterEach(() => { resetPaths(); cleanupDir(tmpDir); });
|
|
619
619
|
|
|
620
620
|
it('write then parse produces identical data', () => {
|
|
621
|
+
// parseReposMd always returns a `setup` field (defensive 6th-column read);
|
|
622
|
+
// writeReposMd's standard 4-column table omits an empty setup, so the
|
|
623
|
+
// roundtrip normalizes setup to '' for every row. Expected objects must
|
|
624
|
+
// include it to match shipped parser behaviour.
|
|
621
625
|
const repos = [
|
|
622
|
-
{ name: 'web', path: './web', url: 'https://github.com/org/web', description: 'Frontend' },
|
|
623
|
-
{ name: 'api', path: './api', url: '', description: 'Backend API' },
|
|
626
|
+
{ name: 'web', path: './web', url: 'https://github.com/org/web', description: 'Frontend', setup: '' },
|
|
627
|
+
{ name: 'api', path: './api', url: '', description: 'Backend API', setup: '' },
|
|
624
628
|
];
|
|
625
629
|
writeReposMd(tmpDir, repos);
|
|
626
630
|
const parsed = parseReposMd(tmpDir);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal, resolveProjectPath } = require('./core.cjs');
|
|
7
|
+
const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal, resolveProjectPath, phasesDir } = require('./core.cjs');
|
|
8
8
|
|
|
9
9
|
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
|
|
10
10
|
const roadmapPath = path.join(cwd, resolveProjectPath(cwd, 'ROADMAP.md'));
|
|
@@ -99,7 +99,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
102
|
-
const
|
|
102
|
+
const phasesAbs = path.join(cwd, phasesDir(cwd));
|
|
103
103
|
|
|
104
104
|
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
|
|
105
105
|
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
|
@@ -132,12 +132,12 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
132
132
|
let hasResearch = false;
|
|
133
133
|
|
|
134
134
|
try {
|
|
135
|
-
const entries = fs.readdirSync(
|
|
135
|
+
const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
|
|
136
136
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
137
137
|
const dirMatch = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
|
|
138
138
|
|
|
139
139
|
if (dirMatch) {
|
|
140
|
-
const phaseFiles = fs.readdirSync(path.join(
|
|
140
|
+
const phaseFiles = fs.readdirSync(path.join(phasesAbs, dirMatch));
|
|
141
141
|
planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
|
142
142
|
summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
|
143
143
|
hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
|
|
@@ -237,8 +237,11 @@ function roadmapUpdatePlanProgressInternal(cwd, phaseNum) {
|
|
|
237
237
|
const roadmapPath = path.join(cwd, resolveProjectPath(cwd, 'ROADMAP.md'));
|
|
238
238
|
|
|
239
239
|
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
// Guard on `found`: a LOOK-01 ambiguity object is truthy but has no .plans,
|
|
241
|
+
// so `phaseInfo.plans.length` is not reached for an ambiguity object. Surface
|
|
242
|
+
// its milestone-qualified message instead of throwing.
|
|
243
|
+
if (!phaseInfo || !phaseInfo.found) {
|
|
244
|
+
error(phaseInfo?.message || `Phase ${phaseNum} not found`);
|
|
242
245
|
}
|
|
243
246
|
|
|
244
247
|
const planCount = phaseInfo.plans.length;
|
|
@@ -265,17 +268,24 @@ function roadmapUpdatePlanProgressInternal(cwd, phaseNum) {
|
|
|
265
268
|
}
|
|
266
269
|
|
|
267
270
|
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
//
|
|
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
|
|
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} $
|
|
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
|
-
|
|
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(
|
|
304
|
-
const phaseDirs = fs.readdirSync(
|
|
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(
|
|
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
|
|
642
|
+
let phasesAbs;
|
|
629
643
|
try {
|
|
630
|
-
|
|
631
|
-
phasesDir = path.join(cwd, projectRoot, 'phases');
|
|
644
|
+
phasesAbs = path.join(cwd, phasesDir(cwd));
|
|
632
645
|
} catch {
|
|
633
|
-
|
|
646
|
+
phasesAbs = path.join(getPlanningRoot(cwd), 'phases');
|
|
634
647
|
}
|
|
635
|
-
if (fs.existsSync(
|
|
648
|
+
if (fs.existsSync(phasesAbs)) {
|
|
636
649
|
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
637
|
-
const phaseDirs = fs.readdirSync(
|
|
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(
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
149
|
-
- 📋 **v2.0 [Name]** - Phases
|
|
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
|
-
|
|
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**:
|
|
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
|
-
- [ ]
|
|
180
|
-
- [ ]
|
|
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
|
-
|
|
|
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
|
-
-
|
|
205
|
+
- Per-milestone phase numbering: each milestone restarts at `01` (zero-padded), namespaced under `phases/<version>/`
|
|
204
206
|
- Progress table includes milestone column
|