@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
@@ -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 */ }
@@ -5,7 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { execSync } = require('child_process');
8
- const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error, resolveProjectPath, getProjectRoot, isV2Install, getProjectFolders, getV2Hint, safeReadFile } = require('./core.cjs');
8
+ const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosixPath, output, error, resolveProjectPath, getProjectRoot, phasesDir, isV2Install, getProjectFolders, getV2Hint, safeReadFile, isValidMilestoneVersion } = require('./core.cjs');
9
9
  const { requireGitIdentity, formatAuthorString } = require('./identity.cjs');
10
10
  const { getPlanningRoot, PROJECTS_DIR } = require('./paths.cjs');
11
11
  const { parseReposMd, validateReposMdEager } = require('./repos.cjs');
@@ -242,7 +242,7 @@ function cmdInitExecutePhase(cwd, phase, raw) {
242
242
  const config = loadConfig(cwd);
243
243
  const ctx = resolveProjectContext(cwd);
244
244
  let phaseInfo = findPhaseInternal(cwd, phase);
245
- if (phaseInfo?.archived) phaseInfo = null;
245
+ if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
246
246
  const milestone = getMilestoneInfo(cwd);
247
247
  const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
248
248
 
@@ -348,7 +348,7 @@ function cmdInitPlanPhase(cwd, phase, raw) {
348
348
  const config = loadConfig(cwd);
349
349
  const ctx = resolveProjectContext(cwd);
350
350
  let phaseInfo = findPhaseInternal(cwd, phase);
351
- if (phaseInfo?.archived) phaseInfo = null;
351
+ if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
352
352
  const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
353
353
 
354
354
  const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
@@ -556,8 +556,11 @@ function cmdInitNewMilestone(cwd, raw) {
556
556
  cadence_pull: getCadence('new-milestone').pull,
557
557
  cadence_push: getCadence('new-milestone').push,
558
558
 
559
- // Current milestone
560
- current_milestone: milestone.version,
559
+ // Current milestone — current_milestone is the authoritative structured
560
+ // version signal. new-milestone (this flow) and init.cjs are the SOLE
561
+ // setters. Emit only a grammar-valid value; surface a gap as null rather
562
+ // than persisting an out-of-grammar value (and NEVER coerce to v1.0).
563
+ current_milestone: isValidMilestoneVersion(milestone.version) ? milestone.version : null,
561
564
  current_milestone_name: milestone.name,
562
565
 
563
566
  // File existence (project-qualified)
@@ -747,7 +750,7 @@ function cmdInitVerifyWork(cwd, phase, raw) {
747
750
  const config = loadConfig(cwd);
748
751
  const ctx = resolveProjectContext(cwd);
749
752
  let phaseInfo = findPhaseInternal(cwd, phase);
750
- if (phaseInfo?.archived) phaseInfo = null;
753
+ if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
751
754
 
752
755
  const result = {
753
756
  // Models
@@ -792,7 +795,7 @@ function cmdInitAuditPhase(cwd, phase, raw) {
792
795
  const config = loadConfig(cwd);
793
796
  const ctx = resolveProjectContext(cwd);
794
797
  let phaseInfo = findPhaseInternal(cwd, phase);
795
- if (phaseInfo?.archived) phaseInfo = null;
798
+ if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
796
799
 
797
800
  const result = {
798
801
  // Models
@@ -834,7 +837,7 @@ function cmdInitPhaseOp(cwd, phase, raw, workflow) {
834
837
  const ctx = resolveProjectContext(cwd);
835
838
  const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
836
839
  let phaseInfo = findPhaseInternal(cwd, phase);
837
- if (phaseInfo?.archived) phaseInfo = null;
840
+ if (phaseInfo?.archived || phaseInfo?.ambiguous) phaseInfo = null;
838
841
  const cadence = getCadence(workflow || 'plan-phase');
839
842
 
840
843
  // Fallback to ROADMAP.md if no directory exists (e.g., Plans: TBD)
@@ -858,6 +861,9 @@ function cmdInitPhaseOp(cwd, phase, raw, workflow) {
858
861
  }
859
862
  }
860
863
 
864
+ let phasesDirRel;
865
+ try { phasesDirRel = phasesDir(cwd); } catch { phasesDirRel = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'); }
866
+
861
867
  const result = {
862
868
  // Config
863
869
  commit_docs: config.commit_docs,
@@ -891,7 +897,7 @@ function cmdInitPhaseOp(cwd, phase, raw, workflow) {
891
897
  roadmap_path: ctx.root ? path.join(ctx.root, 'ROADMAP.md') : path.join(planRootRel, 'ROADMAP.md'),
892
898
  requirements_path: ctx.root ? path.join(ctx.root, 'REQUIREMENTS.md') : path.join(planRootRel, 'REQUIREMENTS.md'),
893
899
  project_path: ctx.root ? path.join(ctx.root, 'PROJECT.md') : path.join(planRootRel, 'PROJECT.md'),
894
- phases_dir: ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'),
900
+ phases_dir: phasesDirRel,
895
901
 
896
902
  // Author
897
903
  author: resolveAuthorSafe(cwd),
@@ -1055,17 +1061,18 @@ function cmdInitMilestoneOp(cwd, raw, workflow) {
1055
1061
  // Count phases (project-qualified)
1056
1062
  let phaseCount = 0;
1057
1063
  let completedPhases = 0;
1058
- const phasesBase = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases');
1059
- const phasesDir = path.join(cwd, phasesBase);
1064
+ let phasesBase;
1065
+ try { phasesBase = phasesDir(cwd); } catch { phasesBase = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'); }
1066
+ const phasesAbs = path.join(cwd, phasesBase);
1060
1067
  try {
1061
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1068
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
1062
1069
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1063
1070
  phaseCount = dirs.length;
1064
1071
 
1065
1072
  // Count phases with summaries (completed)
1066
1073
  for (const dir of dirs) {
1067
1074
  try {
1068
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
1075
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, dir));
1069
1076
  const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1070
1077
  if (hasSummary) completedPhases++;
1071
1078
  } catch {}
@@ -1251,14 +1258,15 @@ function cmdInitProgress(cwd, raw) {
1251
1258
  const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
1252
1259
 
1253
1260
  // Analyze phases (project-qualified)
1254
- const phasesBase = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases');
1255
- const phasesDir = path.join(cwd, phasesBase);
1261
+ let phasesBase;
1262
+ try { phasesBase = phasesDir(cwd); } catch { phasesBase = ctx.root ? path.join(ctx.root, 'phases') : path.join(planRootRel, 'phases'); }
1263
+ const phasesAbs = path.join(cwd, phasesBase);
1256
1264
  const phases = [];
1257
1265
  let currentPhase = null;
1258
1266
  let nextPhase = null;
1259
1267
 
1260
1268
  try {
1261
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1269
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
1262
1270
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
1263
1271
 
1264
1272
  for (const dir of dirs) {
@@ -1266,7 +1274,7 @@ function cmdInitProgress(cwd, raw) {
1266
1274
  const phaseNumber = match ? match[1] : dir;
1267
1275
  const phaseName = match && match[2] ? match[2] : null;
1268
1276
 
1269
- const phasePath = path.join(phasesDir, dir);
1277
+ const phasePath = path.join(phasesAbs, dir);
1270
1278
  const phaseFiles = fs.readdirSync(phasePath);
1271
1279
 
1272
1280
  const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
@@ -1657,16 +1657,6 @@ describe('cmdInitQuick quick_dir resolution', () => {
1657
1657
  beforeEach(() => {
1658
1658
  fixture = createFixture({
1659
1659
  'config.json': JSON.stringify({ current_project: 'test-project' }),
1660
- 'config.local.json': JSON.stringify({
1661
- execution: { active_context: 'v19-git-worktrees' },
1662
- projects: {
1663
- 'test-project': {
1664
- worktrees: {
1665
- 'v19-git-worktrees': { type: 'milestone' }
1666
- }
1667
- }
1668
- }
1669
- }),
1670
1660
  'PROJECTS.md': '# Projects\n\n| Project | Status |\n|---------|--------|\n| test-project | Active |\n',
1671
1661
  'REPOS.md': '# Repos\n\n| Name | Path |\n|------|------|\n',
1672
1662
  'projects/test-project/STATE.md': '# State',
@@ -1676,6 +1666,35 @@ describe('cmdInitQuick quick_dir resolution', () => {
1676
1666
  'projects/test-project/phases/01-test-phase/01-CONTEXT.md': '# Context',
1677
1667
  'projects/test-project/phases/01-test-phase/01-01-PLAN.md': '---\nphase: 01-test-phase\nplan: 01\n---\n# Plan',
1678
1668
  });
1669
+
1670
+ // Establish a GENUINE milestone context. detectQuickMode treats a
1671
+ // milestone worktree entry as active only when its `repos` map points at
1672
+ // an on-disk directory; a bare { type: 'milestone' } entry with no live
1673
+ // repos is treated as stale and falls through to PRODUCT mode (the
1674
+ // documented stale-defence from bug 260507-pdp). The previous fixture
1675
+ // omitted `repos`, so it silently exercised product mode and the
1676
+ // project-scoped expectations below could never pass. Create a real
1677
+ // worktree dir inside the fixture (cleaned up with it) and point the
1678
+ // entry at it so milestone-context resolution actually fires.
1679
+ const worktreeDir = path.join(fixture.cwd, 'worktrees', 'v19-git-worktrees');
1680
+ fs.mkdirSync(worktreeDir, { recursive: true });
1681
+ fs.writeFileSync(
1682
+ path.join(fixture.cwd, 'config.local.json'),
1683
+ JSON.stringify({
1684
+ execution: { active_context: 'v19-git-worktrees' },
1685
+ projects: {
1686
+ 'test-project': {
1687
+ worktrees: {
1688
+ 'v19-git-worktrees': {
1689
+ type: 'milestone',
1690
+ repos: { 'deliver-great-systems': worktreeDir },
1691
+ },
1692
+ },
1693
+ },
1694
+ },
1695
+ }),
1696
+ 'utf-8'
1697
+ );
1679
1698
  });
1680
1699
 
1681
1700
  afterEach(() => {
@@ -2196,3 +2215,43 @@ describe('v2 mode multi-project: end-to-end', () => {
2196
2215
  }
2197
2216
  });
2198
2217
  });
2218
+
2219
+ // ─── LOOK-01: init coerces a cross-milestone-ambiguous bare number to not-found ─
2220
+
2221
+ describe('init execute-phase coerces ambiguous archive number to not-found (LOOK-01)', () => {
2222
+ // A v2 project where bare "03" is NOT an active phase but exists in TWO
2223
+ // milestone archives. findPhaseInternal returns a {found:false, ambiguous:true}
2224
+ // signal; the init.cjs coercion must treat it as not-found rather than silently
2225
+ // adopting one archive's directory (the pre-fix Group C bug).
2226
+ function ambiguousArchiveFixture() {
2227
+ return createFixture({
2228
+ 'config.json': JSON.stringify({}),
2229
+ 'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
2230
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n|---------|--------|\n| auth-overhaul | Active |\n',
2231
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n|------|------|\n',
2232
+ 'projects/auth-overhaul/STATE.md': '---\ncurrent_milestone: v25.0\n---\n# State',
2233
+ 'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
2234
+ 'projects/auth-overhaul/REQUIREMENTS.md': '# Requirements',
2235
+ 'projects/auth-overhaul/PROJECT.md': '# Project',
2236
+ 'projects/auth-overhaul/phases/': null,
2237
+ 'milestones/v3.0-phases/03-alpha/01-PLAN.md': '# Plan',
2238
+ 'milestones/v4.0-phases/03-beta/01-PLAN.md': '# Plan',
2239
+ });
2240
+ }
2241
+
2242
+ it('does NOT silently resolve to either archive directory', () => {
2243
+ const fixture = ambiguousArchiveFixture();
2244
+ try {
2245
+ const result = runInit(fixture.cwd, 'execute-phase 03');
2246
+ // Coerced to not-found: the ambiguity object was nulled out.
2247
+ assert.equal(result.phase_found, false, 'ambiguous number must be not-found');
2248
+ assert.equal(result.phase_dir, null, 'no archive directory adopted');
2249
+ // Belt-and-suspenders: neither archived dir leaked into the result.
2250
+ const blob = JSON.stringify(result);
2251
+ assert.ok(!blob.includes('03-alpha'), 'must not adopt v3.0 archive dir');
2252
+ assert.ok(!blob.includes('03-beta'), 'must not adopt v4.0 archive dir');
2253
+ } finally {
2254
+ fixture.cleanup();
2255
+ }
2256
+ });
2257
+ });