@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
@@ -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);
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Restart-at-1 behaviour test (NUM-01).
3
+ *
4
+ * Dedicated file (separate from phase.test.cjs, which plan 02 owns) proving that a
5
+ * fresh versioned milestone's FIRST add-phase is numbered `01` (zero-padded) and
6
+ * lands UNDER the milestone's own phases/<version>/ directory — i.e. phase numbering
7
+ * restarts per milestone with NO arithmetic change (empty roadmap → maxPhase=0 →
8
+ * newPhaseNum=1 → '01'), and no global / MILESTONES.md continuation leaks into the number.
9
+ *
10
+ * Uses Node.js built-in test runner (node:test) + assert.
11
+ */
12
+
13
+ const { describe, it } = require('node:test');
14
+ const assert = require('node:assert');
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const { createFixture } = require('./test-helpers.cjs');
18
+ const { cmdPhaseInitVersionedDir, cmdPhaseAdd } = require('./phase.cjs');
19
+
20
+ // ─── Helpers ────────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Capture stdout from CLI commands that call output() (process.stdout.write +
24
+ * process.exit). Mirrors phase.test.cjs's captureStdout so multiple invocations
25
+ * can run in sequence. Returns { stdout, exitCode, json }.
26
+ */
27
+ function captureStdout(fn) {
28
+ const chunks = [];
29
+ const origWrite = process.stdout.write.bind(process.stdout);
30
+ const origExit = process.exit;
31
+ let exitCode = null;
32
+ process.stdout.write = (data) => { chunks.push(String(data)); return true; };
33
+ process.exit = (code) => {
34
+ exitCode = code == null ? 0 : code;
35
+ throw new Error('__EXIT__');
36
+ };
37
+ try {
38
+ fn();
39
+ } catch (e) {
40
+ if (e && e.message !== '__EXIT__') throw e;
41
+ } finally {
42
+ process.stdout.write = origWrite;
43
+ process.exit = origExit;
44
+ }
45
+ const stdout = chunks.join('');
46
+ let json = null;
47
+ try { json = JSON.parse(stdout); } catch { /* not JSON */ }
48
+ return { stdout, exitCode, json };
49
+ }
50
+
51
+ /**
52
+ * Fresh-milestone v2 fixture. STATE.md frontmatter `current_milestone` is what
53
+ * resolveMilestoneVersion(required) reads. The ROADMAP has NO `### Phase` heading
54
+ * (fresh milestone) plus a `---` trailer so cmdPhaseAdd's insertion logic has a
55
+ * separator. `roadmapBody` lets a test seed a high-number context to prove
56
+ * milestone-local numbering.
57
+ */
58
+ function freshMilestoneFixture({ slug = 'auth-overhaul', milestone = 'v26.0', roadmapBody } = {}) {
59
+ return createFixture({
60
+ 'config.json': JSON.stringify({}),
61
+ 'config.local.json': JSON.stringify({ current_project: slug }),
62
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
63
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n',
64
+ [`projects/${slug}/PROJECT.md`]: '# Project',
65
+ [`projects/${slug}/STATE.md`]: `---\ncurrent_milestone: ${milestone}\n---\n# State`,
66
+ [`projects/${slug}/ROADMAP.md`]: roadmapBody || '# Roadmap\n\n## Phases\n\n---\n',
67
+ [`projects/${slug}/phases/`]: null,
68
+ });
69
+ }
70
+
71
+ // ─── Restart-at-1 (NUM-01) ────────────────────────────────────────────────────
72
+
73
+ describe('restart-at-1 in a fresh versioned milestone (NUM-01)', () => {
74
+ it('Test 1: first add-phase is 01 and lands under phases/<version>/', () => {
75
+ const fixture = freshMilestoneFixture({ slug: 'auth-overhaul', milestone: 'v26.0' });
76
+ try {
77
+ // new-milestone flow materialises phases/v26.0/ (NUM-02 command).
78
+ captureStdout(() => cmdPhaseInitVersionedDir(fixture.cwd, true));
79
+
80
+ // First add-phase in the fresh versioned milestone.
81
+ const { json } = captureStdout(() =>
82
+ cmdPhaseAdd(fixture.cwd, 'first thing', false)
83
+ );
84
+
85
+ assert.ok(json, 'add-phase should emit JSON');
86
+ // Restart-at-1: empty versioned roadmap → maxPhase=0 → newPhaseNum=1 → '01'.
87
+ assert.equal(json.padded, '01', 'first phase is zero-padded 01');
88
+ assert.equal(json.phase_number, 1, 'first phase_number is 1');
89
+
90
+ // The created dir is UNAMBIGUOUS evidence: phases/v26.0/01-first-thing.
91
+ const expectedDir = path.join('projects', 'auth-overhaul', 'phases', 'v26.0', '01-first-thing');
92
+ assert.equal(json.directory, expectedDir, 'phase dir is numbered 01 under the versioned dir');
93
+ assert.ok(
94
+ fs.existsSync(path.join(fixture.cwd, expectedDir)),
95
+ 'phases/v26.0/01-first-thing exists on disk'
96
+ );
97
+ } finally {
98
+ fixture.cleanup();
99
+ }
100
+ });
101
+
102
+ it('Test 2: numbers stay milestone-local (no global continuation leaks into 01)', () => {
103
+ // Seed a ROADMAP with prose that mentions high phase numbers but NO `### Phase`
104
+ // heading. A global / MILESTONES.md continuation scheme would have produced a
105
+ // high number; the milestone-local maxPhase scan sees zero `### Phase` headings,
106
+ // so the first add-phase is still 01.
107
+ const roadmapBody =
108
+ '# Roadmap\n\n' +
109
+ 'Continuation note: the previous milestone ended at phase 162.\n\n' +
110
+ '## Phases\n\n' +
111
+ '---\n';
112
+ const fixture = freshMilestoneFixture({
113
+ slug: 'auth-overhaul',
114
+ milestone: 'v26.0',
115
+ roadmapBody,
116
+ });
117
+ try {
118
+ captureStdout(() => cmdPhaseInitVersionedDir(fixture.cwd, true));
119
+ const { json } = captureStdout(() =>
120
+ cmdPhaseAdd(fixture.cwd, 'first thing', false)
121
+ );
122
+ assert.ok(json, 'add-phase should emit JSON');
123
+ // Still 01 — proves no MILESTONES.md / global 163-continuation leaks into the number.
124
+ assert.equal(json.padded, '01', 'milestone-local: first phase is 01 despite prose mentioning 162');
125
+ assert.equal(json.phase_number, 1, 'milestone-local: phase_number is 1');
126
+ assert.ok(
127
+ json.directory.startsWith(path.join('projects', 'auth-overhaul', 'phases', 'v26.0')),
128
+ `phase dir must be under the versioned dir: ${json.directory}`
129
+ );
130
+ } finally {
131
+ fixture.cleanup();
132
+ }
133
+ });
134
+ });
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, toPosixPath, output, error, getProjectRoot, loadConfig, execGit } = require('./core.cjs');
7
+ const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, toPosixPath, output, error, getProjectRoot, phasesDir, resolveProjectPath, resolveMilestoneVersion, loadConfig, execGit } = require('./core.cjs');
8
8
  const { extractFrontmatter } = require('./frontmatter.cjs');
9
9
  const { writeStateMd } = require('./state.cjs');
10
10
 
@@ -14,13 +14,13 @@ const { writeStateMd } = require('./state.cjs');
14
14
  * from getProjectRoot() or '.' as fallback.
15
15
  */
16
16
  function resolvePhasesDir(cwd) {
17
- let projectRoot;
17
+ let phasesRel;
18
18
  try {
19
- projectRoot = getProjectRoot(cwd);
19
+ phasesRel = phasesDir(cwd);
20
20
  } catch {
21
- projectRoot = '.';
21
+ phasesRel = 'phases'; // flat-layout fallback (== path.join('.', 'phases'))
22
22
  }
23
- return path.join(cwd, projectRoot, 'phases');
23
+ return path.join(cwd, phasesRel);
24
24
  }
25
25
 
26
26
  /**
@@ -406,6 +406,54 @@ function cmdPhaseAdd(cwd, description, raw) {
406
406
  output(result, raw, paddedNum);
407
407
  }
408
408
 
409
+ /**
410
+ * Create the current milestone's versioned phases directory: phases/<version>/.
411
+ * This is the SOLE creator of a phases/<version> directory (NUM-02). It requires a
412
+ * determinable milestone version (fail-loud) so a phase is never written to phases/v1.0/.
413
+ * Idempotent: recursive mkdir is a no-op when the directory already exists.
414
+ * cmdPhaseAdd never calls this — it routes through the version-aware phasesDir resolver,
415
+ * which returns this directory once it exists.
416
+ */
417
+ /**
418
+ * Internal, output-free creator of the current milestone's versioned phases dir.
419
+ * Shared by the CLI wrapper (cmdPhaseInitVersionedDir) AND create-adhoc seeding
420
+ * (milestone.cjs) so there is exactly ONE versioned-dir creator (Decision C / NUM-02).
421
+ *
422
+ * required:true → throws the Phase-163 remediation Error when the version is
423
+ * undeterminable; never writes phases/v1.0/. Idempotent: recursive mkdir is a
424
+ * no-op when the directory already exists, and `created` reflects whether THIS
425
+ * call materialized the directory (probed via existsSync BEFORE mkdir).
426
+ *
427
+ * @param {string} cwd - planning root
428
+ * @returns {{ version: string, directory: string, created: boolean }}
429
+ * `directory` is relative to cwd.
430
+ */
431
+ function phaseInitVersionedDirInternal(cwd) {
432
+ // required:true → throws the Phase-163 remediation Error when undeterminable; never v1.0.
433
+ const version = resolveMilestoneVersion(cwd, { required: true });
434
+ // resolveProjectPath returns the FLAT relative phases base (NOT version-aware), so joining
435
+ // the version is correct and idempotent on re-run. It is a function call — not a
436
+ // path.join(..., 'phases') literal — so the 164-05 RSLV-03 phases-join gate stays GREEN.
437
+ const flatBase = resolveProjectPath(cwd, 'phases'); // relative: projects/<slug>/phases or phases
438
+ const versionedDir = path.join(cwd, flatBase, version); // absolute phases/<version>
439
+ const created = !fs.existsSync(versionedDir); // did THIS call create it?
440
+ fs.mkdirSync(versionedDir, { recursive: true }); // idempotent
441
+ return { version, directory: path.relative(cwd, versionedDir), created };
442
+ }
443
+
444
+ /**
445
+ * Create the current milestone's versioned phases directory: phases/<version>/.
446
+ * This is the SOLE creator of a phases/<version> directory (NUM-02). It requires a
447
+ * determinable milestone version (fail-loud) so a phase is never written to phases/v1.0/.
448
+ * Idempotent: recursive mkdir is a no-op when the directory already exists.
449
+ * cmdPhaseAdd never calls this — it routes through the version-aware phasesDir resolver,
450
+ * which returns this directory once it exists.
451
+ */
452
+ function cmdPhaseInitVersionedDir(cwd, raw) {
453
+ const { version, directory, created } = phaseInitVersionedDirInternal(cwd);
454
+ output({ version, directory, created }, raw, directory);
455
+ }
456
+
409
457
  function cmdPhaseInsert(cwd, afterPhase, description, raw) {
410
458
  if (!afterPhase || !description) {
411
459
  error('after-phase and description required for phase insert');
@@ -767,8 +815,11 @@ function phaseCompleteInternal(cwd, phaseNum) {
767
815
 
768
816
  // Verify phase info
769
817
  const phaseInfo = findPhaseInternal(cwd, phaseNum);
770
- if (!phaseInfo) {
771
- error(`Phase ${phaseNum} not found`);
818
+ // Guard on `found`: a LOOK-01 ambiguity object is truthy but has no
819
+ // .directory/.plans, so neither is reached for an ambiguity object. Surface
820
+ // its milestone-qualified message instead of throwing.
821
+ if (!phaseInfo || !phaseInfo.found) {
822
+ error(phaseInfo?.message || `Phase ${phaseNum} not found`);
772
823
  }
773
824
 
774
825
  // Absolute phase directory (phaseInfo.directory is relative to cwd)
@@ -1046,6 +1097,8 @@ module.exports = {
1046
1097
  cmdFindPhase,
1047
1098
  cmdPhasePlanIndex,
1048
1099
  cmdPhaseAdd,
1100
+ cmdPhaseInitVersionedDir,
1101
+ phaseInitVersionedDirInternal,
1049
1102
  cmdPhaseInsert,
1050
1103
  cmdPhaseRemove,
1051
1104
  cmdPhaseComplete,
@@ -11,7 +11,8 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { execSync } = require('child_process');
13
13
 
14
- const { createTempProject } = require('./test-helpers.cjs');
14
+ const { createTempProject, createFixture } = require('./test-helpers.cjs');
15
+ const { initPaths, resetPaths } = require('./paths.cjs');
15
16
 
16
17
  // ─── Helpers ──────────────────────────────────────────────────────────────────
17
18
 
@@ -418,3 +419,169 @@ describe('cmdPlanFinalize', () => {
418
419
  assert.equal(gitLastMessage(fixture.cwd), 'docs(04-01): complete execution plan');
419
420
  });
420
421
  });
422
+
423
+ // ─── cmdPhaseInitVersionedDir (NUM-02 versioned write path) ────────────────────
424
+
425
+ describe('cmdPhaseInitVersionedDir (NUM-02 versioned write path)', () => {
426
+ let phase;
427
+
428
+ beforeEach(() => {
429
+ // Reload phase.cjs fresh so each fixture's config/paths are re-read.
430
+ delete require.cache[require.resolve('./phase.cjs')];
431
+ phase = require('./phase.cjs');
432
+ });
433
+
434
+ // Shared v2 project-context builder. STATE.md frontmatter `current_milestone`
435
+ // is what resolveMilestoneVersion(required) reads. `roadmapBody`/`extraDirs`
436
+ // let individual tests scaffold a versioned ROADMAP context or pre-create dirs.
437
+ function v2Fixture({ slug = 'auth-overhaul', stateBody, roadmapBody, extraDirs } = {}) {
438
+ const tree = {
439
+ 'config.json': JSON.stringify({}),
440
+ 'config.local.json': JSON.stringify({ current_project: slug }),
441
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
442
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n',
443
+ [`projects/${slug}/PROJECT.md`]: '# Project',
444
+ [`projects/${slug}/STATE.md`]: stateBody,
445
+ [`projects/${slug}/ROADMAP.md`]: roadmapBody || '# Roadmap',
446
+ [`projects/${slug}/phases/`]: null,
447
+ };
448
+ for (const d of (extraDirs || [])) tree[d] = null;
449
+ return createFixture(tree);
450
+ }
451
+
452
+ it('Test 1: creates the current milestone versioned dir phases/<version>/', () => {
453
+ const fixture = v2Fixture({ stateBody: '---\ncurrent_milestone: v26.0\n---\n# State' });
454
+ try {
455
+ // raw=false → output() emits structured JSON (raw=true would print the human relDir line).
456
+ const { json } = captureStdout(() =>
457
+ phase.cmdPhaseInitVersionedDir(fixture.cwd, false)
458
+ );
459
+ const versioned = path.join(fixture.cwd, 'projects', 'auth-overhaul', 'phases', 'v26.0');
460
+ assert.ok(fs.existsSync(versioned), 'phases/v26.0/ must be created');
461
+ assert.ok(fs.statSync(versioned).isDirectory(), 'phases/v26.0/ must be a directory');
462
+ // Output reports the version + relative directory.
463
+ assert.ok(json, 'should emit JSON output');
464
+ assert.equal(json.version, 'v26.0');
465
+ assert.equal(json.created, true);
466
+ assert.equal(
467
+ json.directory,
468
+ path.join('projects', 'auth-overhaul', 'phases', 'v26.0')
469
+ );
470
+ } finally {
471
+ fixture.cleanup();
472
+ }
473
+ });
474
+
475
+ it('Test 2: idempotent — re-run does NOT throw and creates NO nested phases/<version>/<version>/', () => {
476
+ const fixture = v2Fixture({ stateBody: '---\ncurrent_milestone: v26.0\n---\n# State' });
477
+ try {
478
+ // First invocation creates phases/v26.0/. Now phasesDir(cwd) is version-aware
479
+ // and would return phases/v26.0 — so a wrong (version-aware-base) construction
480
+ // would yield phases/v26.0/v26.0 on the SECOND run. This asserts it does NOT.
481
+ assert.doesNotThrow(() => {
482
+ captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
483
+ captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
484
+ });
485
+ const versioned = path.join(fixture.cwd, 'projects', 'auth-overhaul', 'phases', 'v26.0');
486
+ const nested = path.join(versioned, 'v26.0');
487
+ assert.ok(fs.existsSync(versioned), 'phases/v26.0/ exists exactly once');
488
+ assert.ok(!fs.existsSync(nested), 'NO nested phases/v26.0/v26.0/ (flat-base idempotency)');
489
+ } finally {
490
+ fixture.cleanup();
491
+ }
492
+ });
493
+
494
+ it('Test 3: fail-loud — undeterminable version throws (current_milestone remediation), writes NO phases/v1.0/', () => {
495
+ const fixture = v2Fixture({ stateBody: '# State (no milestone frontmatter)' });
496
+ try {
497
+ let thrown = null;
498
+ try {
499
+ captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
500
+ } catch (e) {
501
+ thrown = e;
502
+ }
503
+ assert.ok(thrown, 'must throw on undeterminable version');
504
+ assert.match(thrown.message, /current_milestone/, 'remediation references current_milestone');
505
+ assert.match(thrown.message, /STATE\.md/, 'remediation references STATE.md');
506
+ assert.ok(!/v1\.0/.test(thrown.message), 'message must NOT mention v1.0');
507
+ // Critical: no phases/v1.0/ and no phases/undefined/ were written.
508
+ const phasesBase = path.join(fixture.cwd, 'projects', 'auth-overhaul', 'phases');
509
+ assert.ok(!fs.existsSync(path.join(phasesBase, 'v1.0')), 'NO phases/v1.0/ created');
510
+ assert.ok(!fs.existsSync(path.join(phasesBase, 'undefined')), 'NO phases/undefined/ created');
511
+ } finally {
512
+ fixture.cleanup();
513
+ }
514
+ });
515
+
516
+ it('Test 4: after create, cmdPhaseAdd lands the new phase under phases/<version>/', () => {
517
+ const fixture = v2Fixture({
518
+ stateBody: '---\ncurrent_milestone: v26.0\n---\n# State',
519
+ // Versioned ROADMAP context so maxPhase resolves (empty here → maxPhase=0 → newPhaseNum=1).
520
+ roadmapBody: '# Roadmap\n\n## Phases\n\n',
521
+ });
522
+ try {
523
+ captureStdout(() => phase.cmdPhaseInitVersionedDir(fixture.cwd, true));
524
+ const { json } = captureStdout(() =>
525
+ phase.cmdPhaseAdd(fixture.cwd, 'First versioned phase', false)
526
+ );
527
+ assert.ok(json, 'add-phase should emit JSON');
528
+ // New phase dir lands UNDER phases/v26.0/ because version-aware phasesDir returns it.
529
+ assert.ok(
530
+ json.directory.startsWith(path.join('projects', 'auth-overhaul', 'phases', 'v26.0')),
531
+ `phase dir must be under versioned dir: ${json.directory}`
532
+ );
533
+ assert.equal(json.padded, '01', 'restart-at-1: empty versioned roadmap numbers from 01');
534
+ assert.ok(fs.existsSync(path.join(fixture.cwd, json.directory)), 'new phase dir exists on disk');
535
+ } finally {
536
+ fixture.cleanup();
537
+ }
538
+ });
539
+
540
+ it('Test 5: two active milestones — each add-phase lands in its own phases/<version>/', () => {
541
+ const roadmap = '# Roadmap\n\n## Phases\n\n';
542
+ const fixtureA = v2Fixture({
543
+ slug: 'proj-a',
544
+ stateBody: '---\ncurrent_milestone: v26.0\n---\n# State',
545
+ roadmapBody: roadmap,
546
+ });
547
+ const fixtureB = v2Fixture({
548
+ slug: 'proj-b',
549
+ stateBody: '---\ncurrent_milestone: v27.0\n---\n# State',
550
+ roadmapBody: roadmap,
551
+ });
552
+ try {
553
+ // Create each milestone's versioned dir, then add-phase in each context.
554
+ // Both fixtures live simultaneously; the planning-root cache is a single
555
+ // per-process value (createFixture primed it for whichever was built last),
556
+ // so reset + re-prime for each project before operating on it, otherwise
557
+ // operations on the other would read a stale root / wrong current_project.
558
+ resetPaths();
559
+ initPaths(fixtureA.cwd);
560
+ captureStdout(() => phase.cmdPhaseInitVersionedDir(fixtureA.cwd, true));
561
+ const { json: jsonA } = captureStdout(() =>
562
+ phase.cmdPhaseAdd(fixtureA.cwd, 'A first phase', false)
563
+ );
564
+ resetPaths();
565
+ initPaths(fixtureB.cwd);
566
+ captureStdout(() => phase.cmdPhaseInitVersionedDir(fixtureB.cwd, true));
567
+ const { json: jsonB } = captureStdout(() =>
568
+ phase.cmdPhaseAdd(fixtureB.cwd, 'B first phase', false)
569
+ );
570
+
571
+ assert.ok(
572
+ jsonA.directory.startsWith(path.join('projects', 'proj-a', 'phases', 'v26.0')),
573
+ `A lands in v26.0: ${jsonA.directory}`
574
+ );
575
+ assert.ok(
576
+ jsonB.directory.startsWith(path.join('projects', 'proj-b', 'phases', 'v27.0')),
577
+ `B lands in v27.0: ${jsonB.directory}`
578
+ );
579
+ // Each numbered relative to its own (initially-empty) versioned roadmap.
580
+ assert.equal(jsonA.padded, '01');
581
+ assert.equal(jsonB.padded, '01');
582
+ } finally {
583
+ fixtureA.cleanup();
584
+ fixtureB.cleanup();
585
+ }
586
+ });
587
+ });
@@ -225,6 +225,44 @@ describe('readProjectState', () => {
225
225
  assert.ok(state !== null);
226
226
  assert.strictEqual(state.phase, 'Unknown');
227
227
  });
228
+
229
+ it('returns a non-stale phase for a shipped, production-shaped STATE.md', () => {
230
+ // Regression (DASH-STALE-01): after a milestone ships, markMilestoneComplete
231
+ // resets the Current Position Phase: line to the between-milestones form. This
232
+ // proves readProjectState — whose Phase: read regex is UNANCHORED — yields a
233
+ // non-stale phase when given a realistic, fully-synced production STATE.md
234
+ // (full frontmatter block, NOT a bare `# Project State` body).
235
+ const shippedState = `---
236
+ dgs_state_version: 1.0
237
+ milestone: v1.0
238
+ milestone_name: milestone
239
+ status: milestone_shipped
240
+ last_updated: "2026-06-27T00:00:00.000Z"
241
+ completed_date: 2026-06-27
242
+ progress:
243
+ total_phases: 8
244
+ completed_phases: 8
245
+ total_plans: 20
246
+ completed_plans: 20
247
+ percent: 100
248
+ ---
249
+
250
+ # Project State
251
+
252
+ ## Current Position
253
+
254
+ Phase: — (between milestones; v1.0 shipped 2026-06-27)
255
+ Status: Milestone v1.0 shipped 2026-06-27
256
+ Progress: [██████████] 100%
257
+ `;
258
+ createProjectManually(tmpDir, 'proj', shippedState);
259
+ const state = readProjectState(tmpDir, 'proj');
260
+ assert.ok(
261
+ !/\d+\s+of\s+\d+/.test(state.phase),
262
+ `Shipped STATE should yield a non-stale phase, got: ${state.phase}`
263
+ );
264
+ assert.ok(/between milestones/.test(state.phase), 'phase reflects between-milestones form');
265
+ });
228
266
  });
229
267
 
230
268
  // ─── scanProjectReposTags ───────────────────────────────────────────────────