@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.
- package/CHANGELOG.md +28 -0
- package/README.md +4 -1
- package/bin/install.js +1 -1
- package/commands/dgs/abandon-milestone.md +28 -0
- package/commands/dgs/new-milestone.md +3 -1
- package/deliver-great-systems/bin/dgs-tools.cjs +22 -4
- package/deliver-great-systems/bin/lib/context.cjs +45 -15
- package/deliver-great-systems/bin/lib/docs.cjs +73 -29
- package/deliver-great-systems/bin/lib/docs.test.cjs +49 -6
- package/deliver-great-systems/bin/lib/init.cjs +9 -3
- package/deliver-great-systems/bin/lib/init.test.cjs +61 -3
- package/deliver-great-systems/bin/lib/milestone.cjs +470 -2
- package/deliver-great-systems/bin/lib/milestone.test.cjs +653 -0
- package/deliver-great-systems/bin/lib/search.cjs +5 -16
- package/deliver-great-systems/bin/lib/state.cjs +152 -1
- package/deliver-great-systems/bin/lib/worktrees.cjs +182 -1
- package/deliver-great-systems/bin/lib/worktrees.test.cjs +409 -0
- package/deliver-great-systems/templates/claude-md.md +2 -0
- package/deliver-great-systems/templates/state.md +16 -0
- package/deliver-great-systems/workflows/abandon-milestone.md +120 -0
- package/deliver-great-systems/workflows/complete-milestone.md +58 -4
- package/deliver-great-systems/workflows/create-milestone-job.md +15 -0
- package/deliver-great-systems/workflows/execute-plan.md +1 -1
- package/deliver-great-systems/workflows/help.md +7 -0
- package/deliver-great-systems/workflows/init-product.md +8 -8
- package/deliver-great-systems/workflows/new-milestone.md +69 -0
- package/deliver-great-systems/workflows/progress.md +5 -1
- package/deliver-great-systems/workflows/run-job.md +23 -1
- package/hooks/dist/dgs-enforce-discipline.js +34 -1
- 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
|
@@ -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
|
|
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
|
-
//
|
|
609
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
|
56
|
-
// Create the idea file that resolveDocsDir
|
|
57
|
-
fs.mkdirSync(path.join(tmpDir, 'ideas'
|
|
58
|
-
fs.writeFileSync(path.join(tmpDir, 'ideas', '
|
|
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', '
|
|
61
|
-
`Expected ideas/
|
|
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}',
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 ────────────────────────────────────────
|