@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
@@ -99,22 +99,22 @@ describe('parseTierDefinitions', () => {
99
99
  assert.equal(none.files.length, 0, 'none tier files should be empty');
100
100
  });
101
101
 
102
- it('lite tier has 3 files (PROJECT.md, STATE.md, config.json)', () => {
102
+ it('lite tier has 4 files (PROJECT.md, PRODUCT-SUMMARY.md, STATE.md, config.json)', () => {
103
103
  const tiers = parseTierDefinitions();
104
104
  const lite = tiers.get('lite');
105
105
  assert.ok(Array.isArray(lite.files), 'lite tier files should be array');
106
- assert.equal(lite.files.length, 3, 'lite tier should have 3 files');
106
+ assert.equal(lite.files.length, 4, 'lite tier should have 4 files');
107
107
  const paths = lite.files.map(f => f.path);
108
108
  assert.ok(paths.includes('PROJECT.md'), 'should include PROJECT.md');
109
109
  assert.ok(paths.includes('STATE.md'), 'should include STATE.md');
110
110
  assert.ok(paths.includes('config.json'), 'should include config.json');
111
111
  });
112
112
 
113
- it('planning tier has 3 own files plus dynamic codebase glob', () => {
113
+ it('planning tier has 4 own files plus dynamic codebase glob', () => {
114
114
  const tiers = parseTierDefinitions();
115
115
  const planning = tiers.get('planning');
116
116
  assert.ok(Array.isArray(planning.files), 'planning tier files should be array');
117
- assert.equal(planning.files.length, 3, 'planning tier should have 3 own files');
117
+ assert.equal(planning.files.length, 4, 'planning tier should have 4 own files');
118
118
  const paths = planning.files.map(f => f.path);
119
119
  assert.ok(paths.includes('ROADMAP.md'), 'should include ROADMAP.md');
120
120
  assert.ok(paths.includes('REQUIREMENTS.md'), 'should include REQUIREMENTS.md');
@@ -674,20 +674,20 @@ describe('resolveTierFiles', () => {
674
674
  it('returns lite files for lite tier', () => {
675
675
  const tiers = parseTierDefinitions();
676
676
  const result = resolveTierFiles('lite', tiers);
677
- assert.equal(result.length, 3);
677
+ assert.equal(result.length, 4);
678
678
  });
679
679
 
680
680
  it('planning tier inherits lite files', () => {
681
681
  const tiers = parseTierDefinitions();
682
682
  const result = resolveTierFiles('planning', tiers);
683
- // lite has 3, planning adds 3 more
684
- assert.equal(result.length, 6, 'planning should have 6 static files (3 lite + 3 planning)');
683
+ // lite has 4, planning adds 4 more
684
+ assert.equal(result.length, 8, 'planning should have 8 static files (4 lite + 4 planning)');
685
685
  });
686
686
 
687
687
  it('execution tier inherits planning+lite files', () => {
688
688
  const tiers = parseTierDefinitions();
689
689
  const result = resolveTierFiles('execution', tiers);
690
- // execution has files: [] itself but inherits from planning which has 6
691
- assert.equal(result.length, 6, 'execution inherits all planning files');
690
+ // execution has files: [] itself but inherits from planning which has 8
691
+ assert.equal(result.length, 8, 'execution inherits all planning files');
692
692
  });
693
693
  });
@@ -289,18 +289,17 @@ function findPhaseInternal(cwd, phase) {
289
289
 
290
290
  const normalized = normalizePhaseName(phase);
291
291
 
292
- // Resolve phases directory: project root or '.' fallback
293
- let projectRoot;
292
+ // Resolve phases directory via canonical resolver; keep soft '.' fallback
293
+ let phasesRel;
294
294
  try {
295
- projectRoot = getProjectRoot(cwd);
295
+ phasesRel = phasesDir(cwd);
296
296
  } catch {
297
- projectRoot = '.';
297
+ phasesRel = 'phases'; // flat-layout fallback (== path.join('.', 'phases'))
298
298
  }
299
- const phasesRel = path.join(projectRoot, 'phases');
300
- const phasesDir = path.join(cwd, phasesRel);
299
+ const phasesAbs = path.join(cwd, phasesRel);
301
300
 
302
301
  // Search current phases first
303
- const current = searchPhaseInDir(phasesDir, phasesRel, normalized);
302
+ const current = searchPhaseInDir(phasesAbs, phasesRel, normalized);
304
303
  if (current) return current;
305
304
 
306
305
  // Search archived milestone phases (newest first)
@@ -318,6 +317,12 @@ function findPhaseInternal(cwd, phase) {
318
317
  .reverse();
319
318
 
320
319
  const planRootRel = path.relative(cwd, planRoot);
320
+
321
+ // LOOK-01: collect ALL archive matches before deciding, instead of
322
+ // returning the first newest-first hit. Once phase numbers restart per
323
+ // milestone (Phase 165), a bare number can collide across several milestone
324
+ // archives; silently returning the newest is a correctness bug.
325
+ const collected = [];
321
326
  for (const archiveName of archiveDirs) {
322
327
  const version = archiveName.match(/^(v[\d.]+)-phases$/)[1];
323
328
  const archivePath = path.join(milestonesDir, archiveName);
@@ -325,9 +330,35 @@ function findPhaseInternal(cwd, phase) {
325
330
  const result = searchPhaseInDir(archivePath, relBase, normalized);
326
331
  if (result) {
327
332
  result.archived = version;
328
- return result;
333
+ collected.push({ version, result });
329
334
  }
330
335
  }
336
+
337
+ // Branch on the collected count:
338
+ // - 0 matches → null (unchanged)
339
+ // - 1 match → return that match with .archived set — BYTE-IDENTICAL to
340
+ // the previous single-archive path. Flat-layout / globally-
341
+ // unique numbers always land here, so their behaviour is
342
+ // preserved exactly.
343
+ // - 2+ matches → return a PINNED, NON-throwing structured ambiguity signal:
344
+ // { found:false, ambiguous:true, phase, matches:[{milestone,
345
+ // directory}...], message }. It carries NO top-level
346
+ // `directory` and NO single `archived` tag, so callers that
347
+ // guard on `!phaseInfo.found` treat it as not-found, while
348
+ // hardened callers surface its `message`. This object shape
349
+ // is part of findPhaseInternal's return contract.
350
+ if (collected.length === 0) return null;
351
+ if (collected.length === 1) return collected[0].result;
352
+
353
+ const versions = collected.map(c => c.version);
354
+ const newestVersion = [...versions].sort().reverse()[0];
355
+ return {
356
+ found: false,
357
+ ambiguous: true,
358
+ phase: normalized,
359
+ matches: collected.map(c => ({ milestone: c.version, directory: c.result.directory })),
360
+ message: `Phase ${normalized} is ambiguous across milestones ${versions.join(', ')} — use a milestone-qualified reference (e.g. ${newestVersion}/${normalized})`,
361
+ };
331
362
  } catch {}
332
363
 
333
364
  return null;
@@ -514,12 +545,122 @@ function getMilestoneInfo(cwd) {
514
545
  }
515
546
  }
516
547
 
548
+ // ─── Authoritative milestone-version signal (Phase 163-01) ─────────────────────
549
+ //
550
+ // Grounding (verified 2026-06-25) — the canonical structured field name:
551
+ // - The field persisted in STATE.md frontmatter TODAY is `milestone:`
552
+ // (written by state.cjs buildStateFrontmatter, derived from the getMilestoneInfo
553
+ // ROADMAP prose scrape). `current_milestone` exists only in init.cjs JSON output
554
+ // (init.cjs:560), NOT as a persisted STATE.md field.
555
+ // - The spec/CONTEXT name the authoritative field `current_milestone`.
556
+ // - To honour the locked name WHILE staying compatible with what exists today,
557
+ // resolveMilestoneVersion reads `current_milestone` FIRST and falls back to
558
+ // `milestone` SECOND. Plan 02 standardises the writer on `current_milestone`.
559
+ //
560
+ // This is the structured-first, grammar-validated, FAIL-LOUD signal. It deliberately
561
+ // does NOT trust getMilestoneInfo's `v1.0` default — that scrape is advisory only and
562
+ // is consulted here solely for an optional mismatch warning.
563
+
564
+ /** Canonical milestone-version grammar (RSLV-05): exactly `vN.N`, no trim, no flags. */
565
+ const MILESTONE_VERSION_RE = /^v\d+\.\d+$/;
566
+
567
+ /**
568
+ * Validate a milestone-version string against the canonical grammar `^v\d+\.\d+$`.
569
+ * Out-of-grammar values are rejected, never coerced. Does NOT trim — trimming is the
570
+ * caller's choice, so malformed (whitespace-padded) input is not silently masked.
571
+ *
572
+ * @param {*} value
573
+ * @returns {boolean}
574
+ */
575
+ function isValidMilestoneVersion(value) {
576
+ return typeof value === 'string' && MILESTONE_VERSION_RE.test(value);
577
+ }
578
+
579
+ /**
580
+ * Resolve the current milestone version from the structured STATE.md frontmatter
581
+ * field (authoritative), validate it against the canonical grammar, and FAIL LOUD
582
+ * when it is required but undeterminable. NEVER defaults to or returns 'v1.0'.
583
+ *
584
+ * Read precedence within STATE.md frontmatter: `current_milestone` then `milestone`.
585
+ * On a STATE-vs-ROADMAP mismatch, warns to stderr and trusts STATE.
586
+ *
587
+ * @param {string} cwd - Working directory
588
+ * @param {{ required?: boolean }} [opts]
589
+ * @returns {string|null} The validated `vN.N` version, or null when undeterminable
590
+ * and not required.
591
+ * @throws {Error} when required is true and the version is undeterminable/invalid.
592
+ */
593
+ function resolveMilestoneVersion(cwd, { required = false } = {}) {
594
+ const remediation =
595
+ "Cannot determine current milestone version — set 'current_milestone' " +
596
+ '(e.g. v25.0) in STATE.md frontmatter.';
597
+
598
+ // 1. Read STATE.md (structured, authoritative). Mirror getMilestoneInfo's
599
+ // project-scoped read: project STATE.md first, then planning-root STATE.md.
600
+ let stateContent = null;
601
+ try {
602
+ const projectRoot = getProjectRoot(cwd);
603
+ stateContent = safeReadFile(path.join(cwd, projectRoot, 'STATE.md'));
604
+ } catch {
605
+ stateContent = null;
606
+ }
607
+ if (stateContent === null) {
608
+ stateContent = safeReadFile(path.join(getPlanningRoot(cwd), 'STATE.md'));
609
+ }
610
+
611
+ // 2. Local minimal frontmatter parse. Intentionally does NOT import the
612
+ // frontmatter parser module to avoid a require cycle (that module requires this one).
613
+ let raw;
614
+ if (stateContent) {
615
+ const fmMatch = stateContent.match(/^---\n([\s\S]+?)\n---/);
616
+ if (fmMatch) {
617
+ const block = fmMatch[1];
618
+ const currentMatch = block.match(/^current_milestone:\s*["']?(\S+?)["']?\s*$/m);
619
+ const fallbackMatch = block.match(/^milestone:\s*["']?(\S+?)["']?\s*$/m);
620
+ raw = (currentMatch && currentMatch[1]) || (fallbackMatch && fallbackMatch[1]);
621
+ }
622
+ }
623
+
624
+ // 3-4. Valid grammar → optional advisory mismatch warning, then return.
625
+ if (isValidMilestoneVersion(raw)) {
626
+ try {
627
+ const advisory = getMilestoneInfo(cwd);
628
+ if (advisory && advisory.version && advisory.version !== raw) {
629
+ process.stderr.write(
630
+ `Warning: milestone version mismatch — STATE.md says ${raw} but ROADMAP ` +
631
+ `says ${advisory.version}. Trusting STATE.md (${raw}).\n`
632
+ );
633
+ }
634
+ } catch { /* advisory only — never block on the prose scrape */ }
635
+ return raw;
636
+ }
637
+
638
+ // 5-6. Present-but-invalid or absent → fail loud if required, else null.
639
+ // The abandoned getMilestoneInfo default is deliberately NOT used here.
640
+ if (required) {
641
+ throw new Error(remediation);
642
+ }
643
+ return null;
644
+ }
645
+
517
646
  /**
518
647
  * Returns a filter function that checks whether a phase directory belongs
519
- * to the current milestone based on ROADMAP.md phase headings.
648
+ * to the current milestone.
649
+ * Under versioned layout (phases/<version>/) membership is structural — every
650
+ * entry already belongs to the milestone — so this returns a pass-all predicate.
651
+ * Under flat layout (shared phases/) it returns a ROADMAP number-set predicate.
520
652
  * If no ROADMAP exists or no phases are listed, returns a pass-all filter.
521
653
  */
522
654
  function getMilestonePhaseFilter(cwd) {
655
+ // Versioned layout: phases/<version>/ is already milestone-scoped — every entry belongs to the milestone, so number-set filtering is redundant (NUM-04).
656
+ let versioned = false;
657
+ try { versioned = /^v\d+\.\d+$/.test(path.basename(phasesDir(cwd))); } catch { versioned = false; }
658
+ if (versioned) {
659
+ const passAll = () => true;
660
+ passAll.phaseCount = 0;
661
+ return passAll;
662
+ }
663
+
523
664
  const milestonePhaseNums = new Set();
524
665
  try {
525
666
  let roadmap;
@@ -662,6 +803,52 @@ function resolveProjectPath(cwd, relativePath) {
662
803
  return relativePath ? path.join(root, relativePath) : root;
663
804
  }
664
805
 
806
+ /**
807
+ * Canonical resolver for the active-project phases directory.
808
+ *
809
+ * Returns the RELATIVE project-scoped phases path:
810
+ * - v2 layout: 'projects/<slug>/phases'
811
+ * - v1 / flat layout: 'phases'
812
+ *
813
+ * FAIL-LOUD: delegates to resolveProjectPath -> requireProjectRoot, which
814
+ * throws when there is no project root. Callers that want a soft fallback
815
+ * (e.g. flat-layout tooling) keep their own try/catch around this call —
816
+ * the fallback policy stays local to each call site, never buried here.
817
+ *
818
+ * This is the SINGLE seam for phases-path resolution, and it IS version-aware
819
+ * (Phase 165 / RSLV-04): when a 'phases/<version>' directory exists on disk under
820
+ * the active project — where <version> is the validated `vN.N` milestone signal
821
+ * (resolveMilestoneVersion, Phase 163) — it returns the RELATIVE versioned path
822
+ * 'phases/<version>'. Otherwise it falls back to flat 'phases'. The fallback is
823
+ * permanent and intentional (dual-mode cutover): pre-existing flat layouts and the
824
+ * in-flight milestone (no 'phases/<version>' dir on disk) resolve FLAT by design —
825
+ * versioning is never applied retroactively. The read path NEVER defaults to 'v1.0'
826
+ * and NEVER throws on an undeterminable version (it just resolves flat).
827
+ *
828
+ * Do not reintroduce ad-hoc path.join(...,'phases') sites — path-audit.test.cjs
829
+ * gates against that. The only phases-bearing joins below are path.join(base, version)
830
+ * (relative return) and the existsSync probe path.join(cwd, base, version); neither is
831
+ * a bare path.join(..., 'phases') literal, so the 164-05 gate stays satisfied.
832
+ *
833
+ * @param {string} cwd - Working directory
834
+ * @returns {string} Relative project-scoped phases path ('phases', 'projects/<slug>/phases',
835
+ * or the versioned '…/phases/<version>')
836
+ * @throws {Error} NO_CURRENT_PROJECT_V2 | PROJECT_NOT_FOUND | INVALID_PROJECT_NAME | PROJECT_COMPLETED
837
+ */
838
+ function phasesDir(cwd) {
839
+ const base = resolveProjectPath(cwd, 'phases'); // RELATIVE, fail-loud (unchanged)
840
+ let version = null;
841
+ try {
842
+ version = resolveMilestoneVersion(cwd); // non-required: validated vN.N or null, never throws, never v1.0
843
+ } catch {
844
+ version = null; // read path never blocks on an undeterminable version
845
+ }
846
+ if (version && fs.existsSync(path.join(cwd, base, version))) {
847
+ return path.join(base, version); // versioned dir present → return RELATIVE versioned path
848
+ }
849
+ return base; // dual-mode fallback: flat phases/ (incl. the in-flight v25.0)
850
+ }
851
+
665
852
  /**
666
853
  * Check if a project is completed by reading its STATE.md directly.
667
854
  *
@@ -749,9 +936,12 @@ module.exports = {
749
936
  pathExistsInternal,
750
937
  generateSlugInternal,
751
938
  getMilestoneInfo,
939
+ isValidMilestoneVersion,
940
+ resolveMilestoneVersion,
752
941
  getMilestonePhaseFilter,
753
942
  toPosixPath,
754
943
  resolveProjectPath,
944
+ phasesDir,
755
945
  getProjectRoot,
756
946
  requireProjectRoot,
757
947
  isV2Install,
@@ -16,6 +16,7 @@ const { createFixture, createTempProject } = require('./test-helpers.cjs');
16
16
  // Import the functions under test
17
17
  const {
18
18
  resolveProjectPath,
19
+ phasesDir,
19
20
  getProjectRoot,
20
21
  requireProjectRoot,
21
22
  isV2Install,
@@ -26,6 +27,7 @@ const {
26
27
  getProjectDir,
27
28
  resolveModelInternal,
28
29
  MODEL_PROFILES,
30
+ findPhaseInternal,
29
31
  } = require('./core.cjs');
30
32
 
31
33
  // ─── Root layout (no v2 markers) Tests ───────────────────────────────────────
@@ -145,6 +147,246 @@ describe('v2 mode without current_project (guard trigger)', () => {
145
147
  });
146
148
  });
147
149
 
150
+ // ─── phasesDir canonical resolver contract Tests ──────────────────────────────
151
+
152
+ describe('phasesDir canonical resolver', () => {
153
+ it('v1/flat layout returns phases', () => {
154
+ const fixture = createFixture({
155
+ 'config.json': JSON.stringify({}),
156
+ 'STATE.md': '# State',
157
+ 'ROADMAP.md': '# Roadmap',
158
+ 'phases/': null,
159
+ });
160
+
161
+ try {
162
+ const result = phasesDir(fixture.cwd);
163
+ assert.equal(result, path.join('.', 'phases'));
164
+ assert.equal(result, 'phases');
165
+ } finally {
166
+ fixture.cleanup();
167
+ }
168
+ });
169
+
170
+ it('v2 layout returns projects/<slug>/phases', () => {
171
+ const fixture = createFixture({
172
+ 'config.json': JSON.stringify({}),
173
+ 'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
174
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
175
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n',
176
+ 'projects/auth-overhaul/STATE.md': '# State',
177
+ 'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
178
+ 'projects/auth-overhaul/phases/': null,
179
+ });
180
+
181
+ try {
182
+ const result = phasesDir(fixture.cwd);
183
+ assert.equal(result, path.join('projects', 'auth-overhaul', 'phases'));
184
+ } finally {
185
+ fixture.cleanup();
186
+ }
187
+ });
188
+
189
+ it('v2 install with NO current_project throws (fail-loud, no fallback)', () => {
190
+ const fixture = createFixture({
191
+ 'config.json': JSON.stringify({}),
192
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
193
+ });
194
+
195
+ try {
196
+ assert.throws(
197
+ () => phasesDir(fixture.cwd),
198
+ (err) => err.message === 'NO_CURRENT_PROJECT_V2'
199
+ );
200
+ } finally {
201
+ fixture.cleanup();
202
+ }
203
+ });
204
+ });
205
+
206
+ // ─── Version-aware (dual-mode) resolver Tests (Phase 165, RSLV-04) ─────────────
207
+
208
+ describe('phasesDir version-aware (dual-mode)', () => {
209
+ // Shared v2 project context builder. STATE.md frontmatter `current_milestone`
210
+ // is what resolveMilestoneVersion reads; `phasesExtra` lets each test add the
211
+ // versioned sub-directory (or not) to exercise the existsSync branch.
212
+ function v2Fixture(stateBody, phasesExtra) {
213
+ const tree = {
214
+ 'config.json': JSON.stringify({}),
215
+ 'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
216
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
217
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n',
218
+ 'projects/auth-overhaul/PROJECT.md': '# Project',
219
+ 'projects/auth-overhaul/STATE.md': stateBody,
220
+ 'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
221
+ 'projects/auth-overhaul/phases/': null,
222
+ };
223
+ if (phasesExtra) tree[phasesExtra] = null;
224
+ return createFixture(tree);
225
+ }
226
+
227
+ it('Test A: versioned dir present → returns projects/<slug>/phases/<version>', () => {
228
+ const fixture = v2Fixture(
229
+ '---\ncurrent_milestone: v26.0\n---\n# State',
230
+ 'projects/auth-overhaul/phases/v26.0/'
231
+ );
232
+ try {
233
+ const result = phasesDir(fixture.cwd);
234
+ assert.equal(
235
+ result,
236
+ path.join('projects', 'auth-overhaul', 'phases', 'v26.0')
237
+ );
238
+ } finally {
239
+ fixture.cleanup();
240
+ }
241
+ });
242
+
243
+ it('Test B: version present but NO versioned dir → flat fallback (v25.0 invariant)', () => {
244
+ const fixture = v2Fixture(
245
+ '---\ncurrent_milestone: v25.0\n---\n# State',
246
+ null
247
+ );
248
+ try {
249
+ const result = phasesDir(fixture.cwd);
250
+ assert.equal(result, path.join('projects', 'auth-overhaul', 'phases'));
251
+ assert.ok(!result.includes('v25.0'), `must not resolve versioned: ${result}`);
252
+ } finally {
253
+ fixture.cleanup();
254
+ }
255
+ });
256
+
257
+ it('Test C: version undeterminable → flat fallback, no throw, never v1.0', () => {
258
+ const fixture = v2Fixture('# State (no milestone frontmatter)', null);
259
+ try {
260
+ let result;
261
+ assert.doesNotThrow(() => { result = phasesDir(fixture.cwd); });
262
+ assert.equal(result, path.join('projects', 'auth-overhaul', 'phases'));
263
+ assert.ok(!result.includes('v1.0'), `must never default to v1.0: ${result}`);
264
+ } finally {
265
+ fixture.cleanup();
266
+ }
267
+ });
268
+
269
+ it('Test D: flat/v1 layout unchanged (regression guard)', () => {
270
+ const fixture = createFixture({
271
+ 'config.json': JSON.stringify({}),
272
+ 'STATE.md': '# State',
273
+ 'ROADMAP.md': '# Roadmap',
274
+ 'phases/': null,
275
+ });
276
+ try {
277
+ const result = phasesDir(fixture.cwd);
278
+ assert.equal(result, 'phases');
279
+ } finally {
280
+ fixture.cleanup();
281
+ }
282
+ });
283
+ });
284
+
285
+ // ─── findPhaseInternal version-aware archive (LOOK-01) Tests ──────────────────
286
+
287
+ describe('findPhaseInternal version-aware archive (LOOK-01)', () => {
288
+ // Build a v2 project with active phases plus milestone archives. Active phases
289
+ // live (flat, v25.0 invariant) at projects/<slug>/phases/<NN>-slug/; archives
290
+ // live product-level at milestones/<v>-phases/<NN>-slug/. Each phase dir needs
291
+ // at least one *-PLAN.md so searchPhaseInDir matches it.
292
+ function look01Fixture(activePhaseDirs, archives) {
293
+ const tree = {
294
+ 'config.json': JSON.stringify({}),
295
+ 'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
296
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
297
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n',
298
+ 'projects/auth-overhaul/PROJECT.md': '# Project',
299
+ 'projects/auth-overhaul/STATE.md': '---\ncurrent_milestone: v25.0\n---\n# State',
300
+ 'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
301
+ 'projects/auth-overhaul/phases/': null,
302
+ };
303
+ for (const dir of activePhaseDirs || []) {
304
+ tree[`projects/auth-overhaul/phases/${dir}/01-PLAN.md`] = '# Plan';
305
+ }
306
+ // archives: { 'v3.0': ['07-foo'], 'v4.0': ['03-b'], ... }
307
+ for (const [version, dirs] of Object.entries(archives || {})) {
308
+ for (const dir of dirs) {
309
+ tree[`milestones/${version}-phases/${dir}/01-PLAN.md`] = '# Plan';
310
+ }
311
+ }
312
+ return createFixture(tree);
313
+ }
314
+
315
+ it('Test 1: active phase wins (unchanged) — bare number in active phases', () => {
316
+ const fixture = look01Fixture(
317
+ ['03-active-feature'],
318
+ { 'v3.0': ['03-archived-feature'] }
319
+ );
320
+ try {
321
+ const result = findPhaseInternal(fixture.cwd, '03');
322
+ assert.ok(result, 'expected an active match');
323
+ assert.equal(result.found, true);
324
+ assert.equal(result.archived, undefined, 'active match must not carry .archived');
325
+ assert.equal(result.ambiguous, undefined, 'active match must not be ambiguous');
326
+ assert.ok(result.directory.includes('03-active-feature'));
327
+ } finally {
328
+ fixture.cleanup();
329
+ }
330
+ });
331
+
332
+ it('Test 2: unique archive (flat-layout preserved) — bare number in exactly ONE archive', () => {
333
+ const fixture = look01Fixture(
334
+ [],
335
+ { 'v3.0': ['07-foo'] }
336
+ );
337
+ try {
338
+ const result = findPhaseInternal(fixture.cwd, '07');
339
+ assert.ok(result, 'expected a single archived match');
340
+ assert.equal(result.found, true, 'single-archive match preserves found:true');
341
+ assert.equal(result.archived, 'v3.0', 'single-archive match tagged with its version');
342
+ assert.equal(result.ambiguous, undefined, 'single archive is not ambiguous');
343
+ assert.ok(result.directory.includes('07-foo'));
344
+ } finally {
345
+ fixture.cleanup();
346
+ }
347
+ });
348
+
349
+ it('Test 3: cross-milestone collision → structured ambiguity, NOT silent newest', () => {
350
+ const fixture = look01Fixture(
351
+ [],
352
+ { 'v3.0': ['03-a'], 'v4.0': ['03-b'] }
353
+ );
354
+ try {
355
+ const result = findPhaseInternal(fixture.cwd, '03');
356
+ assert.ok(result, 'expected a structured ambiguity object, not null');
357
+ assert.equal(result.found, false, 'ambiguity is found:false');
358
+ assert.equal(result.ambiguous, true, 'ambiguity flag set');
359
+ assert.equal(result.directory, undefined, 'ambiguity must NOT point at either archive dir');
360
+ assert.equal(result.archived, undefined, 'ambiguity carries no single archived tag');
361
+ assert.ok(Array.isArray(result.matches), 'matches is an array');
362
+ assert.equal(result.matches.length, 2, 'both archives listed');
363
+ const versions = result.matches.map(m => m.milestone).sort();
364
+ assert.deepEqual(versions, ['v3.0', 'v4.0'], 'both milestone versions named');
365
+ assert.ok(typeof result.message === 'string' && result.message.length > 0);
366
+ assert.ok(result.message.includes('v3.0'), 'message names v3.0');
367
+ assert.ok(result.message.includes('v4.0'), 'message names v4.0');
368
+ } finally {
369
+ fixture.cleanup();
370
+ }
371
+ });
372
+
373
+ it('Test 4: collision with an active number is irrelevant — active wins', () => {
374
+ const fixture = look01Fixture(
375
+ ['01-active'],
376
+ { 'v3.0': ['01-a'], 'v4.0': ['01-b'] }
377
+ );
378
+ try {
379
+ const result = findPhaseInternal(fixture.cwd, '01');
380
+ assert.ok(result, 'expected the active match');
381
+ assert.equal(result.found, true);
382
+ assert.equal(result.ambiguous, undefined, 'active match short-circuits ambiguity');
383
+ assert.ok(result.directory.includes('01-active'));
384
+ } finally {
385
+ fixture.cleanup();
386
+ }
387
+ });
388
+ });
389
+
148
390
  // ─── Strict v2 Marker Validation Tests ────────────────────────────────────────
149
391
 
150
392
  describe('strict v2 marker validation', () => {
@@ -404,6 +404,13 @@ function detectRepoChanges(cwd, repoNames, useActiveContext) {
404
404
  */
405
405
  function createRepoBranches(cwd, repoNames, branchName, config, baseBranch) {
406
406
 
407
+ // Honour the branching strategy: 'none' disables branch creation entirely.
408
+ // The function accepts config.branching_strategy and must respect it — a
409
+ // 'none' strategy is an explicit signal to create no branches.
410
+ if (config && config.branching_strategy === 'none') {
411
+ return { created: false, reason: 'branching_disabled' };
412
+ }
413
+
407
414
  const parsed = parseReposMd(cwd);
408
415
  const repos = parsed ? parsed.repos : [];
409
416
  const branches = [];
@@ -10,7 +10,7 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { extractNameFromAuthor } = require('./identity.cjs');
13
- const { getMilestonePhaseFilter, getProjectRoot } = require('./core.cjs');
13
+ const { getMilestonePhaseFilter, getProjectRoot, phasesDir } = require('./core.cjs');
14
14
  const { extractFrontmatter } = require('./frontmatter.cjs');
15
15
  const { getPlanningRoot } = require('./paths.cjs');
16
16
 
@@ -58,23 +58,23 @@ function getContributors(cwd) {
58
58
  const planRoot = getPlanningRoot(cwd);
59
59
  const projectRootRel = getProjectRoot(cwd);
60
60
  const projectRoot = path.join(planRoot, projectRootRel);
61
- const phasesDir = path.join(projectRoot, 'phases');
61
+ const phasesAbs = path.join(cwd, phasesDir(cwd));
62
62
  const isDirInMilestone = getMilestonePhaseFilter(cwd);
63
63
 
64
- if (fs.existsSync(phasesDir)) {
65
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
64
+ if (fs.existsSync(phasesAbs)) {
65
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
66
66
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
67
67
 
68
68
  for (const dir of dirs) {
69
69
  if (!isDirInMilestone(dir)) continue;
70
70
 
71
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
71
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, dir));
72
72
 
73
73
  // Read SUMMARY.md executed_by
74
74
  const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
75
75
  for (const s of summaries) {
76
76
  try {
77
- const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
77
+ const content = fs.readFileSync(path.join(phasesAbs, dir, s), 'utf-8');
78
78
  const fm = extractFrontmatter(content);
79
79
  addContributor(fm.executed_by);
80
80
  } catch { /* skip unreadable files */ }
@@ -84,7 +84,7 @@ function getContributors(cwd) {
84
84
  const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
85
85
  for (const p of plans) {
86
86
  try {
87
- const content = fs.readFileSync(path.join(phasesDir, dir, p), 'utf-8');
87
+ const content = fs.readFileSync(path.join(phasesAbs, dir, p), 'utf-8');
88
88
  const fm = extractFrontmatter(content);
89
89
  addContributor(fm.created_by);
90
90
  } catch { /* skip unreadable files */ }