@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
package/CHANGELOG.md CHANGED
@@ -8,6 +8,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [3.5.0] - 2026-06-27
12
+
13
+ ### Added — v25.0 Per-Milestone Phase Numbering via Directory Namespacing (Phases 163-170)
14
+ - **Phase numbers now restart at `01` per milestone**, stored in version-namespaced directories `phases/<version>/NN-slug/` instead of one global flat `phases/`. The product-global version sequence (`vX.Y`) is unchanged — only phase *numbers* became milestone-local. Pre-existing flat-layout projects keep working (dual-mode), so no migration is forced.
15
+ - **Authoritative version signal** — the active milestone version is sourced from STATE.md `current_milestone` (grammar-validated `^v\d+\.\d+$`, fail-loud, never silently defaulting to `v1.0`); the ROADMAP marker is advisory, and on mismatch the resolver warns and trusts STATE.md. `new-milestone` + `init.cjs` are the sole setters.
16
+ - **Canonical version-aware resolver** — a single `phasesDir(cwd)` returns `phases/<version>/` when that directory exists and falls back to flat `phases/` otherwise, consolidating phase-path logic that was previously duplicated across the init/commands/context/state/phase/roadmap/jobs/governance/milestone modules.
17
+ - **Version-aware lookup, state context, and structural archival**, with dual-mode (versioned + legacy-flat) test fixtures and the full library test suite greened.
18
+
19
+ ### Added
20
+ - **Ad-hoc milestones support the full phase lifecycle (quick-260627-mv4)** — `milestone create-adhoc` now atomically seeds the planning scaffolding it previously skipped: it writes `current_milestone` to STATE.md, creates the versioned `phases/<version>/` directory, and seeds an `## Active Milestone` ROADMAP section. A hand-added phase therefore flows through `/dgs:add-phase` → `plan-phase` → `execute-phase` inside an ad-hoc container, with no requirements/roadmapper ceremony. `abandon-milestone` mirrors the teardown (removes the seeded versioned dir and restores the docs), and the three new artifacts fold into create-adhoc's existing atomic rollback so a failed creation leaves no residue.
21
+
22
+ ### Fixed
23
+ - **Workflow-discipline hook self-heals stale installs (quick-260627-o37)** — the installer's PreToolUse registration now upgrades an existing `dgs-enforce-discipline` entry whose matcher lacks `Bash` by appending `|Bash` in place, instead of only adding the entry when absent. Previously a machine that installed before the `Bash` matcher existed kept the stale `Edit|Write|Skill` matcher forever — the marker-on-init branch never fired for inline `/dgs:*` flows, so Edit/Write was falsely blocked, and `/dgs:update` could not fix it. Idempotent once `Bash` is present.
24
+
25
+ ## [3.4.2] - 2026-06-25
26
+
27
+ ### Fixed
28
+ - **Shipped workflows no longer hardcode the author's home path (quick-260625-0b3)** — `workflows/init-product.md` (8 sites) and `workflows/execute-plan.md` (1 site) invoked `node`/`cat` against the literal `/Users/adrian/.claude/deliver-great-systems/...`, so on any other user's machine those `dgs-tools` calls and the CLAUDE.md template read pointed at a nonexistent directory and failed. Replaced every occurrence with the portable `~/.claude/deliver-great-systems/...` form already used by all other workflows. No code or config files were affected — this was the only path leak in the distribution; `git grep /Users/adrian` over the shipped workflows now returns nothing.
29
+
11
30
  ## [3.4.1] - 2026-06-12
12
31
 
13
32
  ### Fixed
package/README.md CHANGED
@@ -612,6 +612,8 @@ See the [User Guide](docs/USER-GUIDE.md#context-tiers) for the complete command-
612
612
 
613
613
  ### Testing & Dependency Scanning
614
614
 
615
+ Run the full automated test suite (repo-root `tests/` plus the nested `deliver-great-systems/bin/lib/` library tree) with a single command: `npm test`. To run only the library tree: `npm run test:lib`.
616
+
615
617
  | Command | What it does |
616
618
  |---------|--------------|
617
619
  | `/dgs:package-scan [--threshold critical\|high\|medium\|low] [--repo <name>] [--json] [--include-dev-deps\|--no-include-dev-deps]` | Scan every registered repo + product root for known dependency vulnerabilities and licence issues. Cascades Snyk → OSV-Scanner → ecosystem-native tool (`npm audit`, `pip-audit`, `govulncheck`, `bundler-audit`). Report is committed to the active phase dir, active milestone dir, or a timestamped project-root file. See `deliver-great-systems/references/package-scan-config.md` for config keys and installation. |
@@ -11,7 +11,7 @@ You are a DGS codebase cross-analyzer. You read per-repo codebase documents and
11
11
  You are spawned by `/dgs:map-codebase` after all synthesizer agents have completed. There is one cross-analyzer instance per mapping run.
12
12
 
13
13
  You receive these prompt variables from the orchestrator:
14
- - **codebase_dir**: Path to the codebase directory (e.g., `.planning/codebase`)
14
+ - **codebase_dir**: Path to the codebase directory (e.g., `codebase/` under the planning root)
15
15
  - **repo_names**: JSON array of repo names that were mapped (e.g., `["business", "newarch"]`)
16
16
 
17
17
  Your job: Read per-repo maps, compare across repos, write CROSS-REPO.md with comparison tables, return confirmation only.
@@ -156,7 +156,7 @@ Read key files identified during exploration. Use Glob and Grep with paths under
156
156
  <step name="write_documents">
157
157
  Write document(s) to `${codebase_dir}/` using the templates below.
158
158
 
159
- **Note:** `${codebase_dir}` is set by the orchestrator and already includes the repo subdirectory path (e.g., `.planning/codebase/business/`). Write directly to it.
159
+ **Note:** `${codebase_dir}` is set by the orchestrator and already includes the repo subdirectory path (e.g., `codebase/business/`). Write directly to it.
160
160
 
161
161
  **Document naming:** UPPERCASE.md (e.g., STACK.md, ARCHITECTURE.md)
162
162
 
@@ -11,7 +11,7 @@ You are a DGS codebase synthesizer. You read per-repo codebase documents and pro
11
11
  You are spawned by `/dgs:map-codebase` after all per-repo mapper agents have completed. Each synthesizer instance handles one document type.
12
12
 
13
13
  You receive these prompt variables from the orchestrator:
14
- - **codebase_dir**: Path to the codebase directory (e.g., `.planning/codebase`)
14
+ - **codebase_dir**: Path to the codebase directory (e.g., `codebase/` under the planning root)
15
15
  - **repo_names**: List of repo names that were mapped (e.g., `["business", "newarch"]`)
16
16
  - **doc_type**: Which document to synthesize (one of: ARCHITECTURE, STACK, STRUCTURE, CONVENTIONS, TESTING, INTEGRATIONS, CONCERNS)
17
17
 
@@ -306,7 +306,7 @@ Verified patterns from official sources:
306
306
 
307
307
  ## Validation Architecture
308
308
 
309
- > Skip this section entirely if workflow.nyquist_validation is explicitly set to false in .planning/config.json. If the key is absent, treat as enabled.
309
+ > Skip this section entirely if workflow.nyquist_validation is explicitly set to false in `${config_path}`. If the key is absent, treat as enabled.
310
310
 
311
311
  ### Test Framework
312
312
  | Property | Value |
package/bin/install.js CHANGED
@@ -1816,11 +1816,11 @@ function install(isGlobal, runtime = 'claude') {
1816
1816
  settings.hooks.PreToolUse = [];
1817
1817
  }
1818
1818
 
1819
- const hasDisciplineHook = settings.hooks.PreToolUse.some(entry =>
1819
+ const disciplineHookEntry = settings.hooks.PreToolUse.find(entry =>
1820
1820
  entry.hooks && entry.hooks.some(h => h.command && h.command.includes('dgs-enforce-discipline'))
1821
1821
  );
1822
1822
 
1823
- if (!hasDisciplineHook) {
1823
+ if (!disciplineHookEntry) {
1824
1824
  settings.hooks.PreToolUse.push({
1825
1825
  matcher: 'Edit|Write|Skill|Bash',
1826
1826
  hooks: [
@@ -1831,6 +1831,16 @@ function install(isGlobal, runtime = 'claude') {
1831
1831
  ]
1832
1832
  });
1833
1833
  console.log(` ${green}✓${reset} Configured workflow discipline hook`);
1834
+ } else if (
1835
+ typeof disciplineHookEntry.matcher === 'string' &&
1836
+ !disciplineHookEntry.matcher.split('|').includes('Bash')
1837
+ ) {
1838
+ // Self-heal stale installs: older versions registered this matcher without
1839
+ // Bash, so the marker-on-init branch never fired for inline /dgs:* flows and
1840
+ // Edit/Write got falsely blocked. Upgrade the existing entry in place so a
1841
+ // plain /dgs:update fixes it. Idempotent once Bash is present.
1842
+ disciplineHookEntry.matcher = disciplineHookEntry.matcher + '|Bash';
1843
+ console.log(` ${green}✓${reset} Upgraded workflow discipline hook matcher (added Bash)`);
1834
1844
  }
1835
1845
 
1836
1846
  // Configure PostToolUse hook for discipline marker cleanup (Skill completion)
@@ -15,6 +15,7 @@
15
15
  * state patch --field val ... Batch update STATE.md fields
16
16
  * state archive-quick-tasks Archive excess quick task rows to HISTORY.md
17
17
  * state mark-milestone-complete Mark milestone complete in STATE.md
18
+ * state reconcile-milestone Self-heal a shipped-but-not-flipped STATE.md
18
19
  * resolve-model <agent-type> Get model for agent based on profile
19
20
  * find-phase <phase> Find phase directory by number
20
21
  * commit <message> [--files f1 f2] Commit planning docs
@@ -37,6 +38,7 @@
37
38
  * Phase Operations:
38
39
  * phase next-decimal <phase> Calculate next decimal phase number
39
40
  * phase add <description> Append new phase to roadmap + create dir
41
+ * phase init-versioned-dir Create current milestone's phases/<version>/ (fail-loud; new-milestone only)
40
42
  * phase insert <after> <description> Insert decimal phase after existing
41
43
  * phase remove <phase> [--force] Remove phase, renumber all subsequent
42
44
  * phase complete <phase> Mark phase done, update state + roadmap
@@ -708,6 +710,8 @@ async function main() {
708
710
  state.cmdStateArchiveQuickTasks(cwd, raw);
709
711
  } else if (subcommand === 'mark-milestone-complete') {
710
712
  state.cmdMarkMilestoneComplete(cwd, raw);
713
+ } else if (subcommand === 'reconcile-milestone') {
714
+ state.cmdReconcileMilestone(cwd, raw);
711
715
  } else if (subcommand === 'read-adhoc') {
712
716
  state.cmdStateReadAdhoc(cwd, raw);
713
717
  } else if (subcommand === 'adhoc-readiness') {
@@ -1178,6 +1182,8 @@ async function main() {
1178
1182
  phase.cmdPhaseNextDecimal(cwd, args[2], raw);
1179
1183
  } else if (subcommand === 'add') {
1180
1184
  phase.cmdPhaseAdd(cwd, args.slice(2).join(' '), raw);
1185
+ } else if (subcommand === 'init-versioned-dir') {
1186
+ phase.cmdPhaseInitVersionedDir(cwd, raw);
1181
1187
  } else if (subcommand === 'insert') {
1182
1188
  phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
1183
1189
  } else if (subcommand === 'remove') {
@@ -1189,7 +1195,7 @@ async function main() {
1189
1195
  const push = args.includes('--push');
1190
1196
  phase.cmdPhaseFinalize(cwd, args[2], { push }, raw);
1191
1197
  } else {
1192
- error('Unknown phase subcommand. Available: next-decimal, add, insert, remove, complete, finalize');
1198
+ error('Unknown phase subcommand. Available: next-decimal, add, init-versioned-dir, insert, remove, complete, finalize');
1193
1199
  }
1194
1200
  break;
1195
1201
  }
@@ -4,7 +4,7 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { execSync } = require('child_process');
7
- const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getProjectRoot, resolveModelInternal, MODEL_PROFILES, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs');
7
+ const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getProjectRoot, phasesDir, resolveMilestoneVersion, resolveModelInternal, MODEL_PROFILES, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs');
8
8
  const { extractFrontmatter, spliceFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
9
9
  const { getPlanningRoot } = require('./paths.cjs');
10
10
 
@@ -146,15 +146,35 @@ function cmdVerifyPathExists(cwd, targetPath, raw) {
146
146
  }
147
147
 
148
148
  function cmdHistoryDigest(cwd, raw) {
149
- let phasesDir;
149
+ let phasesAbs;
150
150
  try {
151
- const projectRoot = getProjectRoot(cwd);
152
- phasesDir = path.join(cwd, projectRoot, 'phases');
151
+ phasesAbs = path.join(cwd, phasesDir(cwd));
153
152
  } catch {
154
- phasesDir = path.join(getPlanningRoot(cwd), 'phases');
153
+ phasesAbs = path.join(getPlanningRoot(cwd), 'phases');
155
154
  }
156
155
  const digest = { phases: {}, decisions: [], tech_stack: new Set() };
157
156
 
157
+ // Output shape (LOOK-02, composite {milestone, phase} key):
158
+ // Phases are keyed by a composite of milestone + phase number so a restarted
159
+ // phase number that exists in two milestones is NEVER conflated into one bucket.
160
+ // - Milestone-qualified entries (archived dirs, and current-phase entries once
161
+ // the current milestone is resolvable) → key = "<milestone>/<phase>"
162
+ // e.g. digest.phases['v4.0/03']
163
+ // - Entries with no resolvable milestone (flat-layout repos) → key = "<phase>"
164
+ // e.g. digest.phases['03'] — byte-identical to the pre-LOOK-02 shape, so
165
+ // flat installs see no data loss and no key change.
166
+ // decisions[] entries carry both `phase` (readable) and `milestone` so two
167
+ // milestones' same-numbered decisions are unambiguous.
168
+
169
+ // Resolve the current milestone once (non-required: validated vN.N or null,
170
+ // never throws on a read path — flat repos must keep working).
171
+ let currentMilestone = null;
172
+ try {
173
+ currentMilestone = resolveMilestoneVersion(cwd);
174
+ } catch {
175
+ currentMilestone = null;
176
+ }
177
+
158
178
  // Collect all phase directories: archived + current
159
179
  const allPhaseDirs = [];
160
180
 
@@ -164,15 +184,16 @@ function cmdHistoryDigest(cwd, raw) {
164
184
  allPhaseDirs.push({ name: a.name, fullPath: a.fullPath, milestone: a.milestone });
165
185
  }
166
186
 
167
- // Add current phases
168
- if (fs.existsSync(phasesDir)) {
187
+ // Add current phases — assign the current milestone (archived entries already
188
+ // carry their own milestone; current entries are milestone: null until now).
189
+ if (fs.existsSync(phasesAbs)) {
169
190
  try {
170
- const currentDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
191
+ const currentDirs = fs.readdirSync(phasesAbs, { withFileTypes: true })
171
192
  .filter(e => e.isDirectory())
172
193
  .map(e => e.name)
173
194
  .sort();
174
195
  for (const dir of currentDirs) {
175
- allPhaseDirs.push({ name: dir, fullPath: path.join(phasesDir, dir), milestone: null });
196
+ allPhaseDirs.push({ name: dir, fullPath: path.join(phasesAbs, dir), milestone: currentMilestone });
176
197
  }
177
198
  } catch {}
178
199
  }
@@ -184,7 +205,7 @@ function cmdHistoryDigest(cwd, raw) {
184
205
  }
185
206
 
186
207
  try {
187
- for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
208
+ for (const { name: dir, fullPath: dirPath, milestone } of allPhaseDirs) {
188
209
  const summaries = fs.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
189
210
 
190
211
  for (const summary of summaries) {
@@ -193,9 +214,12 @@ function cmdHistoryDigest(cwd, raw) {
193
214
  const fm = extractFrontmatter(content);
194
215
 
195
216
  const phaseNum = fm.phase || dir.split('-')[0];
217
+ // Composite key: "<milestone>/<phase>" when a milestone is present,
218
+ // else the bare phase number (flat-layout byte-identical behaviour).
219
+ const phaseKey = milestone ? `${milestone}/${phaseNum}` : phaseNum;
196
220
 
197
- if (!digest.phases[phaseNum]) {
198
- digest.phases[phaseNum] = {
221
+ if (!digest.phases[phaseKey]) {
222
+ digest.phases[phaseKey] = {
199
223
  name: fm.name || dir.split('-').slice(1).join(' ') || 'Unknown',
200
224
  provides: new Set(),
201
225
  affects: new Set(),
@@ -205,25 +229,26 @@ function cmdHistoryDigest(cwd, raw) {
205
229
 
206
230
  // Merge provides
207
231
  if (fm['dependency-graph'] && fm['dependency-graph'].provides) {
208
- fm['dependency-graph'].provides.forEach(p => digest.phases[phaseNum].provides.add(p));
232
+ fm['dependency-graph'].provides.forEach(p => digest.phases[phaseKey].provides.add(p));
209
233
  } else if (fm.provides) {
210
- fm.provides.forEach(p => digest.phases[phaseNum].provides.add(p));
234
+ fm.provides.forEach(p => digest.phases[phaseKey].provides.add(p));
211
235
  }
212
236
 
213
237
  // Merge affects
214
238
  if (fm['dependency-graph'] && fm['dependency-graph'].affects) {
215
- fm['dependency-graph'].affects.forEach(a => digest.phases[phaseNum].affects.add(a));
239
+ fm['dependency-graph'].affects.forEach(a => digest.phases[phaseKey].affects.add(a));
216
240
  }
217
241
 
218
242
  // Merge patterns
219
243
  if (fm['patterns-established']) {
220
- fm['patterns-established'].forEach(p => digest.phases[phaseNum].patterns.add(p));
244
+ fm['patterns-established'].forEach(p => digest.phases[phaseKey].patterns.add(p));
221
245
  }
222
246
 
223
- // Merge decisions
247
+ // Merge decisions — tag with milestone so two milestones' same-numbered
248
+ // decisions are unambiguous; keep `phase` for readability.
224
249
  if (fm['key-decisions']) {
225
250
  fm['key-decisions'].forEach(d => {
226
- digest.decisions.push({ phase: phaseNum, decision: d });
251
+ digest.decisions.push({ milestone: milestone || null, phase: phaseNum, decision: d });
227
252
  });
228
253
  }
229
254
 
@@ -622,7 +647,10 @@ function cmdPlanFinalize(cwd, phaseNum, planNum, options, raw) {
622
647
 
623
648
  // Resolve phase dir via findPhaseInternal (returns phases/NN-name relative path)
624
649
  const phaseInfo = findPhaseInternal(cwd, phaseNum);
625
- if (!phaseInfo) error(`Phase ${phaseNum} not found`);
650
+ // Guard on `found`: a LOOK-01 ambiguity object is truthy but has no .directory,
651
+ // so a bare `!phaseInfo` guard would deref undefined and crash. Surface its
652
+ // milestone-qualified message instead.
653
+ if (!phaseInfo || !phaseInfo.found) error(phaseInfo?.message || `Phase ${phaseNum} not found`);
626
654
  const phaseDir = path.join(cwd, phaseInfo.directory);
627
655
 
628
656
  // Locate PLAN.md + SUMMARY.md within the phase dir using `${phaseNum}-${planNum}` prefix
@@ -862,15 +890,15 @@ async function cmdWebsearch(query, options, raw) {
862
890
  }
863
891
 
864
892
  function cmdProgressRender(cwd, format, raw) {
865
- let phasesDir, roadmapPath;
893
+ let phasesAbs, roadmapPath;
866
894
  try {
867
895
  const projectRoot = getProjectRoot(cwd);
868
896
  const projectAbs = path.join(cwd, projectRoot);
869
- phasesDir = path.join(projectAbs, 'phases');
897
+ phasesAbs = path.join(cwd, phasesDir(cwd));
870
898
  roadmapPath = path.join(projectAbs, 'ROADMAP.md');
871
899
  } catch {
872
900
  const planRoot = getPlanningRoot(cwd);
873
- phasesDir = path.join(planRoot, 'phases');
901
+ phasesAbs = path.join(planRoot, 'phases');
874
902
  roadmapPath = path.join(planRoot, 'ROADMAP.md');
875
903
  }
876
904
  const milestone = getMilestoneInfo(cwd);
@@ -880,14 +908,14 @@ function cmdProgressRender(cwd, format, raw) {
880
908
  let totalSummaries = 0;
881
909
 
882
910
  try {
883
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
911
+ const entries = fs.readdirSync(phasesAbs, { withFileTypes: true });
884
912
  const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
885
913
 
886
914
  for (const dir of dirs) {
887
915
  const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
888
916
  const phaseNum = dm ? dm[1] : dir;
889
917
  const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
890
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
918
+ const phaseFiles = fs.readdirSync(path.join(phasesAbs, dir));
891
919
  const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
892
920
  const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
893
921
 
@@ -1029,10 +1057,12 @@ function cmdScaffold(cwd, type, options, raw) {
1029
1057
 
1030
1058
  // Find phase directory
1031
1059
  const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null;
1032
- const phaseDir = phaseInfo ? path.join(cwd, phaseInfo.directory) : null;
1060
+ // Gate phaseDir on `found`+`directory`: a LOOK-01 ambiguity object is truthy
1061
+ // but carries no .directory, so `path.join(cwd, undefined)` would throw.
1062
+ const phaseDir = (phaseInfo && phaseInfo.found && phaseInfo.directory) ? path.join(cwd, phaseInfo.directory) : null;
1033
1063
 
1034
1064
  if (phase && !phaseDir && type !== 'phase-dir') {
1035
- error(`Phase ${phase} directory not found`);
1065
+ error(phaseInfo?.message || `Phase ${phase} directory not found`);
1036
1066
  }
1037
1067
 
1038
1068
  let filePath, content;
@@ -1059,12 +1089,19 @@ function cmdScaffold(cwd, type, options, raw) {
1059
1089
  }
1060
1090
  const slug = generateSlugInternal(name);
1061
1091
  const dirName = `${padded}-${slug}`;
1062
- const phasesParent = path.join(getPlanningRoot(cwd), 'phases');
1092
+ const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
1093
+ // RSLV-03 residue (Phase 170): create the phase dir through the canonical
1094
+ // version-aware resolver so the dir we CREATE is the dir we REPORT. Under a
1095
+ // versioned layout this mkdir's phases/<version>/NN-slug/, not the flat root.
1096
+ let phasesRel;
1097
+ try { phasesRel = phasesDir(cwd); }
1098
+ catch { phasesRel = path.join(planRootRel, 'phases'); }
1099
+ const phasesParent = path.join(cwd, phasesRel);
1063
1100
  fs.mkdirSync(phasesParent, { recursive: true });
1064
1101
  const dirPath = path.join(phasesParent, dirName);
1065
1102
  fs.mkdirSync(dirPath, { recursive: true });
1066
- const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
1067
- output({ created: true, directory: path.join(planRootRel, 'phases', dirName), path: dirPath }, raw, dirPath);
1103
+ const dirRel = path.join(phasesRel, dirName);
1104
+ output({ created: true, directory: dirRel, path: dirPath }, raw, dirPath);
1068
1105
  return;
1069
1106
  }
1070
1107
  default:
@@ -11,7 +11,7 @@ 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
15
 
16
16
  // ─── Helpers ──────────────────────────────────────────────────────────────────
17
17
 
@@ -774,3 +774,223 @@ describe('cmdListTodos flat-first scanning', () => {
774
774
  }
775
775
  });
776
776
  });
777
+
778
+ // ─── findPhaseInternal ambiguity surfacing (LOOK-01 callers) ──────────────────
779
+
780
+ describe('findPhaseInternal ambiguity surfacing (LOOK-01 callers)', () => {
781
+ // Captures stderr + the error()-driven process.exit so a guarded caller's
782
+ // milestone-qualified message is inspectable without killing the test worker.
783
+ // error() (core.cjs) writes to process.stderr then process.exit(1).
784
+ function captureError(fn) {
785
+ const chunks = [];
786
+ const origStderr = process.stderr.write.bind(process.stderr);
787
+ const origStdout = process.stdout.write.bind(process.stdout);
788
+ const origExit = process.exit;
789
+ let exitCode = null;
790
+ let threw = null;
791
+ process.stderr.write = (data) => { chunks.push(String(data)); return true; };
792
+ process.stdout.write = (data) => { chunks.push(String(data)); return true; };
793
+ process.exit = (code) => { exitCode = code == null ? 0 : code; throw new Error('__EXIT__'); };
794
+ try {
795
+ fn();
796
+ } catch (e) {
797
+ if (e && e.message !== '__EXIT__') threw = e;
798
+ } finally {
799
+ process.stderr.write = origStderr;
800
+ process.stdout.write = origStdout;
801
+ process.exit = origExit;
802
+ }
803
+ return { output: chunks.join(''), exitCode, threw };
804
+ }
805
+
806
+ // Build a v2 project where bare "03" is NOT an active phase but exists in TWO
807
+ // milestone archives — the cross-milestone collision case.
808
+ function ambiguousFixture() {
809
+ return createFixture({
810
+ 'config.json': JSON.stringify({}),
811
+ 'config.local.json': JSON.stringify({ current_project: 'auth-overhaul' }),
812
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n',
813
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n',
814
+ 'projects/auth-overhaul/PROJECT.md': '# Project',
815
+ 'projects/auth-overhaul/STATE.md': '---\ncurrent_milestone: v25.0\n---\n# State',
816
+ 'projects/auth-overhaul/ROADMAP.md': '# Roadmap',
817
+ 'projects/auth-overhaul/phases/': null,
818
+ 'milestones/v3.0-phases/03-alpha/01-PLAN.md': '# Plan',
819
+ 'milestones/v4.0-phases/03-beta/01-PLAN.md': '# Plan',
820
+ });
821
+ }
822
+
823
+ it('cmdPlanFinalize (commands.cjs:623 path) surfaces the milestone-qualified message, not a TypeError or silent resolution', () => {
824
+ const commands = require('./commands.cjs');
825
+ const fixture = ambiguousFixture();
826
+ try {
827
+ const { output, exitCode, threw } = captureError(() =>
828
+ commands.cmdPlanFinalize(fixture.cwd, '03', '01', {}, true)
829
+ );
830
+ // Must NOT crash with a TypeError (the pre-hardening failure mode).
831
+ assert.equal(threw, null, threw ? `unexpected throw: ${threw.stack}` : 'no throw');
832
+ // The guard fired with error() → exit(1).
833
+ assert.equal(exitCode, 1, 'guarded caller exits via error()');
834
+ // The milestone-qualified ambiguity message surfaced, naming both versions.
835
+ assert.ok(output.includes('v3.0'), `output names v3.0: ${output}`);
836
+ assert.ok(output.includes('v4.0'), `output names v4.0: ${output}`);
837
+ assert.ok(/ambiguous/i.test(output), `output flags ambiguity: ${output}`);
838
+ // NOT silently resolved to one archive directory.
839
+ assert.ok(!output.includes('03-alpha'), 'must not silently resolve to v3.0 dir');
840
+ assert.ok(!output.includes('03-beta'), 'must not silently resolve to v4.0 dir');
841
+ } finally {
842
+ fixture.cleanup();
843
+ }
844
+ });
845
+ });
846
+
847
+ // ─── cmdHistoryDigest composite key (LOOK-02) ──────────────────────────────────
848
+ //
849
+ // Documented output shape (composite {milestone, phase} key, string encoding):
850
+ // - When an entry carries a milestone (archived dirs, or current-phase entries
851
+ // once the current milestone is resolvable via resolveMilestoneVersion), the
852
+ // bucket key is `"<milestone>/<phase>"` (e.g. `digest.phases['v4.0/03']`).
853
+ // - When an entry has no resolvable milestone (flat-layout repos), the key is the
854
+ // bare phase number (e.g. `digest.phases['03']`) — byte-identical to the
855
+ // pre-LOOK-02 shape, so flat installs see no data loss and no key change.
856
+ // - decisions[] entries carry both `phase` and `milestone` so two milestones'
857
+ // same-numbered decisions are never ambiguous.
858
+ describe('cmdHistoryDigest composite key (LOOK-02)', () => {
859
+ const { cmdHistoryDigest } = require('./commands.cjs');
860
+
861
+ // Build a fixture with TWO milestone archives that BOTH contain a phase "03",
862
+ // plus a current active phase. STATE.md carries current_milestone so the
863
+ // current-phase entry resolves to that milestone.
864
+ function twoMilestoneFixture(currentMilestone) {
865
+ const structure = {
866
+ 'config.json': JSON.stringify({}),
867
+ 'config.local.json': JSON.stringify({ planningRoot: '.' }),
868
+ 'PROJECT.md': '# Project\n',
869
+ 'ROADMAP.md': '# Roadmap\n',
870
+ 'PROJECTS.md': '# Projects\n',
871
+ 'REPOS.md': '# Repos\n',
872
+ 'STATE.md':
873
+ '---\n' +
874
+ 'dgs_state_version: 1.0\n' +
875
+ (currentMilestone ? `current_milestone: ${currentMilestone}\n` : '') +
876
+ 'milestone: v5.0\n' +
877
+ '---\n\n# Project State\n\nPhase: 7\n',
878
+ // v3.0 archive, phase 03 → provides A
879
+ 'milestones/v3.0-phases/v3.0-ROADMAP.md': '# v3.0\n',
880
+ 'milestones/v3.0-phases/03-alpha/03-SUMMARY.md':
881
+ '---\nphase: "03"\nname: "Alpha"\nprovides:\n - "A"\nkey-decisions:\n - "Decision Alpha"\n---\n',
882
+ // v4.0 archive, phase 03 → provides B
883
+ 'milestones/v4.0-phases/v4.0-ROADMAP.md': '# v4.0\n',
884
+ 'milestones/v4.0-phases/03-beta/03-SUMMARY.md':
885
+ '---\nphase: "03"\nname: "Beta"\nprovides:\n - "B"\nkey-decisions:\n - "Decision Beta"\n---\n',
886
+ // current active phase 07 → provides Current
887
+ 'phases/07-current/07-SUMMARY.md':
888
+ '---\nphase: "07"\nname: "Current"\nprovides:\n - "Current"\n---\n',
889
+ };
890
+ return createFixture(structure);
891
+ }
892
+
893
+ function runDigest(cwd) {
894
+ const { json } = captureStdout(() => cmdHistoryDigest(cwd, false));
895
+ assert.ok(json, 'digest JSON emitted');
896
+ return json;
897
+ }
898
+
899
+ it('Test 1 (no conflation): restarted "03" from two milestones lands in DISTINCT buckets', () => {
900
+ const fixture = twoMilestoneFixture('v5.0');
901
+ try {
902
+ const digest = runDigest(fixture.cwd);
903
+ const v3 = digest.phases['v3.0/03'];
904
+ const v4 = digest.phases['v4.0/03'];
905
+ assert.ok(v3, "v3.0/03 bucket exists");
906
+ assert.ok(v4, "v4.0/03 bucket exists");
907
+ // v3.0 bucket has A, NOT B; v4.0 bucket has B, NOT A.
908
+ assert.ok(v3.provides.includes('A'), `v3.0/03 provides A: ${JSON.stringify(v3.provides)}`);
909
+ assert.ok(!v3.provides.includes('B'), 'v3.0/03 must NOT contain B');
910
+ assert.ok(v4.provides.includes('B'), `v4.0/03 provides B: ${JSON.stringify(v4.provides)}`);
911
+ assert.ok(!v4.provides.includes('A'), 'v4.0/03 must NOT contain A');
912
+ // The old conflated bare bucket must NOT exist for these milestone entries.
913
+ assert.ok(!digest.phases['03'], 'no conflated bare "03" bucket for milestone entries');
914
+ } finally {
915
+ fixture.cleanup();
916
+ }
917
+ });
918
+
919
+ it('Test 2 (current phase carries milestone): current-phase entry keyed under current milestone', () => {
920
+ const fixture = twoMilestoneFixture('v5.0');
921
+ try {
922
+ const digest = runDigest(fixture.cwd);
923
+ assert.ok(digest.phases['v5.0/07'], 'current phase 07 keyed under v5.0');
924
+ assert.ok(
925
+ digest.phases['v5.0/07'].provides.includes('Current'),
926
+ 'current phase provides surfaced under its milestone bucket'
927
+ );
928
+ // Must NOT be keyed under bare "07" (would mean milestone was not assigned).
929
+ assert.ok(!digest.phases['07'], 'current phase must not land in a bare "07" bucket');
930
+ } finally {
931
+ fixture.cleanup();
932
+ }
933
+ });
934
+
935
+ it('Test 3 (documented shape): keys use the "<milestone>/<phase>" encoding', () => {
936
+ const fixture = twoMilestoneFixture('v5.0');
937
+ try {
938
+ const digest = runDigest(fixture.cwd);
939
+ const keys = Object.keys(digest.phases);
940
+ assert.ok(keys.includes('v3.0/03'), `keys include v3.0/03: ${keys}`);
941
+ assert.ok(keys.includes('v4.0/03'), `keys include v4.0/03: ${keys}`);
942
+ assert.ok(keys.includes('v5.0/07'), `keys include v5.0/07: ${keys}`);
943
+ // Every milestone-qualified key matches the documented grammar.
944
+ for (const k of keys) {
945
+ assert.ok(/^(v\d+\.\d+\/.+|[^/]+)$/.test(k), `key '${k}' matches documented shape`);
946
+ }
947
+ } finally {
948
+ fixture.cleanup();
949
+ }
950
+ });
951
+
952
+ it('Test 4 (decisions carry milestone): decisions tagged so two milestones are not ambiguous', () => {
953
+ const fixture = twoMilestoneFixture('v5.0');
954
+ try {
955
+ const digest = runDigest(fixture.cwd);
956
+ const alpha = digest.decisions.find(d => d.decision === 'Decision Alpha');
957
+ const beta = digest.decisions.find(d => d.decision === 'Decision Beta');
958
+ assert.ok(alpha, 'Decision Alpha present');
959
+ assert.ok(beta, 'Decision Beta present');
960
+ assert.strictEqual(alpha.milestone, 'v3.0', 'Decision Alpha tagged with v3.0');
961
+ assert.strictEqual(beta.milestone, 'v4.0', 'Decision Beta tagged with v4.0');
962
+ assert.strictEqual(alpha.phase, '03', 'Decision Alpha keeps readable phase');
963
+ } finally {
964
+ fixture.cleanup();
965
+ }
966
+ });
967
+
968
+ it('Test 5 (flat-layout backward-compat): no milestone → bare phase key preserved', () => {
969
+ // Flat repo: no milestones/ archives, no current_milestone. Current phases
970
+ // must keep the byte-identical bare-number key shape (information preserved).
971
+ const fixture = createFixture({
972
+ 'config.json': JSON.stringify({}),
973
+ 'config.local.json': JSON.stringify({ planningRoot: '.' }),
974
+ 'PROJECT.md': '# Project\n',
975
+ 'ROADMAP.md': '# Roadmap\n',
976
+ 'PROJECTS.md': '# Projects\n',
977
+ 'REPOS.md': '# Repos\n',
978
+ 'STATE.md': '# Project State\n\nPhase: 1\n',
979
+ 'phases/01-foundation/01-SUMMARY.md':
980
+ '---\nphase: "01"\nname: "Foundation"\nprovides:\n - "DB"\n---\n',
981
+ 'phases/02-api/02-SUMMARY.md':
982
+ '---\nphase: "02"\nname: "API"\nprovides:\n - "REST"\n---\n',
983
+ });
984
+ try {
985
+ const digest = runDigest(fixture.cwd);
986
+ assert.ok(digest.phases['01'], 'flat phase 01 keyed bare');
987
+ assert.ok(digest.phases['02'], 'flat phase 02 keyed bare');
988
+ assert.ok(digest.phases['01'].provides.includes('DB'), 'flat 01 provides preserved');
989
+ assert.ok(digest.phases['02'].provides.includes('REST'), 'flat 02 provides preserved');
990
+ // No spurious milestone-qualified keys in a flat repo.
991
+ assert.ok(!Object.keys(digest.phases).some(k => k.includes('/')), 'no milestone-qualified keys in flat repo');
992
+ } finally {
993
+ fixture.cleanup();
994
+ }
995
+ });
996
+ });
@@ -526,15 +526,15 @@ function getMilestoneSummaries(cwd, planningRoot, currentPhaseNum) {
526
526
  const isDirInMilestone = getMilestonePhaseFilter(cwd);
527
527
 
528
528
  // Scan phases directory for milestone phases with summaries
529
- let projectRoot;
529
+ const { phasesDir: resolvePhasesDir } = require('./core.cjs');
530
+ let phasesRel;
530
531
  try {
531
- const { getProjectRoot } = require('./core.cjs');
532
- projectRoot = getProjectRoot(cwd);
532
+ phasesRel = resolvePhasesDir(cwd);
533
533
  } catch {
534
- projectRoot = path.relative(cwd, planningRoot) || '.';
534
+ phasesRel = path.join(path.relative(cwd, planningRoot) || '.', 'phases');
535
535
  }
536
536
 
537
- const phasesDir = path.join(cwd, projectRoot, 'phases');
537
+ const phasesDir = path.join(cwd, phasesRel);
538
538
  if (!fs.existsSync(phasesDir)) return results;
539
539
 
540
540
  try {
@@ -561,7 +561,7 @@ function getMilestoneSummaries(cwd, planningRoot, currentPhaseNum) {
561
561
  const phaseFiles = fs.readdirSync(phasePath);
562
562
  const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
563
563
  for (const summary of summaries) {
564
- const relPath = toPosixPath(path.join(projectRoot, 'phases', dir, summary));
564
+ const relPath = toPosixPath(path.join(phasesRel, dir, summary));
565
565
  results.push({ path: relPath, category: 'milestone-summary' });
566
566
  }
567
567
  } catch {}