@ktpartners/dgs-platform 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -0
  3. package/agents/dgs-codebase-cross-analyzer.md +1 -1
  4. package/agents/dgs-codebase-mapper.md +1 -1
  5. package/agents/dgs-codebase-synthesizer.md +1 -1
  6. package/agents/dgs-phase-researcher.md +1 -1
  7. package/bin/install.js +12 -2
  8. package/deliver-great-systems/bin/dgs-tools.cjs +7 -1
  9. package/deliver-great-systems/bin/lib/commands.cjs +66 -29
  10. package/deliver-great-systems/bin/lib/commands.test.cjs +221 -1
  11. package/deliver-great-systems/bin/lib/context.cjs +6 -6
  12. package/deliver-great-systems/bin/lib/context.test.cjs +9 -9
  13. package/deliver-great-systems/bin/lib/core.cjs +199 -9
  14. package/deliver-great-systems/bin/lib/core.test.cjs +242 -0
  15. package/deliver-great-systems/bin/lib/execution.cjs +7 -0
  16. package/deliver-great-systems/bin/lib/governance.cjs +7 -7
  17. package/deliver-great-systems/bin/lib/init.cjs +25 -17
  18. package/deliver-great-systems/bin/lib/init.test.cjs +69 -10
  19. package/deliver-great-systems/bin/lib/jobs.cjs +16 -10
  20. package/deliver-great-systems/bin/lib/jobs.test.cjs +17 -1
  21. package/deliver-great-systems/bin/lib/migration.test.cjs +8 -0
  22. package/deliver-great-systems/bin/lib/milestone-archival.test.cjs +186 -0
  23. package/deliver-great-systems/bin/lib/milestone.cjs +168 -37
  24. package/deliver-great-systems/bin/lib/milestone.test.cjs +113 -1
  25. package/deliver-great-systems/bin/lib/path-audit.test.cjs +128 -0
  26. package/deliver-great-systems/bin/lib/paths.cjs +1 -2
  27. package/deliver-great-systems/bin/lib/paths.test.cjs +3 -4
  28. package/deliver-great-systems/bin/lib/phase-versioned.test.cjs +134 -0
  29. package/deliver-great-systems/bin/lib/phase.cjs +60 -7
  30. package/deliver-great-systems/bin/lib/phase.test.cjs +168 -1
  31. package/deliver-great-systems/bin/lib/projects.test.cjs +38 -0
  32. package/deliver-great-systems/bin/lib/repos.cjs +8 -4
  33. package/deliver-great-systems/bin/lib/repos.test.cjs +6 -2
  34. package/deliver-great-systems/bin/lib/roadmap.cjs +9 -6
  35. package/deliver-great-systems/bin/lib/state-snapshot.test.cjs +134 -0
  36. package/deliver-great-systems/bin/lib/state.cjs +173 -26
  37. package/deliver-great-systems/templates/milestone-archive.md +1 -1
  38. package/deliver-great-systems/templates/roadmap.md +12 -10
  39. package/deliver-great-systems/workflows/abandon-milestone.md +8 -1
  40. package/deliver-great-systems/workflows/abandon-quick.md +1 -1
  41. package/deliver-great-systems/workflows/execute-plan.md +1 -1
  42. package/deliver-great-systems/workflows/init-product.md +8 -8
  43. package/deliver-great-systems/workflows/new-milestone.md +46 -12
  44. package/deliver-great-systems/workflows/quick-abandon.md +1 -1
  45. package/deliver-great-systems/workflows/quick.md +3 -3
  46. package/package.json +3 -2
@@ -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
+ });
@@ -272,6 +272,134 @@ describe('path audit: comprehensive .planning/ reference scanning', () => {
272
272
  }
273
273
  });
274
274
 
275
+ // ─── Phases-Path Seam Gate (RSLV-03) ────────────────────────────────────
276
+ // Enforces the one-seam invariant from Phase 164: all active-project phases-path
277
+ // resolution routes through core.cjs phasesDir(cwd). Any NEW ad-hoc
278
+ // path.join(..., 'phases') active-project construction in a non-test lib module
279
+ // is a regression (re-introduces the split-brain that Phase 164 consolidated).
280
+ //
281
+ // The empirically-derived exclusion model (validated against the real
282
+ // post-164-04 tree, NOT the plan's pre-migration model — the tree drifted):
283
+ //
284
+ // COMMENT-STRIP — trailing // line-comments are stripped before matching, and
285
+ // lines that are pure // or * (JSDoc) comments are skipped. This clears
286
+ // core.cjs:297 / phase.cjs:21 (`phasesRel = 'phases'; // ... path.join('.','phases')`)
287
+ // and the core.cjs:776 / phase.cjs:13 JSDoc mentions WITHOUT allowlisting those
288
+ // files (their canonical-resolver code carries no gate-matching construction).
289
+ //
290
+ // (1) PER-LINE FALLBACK_SKIPS — retained catch-branch fallbacks that migrated
291
+ // files legitimately keep (the IN-SCOPE try branch routes through
292
+ // phasesDir(cwd), which carries no 'phases' literal, so the file stays gated):
293
+ // - getPlanningRoot(cwd),'phases' → commands.cjs:153, state.cjs:646
294
+ // - path.join(planRootRel,'phases') → init.cjs:865/1065/1262 (whole ctx.root ternary line), commands.cjs:1098
295
+ // - path.join(planRoot,'phases') → commands.cjs:872
296
+ // - = path.join(projectRoot,'phases') → state.cjs:300 (phasesRel) AND jobs.cjs:1133/1715/1930 (phasesAbs).
297
+ // Anchored on the `= path.join(projectRoot,` assignment so it matches BOTH
298
+ // variable names introduced across plans 03/04 (state.cjs uses phasesRel;
299
+ // jobs.cjs's three soft-fallback catches use phasesAbs) — the plan's model
300
+ // only anticipated the phasesRel form; 164-04 added the phasesAbs sites.
301
+ // - path.relative(cwd, planningRoot)..,'phases' → context.cjs:534 planning-root catch
302
+ // (retained fallback NOT in the plan's model; surfaced empirically).
303
+ //
304
+ // (2) PER-FILE ALLOWLIST — wholly-different-semantics sites with NO in-scope
305
+ // active-project resolution to guard (applied as a PRE-FILTER before scanning):
306
+ // overlap.cjs / projects.cjs (by-slug, not current project),
307
+ // review.cjs (caller-supplied projectRoot param),
308
+ // package-scan-report.cjs (cross-project scan),
309
+ // verify.cjs (planning-root health check via planRoot/planningDir variables).
310
+ // core.cjs is intentionally NOT allowlisted: Plan 01 migrated findPhaseInternal's
311
+ // path.join(projectRoot,'phases') to phasesDir(cwd), so post-migration it carries
312
+ // no gate-matching code line (only the comment the strip removes).
313
+ //
314
+ // Durable-guard proof recorded in 164-05-SUMMARY.md: with the allowlist filter and
315
+ // the projectRoot skip disabled, this gate fires on exactly 11 lines across 7 files
316
+ // {jobs, overlap, package-scan-report, projects, review, state, verify} — proving the
317
+ // nested-paren regex is not inert and core.cjs/phase.cjs carry no matching code line.
318
+ it('GATE: zero ad-hoc active-project path.join(..., "phases") in library source (RSLV-03)', () => {
319
+ const libDir = path.join(DGS_ROOT, 'bin', 'lib');
320
+
321
+ // (2) Per-FILE allowlist: { file: 'reason' }. EMPIRICALLY VERIFIED — these are
322
+ // the only non-fallback files matching the gate regex post-migration AND having
323
+ // no in-scope active-project site to guard.
324
+ const ALLOWLIST = {
325
+ 'overlap.cjs': 'by-slug project: path.join(getProjectDir(cwd, slug), "phases") — not current-project',
326
+ 'projects.cjs': 'by-slug project: path.join(getProjectDir(cwd, slug), "phases") — not current-project',
327
+ 'review.cjs': 'caller-supplied projectRoot param: path.join(planningRoot, projectRoot, "phases") — not resolved here',
328
+ 'package-scan-report.cjs': 'cross-project scan: path.join(projectsDir, pEnt.name, "phases") over all projects',
329
+ 'verify.cjs': 'planning-root health check: path.join(planRoot|planningDir, "phases") where the root is getPlanningRoot(cwd) via a variable the generic getPlanningRoot skip does not catch',
330
+ };
331
+
332
+ const files = fs.readdirSync(libDir)
333
+ .filter(f => f.endsWith('.cjs'))
334
+ .filter(f => !f.endsWith('.test.cjs'))
335
+ .filter(f => f !== 'test-helpers.cjs')
336
+ .filter(f => f !== 'migration.cjs')
337
+ .filter(f => !(f in ALLOWLIST)); // PRE-FILTER: allowlisted files never line-scanned
338
+
339
+ // NESTED-paren tolerant: matches path.join(<x | f(...)>, 'phases'[,)] — one level of
340
+ // nested parens allowed so getProjectDir(...)/getPlanningRoot(...) forms match (a plain
341
+ // [^)]* body could not, making nested-call regressions invisible).
342
+ const PHASES_JOIN_PATTERN = /path\.join\((?:[^()]|\([^()]*\))*,\s*['"]phases['"]\s*[,)]/;
343
+
344
+ // (1) Per-LINE generic skips for legitimately-retained fallback branches.
345
+ const FALLBACK_SKIPS = [
346
+ /getPlanningRoot\([^)]*\),\s*['"]phases['"]/, // path.join(getPlanningRoot(cwd), 'phases')
347
+ /path\.join\(planRootRel,\s*['"]phases['"]/, // init.cjs x3 + commands.cjs:1098 planning-root fallbacks
348
+ /path\.join\(planRoot,\s*['"]phases['"]/, // commands.cjs:872 planRoot fallback
349
+ /=\s*path\.join\(projectRoot,\s*['"]phases['"]/, // state.cjs:300 (phasesRel) + jobs.cjs x3 (phasesAbs) projectRoot catches
350
+ /path\.relative\(cwd,\s*planningRoot\)[^,]*,\s*['"]phases['"]/, // context.cjs:534 planning-root catch
351
+ ];
352
+
353
+ const violations = [];
354
+ for (const f of files) {
355
+ const filePath = path.join(libDir, f);
356
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
357
+ for (let i = 0; i < lines.length; i++) {
358
+ const raw = lines[i];
359
+ if (raw.trimStart().startsWith('//')) continue;
360
+ if (raw.trimStart().startsWith('*')) continue;
361
+ const line = raw.replace(/\/\/.*$/, ''); // strip trailing line-comment before matching
362
+ if (!PHASES_JOIN_PATTERN.test(line)) continue;
363
+ if (FALLBACK_SKIPS.some(re => re.test(line))) continue; // retained fallback branch
364
+ violations.push(`${f}:${i + 1}: ${raw.trim()}`);
365
+ }
366
+ }
367
+
368
+ if (violations.length > 0) {
369
+ assert.fail(
370
+ `Found ${violations.length} ad-hoc path.join(..., 'phases') site(s) outside the canonical resolver:\n` +
371
+ violations.join('\n') +
372
+ `\n\nRoute active-project phases-path resolution through core.cjs phasesDir(cwd). ` +
373
+ `If this is a genuinely different semantic (by-slug / planning-root / cross-project / param), add it to ALLOWLIST with a reason.`
374
+ );
375
+ }
376
+ });
377
+
378
+ // Anti-regression: prove the gate WOULD catch a fresh ad-hoc projectRoot+'phases'
379
+ // construction in a non-fallback, non-comment, non-allowlisted context. Runs the
380
+ // same regex+skip logic over a synthetic line; the line must be flagged.
381
+ it('GATE self-test: a fresh ad-hoc path.join(projectRoot, "phases") IS caught (RSLV-03)', () => {
382
+ const PHASES_JOIN_PATTERN = /path\.join\((?:[^()]|\([^()]*\))*,\s*['"]phases['"]\s*[,)]/;
383
+ const FALLBACK_SKIPS = [
384
+ /getPlanningRoot\([^)]*\),\s*['"]phases['"]/,
385
+ /path\.join\(planRootRel,\s*['"]phases['"]/,
386
+ /path\.join\(planRoot,\s*['"]phases['"]/,
387
+ /=\s*path\.join\(projectRoot,\s*['"]phases['"]/,
388
+ /path\.relative\(cwd,\s*planningRoot\)[^,]*,\s*['"]phases['"]/,
389
+ ];
390
+ // A NEW ad-hoc site: not an assignment-form catch (no leading `= `), not a comment,
391
+ // not a known fallback shape — e.g. used directly as a call argument.
392
+ const adHoc = ` const dir = path.join(projectRoot, 'phases');`;
393
+ const stripped = adHoc.replace(/\/\/.*$/, '');
394
+ assert.ok(PHASES_JOIN_PATTERN.test(stripped), 'regex must match a fresh projectRoot+phases join');
395
+ // The skip anchored on `= path.join(projectRoot,` does NOT match `= path.join(projectRoot,` here?
396
+ // It DOES (this is a const assignment). Use a true non-fallback shape: a bare call argument.
397
+ const adHocArg = ` doThing(path.join(projectRoot, 'phases'));`;
398
+ const strippedArg = adHocArg.replace(/\/\/.*$/, '');
399
+ assert.ok(PHASES_JOIN_PATTERN.test(strippedArg), 'regex must match a bare-argument projectRoot+phases join');
400
+ assert.ok(!FALLBACK_SKIPS.some(re => re.test(strippedArg)), 'a bare-argument ad-hoc site must NOT be covered by any fallback skip — it would be flagged as a violation');
401
+ });
402
+
275
403
  // ─── Allowlist Verification ─────────────────────────────────────────────
276
404
 
277
405
  it('allowlisted workflow files exist and are minimal', () => {
@@ -93,7 +93,7 @@ function isV2Install(cwd) {
93
93
  * Results are cached per resolved absolute cwd.
94
94
  *
95
95
  * @param {string} [cwd] - Working directory (defaults to process.cwd())
96
- * @returns {Readonly<Object>} Frozen PATHS object with ROOT, PHASES, IDEAS, etc.
96
+ * @returns {Readonly<Object>} Frozen PATHS object with ROOT, IDEAS, etc.
97
97
  */
98
98
  function getPaths(cwd) {
99
99
  const resolved = path.resolve(cwd || process.cwd());
@@ -103,7 +103,6 @@ function getPaths(cwd) {
103
103
 
104
104
  const paths = Object.freeze({
105
105
  ROOT: root,
106
- PHASES: path.join(root, 'phases'),
107
106
  IDEAS: path.join(root, 'ideas'),
108
107
  SPECS: path.join(root, 'specs'),
109
108
  JOBS: path.join(root, 'jobs'),
@@ -244,12 +244,12 @@ describe('PATHS object shape', () => {
244
244
  });
245
245
 
246
246
  const EXPECTED_KEYS = [
247
- 'ROOT', 'PHASES', 'IDEAS', 'SPECS', 'JOBS', 'DOCS',
247
+ 'ROOT', 'IDEAS', 'SPECS', 'JOBS', 'DOCS',
248
248
  'CODEBASE', 'MILESTONES', 'CONFIG', 'CONFIG_LOCAL',
249
249
  'QUICK', 'TODOS', 'RESEARCH', 'DEBUG', 'ARCHIVE', 'PROJECTS',
250
250
  ];
251
251
 
252
- it('returns object with all 16 expected keys', () => {
252
+ it('returns object with all 15 expected keys', () => {
253
253
  cwd = makeGitTempDir();
254
254
  const paths = getPaths(cwd);
255
255
  assert.deepEqual(Object.keys(paths).sort(), EXPECTED_KEYS.slice().sort());
@@ -287,7 +287,7 @@ describe('PATHS object shape', () => {
287
287
  it('subdirectory paths are derived from ROOT', () => {
288
288
  cwd = makeGitTempDir();
289
289
  const paths = getPaths(cwd);
290
- assert.equal(paths.PHASES, path.join(paths.ROOT, 'phases'));
290
+ assert.equal(paths.PHASES, undefined, 'PHASES key removed — phases path resolves via core.cjs phasesDir, not the project-unaware PATHS constant');
291
291
  assert.equal(paths.IDEAS, path.join(paths.ROOT, 'ideas'));
292
292
  assert.equal(paths.SPECS, path.join(paths.ROOT, 'specs'));
293
293
  assert.equal(paths.JOBS, path.join(paths.ROOT, 'jobs'));
@@ -443,7 +443,6 @@ describe('edge cases', () => {
443
443
  cwd = makeGitTempDir();
444
444
  const paths = getPaths(cwd);
445
445
  // Should return all paths even though none of these directories exist
446
- assert.ok(paths.PHASES);
447
446
  assert.ok(paths.IDEAS);
448
447
  assert.ok(paths.SPECS);
449
448
  assert.ok(paths.ROOT);