@ktpartners/dgs-platform 3.3.0 → 3.4.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.
- package/CHANGELOG.md +32 -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/core.cjs +2 -6
- package/deliver-great-systems/bin/lib/docs.cjs +95 -41
- package/deliver-great-systems/bin/lib/docs.test.cjs +49 -6
- package/deliver-great-systems/bin/lib/init.cjs +60 -13
- 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/sync.cjs +2 -6
- package/deliver-great-systems/bin/lib/verify.cjs +2 -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/help.md +7 -0
- 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,38 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [3.4.1] - 2026-06-12
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **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:
|
|
15
|
+
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.
|
|
16
|
+
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.
|
|
17
|
+
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.
|
|
18
|
+
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.
|
|
19
|
+
- Ad-hoc milestones (`milestone create-adhoc`) adopt the same `composeMilestoneSlug` composition, so ad-hoc branches are version-prefixed and consistent. `detectQuickMode` is byte-unchanged.
|
|
20
|
+
|
|
21
|
+
## [3.4.0] - 2026-06-11
|
|
22
|
+
|
|
23
|
+
### Added — v24.0 Ad-hoc Container Milestone with Worktree Isolation
|
|
24
|
+
- **`/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).
|
|
25
|
+
- **`/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).
|
|
26
|
+
- **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).
|
|
27
|
+
- **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).
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- **`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).
|
|
31
|
+
- **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).
|
|
32
|
+
- **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.
|
|
33
|
+
|
|
34
|
+
## [3.3.1] - 2026-05-26
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- **`execGit` works on Windows** — `bin/lib/core.cjs:execGit` previously POSIX-quoted args (`'arg with spaces'`) then ran them through `execSync`'s shell. On Windows `execSync` invokes `cmd.exe`, which treats single quotes as literal characters — so `git commit -m 'docs(88): create phase plan'` was tokenised into separate args and git interpreted the trailing words as pathspecs. Every multi-word commit message broke on Windows, affecting essentially every DGS command that commits (`/dgs:fast`, `/dgs:quick`, `/dgs:execute-phase`, `/dgs:complete-quick`, the v1→v2 migration self-commit, etc.). Replaced with `execFileSync('git', args, ...)` which bypasses the shell entirely on both platforms — no quoting needed since `args` is already an array. Preserved the existing `{exitCode, stdout, stderr}` return shape and `.trim()` behaviour. Added round-trip regression test in `tests/core.test.cjs` that asserts a message with spaces, parens, colon, ampersand, and percent (`docs(88): create phase plan & 100% coverage`) survives `init`→`commit`→`log` verbatim (quick-260526-dip).
|
|
38
|
+
- **`execGitWithTimeout` works on Windows** — `bin/lib/sync.cjs:execGitWithTimeout` was a fork of `execGit` (added a `timeout` option per its `(NOT execGit)` comment) and inherited the identical POSIX-quoting bug. Affected `/dgs:sync` pre-flight checks across every registered repo (`remote`, `rev-parse --abbrev-ref HEAD`, etc.). Same fix: `execFileSync('git', args, { ..., timeout: timeoutMs })`. Preserved the `isTimeout`/`isAuth` error classification in the catch block (quick-260526-e5l).
|
|
39
|
+
- **`hasCodeFiles` no longer shells out** — `bin/lib/init.cjs` used `execSync('find . -maxdepth 3 \\( -name "*.ts" -o ... \\) | grep -v node_modules | head -5')` during `/dgs:new-project` to detect existing source files for template selection. `find`/`grep`/`head` aren't on Windows by default and the bash-style `\\(` escaping is shell-specific; the catch-block silently fell back to `hasCode = false`, producing wrong init heuristics on Windows. Extracted a pure-Node `hasCodeFiles(cwd)` helper using `fs.readdirSync({ withFileTypes: true })` — depth 3, skips `node_modules` and `.git`, early-exits at 5 matches across `.ts/.js/.py/.go/.rs/.swift/.java`. Exported for testability; added unit tests in `tests/init.test.cjs` covering depth-3 boundary, depth-4 cap, `node_modules`/`.git` skip, the 7-extension matrix, and nonexistent paths (quick-260526-e5l).
|
|
40
|
+
- **`~/` tilde expansion works on Windows** — `bin/lib/verify.cjs` resolved `~/path` canonical_refs via `path.join(process.env.HOME || '', cleanRef.slice(2))`. On Windows `HOME` is unset, so `|| ''` produced a relative path and `cmdVerifyReferences` falsely reported the ref as missing. Replaced with `path.join(os.homedir(), cleanRef.slice(2))` — `os.homedir()` returns `USERPROFILE` on Windows and `HOME` elsewhere. Added a sentinel-file test in `tests/verify.test.cjs` that creates a real file under `os.homedir()` and verifies the `~/` resolution finds it (quick-260526-e5l).
|
|
41
|
+
- **PDF/DOCX/XLSX extraction no longer shells out** — `bin/lib/docs.cjs` ran the async `pdf-parse`/`mammoth`/`exceljs` libs in a sync context via three near-identical `` execSync(`node -e ${JSON.stringify(script)}`) `` sites. Although `JSON.stringify`'s double quotes are cmd.exe-friendly, this path (a) triggered cmd.exe %-variable expansion on filenames containing `%`, and (b) pushed the script body toward cmd.exe's 8191-char command-line limit on long inputs. Replaced all three with `spawnSync('node', ['-e', script], { shell: false, ... })` — same script body, no shell at all. Added structural source-grep guard in `tests/docs.test.cjs` (`!/execSync\(\s*\`node -e/.test(src)`) so the bug pattern can't regress (quick-260526-e5l).
|
|
42
|
+
|
|
11
43
|
## [3.3.0] - 2026-05-15
|
|
12
44
|
|
|
13
45
|
### Added
|
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
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { execSync } = require('child_process');
|
|
7
|
+
const { execSync, execFileSync } = require('child_process');
|
|
8
8
|
const { getPlanningRoot, isV2Install, PROJECTS_DIR } = require('./paths.cjs');
|
|
9
9
|
|
|
10
10
|
// ─── Path helpers ────────────────────────────────────────────────────────────
|
|
@@ -181,11 +181,7 @@ function isGitIgnored(cwd, targetPath) {
|
|
|
181
181
|
|
|
182
182
|
function execGit(cwd, args) {
|
|
183
183
|
try {
|
|
184
|
-
const
|
|
185
|
-
if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a;
|
|
186
|
-
return "'" + a.replace(/'/g, "'\\''") + "'";
|
|
187
|
-
});
|
|
188
|
-
const stdout = execSync('git ' + escaped.join(' '), {
|
|
184
|
+
const stdout = execFileSync('git', args, {
|
|
189
185
|
cwd,
|
|
190
186
|
stdio: 'pipe',
|
|
191
187
|
encoding: 'utf-8',
|
|
@@ -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.
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
const fs = require('fs');
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const crypto = require('crypto');
|
|
17
|
+
const { spawnSync } = require('child_process');
|
|
17
18
|
const { safeReadFile, execGit, generateSlugInternal, output, error } = require('./core.cjs');
|
|
18
19
|
const { getPlanningRoot } = require('./paths.cjs');
|
|
19
|
-
const { findIdeaFile } = require('./ideas.cjs');
|
|
20
|
+
const { findIdeaFile, IDEA_STATES } = require('./ideas.cjs');
|
|
20
21
|
|
|
21
22
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -84,9 +85,12 @@ function resolveDocsDir(cwd, scope, scopeId) {
|
|
|
84
85
|
if (!idea) {
|
|
85
86
|
error(`idea not found: ${scopeId}. No matching idea file exists in pending, done, or rejected`);
|
|
86
87
|
}
|
|
87
|
-
// Derive
|
|
88
|
-
|
|
89
|
-
|
|
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$/, '');
|
|
90
94
|
// Auto-create idea directory if it only exists as a file
|
|
91
95
|
fs.mkdirSync(ideaDir, { recursive: true });
|
|
92
96
|
return path.join(ideaDir, 'docs');
|
|
@@ -109,6 +113,63 @@ function ensureDocsDir(dirPath) {
|
|
|
109
113
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
110
114
|
}
|
|
111
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
|
+
|
|
112
173
|
// ─── Text Extraction ────────────────────────────────────────────────────────
|
|
113
174
|
|
|
114
175
|
/**
|
|
@@ -129,10 +190,11 @@ function extractText(filePath, ext) {
|
|
|
129
190
|
try {
|
|
130
191
|
const pdfParse = require('pdf-parse');
|
|
131
192
|
const buffer = fs.readFileSync(filePath);
|
|
132
|
-
// pdf-parse returns a promise;
|
|
133
|
-
//
|
|
193
|
+
// pdf-parse returns a promise; run it in a child node process via
|
|
194
|
+
// spawnSync so the parent can stay synchronous. spawnSync (not
|
|
195
|
+
// execSync with a shell-built command) is required on Windows where
|
|
196
|
+
// cmd.exe applies different quoting rules.
|
|
134
197
|
let result = null;
|
|
135
|
-
const { execSync } = require('child_process');
|
|
136
198
|
const script = `
|
|
137
199
|
const pdfParse = require('pdf-parse');
|
|
138
200
|
const fs = require('fs');
|
|
@@ -143,12 +205,15 @@ function extractText(filePath, ext) {
|
|
|
143
205
|
process.stdout.write(JSON.stringify({ error: err.message }));
|
|
144
206
|
});
|
|
145
207
|
`;
|
|
146
|
-
const
|
|
208
|
+
const r = spawnSync('node', ['-e', script], {
|
|
147
209
|
encoding: 'utf-8',
|
|
148
210
|
timeout: 30000,
|
|
149
211
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
150
212
|
});
|
|
151
|
-
|
|
213
|
+
if (r.status !== 0) {
|
|
214
|
+
return { text: null, error: `PDF extraction failed: ${r.stderr || r.error?.message || 'unknown'}` };
|
|
215
|
+
}
|
|
216
|
+
result = JSON.parse(r.stdout.trim());
|
|
152
217
|
if (result.error) {
|
|
153
218
|
return { text: null, error: result.error };
|
|
154
219
|
}
|
|
@@ -160,7 +225,6 @@ function extractText(filePath, ext) {
|
|
|
160
225
|
|
|
161
226
|
if (ext === '.xlsx') {
|
|
162
227
|
try {
|
|
163
|
-
const { execSync } = require('child_process');
|
|
164
228
|
const script = `
|
|
165
229
|
const ExcelJS = require('exceljs');
|
|
166
230
|
const workbook = new ExcelJS.Workbook();
|
|
@@ -178,11 +242,15 @@ function extractText(filePath, ext) {
|
|
|
178
242
|
process.stdout.write(JSON.stringify({ error: err.message }));
|
|
179
243
|
});
|
|
180
244
|
`;
|
|
181
|
-
const
|
|
245
|
+
const r = spawnSync('node', ['-e', script], {
|
|
182
246
|
encoding: 'utf-8',
|
|
183
247
|
timeout: 30000,
|
|
184
|
-
|
|
185
|
-
|
|
248
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
249
|
+
});
|
|
250
|
+
if (r.status !== 0) {
|
|
251
|
+
return { text: null, error: `XLSX extraction failed: ${r.stderr || r.error?.message || 'unknown'}` };
|
|
252
|
+
}
|
|
253
|
+
const parsed = JSON.parse(r.stdout.trim());
|
|
186
254
|
if (parsed.error) return { text: null, error: parsed.error };
|
|
187
255
|
return { text: parsed.text, error: null };
|
|
188
256
|
} catch (e) {
|
|
@@ -202,7 +270,6 @@ function extractText(filePath, ext) {
|
|
|
202
270
|
if (ext === '.docx') {
|
|
203
271
|
try {
|
|
204
272
|
const mammoth = require('mammoth');
|
|
205
|
-
const { execSync } = require('child_process');
|
|
206
273
|
const script = `
|
|
207
274
|
const mammoth = require('mammoth');
|
|
208
275
|
mammoth.extractRawText({ path: ${JSON.stringify(filePath)} })
|
|
@@ -213,12 +280,15 @@ function extractText(filePath, ext) {
|
|
|
213
280
|
process.stdout.write(JSON.stringify({ error: err.message }));
|
|
214
281
|
});
|
|
215
282
|
`;
|
|
216
|
-
const
|
|
283
|
+
const r = spawnSync('node', ['-e', script], {
|
|
217
284
|
encoding: 'utf-8',
|
|
218
285
|
timeout: 30000,
|
|
219
286
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
220
287
|
});
|
|
221
|
-
|
|
288
|
+
if (r.status !== 0) {
|
|
289
|
+
return { text: null, error: `DOCX extraction failed: ${r.stderr || r.error?.message || 'unknown'}` };
|
|
290
|
+
}
|
|
291
|
+
const result = JSON.parse(r.stdout.trim());
|
|
222
292
|
if (result.error) {
|
|
223
293
|
return { text: null, error: result.error };
|
|
224
294
|
}
|
|
@@ -611,31 +681,14 @@ function cmdDocsList(cwd, scopeFilter, raw) {
|
|
|
611
681
|
}
|
|
612
682
|
}
|
|
613
683
|
|
|
614
|
-
// Idea docs
|
|
684
|
+
// Idea docs — flat-aware via the shared enumerator (legacy dirs still resolved)
|
|
615
685
|
if (!filterScope || filterScope === 'idea') {
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
try {
|
|
623
|
-
ideaDirs = fs.readdirSync(stateDir, { withFileTypes: true })
|
|
624
|
-
.filter(e => e.isDirectory())
|
|
625
|
-
.map(e => e.name);
|
|
626
|
-
} catch {
|
|
627
|
-
continue;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
for (const ideaSlug of ideaDirs) {
|
|
631
|
-
if (filterId && ideaSlug !== filterId) continue;
|
|
632
|
-
const docsDir = path.join(stateDir, ideaSlug, 'docs');
|
|
633
|
-
if (!fs.existsSync(docsDir)) continue;
|
|
634
|
-
|
|
635
|
-
const docs = scanDocsDir(docsDir);
|
|
636
|
-
if (docs.length > 0) {
|
|
637
|
-
scopes.push({ scope: 'idea', scope_id: `${state}/${ideaSlug}`, docs });
|
|
638
|
-
}
|
|
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 });
|
|
639
692
|
}
|
|
640
693
|
}
|
|
641
694
|
}
|
|
@@ -918,6 +971,7 @@ module.exports = {
|
|
|
918
971
|
computeChecksum,
|
|
919
972
|
resolveDocsDir,
|
|
920
973
|
ensureDocsDir,
|
|
974
|
+
enumerateIdeaDocsDirs,
|
|
921
975
|
extractText,
|
|
922
976
|
updateIndex,
|
|
923
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', () => {
|