@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
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Tests for cmdMilestoneComplete — structural phase archival (Phase 167, NUM-03/NUM-04)
3
+ *
4
+ * Covers the versioned single-move archival path, the collision-abort guard,
5
+ * mixed-layout correctness, and flat-mode preservation.
6
+ *
7
+ * Uses Node.js built-in test runner (node:test) and assert (node:assert/strict).
8
+ * Each test creates an isolated temp directory fixture and cleans up after.
9
+ */
10
+
11
+ const { describe, it, afterEach } = require('node:test');
12
+ const assert = require('node:assert/strict');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const { createFixture } = require('./test-helpers.cjs');
17
+ const { cmdMilestoneComplete } = require('./milestone.cjs');
18
+
19
+ // Helper: capture stdout from cmdMilestoneComplete (which calls output()).
20
+ // Also stubs process.exit so error()-guarded paths return instead of exiting.
21
+ function captureResult(fn) {
22
+ const chunks = [];
23
+ const origWrite = process.stdout.write;
24
+ process.stdout.write = function (chunk) {
25
+ chunks.push(String(chunk));
26
+ };
27
+ const origStderrWrite = process.stderr.write;
28
+ process.stderr.write = function () {};
29
+ const origExit = process.exit;
30
+ process.exit = function () {};
31
+ try {
32
+ fn();
33
+ } finally {
34
+ process.stdout.write = origWrite;
35
+ process.stderr.write = origStderrWrite;
36
+ process.exit = origExit;
37
+ }
38
+ const raw = chunks.join('');
39
+ try {
40
+ return JSON.parse(raw);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Creates a root-layout fixture suitable for cmdMilestoneComplete.
48
+ * Pass `extras` to add/override structure entries (e.g. a versioned STATE.md
49
+ * with current_milestone frontmatter and phases/v26.0/ dirs).
50
+ */
51
+ function createMilestoneFixture(extras) {
52
+ const base = {
53
+ 'config.json': JSON.stringify({}),
54
+ 'config.local.json': JSON.stringify({ planningRoot: '.' }),
55
+ 'STATE.md': '# Project State\n\nPhase: 1\nStatus: Ready\nProgress: [----------] 0%\n',
56
+ 'ROADMAP.md': '# Roadmap\n\n## Phase 1: Test\n',
57
+ 'phases/': null,
58
+ };
59
+ if (extras) {
60
+ Object.assign(base, extras);
61
+ }
62
+ return createFixture(base);
63
+ }
64
+
65
+ // Versioned STATE.md with current_milestone frontmatter so resolveMilestoneVersion → v26.0.
66
+ const VERSIONED_STATE =
67
+ '---\ncurrent_milestone: v26.0\nmilestone: v26.0\n---\n\n# Project State\n\nPhase: 1\nStatus: Ready\n';
68
+
69
+ describe('cmdMilestoneComplete versioned structural archival', () => {
70
+ let fixture;
71
+
72
+ afterEach(() => {
73
+ if (fixture) fixture.cleanup();
74
+ fixture = undefined;
75
+ });
76
+
77
+ it('versioned single-move: archives phases/v26.0 to milestones/v26.0-phases via one rename', () => {
78
+ fixture = createMilestoneFixture({
79
+ 'STATE.md': VERSIONED_STATE,
80
+ 'phases/v26.0/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
81
+ 'phases/v26.0/02-bar/02-bar-SUMMARY.md': '---\none-liner: bar\n---\n## Task 1\n',
82
+ });
83
+ const cwd = fixture.cwd;
84
+
85
+ const result = captureResult(() => {
86
+ cmdMilestoneComplete(cwd, 'v26.0', { archivePhases: true }, false);
87
+ });
88
+
89
+ assert.ok(
90
+ fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '01-foo')),
91
+ '01-foo should be archived under milestones/v26.0-phases/'
92
+ );
93
+ assert.ok(
94
+ fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '02-bar')),
95
+ '02-bar should be archived under milestones/v26.0-phases/'
96
+ );
97
+ assert.ok(
98
+ !fs.existsSync(path.join(cwd, 'phases', 'v26.0')),
99
+ 'phases/v26.0 should be MOVED (not copied) — source must no longer exist'
100
+ );
101
+ assert.ok(result, 'Should return result JSON');
102
+ assert.equal(result.archived.phases, true, 'archived.phases should be true');
103
+ });
104
+
105
+ it('versioned collision abort: aborts when milestones/v26.0-phases already exists, leaves source intact', () => {
106
+ fixture = createMilestoneFixture({
107
+ 'STATE.md': VERSIONED_STATE,
108
+ 'phases/v26.0/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
109
+ 'milestones/v26.0-phases/old/keep.md': 'PRESERVE ME',
110
+ });
111
+ const cwd = fixture.cwd;
112
+
113
+ captureResult(() => {
114
+ cmdMilestoneComplete(cwd, 'v26.0', { archivePhases: true }, false);
115
+ });
116
+
117
+ // Source NOT moved on collision.
118
+ assert.ok(
119
+ fs.existsSync(path.join(cwd, 'phases', 'v26.0', '01-foo')),
120
+ 'source phases/v26.0/01-foo should still exist after collision abort'
121
+ );
122
+ // Pre-existing archive untouched — no overwrite, no merge.
123
+ const keepPath = path.join(cwd, 'milestones', 'v26.0-phases', 'old', 'keep.md');
124
+ assert.ok(fs.existsSync(keepPath), 'pre-existing archive entry should be untouched');
125
+ assert.equal(
126
+ fs.readFileSync(keepPath, 'utf-8'),
127
+ 'PRESERVE ME',
128
+ 'pre-existing archive content should not be overwritten'
129
+ );
130
+ // No source dir merged into the existing archive.
131
+ assert.ok(
132
+ !fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '01-foo')),
133
+ 'source dir should NOT be merged into the existing archive'
134
+ );
135
+ });
136
+
137
+ it('mixed-layout: archives only phases/v26.0, leaves sibling flat phases/NN-slug dirs untouched', () => {
138
+ fixture = createMilestoneFixture({
139
+ 'STATE.md': VERSIONED_STATE,
140
+ 'phases/v26.0/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
141
+ 'phases/90-legacy/PLAN.md': '# Legacy flat phase',
142
+ });
143
+ const cwd = fixture.cwd;
144
+
145
+ captureResult(() => {
146
+ cmdMilestoneComplete(cwd, 'v26.0', { archivePhases: true }, false);
147
+ });
148
+
149
+ assert.ok(
150
+ fs.existsSync(path.join(cwd, 'milestones', 'v26.0-phases', '01-foo')),
151
+ 'versioned phase 01-foo should be archived'
152
+ );
153
+ assert.ok(
154
+ fs.existsSync(path.join(cwd, 'phases', '90-legacy', 'PLAN.md')),
155
+ 'unrelated sibling flat phase phases/90-legacy/ must NOT be disturbed'
156
+ );
157
+ assert.ok(
158
+ !fs.existsSync(path.join(cwd, 'phases', 'v26.0')),
159
+ 'versioned subtree should be moved away'
160
+ );
161
+ });
162
+
163
+ it('flat mode preserved: per-directory filtered move still works (no frontmatter version)', () => {
164
+ fixture = createMilestoneFixture({
165
+ 'ROADMAP.md': '# Roadmap\n\n## Phase 1: Test\n',
166
+ 'phases/01-foo/01-foo-SUMMARY.md': '---\none-liner: foo\n---\n## Task 1\n',
167
+ });
168
+ const cwd = fixture.cwd;
169
+
170
+ const result = captureResult(() => {
171
+ cmdMilestoneComplete(cwd, 'v1.0', { archivePhases: true }, false);
172
+ });
173
+
174
+ assert.ok(
175
+ fs.existsSync(path.join(cwd, 'milestones', 'v1.0-phases', '01-foo')),
176
+ 'flat per-dir move should archive 01-foo to milestones/v1.0-phases/'
177
+ );
178
+ // Flat phases/ is mkdir-archived FROM, not moved wholesale → it still exists as a dir.
179
+ assert.ok(
180
+ fs.existsSync(path.join(cwd, 'phases')),
181
+ 'flat phases/ directory should still exist (archived from, not moved wholesale)'
182
+ );
183
+ assert.ok(result, 'Should return result JSON');
184
+ assert.equal(result.archived.phases, true, 'archived.phases should be true in flat mode');
185
+ });
186
+ });
@@ -5,7 +5,8 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { execSync } = require('child_process');
8
- const { escapeRegex, getMilestonePhaseFilter, getProjectRoot, output, error, loadConfig } = require('./core.cjs');
8
+ const { escapeRegex, getMilestonePhaseFilter, getProjectRoot, output, error, loadConfig, phasesDir, resolveMilestoneVersion, resolveProjectPath } = require('./core.cjs');
9
+ const { phaseInitVersionedDirInternal } = require('./phase.cjs');
9
10
  const { extractFrontmatter, spliceFrontmatter } = require('./frontmatter.cjs');
10
11
  const { getPlanningRoot } = require('./paths.cjs');
11
12
  const { writeStateMd, stateReadAdhoc } = require('./state.cjs');
@@ -118,7 +119,9 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
118
119
  const statePath = path.join(projectRoot, 'STATE.md');
119
120
  const milestonesPath = path.join(planRoot, 'MILESTONES.md');
120
121
  const archiveDir = path.join(planRoot, 'milestones');
121
- const phasesDir = path.join(projectRoot, 'phases');
122
+ const phasesAbs = path.join(cwd, phasesDir(cwd));
123
+ // Versioned layout → phases/<version>/ is milestone-scoped; flat → shared phases/ needs number-set filtering.
124
+ const versionedLayout = /^v\d+\.\d+$/.test(path.basename(phasesDir(cwd)));
122
125
  const today = new Date().toISOString().split('T')[0];
123
126
  const milestoneName = options.name || version;
124
127
 
@@ -128,6 +131,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
128
131
  // Scope stats and accomplishments to only the phases belonging to the
129
132
  // current milestone's ROADMAP. Uses the shared filter from core.cjs
130
133
  // (same logic used by cmdPhasesList and other callers).
134
+ // Version-aware as of NUM-04: returns a pass-all predicate under versioned
135
+ // layout, so the stats loop counts every entry in phases/<version>/.
131
136
  const isDirInMilestone = getMilestonePhaseFilter(cwd);
132
137
 
133
138
  // Gather stats from phases (scoped to current milestone only)
@@ -137,14 +142,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
137
142
  const accomplishments = [];
138
143
 
139
144
  try {
140
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
145
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
141
146
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
142
147
 
143
148
  for (const dir of dirs) {
144
149
  if (!isDirInMilestone(dir)) continue;
145
150
 
146
151
  phaseCount++;
147
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
152
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, dir));
148
153
  const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
149
154
  const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
150
155
  totalPlans += plans.length;
@@ -152,7 +157,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
152
157
  // Extract one-liners from summaries
153
158
  for (const s of summaries) {
154
159
  try {
155
- const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
160
+ const content = fs.readFileSync(path.join(phasesAbs, dir, s), 'utf-8');
156
161
  const fm = extractFrontmatter(content);
157
162
  if (fm['one-liner']) {
158
163
  accomplishments.push(fm['one-liner']);
@@ -302,17 +307,29 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
302
307
  if (options.archivePhases) {
303
308
  try {
304
309
  const phaseArchiveDir = path.join(archiveDir, `${version}-phases`);
305
- fs.mkdirSync(phaseArchiveDir, { recursive: true });
306
-
307
- const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
308
- const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name);
309
- let archivedCount = 0;
310
- for (const dir of phaseDirNames) {
311
- if (!isDirInMilestone(dir)) continue;
312
- fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
313
- archivedCount++;
310
+ if (versionedLayout) {
311
+ // NUM-03: phases/<version>/ is the whole milestone → archive in ONE move.
312
+ // Collision guard: never overwrite/merge an existing archive (no mkdir of dest
313
+ // renameSync of a dir requires the destination to NOT exist).
314
+ if (fs.existsSync(phaseArchiveDir)) {
315
+ error(`Cannot archive phases: ${phaseArchiveDir} already exists. Move or remove it, then re-run milestone complete --archive-phases.`);
316
+ return;
317
+ }
318
+ fs.renameSync(phasesAbs, phaseArchiveDir);
319
+ phasesArchived = true;
320
+ } else {
321
+ // Flat mode: shared phases/ — preserve the per-directory filtered move byte-for-byte.
322
+ fs.mkdirSync(phaseArchiveDir, { recursive: true });
323
+ const phaseEntries = fs.readdirSync(phasesAbs, { withFileTypes: true });
324
+ const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name);
325
+ let archivedCount = 0;
326
+ for (const dir of phaseDirNames) {
327
+ if (!isDirInMilestone(dir)) continue;
328
+ fs.renameSync(path.join(phasesAbs, dir), path.join(phaseArchiveDir, dir));
329
+ archivedCount++;
330
+ }
331
+ phasesArchived = archivedCount > 0;
314
332
  }
315
- phasesArchived = archivedCount > 0;
316
333
  } catch {}
317
334
  }
318
335
 
@@ -431,6 +448,46 @@ function _adhocTools(cwd, argStr) {
431
448
  }
432
449
  }
433
450
 
451
+ /**
452
+ * Seed an `## Active Milestone: <version>` section into ROADMAP.md (Decision A).
453
+ *
454
+ * Idempotent: if a `## Active Milestone: <version>` heading already exists, it
455
+ * returns false without writing. Otherwise it strips a placeholder
456
+ * `### 📋 Next Milestone — TBD` block (if present) and APPENDS the active section
457
+ * terminated by `\n---\n`, so the section's trailing separator becomes the file's
458
+ * LAST `\n---` — exactly the anchor cmdPhaseAdd inserts `### Phase N:` entries
459
+ * before. With zero `### Phase` headings present, the first add-phase computes
460
+ * maxPhase=0 → Phase 1 / 01-<slug> under phases/<version>/.
461
+ *
462
+ * Guard: if the roadmap file does not exist, returns false (create-adhoc's STATE
463
+ * existence check is the authoritative precondition; we never crash here).
464
+ *
465
+ * @returns {boolean} true if it wrote the section, false on no-op.
466
+ */
467
+ function seedAdhocRoadmapSection(roadmapPath, version, name) {
468
+ if (!fs.existsSync(roadmapPath)) return false;
469
+ let content = fs.readFileSync(roadmapPath, 'utf-8');
470
+ // Idempotent: already seeded for this version → no-op.
471
+ if (new RegExp('^##\\s+Active Milestone:\\s*' + escapeRegex(version) + '\\b', 'm').test(content)) {
472
+ return false;
473
+ }
474
+ // Strip a placeholder "### 📋 Next Milestone — TBD" heading-and-body block if
475
+ // present (consume up to the next heading / separator / EOF).
476
+ content = content.replace(
477
+ /\n?#{2,3}\s*(?:📋\s*)?Next Milestone\s*[—-]\s*TBD[\s\S]*?(?=\n#{1,3}\s|\n---|$)/,
478
+ ''
479
+ );
480
+ // Normalize trailing whitespace to a single newline, then append the section.
481
+ content = content.replace(/\s*$/, '\n');
482
+ const section =
483
+ '\n## Active Milestone: ' + version + ' ' + name + '\n\n' +
484
+ '**Goal:** ' + name + '\n' +
485
+ '**Status:** In progress (ad-hoc container — phases added on demand via /dgs:add-phase)\n\n' +
486
+ 'Phases:\n\n---\n';
487
+ fs.writeFileSync(roadmapPath, content + section, 'utf-8');
488
+ return true;
489
+ }
490
+
434
491
  /**
435
492
  * Create an ad-hoc milestone container atomically (Phase 159).
436
493
  *
@@ -508,13 +565,34 @@ function cmdMilestoneCreateAdhoc(cwd, args, raw) {
508
565
  let refCreated = false;
509
566
  let worktreesCreated = false;
510
567
  let stateCommitHash = null;
511
- const statePath = path.join(planningRoot, getProjectRoot(cwd), 'STATE.md');
568
+ // Decision D — seeded-scaffolding rollback bookkeeping. The versioned dir is
569
+ // untracked (empty at creation) so a commit-reset never removes it; rollback
570
+ // drops it explicitly.
571
+ let versionedDirCreated = false;
572
+ let versionedDirAbs = null;
573
+ const projRootRel = getProjectRoot(cwd);
574
+ const statePath = path.join(planningRoot, projRootRel, 'STATE.md');
575
+ const roadmapPath = path.join(planningRoot, projRootRel, 'ROADMAP.md');
512
576
 
513
577
  const rollback = (reasonMsg) => {
514
578
  // Reverse order. active_context is set LAST so a pre-step-6 failure never set it.
579
+ // (i) Drop the seeded versioned dir first — it is untracked, so neither the
580
+ // commit-reset nor a doc restore would remove it.
581
+ if (versionedDirCreated && versionedDirAbs) {
582
+ try { fs.rmSync(versionedDirAbs, { recursive: true, force: true }); } catch {}
583
+ }
515
584
  if (stateCommitHash) {
516
- // Reset the just-made STATE commit (not yet pushed at this point).
585
+ // Post-commit failure: the reset reverts BOTH staged docs (STATE + ROADMAP).
517
586
  _adhocGit(planningRoot, ['reset', '--hard', stateCommitHash + '^']);
587
+ } else {
588
+ // Pre-commit failure (5c versioned-dir / 5d add-or-commit): the on-disk
589
+ // current_milestone + Active Milestone section were written but never
590
+ // committed. Restore both docs from HEAD so NO residue survives — this
591
+ // makes the "no residue on any step-5 failure" guarantee hold literally
592
+ // regardless of which window the failure hits (plan-check advisory).
593
+ _adhocGit(planningRoot, ['checkout', 'HEAD', '--',
594
+ path.relative(planningRoot, statePath),
595
+ path.relative(planningRoot, roadmapPath)]);
518
596
  }
519
597
  if (worktreesCreated) {
520
598
  _adhocTools(cwd, 'worktrees remove ' + JSON.stringify(slug) + ' --force');
@@ -522,7 +600,7 @@ function cmdMilestoneCreateAdhoc(cwd, args, raw) {
522
600
  if (refCreated) {
523
601
  _adhocGit(planningRoot, ['update-ref', '-d', baseRef]);
524
602
  }
525
- error(reasonMsg + ' — rolled back (no worktree, no base ref, no STATE marker, active_context unset).');
603
+ error(reasonMsg + ' — rolled back (no versioned dir, no roadmap section, no current_milestone, no worktree, no base ref, no STATE marker, active_context unset).');
526
604
  };
527
605
 
528
606
  // ── Step 2 — capture planning base ref (ADH-06). ──
@@ -549,7 +627,10 @@ function cmdMilestoneCreateAdhoc(cwd, args, raw) {
549
627
  } catch { entryOk = false; }
550
628
  if (!entryOk) rollback('Worktree entry missing adhoc marker / base-ref');
551
629
 
552
- // ── Step 5 — commit STATE.md with adhoc:true + milestone vX.Y (ADH-04 primary). ──
630
+ // ── Step 5 — seed planning scaffolding + commit STATE.md & ROADMAP.md atomically
631
+ // (ADH-04 primary + Decision D). Folds current_milestone (5a), the Active
632
+ // Milestone ROADMAP section (5b), and the versioned phases dir (5c) into ONE
633
+ // commit (5d), all undone in reverse by rollback(). ──
553
634
  if (!fs.existsSync(statePath)) rollback('STATE.md not found at ' + statePath);
554
635
  try {
555
636
  const content = fs.readFileSync(statePath, 'utf-8');
@@ -558,17 +639,37 @@ function cmdMilestoneCreateAdhoc(cwd, args, raw) {
558
639
  fm.milestone_name = name;
559
640
  fm.status = 'executing';
560
641
  fm.adhoc = true;
642
+ // 5a — the authoritative version signal. The verb already validated/normalized
643
+ // `version` to a grammar-valid vX.Y, so create-adhoc is a SOLE setter of it.
644
+ fm.current_milestone = version;
561
645
  const newContent = spliceFrontmatter(content, fm);
562
646
  fs.writeFileSync(statePath, newContent, 'utf-8');
563
647
  } catch (e) {
564
648
  rollback('Failed to write STATE.md adhoc marker: ' + e.message);
565
649
  }
650
+
651
+ // 5b — seed the ROADMAP active-milestone section (idempotent, guarded).
652
+ seedAdhocRoadmapSection(roadmapPath, version, name);
653
+
654
+ // 5c — create phases/<version>/ via the SHARED versioned-dir creator. Track
655
+ // whether THIS call materialized it so rollback can drop the untracked dir.
656
+ try {
657
+ const r = phaseInitVersionedDirInternal(cwd);
658
+ versionedDirAbs = path.join(cwd, r.directory);
659
+ versionedDirCreated = r.created;
660
+ } catch (e) {
661
+ rollback('Failed to create versioned phases dir: ' + e.message);
662
+ }
663
+
664
+ // 5d — stage BOTH docs (STATE.md + ROADMAP.md if present) and commit atomically.
566
665
  const stateRel = path.relative(planningRoot, statePath);
567
- const addRes = _adhocGit(planningRoot, ['add', stateRel]);
568
- if (!addRes.ok) rollback('Failed to stage STATE.md: ' + addRes.stderr);
666
+ const stagePaths = [stateRel];
667
+ if (fs.existsSync(roadmapPath)) stagePaths.push(path.relative(planningRoot, roadmapPath));
668
+ const addRes = _adhocGit(planningRoot, ['add'].concat(stagePaths));
669
+ if (!addRes.ok) rollback('Failed to stage planning docs: ' + addRes.stderr);
569
670
  const commitRes = _adhocGit(planningRoot, ['commit', '-m',
570
671
  'feat(milestone): establish ad-hoc container ' + slug + ' (' + version + ')']);
571
- if (!commitRes.ok) rollback('Failed to commit STATE.md: ' + commitRes.stderr);
672
+ if (!commitRes.ok) rollback('Failed to commit planning docs: ' + commitRes.stderr);
572
673
  const hashRes = _adhocGit(planningRoot, ['rev-parse', 'HEAD']);
573
674
  stateCommitHash = hashRes.ok ? hashRes.stdout : null;
574
675
 
@@ -671,6 +772,20 @@ function cmdAbandonMilestone(cwd, args, raw) {
671
772
  error('Planning docs have uncommitted/staged edits (' + dirty.stdout.trim() + '). Commit or discard them first, then retry /dgs:abandon-milestone. No changes made.');
672
773
  }
673
774
 
775
+ // ── Decision E — resolve the seeded versioned phases dir (phases/<version>/) EARLY,
776
+ // from the still-present current_milestone (the 6b restore wipes it). It is removed
777
+ // in teardown so abandon leaves no versioned residue. Guarded/non-fatal: a no-op when
778
+ // the version is undeterminable or the dir is absent. ──
779
+ let versionedAbs = null, relVersioned = null;
780
+ try {
781
+ const v = resolveMilestoneVersion(cwd);
782
+ if (v) {
783
+ const base = resolveProjectPath(cwd, 'phases');
784
+ const abs = path.join(planningRoot, base, v);
785
+ if (fs.existsSync(abs)) { versionedAbs = abs; relVersioned = path.relative(planningRoot, abs); }
786
+ }
787
+ } catch { /* undeterminable version — leave versionedAbs null (no-op) */ }
788
+
674
789
  // ── Step 5 — delete the OWNED pushed milestone branch on origin (ADH-20).
675
790
  // For each repo whose remote-tracking ref exists, run a NON-FATAL
676
791
  // `git push origin --delete milestone/<slug>` so the milestone name is
@@ -706,6 +821,20 @@ function cmdAbandonMilestone(cwd, args, raw) {
706
821
  'Re-run /dgs:abandon-milestone after resolving, or remove the worktree manually.');
707
822
  }
708
823
 
824
+ // Decision E — remove the seeded versioned phases dir (phases/<version>/) so abandon
825
+ // leaves no versioned residue. Stage any TRACKED phase files for deletion so they land
826
+ // in the 6c attributed reversion commit. Non-fatal/guarded: abandon still reports
827
+ // abandoned:true when the dir was already absent (the seeded dir is normally empty +
828
+ // untracked, so this is a pure on-disk cleanup unless real phases were added/committed).
829
+ let phasesDirRemoved = null;
830
+ if (versionedAbs) {
831
+ try { fs.rmSync(versionedAbs, { recursive: true, force: true }); } catch {}
832
+ if (relVersioned) {
833
+ _adhocGit(planningRoot, ['add', '-A', '--', relVersioned]);
834
+ phasesDirRemoved = relVersioned;
835
+ }
836
+ }
837
+
709
838
  // 6b. Path-scoped restore of exactly the present docs (ADH-09). NEVER a
710
839
  // whole-tree reset / checkout <ref> .
711
840
  if (present.length > 0) {
@@ -720,29 +849,30 @@ function cmdAbandonMilestone(cwd, args, raw) {
720
849
  reverted.docs = true; // nothing to restore
721
850
  }
722
851
 
723
- // 6c. Attributed reversion commit (ADH-20). Skip gracefully if the restore
724
- // produced no staged change (base === current for these docs).
852
+ // 6c. Attributed reversion commit (ADH-20). The commit guard is the STAGED state,
853
+ // not present.length, so it also fires when the ONLY change is the versioned-dir
854
+ // deletion staged above. Skips gracefully if nothing is staged (base === current
855
+ // for the docs AND no tracked versioned files existed).
725
856
  if (present.length > 0) {
726
857
  const addRes = _adhocGit(planningRoot, ['add', '--'].concat(present));
727
858
  if (!addRes.ok) {
728
859
  error('Docs restored but failed to stage them: ' + addRes.stderr +
729
860
  '. Reverted so far: ' + JSON.stringify(reverted) + '. Recoverable — stage and commit the restored docs manually.');
730
861
  }
731
- const hasStaged = !_adhocGit(planningRoot, ['diff', '--cached', '--quiet']).ok;
732
- if (hasStaged) {
733
- const author = formatAuthorString(requireGitIdentity(cwd));
734
- const commitRes = _adhocGit(planningRoot, ['commit', '--author', author, '-m',
735
- 'revert(milestone): abandon ad-hoc ' + slug + ' — restore project docs to pre-milestone state']);
736
- reverted.committed = commitRes.ok;
737
- if (!commitRes.ok) {
738
- error('Docs restored + staged but commit failed: ' + commitRes.stderr +
739
- '. Reverted so far: ' + JSON.stringify(reverted) + '. Recoverable — commit the staged docs manually.');
740
- }
741
- } else {
742
- // No diff to commit — restore was a no-op (base == current). Treated as success.
743
- reverted.committed = true;
862
+ }
863
+ const hasStaged = !_adhocGit(planningRoot, ['diff', '--cached', '--quiet']).ok;
864
+ if (hasStaged) {
865
+ const author = formatAuthorString(requireGitIdentity(cwd));
866
+ const commitRes = _adhocGit(planningRoot, ['commit', '--author', author, '-m',
867
+ 'revert(milestone): abandon ad-hoc ' + slug + ' — restore project docs to pre-milestone state']);
868
+ reverted.committed = commitRes.ok;
869
+ if (!commitRes.ok) {
870
+ error('Docs restored + staged but commit failed: ' + commitRes.stderr +
871
+ '. Reverted so far: ' + JSON.stringify(reverted) + '. Recoverable — commit the staged docs manually.');
744
872
  }
745
873
  } else {
874
+ // Nothing staged — restore was a no-op (base == current) and no tracked versioned
875
+ // files existed. Treated as success.
746
876
  reverted.committed = true;
747
877
  }
748
878
 
@@ -761,6 +891,7 @@ function cmdAbandonMilestone(cwd, args, raw) {
761
891
  base_ref: baseRef,
762
892
  restored: present,
763
893
  reverted,
894
+ phases_dir_removed: phasesDirRemoved,
764
895
  base_ref_deleted: cleanup.ref_deleted,
765
896
  warnings,
766
897
  }, raw);
@@ -622,7 +622,10 @@ function addMalformedQuickRow(planDir) {
622
622
  * disk_status==='complete'; complete=false → PLAN.md only (planned, not counted).
623
623
  */
624
624
  function addCompletedPhase(planDir, { complete }) {
625
- const phaseDir = path.join(planDir, 'projects', 'tp', 'phases', '01-foo');
625
+ // setupAdhoc seeds phases/v0.1/ and current_milestone:v0.1, so the version-aware
626
+ // resolver now routes phase lookups into phases/v0.1/ — write the fixture phase
627
+ // there (not the flat phases/) so adhoc-readiness enumerates it.
628
+ const phaseDir = path.join(planDir, 'projects', 'tp', 'phases', 'v0.1', '01-foo');
626
629
  fs.mkdirSync(phaseDir, { recursive: true });
627
630
  fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
628
631
  if (complete) {
@@ -854,3 +857,112 @@ describe('milestone cleanup-adhoc / lifecycle cleanup', () => {
854
857
  assert.ok(/abandon-milestone/.test(doc), 'claude-md.md must mention abandon-milestone');
855
858
  });
856
859
  });
860
+
861
+ // ─── ad-hoc full lifecycle seeding (quick-260627-mv4) ────────────────────────
862
+ //
863
+ // Proves create-adhoc seeds the planning scaffolding that lets a hand-added
864
+ // phase flow through discuss/plan/execute-phase: current_milestone + the
865
+ // versioned phases dir + an Active Milestone ROADMAP section, all atomic and
866
+ // rolled back together; that the first add-phase lands 01-<slug> UNDER
867
+ // phases/<version>/ (restart-at-1); that the roadmap seed is idempotent; that
868
+ // abandon removes the versioned dir + restores the docs; and that a precondition
869
+ // failure leaves the first milestone's seed intact with no new residue.
870
+
871
+ describe('ad-hoc full lifecycle seeding', () => {
872
+ let env;
873
+ beforeEach(() => { env = createAbandonEnv(); });
874
+ afterEach(() => { if (env) env.cleanup(); });
875
+
876
+ it('SEED: create-adhoc writes current_milestone + phases/<version>/ + Active Milestone section', () => {
877
+ setupAdhoc(env.planDir);
878
+
879
+ // (a) STATE.md frontmatter carries the authoritative current_milestone signal.
880
+ const stateContent = fs.readFileSync(path.join(env.planDir, _STATE_PATH_REL), 'utf-8');
881
+ assert.match(stateContent, /current_milestone:\s*v0\.1\b/, 'STATE.md must carry current_milestone: v0.1');
882
+
883
+ // (b) the versioned phases dir exists on disk.
884
+ assert.ok(
885
+ fs.existsSync(path.join(env.planDir, 'projects', 'tp', 'phases', 'v0.1')),
886
+ 'projects/tp/phases/v0.1/ must exist after create-adhoc'
887
+ );
888
+
889
+ // (c) ROADMAP.md has the Active Milestone section terminated by a --- anchor.
890
+ const roadmap = fs.readFileSync(path.join(env.planDir, _ROADMAP_PATH_REL), 'utf-8');
891
+ assert.match(roadmap, /^##\s+Active Milestone:\s*v0\.1\b/m, 'ROADMAP must have ## Active Milestone: v0.1');
892
+ assert.ok(roadmap.trimEnd().endsWith('---'), 'ROADMAP must end with a --- anchor for add-phase');
893
+ });
894
+
895
+ it('RESTART-AT-1: first add-phase lands 01-<slug> UNDER phases/v0.1/ (milestone-local)', () => {
896
+ setupAdhoc(env.planDir);
897
+
898
+ const res = abandonRun(env.planDir, 'phase add ' + JSON.stringify('do a thing'));
899
+ assert.equal(res.padded, '01', 'first add-phase under an empty milestone restarts at 01');
900
+ const dirPosix = res.directory.split(path.sep).join('/');
901
+ assert.ok(
902
+ dirPosix.includes('phases/v0.1/01-'),
903
+ 'add-phase directory must be under phases/v0.1/ : ' + res.directory
904
+ );
905
+ assert.ok(fs.existsSync(path.join(env.planDir, res.directory)), 'the 01- phase dir must exist on disk');
906
+ });
907
+
908
+ it('IDEMPOTENT ROADMAP SEED: exactly one ## Active Milestone: v0.1 after create', () => {
909
+ setupAdhoc(env.planDir);
910
+ const roadmap = fs.readFileSync(path.join(env.planDir, _ROADMAP_PATH_REL), 'utf-8');
911
+ const occurrences = (roadmap.match(/^##\s+Active Milestone:\s*v0\.1\b/gm) || []).length;
912
+ assert.equal(occurrences, 1, 'the Active Milestone section must not be duplicated');
913
+ });
914
+
915
+ it('ABANDON REMOVES VERSIONED DIR: phases/v0.1/ gone + docs restored to pre-milestone', () => {
916
+ setupAdhoc(env.planDir);
917
+ const result = abandonRun(env.planDir, 'milestone abandon --confirmed');
918
+ assert.equal(result.abandoned, true);
919
+ // The removed versioned dir is reported.
920
+ assert.match(String(result.phases_dir_removed || ''), /phases[\\/]v0\.1$/, 'phases_dir_removed should name the versioned dir');
921
+
922
+ // Versioned dir is gone from disk.
923
+ assert.ok(
924
+ !fs.existsSync(path.join(env.planDir, 'projects', 'tp', 'phases', 'v0.1')),
925
+ 'phases/v0.1/ must be removed by abandon'
926
+ );
927
+ // ROADMAP restored: no Active Milestone section.
928
+ const roadmap = fs.readFileSync(path.join(env.planDir, _ROADMAP_PATH_REL), 'utf-8');
929
+ assert.ok(!/##\s+Active Milestone/.test(roadmap), 'ROADMAP must no longer contain an Active Milestone section');
930
+ // STATE restored: no current_milestone.
931
+ const stateContent = fs.readFileSync(path.join(env.planDir, _STATE_PATH_REL), 'utf-8');
932
+ assert.ok(!/current_milestone:/.test(stateContent), 'STATE.md must no longer carry current_milestone');
933
+ });
934
+
935
+ it('ROLLBACK-NO-RESIDUE: a precondition failure leaves the first seed intact and adds nothing', () => {
936
+ const { slug } = setupAdhoc(env.planDir);
937
+
938
+ // A second create-adhoc while a context is active fails at the precondition
939
+ // guard (before any mutation). The first milestone's seed must be untouched
940
+ // and NO second versioned dir / dirty doc residue may appear.
941
+ const out = abandonRunFail(env.planDir, 'milestone create-adhoc ' + JSON.stringify('second one') + ' --version v0.2');
942
+ assert.ok(out, 'second create-adhoc must fail while a context is active');
943
+
944
+ // First seed intact.
945
+ const stateContent = fs.readFileSync(path.join(env.planDir, _STATE_PATH_REL), 'utf-8');
946
+ assert.match(stateContent, /current_milestone:\s*v0\.1\b/, 'first current_milestone must survive');
947
+ assert.ok(fs.existsSync(path.join(env.planDir, 'projects', 'tp', 'phases', 'v0.1')), 'first phases/v0.1/ must survive');
948
+
949
+ // No residue from the failed attempt.
950
+ assert.ok(!fs.existsSync(path.join(env.planDir, 'projects', 'tp', 'phases', 'v0.2')), 'no phases/v0.2/ residue');
951
+ const roadmap = fs.readFileSync(path.join(env.planDir, _ROADMAP_PATH_REL), 'utf-8');
952
+ assert.equal(
953
+ (roadmap.match(/^##\s+Active Milestone:/gm) || []).length, 1,
954
+ 'no duplicate / second Active Milestone section from the failed attempt'
955
+ );
956
+
957
+ // active_context unchanged.
958
+ const cfg = readLocal(env.planDir);
959
+ assert.equal(cfg.execution.active_context, slug, 'active_context must be unchanged after a failed create');
960
+
961
+ // Planning tree carries no dirty STATE.md/ROADMAP.md residue (rollback no-residue guarantee).
962
+ const porcelain = _execSync(
963
+ 'git status --porcelain -- ' + JSON.stringify(_STATE_PATH_REL) + ' ' + JSON.stringify(_ROADMAP_PATH_REL),
964
+ { cwd: env.planDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
965
+ ).trim();
966
+ assert.equal(porcelain, '', 'STATE.md/ROADMAP.md must not be left dirty after a failed create-adhoc');
967
+ });
968
+ });