@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
package/CHANGELOG.md CHANGED
@@ -8,6 +8,34 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [3.5.1] - 2026-06-28
12
+
13
+ ### Added
14
+ - **`dgs-tools state reconcile-milestone` (quick-260627-m3k)** — self-heals a project whose milestone shipped (a `## <version>` heading in MILESTONES.md AND a matching git tag both present) but whose STATE.md was never flipped, so `/dgs:list-projects` stops showing a stale in-progress phase or false `executing` status. Conservative (no-op unless both shipped markers are present), idempotent, and current-project scoped.
15
+
16
+ ### Fixed
17
+ - **Dashboard staleness after milestone completion (quick-260627-m3k)** — `markMilestoneComplete` now resets the STATE.md `Phase:` line (via a shared `_finalizeMilestoneStateBody` helper) instead of leaving it frozen at the last in-progress value, so a shipped milestone no longer surfaces a stale phase / `executing` status on the dashboard.
18
+ - **Per-milestone finalize matcher (quick-260628-ikd)** — `roadmapUpdatePlanProgressInternal` is now format-agnostic: it updates progress-table rows keyed in the composite `<version>/N.` form (and bare `N.`) and preserves the optional Milestone column, so `phase finalize` / `plan finalize` update per-milestone ROADMAPs instead of silently no-op'ing.
19
+ - **Branch-bounded diff-ref and self-check lookups (quick-260628-mlk)** — the codereview diff-ref, phase-prefix self-check probes, and the milestone-stats query are now bounded to the milestone branch, so per-milestone commit-prefix collisions (`feat(NN-NN):` reused across milestones) no longer reach unrelated commits from a different milestone.
20
+ - **Jobs healthCheck flat-first (quick-260628-kxr-01)** — reworked the jobs `healthCheck` to be flat-first.
21
+
22
+ ### Documentation
23
+ - **Stale-dashboard recovery guide (quick-260627-kvy)** — added a "Recovering a stale milestone dashboard" section to `docs/USER-GUIDE.md` documenting `state reconcile-milestone` and the per-project recovery playbook.
24
+
25
+ ## [3.5.0] - 2026-06-27
26
+
27
+ ### Added — v25.0 Per-Milestone Phase Numbering via Directory Namespacing (Phases 163-170)
28
+ - **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.
29
+ - **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.
30
+ - **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.
31
+ - **Version-aware lookup, state context, and structural archival**, with dual-mode (versioned + legacy-flat) test fixtures and the full library test suite greened.
32
+
33
+ ### Added
34
+ - **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.
35
+
36
+ ### Fixed
37
+ - **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.
38
+
11
39
  ## [3.4.2] - 2026-06-25
12
40
 
13
41
  ### 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
@@ -615,6 +615,22 @@ function convertClaudeToGeminiToml(content) {
615
615
  return toml;
616
616
  }
617
617
 
618
+ /**
619
+ * Rewrite HOME-relative ".claude" references to the target config dir basename.
620
+ * Swaps ONLY the ".claude" basename, preserving the $HOME / process.env.HOME prefix.
621
+ * For a default ".claude" install cfgBase === ".claude" => both replaces are no-ops.
622
+ * @param {string} content - file content
623
+ * @param {string} cfgBase - target config dir basename, e.g. ".claude-v25-test" or ".claude"
624
+ */
625
+ function rewriteHomeRelativeConfigPaths(content, cfgBase) {
626
+ // shell form: $HOME/.claude/ -> $HOME/<cfgBase>/
627
+ content = content.replace(/\$HOME\/\.claude\//g, '$HOME/' + cfgBase + '/');
628
+ // JS concat form: process.env.HOME + '/.claude/' (both quote styles)
629
+ // matches a quote char followed by /.claude/ ; $1 backreference preserves the quote
630
+ content = content.replace(/(["'])\/\.claude\//g, '$1/' + cfgBase + '/');
631
+ return content;
632
+ }
633
+
618
634
  /**
619
635
  * Copy commands to a flat structure for OpenCode
620
636
  * OpenCode expects: command/dgs-help.md (invoked as /dgs-help)
@@ -664,6 +680,8 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
664
680
  content = content.replace(globalClaudeRegex, pathPrefix);
665
681
  content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
666
682
  content = content.replace(opencodeDirRegex, pathPrefix);
683
+ const cfgBase = pathPrefix.replace(/\/$/, '').split('/').pop();
684
+ content = rewriteHomeRelativeConfigPaths(content, cfgBase);
667
685
  content = processAttribution(content, getCommitAttribution(runtime));
668
686
  content = convertClaudeToOpencodeFrontmatter(content);
669
687
 
@@ -705,6 +723,8 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
705
723
  const localClaudeRegex = /\.\/\.claude\//g;
706
724
  content = content.replace(globalClaudeRegex, pathPrefix);
707
725
  content = content.replace(localClaudeRegex, `./${dirName}/`);
726
+ const cfgBase = pathPrefix.replace(/\/$/, '').split('/').pop();
727
+ content = rewriteHomeRelativeConfigPaths(content, cfgBase);
708
728
  content = processAttribution(content, getCommitAttribution(runtime));
709
729
 
710
730
  // Convert frontmatter for opencode compatibility
@@ -1657,6 +1677,8 @@ function install(isGlobal, runtime = 'claude') {
1657
1677
  // Always replace ~/.claude/ as it is the source of truth in the repo
1658
1678
  const dirRegex = /~\/\.claude\//g;
1659
1679
  content = content.replace(dirRegex, pathPrefix);
1680
+ const cfgBase = pathPrefix.replace(/\/$/, '').split('/').pop();
1681
+ content = rewriteHomeRelativeConfigPaths(content, cfgBase);
1660
1682
  content = processAttribution(content, getCommitAttribution(runtime));
1661
1683
  // Convert frontmatter for runtime compatibility
1662
1684
  if (isOpencode) {
@@ -1816,11 +1838,11 @@ function install(isGlobal, runtime = 'claude') {
1816
1838
  settings.hooks.PreToolUse = [];
1817
1839
  }
1818
1840
 
1819
- const hasDisciplineHook = settings.hooks.PreToolUse.some(entry =>
1841
+ const disciplineHookEntry = settings.hooks.PreToolUse.find(entry =>
1820
1842
  entry.hooks && entry.hooks.some(h => h.command && h.command.includes('dgs-enforce-discipline'))
1821
1843
  );
1822
1844
 
1823
- if (!hasDisciplineHook) {
1845
+ if (!disciplineHookEntry) {
1824
1846
  settings.hooks.PreToolUse.push({
1825
1847
  matcher: 'Edit|Write|Skill|Bash',
1826
1848
  hooks: [
@@ -1831,6 +1853,16 @@ function install(isGlobal, runtime = 'claude') {
1831
1853
  ]
1832
1854
  });
1833
1855
  console.log(` ${green}✓${reset} Configured workflow discipline hook`);
1856
+ } else if (
1857
+ typeof disciplineHookEntry.matcher === 'string' &&
1858
+ !disciplineHookEntry.matcher.split('|').includes('Bash')
1859
+ ) {
1860
+ // Self-heal stale installs: older versions registered this matcher without
1861
+ // Bash, so the marker-on-init branch never fired for inline /dgs:* flows and
1862
+ // Edit/Write got falsely blocked. Upgrade the existing entry in place so a
1863
+ // plain /dgs:update fixes it. Idempotent once Bash is present.
1864
+ disciplineHookEntry.matcher = disciplineHookEntry.matcher + '|Bash';
1865
+ console.log(` ${green}✓${reset} Upgraded workflow discipline hook matcher (added Bash)`);
1834
1866
  }
1835
1867
 
1836
1868
  // 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: