@ktpartners/dgs-platform 3.3.1 → 3.4.2

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 (30) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -1
  3. package/bin/install.js +1 -1
  4. package/commands/dgs/abandon-milestone.md +28 -0
  5. package/commands/dgs/new-milestone.md +3 -1
  6. package/deliver-great-systems/bin/dgs-tools.cjs +22 -4
  7. package/deliver-great-systems/bin/lib/context.cjs +45 -15
  8. package/deliver-great-systems/bin/lib/docs.cjs +73 -29
  9. package/deliver-great-systems/bin/lib/docs.test.cjs +49 -6
  10. package/deliver-great-systems/bin/lib/init.cjs +9 -3
  11. package/deliver-great-systems/bin/lib/init.test.cjs +61 -3
  12. package/deliver-great-systems/bin/lib/milestone.cjs +470 -2
  13. package/deliver-great-systems/bin/lib/milestone.test.cjs +653 -0
  14. package/deliver-great-systems/bin/lib/search.cjs +5 -16
  15. package/deliver-great-systems/bin/lib/state.cjs +152 -1
  16. package/deliver-great-systems/bin/lib/worktrees.cjs +182 -1
  17. package/deliver-great-systems/bin/lib/worktrees.test.cjs +409 -0
  18. package/deliver-great-systems/templates/claude-md.md +2 -0
  19. package/deliver-great-systems/templates/state.md +16 -0
  20. package/deliver-great-systems/workflows/abandon-milestone.md +120 -0
  21. package/deliver-great-systems/workflows/complete-milestone.md +58 -4
  22. package/deliver-great-systems/workflows/create-milestone-job.md +15 -0
  23. package/deliver-great-systems/workflows/execute-plan.md +1 -1
  24. package/deliver-great-systems/workflows/help.md +7 -0
  25. package/deliver-great-systems/workflows/init-product.md +8 -8
  26. package/deliver-great-systems/workflows/new-milestone.md +69 -0
  27. package/deliver-great-systems/workflows/progress.md +5 -1
  28. package/deliver-great-systems/workflows/run-job.md +23 -1
  29. package/hooks/dist/dgs-enforce-discipline.js +34 -1
  30. package/package.json +1 -1
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.4.2] - 2026-06-25
12
+
13
+ ### Fixed
14
+ - **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.
15
+
16
+ ## [3.4.1] - 2026-06-12
17
+
18
+ ### Fixed
19
+ - **Milestone branch-name collision fixed across four levels (quick-260612-b2z)** — a placeholder/blank milestone name previously collapsed to the bare `milestone/milestone` branch (`init.cjs` derived the slug as `generateSlugInternal(name) || 'milestone'`), and lingering remote `milestone/<slug>` branches squatted the name because nothing ran `push origin --delete`. Cross-milestone branch-name collisions resulted. Fixed by:
20
+ 1. **Structural slug composition** — new `composeMilestoneSlug({version,name,project,multiProject})` in `worktrees.cjs` builds `[<project>-]<version>-<name-slug>` (project prefix only when more than one active project exists). A non-empty version always guarantees uniqueness, so a blank name yields the version segment alone — the bare `milestone` slug is returned ONLY as a last resort when both version and name are empty.
21
+ 2. **Stamped-slug persist + read with legacy fallback** — milestone worktree entries are stamped with `milestone_slug` + `slug_formula: 'v2'` on create; the new `resolveMilestoneSlug()` resolver prefers the stamped key and legacy-falls-back to the OLD bare-name slug, so in-flight milestones created before the upgrade are never orphaned. `init.cjs` reads all three slug-derivation sites through the resolver.
22
+ 3. **Remote milestone-branch cleanup** — `rebaseAndMerge` deletes the owned remote `milestone/<slug>` branch on merge SUCCESS only (returning `remoteBranchDeleted`); `cmdAbandonMilestone` deletes it during teardown (replacing the prior warn-only behaviour). Both deletes are NON-FATAL — a failed `git push origin --delete` is collected as a warning and never fails the completion/abandon.
23
+ 4. **Pre-flight collision guard** — new `checkRemoteCollision()` helper + `worktrees check-collision <slug>` CLI verb. `run-job` REFUSES at job start (the chosen safe default) when `origin/milestone/<slug>` exists with commits not reachable from `base_branch`, naming the colliding branch; `create-milestone-job` surfaces a non-blocking advisory.
24
+ - Ad-hoc milestones (`milestone create-adhoc`) adopt the same `composeMilestoneSlug` composition, so ad-hoc branches are version-prefixed and consistent. `detectQuickMode` is byte-unchanged.
25
+
26
+ ## [3.4.0] - 2026-06-11
27
+
28
+ ### Added — v24.0 Ad-hoc Container Milestone with Worktree Isolation
29
+ - **`/dgs:new-milestone --adhoc`** — starts a worktree-isolated ad-hoc milestone container in one command, with no questioning/research/requirements/roadmap. Creation is atomic and canonically ordered (active_context set last) via `milestone create-adhoc`: it captures a `refs/dgs/adhoc/{slug}/base` snapshot ref of the pre-milestone planning HEAD and rolls back every artifact (worktrees, snapshot ref, STATE commit) on any failure — no leak window, no orphan ref. Quicks and fasts run after an ad-hoc start auto-join the `milestone/{slug}` branch with zero change to `detectQuickMode` (ADH-01/02/03/04/05/06/18/19/22).
30
+ - **`/dgs:abandon-milestone`** (`cmdAbandonMilestone`) — the mirror of creation: removes the milestone worktrees/branches and path-scoped-restores the project planning docs (PROJECT/STATE/ROADMAP/REQUIREMENTS + config `new-milestone` keys) from the snapshot ref, attributing the reversion to the invoking user. Never a whole-tree reset; removes local branches only with a pushed-branch warning; gated behind a `--confirmed` data-loss message; errors on non-ad-hoc milestones (ADH-07/08/09/12/20).
31
+ - **Marker-gated relaxed completion** — a quicks-only (zero-phase) ad-hoc milestone can complete and ship under its `vX.Y` tag. New `state adhoc-readiness` predicate counts completed quick rows + phases; the marker-gated `verify_readiness` path skips the 100%-phase assert and traceability audit for ad-hoc milestones while preserving the strict gate (with regression coverage) for normal milestones. Zero-work completion is refused with an actionable message (ADH-10/11/13).
32
+ - **Lifecycle cleanup, status & docs** — `cleanupAdhoc` / `milestone cleanup-adhoc` removes the residual base ref and worktree entry on both complete and abandon (base ref resolved to an immutable SHA before abandon-restore). The `adhoc` marker now renders in `/dgs:progress` and the completion preamble; the `adhoc` field is documented in `templates/state.md`, help, and the command reference (ADH-14/15/21).
33
+
34
+ ### Fixed
35
+ - **`docs add` now honours the flat idea layout** — idea-scoped attachments previously wrote to the legacy `ideas/pending/<id>/docs/` path even for flat-layout ideas (`ideas/<id>-slug.md`). `resolveDocsDir` now derives the docs dir from the resolved flat idea path, and the three previously-duplicated read paths (`docs list`, `search`, `context`) route through one shared flat-aware enumerator with a legacy back-compat fallback so pre-migration idea docs still resolve (quick-260611-jjv).
36
+ - **Workflow-discipline hook no longer false-blocks `/dgs:*` workflow writes on slash-command entry** — the PreToolUse gate keyed its allow-decision on a marker that was only written via the `Skill` tool, so a slash-command-invoked workflow (e.g. `discuss-phase` writing its `CONTEXT.md`) had no marker and every Write denied. The hook now also writes the active-session marker when it observes a `dgs-tools … init` Bash call, and path-exempts planning artifacts, while still blocking real code edits with no active command (quick-260611-m3s).
37
+ - **Hook active-marker persists across the Skill PostToolUse** — the marker was being deleted immediately after creation, re-blocking orchestrator writes mid-workflow; it now persists for the session.
38
+
11
39
  ## [3.3.1] - 2026-05-26
12
40
 
13
41
  ### Fixed
package/README.md CHANGED
@@ -373,6 +373,8 @@ Code repos are isolated using git worktrees. Each milestone gets its own directo
373
373
  | Quick | `dgs:quick` | Ephemeral worktree — auto-cleanup on complete |
374
374
  | Milestone | `execute-phase` | Dedicated worktree — persists across phases |
375
375
 
376
+ You can also start a lightweight **ad-hoc container milestone** with `/dgs:new-milestone --adhoc` — a worktree-isolated branch with no spec or roadmap, just a window for batching small work. Quicks and fasts that run during it auto-join the milestone branch unchanged, no extra flags. When you're done, `/dgs:complete-milestone` ships the whole batch as one vX.Y, or `/dgs:abandon-milestone` discards it as a unit and restores your planning docs.
377
+
376
378
  When a milestone or quick fix finishes, DGS rebases onto main and cleans up automatically. See [How Git is Used](docs/GIT-WORKFLOW.md) for the full model.
377
379
 
378
380
  ---
@@ -591,7 +593,8 @@ See the [User Guide](docs/USER-GUIDE.md#context-tiers) for the complete command-
591
593
  | `/dgs:audit-phase <phase> [--rerun-failed]` | Automated phase verification: test execution + structural inspection |
592
594
  | `/dgs:audit-milestone` | Verify milestone achieved its definition of done |
593
595
  | `/dgs:complete-milestone` | Archive milestone, tag release |
594
- | `/dgs:new-milestone [name]` | Start milestone: research → requirements → roadmap (first or subsequent) |
596
+ | `/dgs:new-milestone [name] [--adhoc]` | Start milestone: research → requirements → roadmap (first or subsequent); `--adhoc` creates a lightweight worktree-isolated container for batching quicks/fasts |
597
+ | `/dgs:abandon-milestone` | Discard an ad-hoc container milestone, restore planning docs |
595
598
 
596
599
  ### Navigation
597
600
 
package/bin/install.js CHANGED
@@ -1822,7 +1822,7 @@ function install(isGlobal, runtime = 'claude') {
1822
1822
 
1823
1823
  if (!hasDisciplineHook) {
1824
1824
  settings.hooks.PreToolUse.push({
1825
- matcher: 'Edit|Write|Skill',
1825
+ matcher: 'Edit|Write|Skill|Bash',
1826
1826
  hooks: [
1827
1827
  {
1828
1828
  type: 'command',
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: dgs:abandon-milestone
3
+ description: Abandon active ad-hoc milestone — remove worktrees and restore planning docs without merging
4
+ argument-hint: "[--confirmed]"
5
+ allowed-tools:
6
+ - Read
7
+ - Bash
8
+ - AskUserQuestion
9
+ ---
10
+ <objective>
11
+ Abandon the active ad-hoc milestone.
12
+
13
+ Remove all milestone worktrees + local code branches and path-scoped-restore the project planning docs (PROJECT.md, STATE.md, ROADMAP.md, REQUIREMENTS.md, config.json) to their pre-milestone state from the snapshot base ref, committing an attributed reversion.
14
+
15
+ Ad-hoc-only; spec/phase-driven milestones are refused with guidance. Requires confirmation; this action cannot be undone.
16
+ </objective>
17
+
18
+ <execution_context>
19
+ @~/.claude/deliver-great-systems/workflows/abandon-milestone.md
20
+ </execution_context>
21
+
22
+ <context>
23
+ $ARGUMENTS
24
+ </context>
25
+
26
+ <process>
27
+ Execute the abandon-milestone workflow from @~/.claude/deliver-great-systems/workflows/abandon-milestone.md end-to-end.
28
+ </process>
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: dgs:new-milestone
3
3
  description: Start a new milestone cycle — update PROJECT.md and route to requirements
4
- argument-hint: "[milestone name, e.g., 'v1.1 Notifications']"
4
+ argument-hint: "[milestone name | --adhoc] (--adhoc: start a worktree-isolated container with no spec/requirements/roadmap)"
5
5
  allowed-tools:
6
6
  - Read
7
7
  - Write
@@ -14,6 +14,8 @@ Start a new milestone: questioning → research (optional) → requirements →
14
14
 
15
15
  Brownfield equivalent of new-project. Project exists, PROJECT.md has history. Gathers "what's next", updates PROJECT.md, then runs requirements → roadmap cycle.
16
16
 
17
+ With `--adhoc`, the command skips questioning, research, requirements, and roadmap and instead establishes a worktree-isolated milestone container directly (creates milestone worktrees for every registered code repo and sets it active). Subsequent quicks/fasts then auto-route into that container. See workflow step `## 1a. Ad-hoc Container (--adhoc)`.
18
+
17
19
  **Creates/Updates:**
18
20
  - `${project_path}` — updated with new milestone goals
19
21
  - `${project_root}/research/` — domain research (optional, NEW features only)
@@ -205,6 +205,7 @@
205
205
  * worktrees prune Remove orphaned worktree entries
206
206
  * worktrees rebase-and-merge <slug> --repo <name> Rebase and merge worktree branch
207
207
  * worktrees health <slug> Check worktree health
208
+ * worktrees check-collision <slug> Pre-flight remote milestone-branch collision check
208
209
  *
209
210
  * Ideas Operations:
210
211
  * ideas create --title T --body B Create new idea with auto-assigned ID
@@ -645,8 +646,9 @@ async function main() {
645
646
  switch (command) {
646
647
  case 'state': {
647
648
  const subcommand = args[1];
648
- // Gate write subcommands
649
- if (subcommand && subcommand !== 'load' && subcommand !== 'get') {
649
+ // Gate write subcommands. read-adhoc and adhoc-readiness are read-only.
650
+ if (subcommand && subcommand !== 'load' && subcommand !== 'get' &&
651
+ subcommand !== 'read-adhoc' && subcommand !== 'adhoc-readiness') {
650
652
  gateIdentity();
651
653
  }
652
654
  if (subcommand === 'update') {
@@ -706,6 +708,12 @@ async function main() {
706
708
  state.cmdStateArchiveQuickTasks(cwd, raw);
707
709
  } else if (subcommand === 'mark-milestone-complete') {
708
710
  state.cmdMarkMilestoneComplete(cwd, raw);
711
+ } else if (subcommand === 'read-adhoc') {
712
+ state.cmdStateReadAdhoc(cwd, raw);
713
+ } else if (subcommand === 'adhoc-readiness') {
714
+ state.cmdStateAdhocReadiness(cwd, raw);
715
+ } else if (subcommand === 'set-adhoc') {
716
+ state.cmdStateSetAdhoc(cwd, args.slice(2), raw);
709
717
  } else {
710
718
  state.cmdStateLoad(cwd, raw);
711
719
  }
@@ -1218,8 +1226,14 @@ async function main() {
1218
1226
  milestoneName = nameArgs.join(' ') || null;
1219
1227
  }
1220
1228
  milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force }, raw);
1229
+ } else if (subcommand === 'create-adhoc') {
1230
+ milestone.cmdMilestoneCreateAdhoc(cwd, args.slice(2), raw);
1231
+ } else if (subcommand === 'abandon') {
1232
+ milestone.cmdAbandonMilestone(cwd, args.slice(2), raw);
1233
+ } else if (subcommand === 'cleanup-adhoc') {
1234
+ milestone.cmdMilestoneCleanupAdhoc(cwd, args[2], raw);
1221
1235
  } else {
1222
- error('Unknown milestone subcommand. Available: complete');
1236
+ error('Unknown milestone subcommand. Available: complete, create-adhoc, abandon, cleanup-adhoc');
1223
1237
  }
1224
1238
  break;
1225
1239
  }
@@ -1986,8 +2000,12 @@ async function main() {
1986
2000
  if (!slug) error('Usage: worktrees health <slug>');
1987
2001
  const result = worktrees.checkWorktreeHealth(cwd, slug);
1988
2002
  wtOutput(result);
2003
+ } else if (subcommand === 'check-collision') {
2004
+ const slug = args[2];
2005
+ if (!slug) error('Usage: worktrees check-collision <slug>');
2006
+ wtOutput(worktrees.checkRemoteCollision(cwd, slug));
1989
2007
  } else {
1990
- error('Unknown worktrees subcommand: ' + subcommand + '. Available: create, remove, list, setup, prune, rebase-and-merge, health');
2008
+ error('Unknown worktrees subcommand: ' + subcommand + '. Available: create, remove, list, setup, prune, rebase-and-merge, health, check-collision');
1991
2009
  }
1992
2010
  break;
1993
2011
  }
@@ -588,6 +588,49 @@ function resolveIdeaScope(ideaId, planningRoot, cwd) {
588
588
  const paddedId = String(parseInt(idStr, 10)).padStart(3, '0');
589
589
  const planRootRel = path.relative(cwd || process.cwd(), planningRoot) || '.';
590
590
 
591
+ const cwdAbs = cwd || process.cwd();
592
+
593
+ // Assemble idea-docs for a matched idea file, flat-first then legacy.
594
+ // `docsBaseDir` is the directory that contains the idea .md file on disk
595
+ // (ideas/ for flat, ideas/<state>/ for legacy). The flat candidate always
596
+ // sits at the ideas root, the legacy candidate beside the matched file.
597
+ function pushIdeaDocs(stem, legacyBaseDir) {
598
+ const flatDocsDir = path.join(planningRoot, 'ideas', stem, 'docs');
599
+ const legacyDocsDir = path.join(legacyBaseDir, stem, 'docs');
600
+ let chosenDocsDir = null;
601
+ if (fs.existsSync(flatDocsDir)) {
602
+ chosenDocsDir = flatDocsDir;
603
+ } else if (fs.existsSync(legacyDocsDir)) {
604
+ chosenDocsDir = legacyDocsDir;
605
+ }
606
+ if (!chosenDocsDir) return;
607
+ const docFiles = readdirRecursive(chosenDocsDir);
608
+ for (const docFile of docFiles) {
609
+ const docRelPath = toPosixPath(path.relative(cwdAbs, path.join(chosenDocsDir, docFile)));
610
+ results.push({ path: docRelPath, category: 'idea-docs' });
611
+ }
612
+ }
613
+
614
+ // FLAT first: idea files live directly at ideas/<id>-slug.md
615
+ const ideasRoot = path.join(planningRoot, 'ideas');
616
+ let flatFiles;
617
+ try {
618
+ flatFiles = fs.readdirSync(ideasRoot, { withFileTypes: true })
619
+ .filter(e => e.isFile() && e.name.endsWith('.md'))
620
+ .map(e => e.name);
621
+ } catch {
622
+ flatFiles = [];
623
+ }
624
+ for (const file of flatFiles) {
625
+ if (file.startsWith(paddedId + '-') || file === ideaId || file === ideaId + '.md') {
626
+ const relPath = toPosixPath(path.join(planRootRel, 'ideas', file));
627
+ results.push({ path: relPath, category: 'idea' });
628
+ pushIdeaDocs(file.replace(/\.md$/, ''), ideasRoot);
629
+ return results; // Found the flat idea, stop searching
630
+ }
631
+ }
632
+
633
+ // LEGACY fallback: idea files under ideas/<state>/<id>-slug.md
591
634
  const ideaStates = ['pending', 'done', 'rejected', 'consolidated'];
592
635
 
593
636
  for (const state of ideaStates) {
@@ -605,21 +648,8 @@ function resolveIdeaScope(ideaId, planningRoot, cwd) {
605
648
  const relPath = toPosixPath(path.join(planRootRel, 'ideas', state, file));
606
649
  results.push({ path: relPath, category: 'idea' });
607
650
 
608
- // Check for docs/ directory alongside the idea
609
- // Idea docs/ is at ideas/<state>/<idea-slug>/docs/ where idea-slug matches the filename stem
610
- const stem = file.replace(/\.md$/, '');
611
- const docsDir = path.join(dir, stem, 'docs');
612
- if (fs.existsSync(docsDir)) {
613
- const docFiles = readdirRecursive(docsDir);
614
- for (const docFile of docFiles) {
615
- const docRelPath = toPosixPath(path.join(planRootRel, 'ideas', state, stem, 'docs', docFile));
616
- results.push({ path: docRelPath, category: 'idea-docs' });
617
- }
618
- }
619
-
620
- // Also check docs/ directly under the ideas state dir named by idea slug
621
- const altDocsDir = path.join(planningRoot, 'ideas', state, 'docs');
622
- // This is less common, skip unless the first pattern doesn't exist
651
+ // Resolve docs/ flat-first then legacy (ideas/<state>/<slug>/docs).
652
+ pushIdeaDocs(file.replace(/\.md$/, ''), dir);
623
653
 
624
654
  return results; // Found the idea, stop searching
625
655
  }
@@ -5,7 +5,7 @@
5
5
  * add, list, remove, move, and INDEX.md maintenance.
6
6
  * Documents are stored in scope-specific docs/ directories:
7
7
  * - Product-level: docs/
8
- * - Idea-scoped: ideas/{state}/{idea-slug}/docs/
8
+ * - Idea-scoped: ideas/{idea-slug}/docs/ (flat; legacy ideas/{state}/{idea-slug}/docs/ still read for back-compat)
9
9
  * - Spec-scoped: specs/{spec-slug}/docs/
10
10
  * Text extraction produces .extracted.txt sidecars for PDF, XLSX, CSV, DOCX.
11
11
  * Large extractions also generate .summary.txt sidecars.
@@ -17,7 +17,7 @@ const crypto = require('crypto');
17
17
  const { spawnSync } = require('child_process');
18
18
  const { safeReadFile, execGit, generateSlugInternal, output, error } = require('./core.cjs');
19
19
  const { getPlanningRoot } = require('./paths.cjs');
20
- const { findIdeaFile } = require('./ideas.cjs');
20
+ const { findIdeaFile, IDEA_STATES } = require('./ideas.cjs');
21
21
 
22
22
  // ─── Constants ──────────────────────────────────────────────────────────────
23
23
 
@@ -85,9 +85,12 @@ function resolveDocsDir(cwd, scope, scopeId) {
85
85
  if (!idea) {
86
86
  error(`idea not found: ${scopeId}. No matching idea file exists in pending, done, or rejected`);
87
87
  }
88
- // Derive directory name from idea filename (strip .md extension)
89
- const ideaDirName = idea.filename.replace(/\.md$/, '');
90
- const ideaDir = path.join(planRoot, 'ideas', idea.state, ideaDirName);
88
+ // Derive the docs dir directly from the idea's REAL on-disk location, never
89
+ // from idea.state. Flat ideas live at ideas/<id>-slug.md, so the docs dir is
90
+ // the sibling ideas/<id>-slug/docs/. Legacy ideas live at
91
+ // ideas/<state>/<id>-slug.md, so findIdeaFile returns that path and the same
92
+ // derivation yields ideas/<state>/<id>-slug/docs/ — correct in both cases.
93
+ const ideaDir = idea.path.replace(/\.md$/, '');
91
94
  // Auto-create idea directory if it only exists as a file
92
95
  fs.mkdirSync(ideaDir, { recursive: true });
93
96
  return path.join(ideaDir, 'docs');
@@ -110,6 +113,63 @@ function ensureDocsDir(dirPath) {
110
113
  fs.mkdirSync(dirPath, { recursive: true });
111
114
  }
112
115
 
116
+ /**
117
+ * Enumerate all idea docs directories, flat-aware with legacy fallback.
118
+ *
119
+ * Flat layout (current): ideas/<id>-slug/docs/ (sibling of ideas/<id>-slug.md)
120
+ * Legacy layout (read-only back-compat): ideas/<state>/<id>-slug/docs/
121
+ *
122
+ * This is the SINGLE shared enumerator used by every idea-docs READ path
123
+ * (docs list, search, context) so they cannot drift into split-brain.
124
+ *
125
+ * @param {string} cwd - Working directory
126
+ * @returns {Array<{ ideaSlug: string, docsDir: string, state: string|null, legacy: boolean }>}
127
+ */
128
+ function enumerateIdeaDocsDirs(cwd) {
129
+ const planRoot = getPlanningRoot(cwd);
130
+ const ideasRoot = path.join(planRoot, 'ideas');
131
+ const out = [];
132
+
133
+ // FLAT first: ideas/<slug>/docs (directories at the ideas root whose name is
134
+ // NOT a legacy state segment).
135
+ let topEntries;
136
+ try {
137
+ topEntries = fs.readdirSync(ideasRoot, { withFileTypes: true });
138
+ } catch {
139
+ return out;
140
+ }
141
+ for (const entry of topEntries) {
142
+ if (!entry.isDirectory()) continue;
143
+ if (IDEA_STATES.includes(entry.name)) continue;
144
+ const docsDir = path.join(ideasRoot, entry.name, 'docs');
145
+ if (fs.existsSync(docsDir)) {
146
+ out.push({ ideaSlug: entry.name, docsDir, state: null, legacy: false });
147
+ }
148
+ }
149
+
150
+ // LEGACY fallback: ideas/<state>/<slug>/docs (pre-migration dirs).
151
+ for (const state of IDEA_STATES) {
152
+ const stateDir = path.join(ideasRoot, state);
153
+ let stateEntries;
154
+ try {
155
+ stateEntries = fs.readdirSync(stateDir, { withFileTypes: true });
156
+ } catch {
157
+ continue;
158
+ }
159
+ for (const entry of stateEntries) {
160
+ if (!entry.isDirectory()) continue;
161
+ const docsDir = path.join(stateDir, entry.name, 'docs');
162
+ if (fs.existsSync(docsDir)) {
163
+ out.push({ ideaSlug: entry.name, docsDir, state, legacy: true });
164
+ }
165
+ }
166
+ }
167
+
168
+ // No de-dup needed: flat (ideas/<slug>/docs) and legacy
169
+ // (ideas/<state>/<slug>/docs) paths can never collide.
170
+ return out;
171
+ }
172
+
113
173
  // ─── Text Extraction ────────────────────────────────────────────────────────
114
174
 
115
175
  /**
@@ -621,31 +681,14 @@ function cmdDocsList(cwd, scopeFilter, raw) {
621
681
  }
622
682
  }
623
683
 
624
- // Idea docs
684
+ // Idea docs — flat-aware via the shared enumerator (legacy dirs still resolved)
625
685
  if (!filterScope || filterScope === 'idea') {
626
- const states = ['pending', 'done', 'rejected'];
627
- for (const state of states) {
628
- const stateDir = path.join(getPlanningRoot(cwd), 'ideas', state);
629
- if (!fs.existsSync(stateDir)) continue;
630
-
631
- let ideaDirs;
632
- try {
633
- ideaDirs = fs.readdirSync(stateDir, { withFileTypes: true })
634
- .filter(e => e.isDirectory())
635
- .map(e => e.name);
636
- } catch {
637
- continue;
638
- }
639
-
640
- for (const ideaSlug of ideaDirs) {
641
- if (filterId && ideaSlug !== filterId) continue;
642
- const docsDir = path.join(stateDir, ideaSlug, 'docs');
643
- if (!fs.existsSync(docsDir)) continue;
644
-
645
- const docs = scanDocsDir(docsDir);
646
- if (docs.length > 0) {
647
- scopes.push({ scope: 'idea', scope_id: `${state}/${ideaSlug}`, docs });
648
- }
686
+ for (const { ideaSlug, docsDir, state, legacy } of enumerateIdeaDocsDirs(cwd)) {
687
+ if (filterId && ideaSlug !== filterId) continue;
688
+ if (!fs.existsSync(docsDir)) continue;
689
+ const docs = scanDocsDir(docsDir);
690
+ if (docs.length > 0) {
691
+ scopes.push({ scope: 'idea', scope_id: legacy ? `${state}/${ideaSlug}` : ideaSlug, docs });
649
692
  }
650
693
  }
651
694
  }
@@ -928,6 +971,7 @@ module.exports = {
928
971
  computeChecksum,
929
972
  resolveDocsDir,
930
973
  ensureDocsDir,
974
+ enumerateIdeaDocsDirs,
931
975
  extractText,
932
976
  updateIndex,
933
977
  updateIndexWithNames,
@@ -21,6 +21,7 @@ const {
21
21
  scanDocsDir,
22
22
  updateIndexWithNames,
23
23
  updateIndex,
24
+ enumerateIdeaDocsDirs,
24
25
  } = require('./docs.cjs');
25
26
 
26
27
  // ─── resolveDocsDir Tests ────────────────────────────────────────────────────
@@ -52,13 +53,15 @@ describe('docs', () => {
52
53
  `Expected path ending with docs/product, got: ${result}`);
53
54
  });
54
55
 
55
- it('idea scope returns docs path for idea found in pending', () => {
56
- // Create the idea file that resolveDocsDir searches for
57
- fs.mkdirSync(path.join(tmpDir, 'ideas', 'pending'), { recursive: true });
58
- fs.writeFileSync(path.join(tmpDir, 'ideas', 'pending', '001-my-idea.md'), '---\nid: 1\ntitle: "My Idea"\n---\n');
56
+ it('idea scope returns flat docs path', () => {
57
+ // Create the flat idea file that resolveDocsDir keys off (idea.path)
58
+ fs.mkdirSync(path.join(tmpDir, 'ideas'), { recursive: true });
59
+ fs.writeFileSync(path.join(tmpDir, 'ideas', '001-my-idea.md'), '---\nid: 1\ntitle: "My Idea"\n---\n');
59
60
  const result = resolveDocsDir(tmpDir, 'idea', '1');
60
- assert.ok(result.includes(path.join('ideas', 'pending', '001-my-idea', 'docs')),
61
- `Expected ideas/pending/001-my-idea/docs in path, got: ${result}`);
61
+ assert.ok(result.includes(path.join('ideas', '001-my-idea', 'docs')),
62
+ `Expected flat ideas/001-my-idea/docs in path, got: ${result}`);
63
+ assert.ok(!result.includes(path.join('ideas', 'pending', '001-my-idea')),
64
+ `Should NOT inject state segment, got: ${result}`);
62
65
  });
63
66
 
64
67
  it('spec scope returns correct path', () => {
@@ -68,6 +71,46 @@ describe('docs', () => {
68
71
  });
69
72
  });
70
73
 
74
+ // ─── enumerateIdeaDocsDirs (flat + legacy) ─────────────────────────────────
75
+
76
+ describe('enumerateIdeaDocsDirs', () => {
77
+ let tmpDir;
78
+
79
+ beforeEach(() => {
80
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-enum-idea-'));
81
+ tmpDir = fs.realpathSync(tmpDir);
82
+ initGitRepo(tmpDir);
83
+ });
84
+
85
+ afterEach(() => {
86
+ resetPaths();
87
+ fs.rmSync(tmpDir, { recursive: true, force: true });
88
+ });
89
+
90
+ it('returns both flat and legacy idea docs dirs', () => {
91
+ // Flat: ideas/001-foo/docs/
92
+ const flatDocs = path.join(tmpDir, 'ideas', '001-foo', 'docs');
93
+ fs.mkdirSync(flatDocs, { recursive: true });
94
+ fs.writeFileSync(path.join(flatDocs, 'a.txt'), 'flat');
95
+ // Legacy: ideas/pending/002-bar/docs/
96
+ const legacyDocs = path.join(tmpDir, 'ideas', 'pending', '002-bar', 'docs');
97
+ fs.mkdirSync(legacyDocs, { recursive: true });
98
+ fs.writeFileSync(path.join(legacyDocs, 'b.txt'), 'legacy');
99
+
100
+ const dirs = enumerateIdeaDocsDirs(tmpDir);
101
+
102
+ const flat = dirs.find(d => d.ideaSlug === '001-foo');
103
+ assert.ok(flat, 'flat idea docs dir should be enumerated');
104
+ assert.equal(flat.legacy, false);
105
+ assert.equal(flat.state, null);
106
+
107
+ const legacy = dirs.find(d => d.ideaSlug === '002-bar');
108
+ assert.ok(legacy, 'legacy idea docs dir should be enumerated');
109
+ assert.equal(legacy.legacy, true);
110
+ assert.equal(legacy.state, 'pending');
111
+ });
112
+ });
113
+
71
114
  // ─── scanDocsDir directory resilience ──────────────────────────────────────
72
115
 
73
116
  describe('scanDocsDir', () => {
@@ -12,6 +12,7 @@ const { parseReposMd, validateReposMdEager } = require('./repos.cjs');
12
12
  const { getCadence, pullAll } = require('./sync.cjs');
13
13
  const { detectQuickMode, generateQuickId, getActiveQuick } = require('./quick.cjs');
14
14
  const { listProjectsReadonly } = require('./projects.cjs');
15
+ const { resolveMilestoneSlug } = require('./worktrees.cjs');
15
16
 
16
17
  /**
17
18
  * Safely resolve the current git author string.
@@ -252,6 +253,10 @@ function cmdInitExecutePhase(cwd, phase, raw) {
252
253
  : null;
253
254
  const phase_req_ids = (reqExtracted && reqExtracted !== 'TBD') ? reqExtracted : null;
254
255
 
256
+ // Multi-project determination for the structural milestone slug's project
257
+ // prefix: only prefix with <project> when more than one ACTIVE project exists.
258
+ const multiProject = listProjectsReadonly(cwd).projects.length > 1;
259
+
255
260
  // Resolve {project} in a branch template — errors if template uses {project} but current_project is not set
256
261
  function resolveProjectInTemplate(template, currentProject) {
257
262
  if (template.includes('{project}')) {
@@ -301,13 +306,13 @@ function cmdInitExecutePhase(cwd, phase, raw) {
301
306
  if (resolved.error) return resolved.error;
302
307
  return resolved.value
303
308
  .replace('{milestone}', milestone.version)
304
- .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone');
309
+ .replace('{slug}', resolveMilestoneSlug(cwd, ctx.current_project, { version: milestone.version, name: milestone.name, multiProject }));
305
310
  })(),
306
311
 
307
312
  // Milestone info
308
313
  milestone_version: milestone.version,
309
314
  milestone_name: milestone.name,
310
- milestone_slug: generateSlugInternal(milestone.name),
315
+ milestone_slug: resolveMilestoneSlug(cwd, ctx.current_project, { version: milestone.version, name: milestone.name, multiProject }),
311
316
 
312
317
  // File existence
313
318
  state_exists: ctx.root ? pathExistsInternal(cwd, path.join(ctx.root, 'STATE.md')) : false,
@@ -1045,6 +1050,7 @@ function cmdInitMilestoneOp(cwd, raw, workflow) {
1045
1050
  const milestone = getMilestoneInfo(cwd);
1046
1051
  const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
1047
1052
  const cadence = getCadence(workflow || 'complete-milestone');
1053
+ const multiProject = listProjectsReadonly(cwd).projects.length > 1;
1048
1054
 
1049
1055
  // Count phases (project-qualified)
1050
1056
  let phaseCount = 0;
@@ -1089,7 +1095,7 @@ function cmdInitMilestoneOp(cwd, raw, workflow) {
1089
1095
  // Current milestone
1090
1096
  milestone_version: milestone.version,
1091
1097
  milestone_name: milestone.name,
1092
- milestone_slug: generateSlugInternal(milestone.name),
1098
+ milestone_slug: resolveMilestoneSlug(cwd, ctx.current_project, { version: milestone.version, name: milestone.name, multiProject }),
1093
1099
 
1094
1100
  // Phase counts
1095
1101
  phase_count: phaseCount,
@@ -1177,7 +1177,10 @@ describe('branch_name with {project} resolution', () => {
1177
1177
  });
1178
1178
  try {
1179
1179
  const result = runInit(fixture.cwd, 'execute-phase 3');
1180
- assert.equal(result.branch_name, 'dgs/myapp/v1.0-milestone');
1180
+ // Structural slug now embeds the version segment (v1.0 -> v1-0) so a
1181
+ // placeholder/default 'milestone' name no longer collapses to a bare
1182
+ // milestone/milestone branch (branch-name collision fix).
1183
+ assert.equal(result.branch_name, 'dgs/myapp/v1.0-v1-0-milestone');
1181
1184
  } finally {
1182
1185
  fixture.cleanup();
1183
1186
  }
@@ -1196,7 +1199,7 @@ describe('branch_name with {project} resolution', () => {
1196
1199
  });
1197
1200
  try {
1198
1201
  const result = runInit(fixture.cwd, 'execute-phase 1');
1199
- assert.equal(result.branch_name, 'dgs/checkout/v1.0-milestone');
1202
+ assert.equal(result.branch_name, 'dgs/checkout/v1.0-v1-0-milestone');
1200
1203
  } finally {
1201
1204
  fixture.cleanup();
1202
1205
  }
@@ -1216,7 +1219,7 @@ describe('branch_name with {project} resolution', () => {
1216
1219
  });
1217
1220
  try {
1218
1221
  const result = runInit(fixture.cwd, 'execute-phase 3');
1219
- assert.equal(result.branch_name, 'dgs/myapp/v1.0-milestone');
1222
+ assert.equal(result.branch_name, 'dgs/myapp/v1.0-v1-0-milestone');
1220
1223
  } finally {
1221
1224
  fixture.cleanup();
1222
1225
  }
@@ -1240,6 +1243,61 @@ describe('branch_name with {project} resolution', () => {
1240
1243
  });
1241
1244
  });
1242
1245
 
1246
+ // ─── milestone_slug resolution (stamped / legacy fallback) ───────────────────
1247
+
1248
+ describe('milestone_slug resolution via resolveMilestoneSlug', () => {
1249
+ // ROADMAP carries an in-progress marker so version+name are deterministic:
1250
+ // 'v25.0' + 'Cool Thing' -> composed slug 'v25-0-cool-thing';
1251
+ // old bare-name slug 'cool-thing'.
1252
+ const roadmap = '# Roadmap\n\n- 🚧 **v25.0 Cool Thing** — Phases 1-2 (in progress)\n';
1253
+
1254
+ function fixtureWithWorktrees(worktrees) {
1255
+ return createFixture({
1256
+ 'config.json': JSON.stringify({ current_project: 'test-project' }),
1257
+ 'config.local.json': JSON.stringify({
1258
+ current_project: 'test-project',
1259
+ projects: { 'test-project': { worktrees } },
1260
+ }),
1261
+ 'PROJECTS.md': '# Projects\n\n| Project | Status |\n|---------|--------|\n| test-project | Active |\n',
1262
+ 'REPOS.md': '# Repos\n\n| Name | Path |\n|------|------|\n',
1263
+ 'projects/test-project/STATE.md': '# State',
1264
+ 'projects/test-project/ROADMAP.md': roadmap,
1265
+ 'projects/test-project/REQUIREMENTS.md': '# Requirements',
1266
+ 'projects/test-project/PROJECT.md': '# Project',
1267
+ });
1268
+ }
1269
+
1270
+ it('returns the stamped NEW composed slug when an entry exists under it', () => {
1271
+ const fixture = fixtureWithWorktrees({ 'v25-0-cool-thing': { type: 'milestone', milestone_slug: 'v25-0-cool-thing', slug_formula: 'v2' } });
1272
+ try {
1273
+ const result = runInit(fixture.cwd, 'milestone-op');
1274
+ assert.equal(result.milestone_slug, 'v25-0-cool-thing');
1275
+ } finally {
1276
+ fixture.cleanup();
1277
+ }
1278
+ });
1279
+
1280
+ it('LEGACY FALLBACK: returns the OLD bare-name slug when only an old-style entry exists (no orphaning)', () => {
1281
+ const fixture = fixtureWithWorktrees({ 'cool-thing': { type: 'milestone' } });
1282
+ try {
1283
+ const result = runInit(fixture.cwd, 'milestone-op');
1284
+ assert.equal(result.milestone_slug, 'cool-thing', 'pre-upgrade in-flight milestone must still resolve');
1285
+ } finally {
1286
+ fixture.cleanup();
1287
+ }
1288
+ });
1289
+
1290
+ it('returns the NEW composed slug when neither entry exists (fresh)', () => {
1291
+ const fixture = fixtureWithWorktrees({});
1292
+ try {
1293
+ const result = runInit(fixture.cwd, 'milestone-op');
1294
+ assert.equal(result.milestone_slug, 'v25-0-cool-thing');
1295
+ } finally {
1296
+ fixture.cleanup();
1297
+ }
1298
+ });
1299
+ });
1300
+
1243
1301
  // ─── Backward Compatibility ──────────────────────────────────────────────────
1244
1302
 
1245
1303
  // ─── Sync Pull: needs_pull field tests ────────────────────────────────────────