@ktpartners/dgs-platform 2.6.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/LICENSE +38 -0
- package/README.md +851 -0
- package/agents/dgs-codebase-cross-analyzer.md +183 -0
- package/agents/dgs-codebase-mapper.md +782 -0
- package/agents/dgs-codebase-synthesizer.md +156 -0
- package/agents/dgs-debugger.md +1256 -0
- package/agents/dgs-executor.md +550 -0
- package/agents/dgs-integration-checker.md +481 -0
- package/agents/dgs-nyquist-auditor.md +178 -0
- package/agents/dgs-phase-researcher.md +563 -0
- package/agents/dgs-phase-verifier.md +450 -0
- package/agents/dgs-plan-checker.md +708 -0
- package/agents/dgs-planner.md +1324 -0
- package/agents/dgs-project-researcher.md +631 -0
- package/agents/dgs-research-synthesizer.md +249 -0
- package/agents/dgs-roadmapper.md +652 -0
- package/agents/dgs-verifier.md +607 -0
- package/bin/install.js +2073 -0
- package/commands/dgs/add-doc.md +45 -0
- package/commands/dgs/add-idea.md +38 -0
- package/commands/dgs/add-phase.md +43 -0
- package/commands/dgs/add-repo.md +54 -0
- package/commands/dgs/add-tests.md +41 -0
- package/commands/dgs/add-todo.md +47 -0
- package/commands/dgs/approve-spec.md +38 -0
- package/commands/dgs/audit-milestone.md +36 -0
- package/commands/dgs/audit-phase.md +37 -0
- package/commands/dgs/cancel-job.md +23 -0
- package/commands/dgs/capture-principle.md +143 -0
- package/commands/dgs/check-todos.md +45 -0
- package/commands/dgs/cleanup.md +18 -0
- package/commands/dgs/complete-milestone.md +136 -0
- package/commands/dgs/complete-project.md +70 -0
- package/commands/dgs/consolidate-ideas.md +50 -0
- package/commands/dgs/create-milestone-job.md +37 -0
- package/commands/dgs/debug.md +164 -0
- package/commands/dgs/develop-idea.md +53 -0
- package/commands/dgs/discuss-idea.md +41 -0
- package/commands/dgs/discuss-phase.md +83 -0
- package/commands/dgs/execute-phase.md +41 -0
- package/commands/dgs/fast.md +38 -0
- package/commands/dgs/find-related-ideas.md +43 -0
- package/commands/dgs/health.md +28 -0
- package/commands/dgs/help.md +22 -0
- package/commands/dgs/import-spec.md +36 -0
- package/commands/dgs/init-product.md +28 -0
- package/commands/dgs/insert-phase.md +32 -0
- package/commands/dgs/join-discord.md +18 -0
- package/commands/dgs/list-docs.md +40 -0
- package/commands/dgs/list-ideas.md +42 -0
- package/commands/dgs/list-jobs.md +22 -0
- package/commands/dgs/list-phase-assumptions.md +46 -0
- package/commands/dgs/list-projects.md +57 -0
- package/commands/dgs/list-specs.md +40 -0
- package/commands/dgs/map-codebase.md +92 -0
- package/commands/dgs/new-milestone.md +44 -0
- package/commands/dgs/new-project.md +42 -0
- package/commands/dgs/node-repair.md +26 -0
- package/commands/dgs/overlap-check.md +20 -0
- package/commands/dgs/pause-work.md +38 -0
- package/commands/dgs/plan-milestone-gaps.md +34 -0
- package/commands/dgs/plan-phase.md +44 -0
- package/commands/dgs/progress.md +24 -0
- package/commands/dgs/quick.md +41 -0
- package/commands/dgs/reactivate-project.md +70 -0
- package/commands/dgs/reapply-patches.md +110 -0
- package/commands/dgs/refine-spec.md +38 -0
- package/commands/dgs/reject-idea.md +43 -0
- package/commands/dgs/remove-doc.md +44 -0
- package/commands/dgs/remove-phase.md +31 -0
- package/commands/dgs/remove-repo.md +69 -0
- package/commands/dgs/research-idea.md +43 -0
- package/commands/dgs/research-phase.md +189 -0
- package/commands/dgs/restore-idea.md +45 -0
- package/commands/dgs/resume-work.md +40 -0
- package/commands/dgs/rollback-job.md +24 -0
- package/commands/dgs/run-job.md +35 -0
- package/commands/dgs/search.md +40 -0
- package/commands/dgs/set-profile.md +34 -0
- package/commands/dgs/settings.md +38 -0
- package/commands/dgs/switch-project.md +58 -0
- package/commands/dgs/undo-consolidation.md +42 -0
- package/commands/dgs/update-idea.md +44 -0
- package/commands/dgs/update.md +37 -0
- package/commands/dgs/validate-phase.md +35 -0
- package/commands/dgs/verify-work.md +39 -0
- package/commands/dgs/write-spec.md +49 -0
- package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-01-SUMMARY.md +84 -0
- package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-02-SUMMARY.md +86 -0
- package/deliver-great-systems/.planning/phases/10-v1-to-v2-migration-flow/10-01-SUMMARY.md +85 -0
- package/deliver-great-systems/bin/dgs-tools.cjs +1444 -0
- package/deliver-great-systems/bin/lib/auto-test.cjs +1365 -0
- package/deliver-great-systems/bin/lib/commands.cjs +570 -0
- package/deliver-great-systems/bin/lib/config.cjs +417 -0
- package/deliver-great-systems/bin/lib/conflict-agent.cjs +1063 -0
- package/deliver-great-systems/bin/lib/conflict-agent.test.cjs +554 -0
- package/deliver-great-systems/bin/lib/context.cjs +929 -0
- package/deliver-great-systems/bin/lib/context.test.cjs +693 -0
- package/deliver-great-systems/bin/lib/core.cjs +744 -0
- package/deliver-great-systems/bin/lib/core.test.cjs +822 -0
- package/deliver-great-systems/bin/lib/docs.cjs +919 -0
- package/deliver-great-systems/bin/lib/docs.test.cjs +211 -0
- package/deliver-great-systems/bin/lib/execution.cjs +705 -0
- package/deliver-great-systems/bin/lib/execution.test.cjs +1472 -0
- package/deliver-great-systems/bin/lib/frontmatter.cjs +324 -0
- package/deliver-great-systems/bin/lib/ideas.cjs +1406 -0
- package/deliver-great-systems/bin/lib/ideas.test.cjs +1417 -0
- package/deliver-great-systems/bin/lib/identity.cjs +125 -0
- package/deliver-great-systems/bin/lib/init.cjs +1114 -0
- package/deliver-great-systems/bin/lib/init.test.cjs +1271 -0
- package/deliver-great-systems/bin/lib/jobs.cjs +2015 -0
- package/deliver-great-systems/bin/lib/jobs.test.cjs +2619 -0
- package/deliver-great-systems/bin/lib/merge-conflicts.cjs +654 -0
- package/deliver-great-systems/bin/lib/merge-conflicts.test.cjs +370 -0
- package/deliver-great-systems/bin/lib/migration.cjs +352 -0
- package/deliver-great-systems/bin/lib/migration.test.cjs +582 -0
- package/deliver-great-systems/bin/lib/milestone.cjs +243 -0
- package/deliver-great-systems/bin/lib/overlap.cjs +437 -0
- package/deliver-great-systems/bin/lib/overlap.test.cjs +747 -0
- package/deliver-great-systems/bin/lib/path-audit.test.cjs +384 -0
- package/deliver-great-systems/bin/lib/paths.cjs +144 -0
- package/deliver-great-systems/bin/lib/paths.test.cjs +486 -0
- package/deliver-great-systems/bin/lib/phase.cjs +910 -0
- package/deliver-great-systems/bin/lib/projects.cjs +691 -0
- package/deliver-great-systems/bin/lib/projects.test.cjs +871 -0
- package/deliver-great-systems/bin/lib/repos.cjs +1432 -0
- package/deliver-great-systems/bin/lib/repos.test.cjs +1882 -0
- package/deliver-great-systems/bin/lib/roadmap.cjs +305 -0
- package/deliver-great-systems/bin/lib/search.cjs +570 -0
- package/deliver-great-systems/bin/lib/specs.cjs +1303 -0
- package/deliver-great-systems/bin/lib/state.cjs +893 -0
- package/deliver-great-systems/bin/lib/template.cjs +228 -0
- package/deliver-great-systems/bin/lib/test-helpers.cjs +291 -0
- package/deliver-great-systems/bin/lib/verify.cjs +796 -0
- package/deliver-great-systems/references/checkpoints.md +776 -0
- package/deliver-great-systems/references/conflict-resolution.md +66 -0
- package/deliver-great-systems/references/context-tiers.md +166 -0
- package/deliver-great-systems/references/continuation-format.md +249 -0
- package/deliver-great-systems/references/decimal-phase-calculation.md +67 -0
- package/deliver-great-systems/references/git-integration.md +250 -0
- package/deliver-great-systems/references/git-planning-commit.md +40 -0
- package/deliver-great-systems/references/model-profile-resolution.md +36 -0
- package/deliver-great-systems/references/model-profiles.md +95 -0
- package/deliver-great-systems/references/phase-argument-parsing.md +61 -0
- package/deliver-great-systems/references/planning-config.md +224 -0
- package/deliver-great-systems/references/questioning.md +162 -0
- package/deliver-great-systems/references/spec-review-loop.md +177 -0
- package/deliver-great-systems/references/tdd.md +265 -0
- package/deliver-great-systems/references/ui-brand.md +160 -0
- package/deliver-great-systems/references/verification-patterns.md +612 -0
- package/deliver-great-systems/templates/DEBUG.md +166 -0
- package/deliver-great-systems/templates/UAT.md +251 -0
- package/deliver-great-systems/templates/VALIDATION.md +95 -0
- package/deliver-great-systems/templates/claude-md.md +74 -0
- package/deliver-great-systems/templates/codebase/architecture.md +257 -0
- package/deliver-great-systems/templates/codebase/concerns.md +312 -0
- package/deliver-great-systems/templates/codebase/conventions.md +309 -0
- package/deliver-great-systems/templates/codebase/integrations.md +282 -0
- package/deliver-great-systems/templates/codebase/stack.md +188 -0
- package/deliver-great-systems/templates/codebase/structure.md +287 -0
- package/deliver-great-systems/templates/codebase/testing.md +482 -0
- package/deliver-great-systems/templates/config.json +38 -0
- package/deliver-great-systems/templates/context.md +354 -0
- package/deliver-great-systems/templates/continue-here.md +80 -0
- package/deliver-great-systems/templates/debug-subagent-prompt.md +93 -0
- package/deliver-great-systems/templates/discovery.md +148 -0
- package/deliver-great-systems/templates/milestone-archive.md +125 -0
- package/deliver-great-systems/templates/milestone.md +117 -0
- package/deliver-great-systems/templates/phase-prompt.md +615 -0
- package/deliver-great-systems/templates/planner-subagent-prompt.md +119 -0
- package/deliver-great-systems/templates/project.md +186 -0
- package/deliver-great-systems/templates/requirements.md +233 -0
- package/deliver-great-systems/templates/research-project/ARCHITECTURE.md +206 -0
- package/deliver-great-systems/templates/research-project/FEATURES.md +149 -0
- package/deliver-great-systems/templates/research-project/PITFALLS.md +202 -0
- package/deliver-great-systems/templates/research-project/STACK.md +122 -0
- package/deliver-great-systems/templates/research-project/SUMMARY.md +172 -0
- package/deliver-great-systems/templates/research.md +554 -0
- package/deliver-great-systems/templates/retrospective.md +54 -0
- package/deliver-great-systems/templates/roadmap.md +204 -0
- package/deliver-great-systems/templates/state.md +178 -0
- package/deliver-great-systems/templates/summary-complex.md +59 -0
- package/deliver-great-systems/templates/summary-minimal.md +41 -0
- package/deliver-great-systems/templates/summary-standard.md +48 -0
- package/deliver-great-systems/templates/summary.md +253 -0
- package/deliver-great-systems/templates/user-setup.md +313 -0
- package/deliver-great-systems/templates/verification-report.md +324 -0
- package/deliver-great-systems/workflows/add-doc.md +151 -0
- package/deliver-great-systems/workflows/add-idea.md +96 -0
- package/deliver-great-systems/workflows/add-phase.md +120 -0
- package/deliver-great-systems/workflows/add-tests.md +359 -0
- package/deliver-great-systems/workflows/add-todo.md +162 -0
- package/deliver-great-systems/workflows/approve-spec.md +194 -0
- package/deliver-great-systems/workflows/audit-milestone.md +364 -0
- package/deliver-great-systems/workflows/audit-phase.md +462 -0
- package/deliver-great-systems/workflows/cancel-job.md +108 -0
- package/deliver-great-systems/workflows/check-todos.md +181 -0
- package/deliver-great-systems/workflows/cleanup.md +247 -0
- package/deliver-great-systems/workflows/codereview.md +526 -0
- package/deliver-great-systems/workflows/complete-milestone.md +1298 -0
- package/deliver-great-systems/workflows/consolidate-ideas.md +365 -0
- package/deliver-great-systems/workflows/create-milestone-job.md +177 -0
- package/deliver-great-systems/workflows/develop-idea.md +544 -0
- package/deliver-great-systems/workflows/diagnose-issues.md +231 -0
- package/deliver-great-systems/workflows/discovery-phase.md +301 -0
- package/deliver-great-systems/workflows/discuss-idea.md +263 -0
- package/deliver-great-systems/workflows/discuss-phase.md +733 -0
- package/deliver-great-systems/workflows/execute-phase.md +571 -0
- package/deliver-great-systems/workflows/execute-plan.md +592 -0
- package/deliver-great-systems/workflows/find-related-ideas.md +271 -0
- package/deliver-great-systems/workflows/health.md +173 -0
- package/deliver-great-systems/workflows/help.md +997 -0
- package/deliver-great-systems/workflows/import-spec.md +381 -0
- package/deliver-great-systems/workflows/init-product.md +767 -0
- package/deliver-great-systems/workflows/insert-phase.md +138 -0
- package/deliver-great-systems/workflows/list-docs.md +119 -0
- package/deliver-great-systems/workflows/list-ideas.md +154 -0
- package/deliver-great-systems/workflows/list-jobs.md +89 -0
- package/deliver-great-systems/workflows/list-phase-assumptions.md +192 -0
- package/deliver-great-systems/workflows/list-specs.md +101 -0
- package/deliver-great-systems/workflows/map-codebase.md +621 -0
- package/deliver-great-systems/workflows/new-milestone.md +591 -0
- package/deliver-great-systems/workflows/new-project.md +1113 -0
- package/deliver-great-systems/workflows/node-repair.md +94 -0
- package/deliver-great-systems/workflows/overlap-check.md +86 -0
- package/deliver-great-systems/workflows/pause-work.md +134 -0
- package/deliver-great-systems/workflows/plan-milestone-gaps.md +306 -0
- package/deliver-great-systems/workflows/plan-phase.md +698 -0
- package/deliver-great-systems/workflows/progress.md +386 -0
- package/deliver-great-systems/workflows/quick.md +845 -0
- package/deliver-great-systems/workflows/refine-spec.md +275 -0
- package/deliver-great-systems/workflows/reject-idea.md +109 -0
- package/deliver-great-systems/workflows/remove-doc.md +117 -0
- package/deliver-great-systems/workflows/remove-phase.md +163 -0
- package/deliver-great-systems/workflows/research-idea.md +325 -0
- package/deliver-great-systems/workflows/research-phase.md +81 -0
- package/deliver-great-systems/workflows/restore-idea.md +101 -0
- package/deliver-great-systems/workflows/resume-project.md +311 -0
- package/deliver-great-systems/workflows/rollback-job.md +130 -0
- package/deliver-great-systems/workflows/run-job.md +498 -0
- package/deliver-great-systems/workflows/search.md +130 -0
- package/deliver-great-systems/workflows/set-profile.md +83 -0
- package/deliver-great-systems/workflows/settings.md +470 -0
- package/deliver-great-systems/workflows/transition.md +563 -0
- package/deliver-great-systems/workflows/undo-consolidation.md +155 -0
- package/deliver-great-systems/workflows/update-idea.md +157 -0
- package/deliver-great-systems/workflows/update.md +242 -0
- package/deliver-great-systems/workflows/validate-phase.md +177 -0
- package/deliver-great-systems/workflows/verify-phase.md +253 -0
- package/deliver-great-systems/workflows/verify-work.md +671 -0
- package/deliver-great-systems/workflows/write-spec.md +450 -0
- package/hooks/dist/dgs-check-update.js +62 -0
- package/hooks/dist/dgs-context-monitor.js +141 -0
- package/hooks/dist/dgs-statusline.js +115 -0
- package/package.json +60 -0
- package/scripts/build-hooks.js +43 -0
|
@@ -0,0 +1,1432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repos — REPOS.md parse/write, repo discovery, prefix-match resolution,
|
|
3
|
+
* .gitignore sync, path validation, PROJECTS.md scaffold
|
|
4
|
+
*
|
|
5
|
+
* This module is the foundation for all Phase 2 commands: init-product,
|
|
6
|
+
* add-repo, remove-repo, and the repos CLI subcommand.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { safeReadFile, execGit, isV2Install, output, error } = require('./core.cjs');
|
|
12
|
+
const { writeConfigField } = require('./config.cjs');
|
|
13
|
+
const { getPlanningRoot, resetPaths } = require('./paths.cjs');
|
|
14
|
+
// Lazy-loaded to avoid circular dependency (migration.cjs requires writeReposMd from this file)
|
|
15
|
+
let _migration;
|
|
16
|
+
function migration() {
|
|
17
|
+
if (!_migration) _migration = require('./migration.cjs');
|
|
18
|
+
return _migration;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── REPOS.md Parse / Write ──────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse .planning/REPOS.md into structured data.
|
|
25
|
+
*
|
|
26
|
+
* Returns null if the file doesn't exist or doesn't start with '# Repos'.
|
|
27
|
+
* Returns { repos: [] } if the header exists but the table has no data rows.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} cwd - Working directory (product root)
|
|
30
|
+
* @returns {{ repos: Array<{name: string, path: string, url: string, description: string}> } | null}
|
|
31
|
+
*/
|
|
32
|
+
function parseReposMd(cwd) {
|
|
33
|
+
const filePath = path.join(getPlanningRoot(cwd), 'REPOS.md');
|
|
34
|
+
const content = safeReadFile(filePath);
|
|
35
|
+
if (!content) return null;
|
|
36
|
+
|
|
37
|
+
// v2 marker check — must start with '# Repos'
|
|
38
|
+
if (!content.startsWith('# Repos')) return null;
|
|
39
|
+
|
|
40
|
+
const lines = content.split('\n');
|
|
41
|
+
const repos = [];
|
|
42
|
+
|
|
43
|
+
// Find table header row (line starting with | containing Name)
|
|
44
|
+
let tableStart = -1;
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
if (lines[i].startsWith('|') && lines[i].includes('Name')) {
|
|
47
|
+
tableStart = i;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (tableStart === -1) return { repos };
|
|
53
|
+
|
|
54
|
+
// Skip header row and separator row
|
|
55
|
+
let dataStart = tableStart + 2;
|
|
56
|
+
|
|
57
|
+
// Parse data rows
|
|
58
|
+
for (let i = dataStart; i < lines.length; i++) {
|
|
59
|
+
const line = lines[i].trim();
|
|
60
|
+
if (!line.startsWith('|')) break;
|
|
61
|
+
|
|
62
|
+
const cells = line.split('|').map(c => c.trim());
|
|
63
|
+
// Split on | gives empty first/last elements: |a|b|c| => ['', 'a', 'b', 'c', '']
|
|
64
|
+
// Need at least name and path (indices 1 and 2)
|
|
65
|
+
if (cells.length < 3) continue;
|
|
66
|
+
|
|
67
|
+
repos.push({
|
|
68
|
+
name: cells[1] || '',
|
|
69
|
+
path: cells[2] || '',
|
|
70
|
+
url: cells[3] || '',
|
|
71
|
+
description: cells[4] || '',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { repos };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Write repos array to .planning/REPOS.md with standard format.
|
|
80
|
+
*
|
|
81
|
+
* Creates the '# Repos' header (v2 marker), explanatory text, and
|
|
82
|
+
* a properly formatted markdown table.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} cwd - Working directory (product root)
|
|
85
|
+
* @param {Array<{name: string, path: string, url: string, description: string}>} repos
|
|
86
|
+
*/
|
|
87
|
+
function writeReposMd(cwd, repos) {
|
|
88
|
+
let content = '# Repos\n\n';
|
|
89
|
+
content += 'Registered repositories for this product. Managed by DGS — manual edits may be overwritten.\n\n';
|
|
90
|
+
content += '| Name | Path | GitHub URL | Description |\n';
|
|
91
|
+
content += '|------|------|------------|-------------|\n';
|
|
92
|
+
|
|
93
|
+
for (const repo of repos) {
|
|
94
|
+
content += `| ${repo.name} | ${repo.path} | ${repo.url || ''} | ${repo.description || ''} |\n`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fs.writeFileSync(path.join(getPlanningRoot(cwd), 'REPOS.md'), content);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Eager Validation ─────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate REPOS.md entries eagerly: path format, disk existence, .git, duplicates.
|
|
104
|
+
*
|
|
105
|
+
* Separate from parseReposMd (which remains a pure parser) so callers can choose:
|
|
106
|
+
* parse-only for reading/writing, or validate for execution.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} cwd - Working directory (product root)
|
|
109
|
+
* @returns {{ repos: Array, errors: string[] }}
|
|
110
|
+
*/
|
|
111
|
+
function validateReposMdEager(cwd) {
|
|
112
|
+
const parsed = parseReposMd(cwd);
|
|
113
|
+
if (!parsed) {
|
|
114
|
+
return { repos: [], errors: ['No REPOS.md found.'] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const errors = [];
|
|
118
|
+
const validatedRepos = [];
|
|
119
|
+
const seenAbsPaths = new Map(); // absPath -> repoName for duplicate detection
|
|
120
|
+
|
|
121
|
+
for (const repo of parsed.repos) {
|
|
122
|
+
// Validate path format: must start with ../ and not be deeper
|
|
123
|
+
if (!repo.path.startsWith('../')) {
|
|
124
|
+
errors.push(`Repo paths must use ../name format. Found: '${repo.path}' for repo '${repo.name}'.`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (repo.path.startsWith('../../') || repo.path.replace(/^\.\.\//, '').includes('../')) {
|
|
128
|
+
errors.push(`Repo paths must use ../name format. Found: '${repo.path}' for repo '${repo.name}'.`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Resolve to absolute path
|
|
133
|
+
const absPath = path.resolve(cwd, repo.path);
|
|
134
|
+
|
|
135
|
+
// Check directory exists
|
|
136
|
+
if (!fs.existsSync(absPath)) {
|
|
137
|
+
errors.push(`Repo '${repo.name}' not found at ${repo.path} -- check the path in REPOS.md or clone the repo there.`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check .git exists
|
|
142
|
+
if (!fs.existsSync(path.join(absPath, '.git'))) {
|
|
143
|
+
errors.push(`Repo '${repo.name}' at ${repo.path} is not a git repository (no .git/).`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check duplicate absolute paths
|
|
148
|
+
if (seenAbsPaths.has(absPath)) {
|
|
149
|
+
errors.push(`Duplicate path: repos '${seenAbsPaths.get(absPath)}' and '${repo.name}' both resolve to ${absPath}.`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
seenAbsPaths.set(absPath, repo.name);
|
|
154
|
+
validatedRepos.push(repo);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (errors.length > 0) {
|
|
158
|
+
return { repos: [], errors };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { repos: validatedRepos, errors: [] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Longest-Prefix-Match Resolution ─────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Strip relative prefixes (./ and ../) from a path for matching purposes.
|
|
168
|
+
* Also collapses double separators and strips trailing slashes.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} p - Path to normalize
|
|
171
|
+
* @returns {string} Normalized path without relative prefix
|
|
172
|
+
*/
|
|
173
|
+
function normalizeForMatch(p) {
|
|
174
|
+
return p.replace(/^\.\.\//, '').replace(/^\.\//, '').replace(/\/+/g, '/').replace(/\/$/, '');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve a file path to its owning repo using longest-prefix-match.
|
|
179
|
+
*
|
|
180
|
+
* Critical edge case handling:
|
|
181
|
+
* - Trailing-slash normalization prevents 'web' from matching 'web-app/src/...'
|
|
182
|
+
* - Sorted longest-first so 'services/api/' matches before 'services/'
|
|
183
|
+
* - Handles both ./repo and ../repo path formats
|
|
184
|
+
* - Accepts absolute paths when cwd is provided
|
|
185
|
+
*
|
|
186
|
+
* @param {string} filePath - File path (relative or absolute)
|
|
187
|
+
* @param {Array<{name: string, path: string}>} repos - Array of repos
|
|
188
|
+
* @param {string} [cwd] - Working directory (product root) for absolute path resolution
|
|
189
|
+
* @returns {{name: string, path: string} | null} Matched repo or null
|
|
190
|
+
*/
|
|
191
|
+
function resolveFileToRepo(filePath, repos, cwd) {
|
|
192
|
+
if (!filePath) return null;
|
|
193
|
+
if (!repos || repos.length === 0) return null;
|
|
194
|
+
|
|
195
|
+
let inputPath = filePath;
|
|
196
|
+
|
|
197
|
+
// Handle absolute paths: resolve relative to cwd's parent (where sibling repos live)
|
|
198
|
+
if (cwd && path.isAbsolute(inputPath)) {
|
|
199
|
+
const parentDir = path.resolve(cwd, '..');
|
|
200
|
+
inputPath = path.relative(parentDir, inputPath);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Normalize input: strip ../ and ./ prefixes, collapse double separators, strip trailing /
|
|
204
|
+
let normalized = normalizeForMatch(inputPath);
|
|
205
|
+
|
|
206
|
+
// Build match entries with trailing-slash normalization
|
|
207
|
+
const entries = repos.map(repo => {
|
|
208
|
+
const repoPath = normalizeForMatch(repo.path) + '/';
|
|
209
|
+
return { repo, matchPath: repoPath };
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Sort by matchPath length descending (longest first)
|
|
213
|
+
entries.sort((a, b) => b.matchPath.length - a.matchPath.length);
|
|
214
|
+
|
|
215
|
+
// Find first match — file path MUST have content after the repo prefix
|
|
216
|
+
// (i.e., normalized must start with matchPath which includes trailing /)
|
|
217
|
+
for (const { repo, matchPath } of entries) {
|
|
218
|
+
if (normalized.startsWith(matchPath)) {
|
|
219
|
+
return { name: repo.name, path: repo.path };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Repo Discovery ──────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Extract a description for a repo from package.json or README.
|
|
230
|
+
*
|
|
231
|
+
* Priority:
|
|
232
|
+
* 1. package.json 'description' field
|
|
233
|
+
* 2. First non-empty, non-header line from README.md/README/readme.md
|
|
234
|
+
* 3. Empty string
|
|
235
|
+
*
|
|
236
|
+
* @param {string} repoPath - Absolute path to repo directory
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
function extractRepoDescription(repoPath) {
|
|
240
|
+
// Try package.json
|
|
241
|
+
try {
|
|
242
|
+
const pkgPath = path.join(repoPath, 'package.json');
|
|
243
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
244
|
+
if (pkg.description) return pkg.description;
|
|
245
|
+
} catch {}
|
|
246
|
+
|
|
247
|
+
// Try README variants
|
|
248
|
+
const readmeNames = ['README.md', 'README', 'readme.md'];
|
|
249
|
+
for (const name of readmeNames) {
|
|
250
|
+
try {
|
|
251
|
+
const content = fs.readFileSync(path.join(repoPath, name), 'utf-8');
|
|
252
|
+
const lines = content.split('\n');
|
|
253
|
+
for (const line of lines) {
|
|
254
|
+
const trimmed = line.trim();
|
|
255
|
+
if (!trimmed) continue;
|
|
256
|
+
if (trimmed.startsWith('#')) continue;
|
|
257
|
+
// Return first non-empty, non-header line (max 100 chars)
|
|
258
|
+
return trimmed.length > 100 ? trimmed.slice(0, 100) : trimmed;
|
|
259
|
+
}
|
|
260
|
+
} catch {}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return '';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Discover git repos at depth-1 in the given directory.
|
|
268
|
+
*
|
|
269
|
+
* Scans for subdirectories containing .git/, excluding:
|
|
270
|
+
* - node_modules
|
|
271
|
+
* - .planning
|
|
272
|
+
* - Dot-directories (starting with '.')
|
|
273
|
+
* - Non-directories (files)
|
|
274
|
+
*
|
|
275
|
+
* Extracts GitHub URL from git remote and description from package.json/README.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} cwd - Directory to scan
|
|
278
|
+
* @returns {Array<{name: string, path: string, url: string, description: string}>}
|
|
279
|
+
*/
|
|
280
|
+
function discoverRepos(cwd) {
|
|
281
|
+
const repos = [];
|
|
282
|
+
|
|
283
|
+
let entries;
|
|
284
|
+
try {
|
|
285
|
+
entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
286
|
+
} catch {
|
|
287
|
+
return repos;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
// Skip non-directories
|
|
292
|
+
if (!entry.isDirectory()) continue;
|
|
293
|
+
|
|
294
|
+
const name = entry.name;
|
|
295
|
+
|
|
296
|
+
// Skip dot-directories
|
|
297
|
+
if (name.startsWith('.')) continue;
|
|
298
|
+
|
|
299
|
+
// Skip node_modules
|
|
300
|
+
if (name === 'node_modules') continue;
|
|
301
|
+
|
|
302
|
+
// Check for .git/
|
|
303
|
+
const gitDir = path.join(cwd, name, '.git');
|
|
304
|
+
if (!fs.existsSync(gitDir)) continue;
|
|
305
|
+
|
|
306
|
+
// Extract GitHub URL from git remote
|
|
307
|
+
let url = '';
|
|
308
|
+
try {
|
|
309
|
+
const result = execGit(cwd, ['-C', name, 'config', '--get', 'remote.origin.url']);
|
|
310
|
+
if (result.exitCode === 0 && result.stdout) {
|
|
311
|
+
url = result.stdout;
|
|
312
|
+
}
|
|
313
|
+
} catch {}
|
|
314
|
+
|
|
315
|
+
// Extract description
|
|
316
|
+
const description = extractRepoDescription(path.join(cwd, name));
|
|
317
|
+
|
|
318
|
+
repos.push({
|
|
319
|
+
name,
|
|
320
|
+
path: './' + name,
|
|
321
|
+
url,
|
|
322
|
+
description,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return repos;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Discover sibling git repos in the parent directory of the given directory.
|
|
331
|
+
*
|
|
332
|
+
* Scans the parent directory for subdirectories containing .git/, excluding:
|
|
333
|
+
* - The product root directory itself (selfName)
|
|
334
|
+
* - node_modules
|
|
335
|
+
* - Dot-directories (starting with '.')
|
|
336
|
+
* - Non-directories (files)
|
|
337
|
+
*
|
|
338
|
+
* Uses ../ path prefix for sibling repos (not ./ like local repos).
|
|
339
|
+
*
|
|
340
|
+
* @param {string} cwd - Directory to scan siblings of (product root)
|
|
341
|
+
* @returns {Array<{name: string, path: string, url: string, description: string}>}
|
|
342
|
+
*/
|
|
343
|
+
function discoverSiblingRepos(cwd) {
|
|
344
|
+
const repos = [];
|
|
345
|
+
const parentDir = path.resolve(cwd, '..');
|
|
346
|
+
const selfName = path.basename(path.resolve(cwd));
|
|
347
|
+
|
|
348
|
+
let entries;
|
|
349
|
+
try {
|
|
350
|
+
entries = fs.readdirSync(parentDir, { withFileTypes: true });
|
|
351
|
+
} catch {
|
|
352
|
+
return repos;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const entry of entries) {
|
|
356
|
+
// Skip non-directories
|
|
357
|
+
if (!entry.isDirectory()) continue;
|
|
358
|
+
|
|
359
|
+
const name = entry.name;
|
|
360
|
+
|
|
361
|
+
// Skip dot-directories
|
|
362
|
+
if (name.startsWith('.')) continue;
|
|
363
|
+
|
|
364
|
+
// Skip node_modules
|
|
365
|
+
if (name === 'node_modules') continue;
|
|
366
|
+
|
|
367
|
+
// Skip the product root directory itself
|
|
368
|
+
if (name === selfName) continue;
|
|
369
|
+
|
|
370
|
+
// Check for .git/
|
|
371
|
+
const gitDir = path.join(parentDir, name, '.git');
|
|
372
|
+
if (!fs.existsSync(gitDir)) continue;
|
|
373
|
+
|
|
374
|
+
// Extract GitHub URL from git remote
|
|
375
|
+
let url = '';
|
|
376
|
+
try {
|
|
377
|
+
const result = execGit(parentDir, ['-C', name, 'config', '--get', 'remote.origin.url']);
|
|
378
|
+
if (result.exitCode === 0 && result.stdout) {
|
|
379
|
+
url = result.stdout;
|
|
380
|
+
}
|
|
381
|
+
} catch {}
|
|
382
|
+
|
|
383
|
+
// Extract description
|
|
384
|
+
const description = extractRepoDescription(path.join(parentDir, name));
|
|
385
|
+
|
|
386
|
+
repos.push({
|
|
387
|
+
name,
|
|
388
|
+
path: '../' + name,
|
|
389
|
+
url,
|
|
390
|
+
description,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return repos;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ─── .gitignore Sync ─────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
const DGS_MARKER_START = '# DGS managed repos - do not edit below';
|
|
400
|
+
const DGS_MARKER_END = '# end DGS managed repos';
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Sync .gitignore with DGS-managed repo paths.
|
|
404
|
+
*
|
|
405
|
+
* Manages a marked section in .gitignore between DGS marker comments.
|
|
406
|
+
* Preserves all user entries outside the marker section.
|
|
407
|
+
* On subsequent calls, replaces only the DGS section (no duplicates).
|
|
408
|
+
*
|
|
409
|
+
* @param {string} cwd - Working directory (product root)
|
|
410
|
+
* @param {string[]} repoPaths - Array of repo paths (e.g., ['./web-app', './server'])
|
|
411
|
+
*/
|
|
412
|
+
function syncGitignore(cwd, repoPaths) {
|
|
413
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
414
|
+
|
|
415
|
+
// Defensive: skip ../ paths (sibling repos are outside git tree)
|
|
416
|
+
repoPaths = repoPaths.filter(p => !p.startsWith('../'));
|
|
417
|
+
if (repoPaths.length === 0) return;
|
|
418
|
+
|
|
419
|
+
// Read existing content
|
|
420
|
+
let existing = '';
|
|
421
|
+
try {
|
|
422
|
+
existing = fs.readFileSync(gitignorePath, 'utf-8');
|
|
423
|
+
} catch {}
|
|
424
|
+
|
|
425
|
+
// Normalize repo paths: strip ./ prefix, add trailing /
|
|
426
|
+
const normalizedPaths = repoPaths.map(p =>
|
|
427
|
+
p.replace(/^\.\//, '').replace(/\/$/, '') + '/'
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
// Build DGS section
|
|
431
|
+
const dgsSection = [
|
|
432
|
+
DGS_MARKER_START,
|
|
433
|
+
...normalizedPaths,
|
|
434
|
+
DGS_MARKER_END,
|
|
435
|
+
].join('\n');
|
|
436
|
+
|
|
437
|
+
// Find existing marker positions
|
|
438
|
+
const startIdx = existing.indexOf(DGS_MARKER_START);
|
|
439
|
+
const endIdx = existing.indexOf(DGS_MARKER_END);
|
|
440
|
+
|
|
441
|
+
let newContent;
|
|
442
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
443
|
+
// Replace existing DGS section
|
|
444
|
+
const prefix = existing.slice(0, startIdx).replace(/\n+$/, '');
|
|
445
|
+
const suffix = existing.slice(endIdx + DGS_MARKER_END.length).replace(/^\n+/, '');
|
|
446
|
+
|
|
447
|
+
const parts = [prefix, dgsSection, suffix].filter(p => p.length > 0);
|
|
448
|
+
newContent = parts.join('\n\n') + '\n';
|
|
449
|
+
} else {
|
|
450
|
+
// Add new DGS section
|
|
451
|
+
if (existing.trim()) {
|
|
452
|
+
newContent = existing.replace(/\n+$/, '') + '\n\n' + dgsSection + '\n';
|
|
453
|
+
} else {
|
|
454
|
+
newContent = dgsSection + '\n';
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fs.writeFileSync(gitignorePath, newContent);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ─── Path Validation ─────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Validate that registered repo paths exist on disk.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} cwd - Working directory (product root)
|
|
467
|
+
* @param {Array<{name: string, path: string}>} repos - Array of repos to validate
|
|
468
|
+
* @returns {{ valid: Array, missing: Array }}
|
|
469
|
+
*/
|
|
470
|
+
function validateRepoPaths(cwd, repos) {
|
|
471
|
+
const valid = [];
|
|
472
|
+
const missing = [];
|
|
473
|
+
|
|
474
|
+
for (const repo of repos) {
|
|
475
|
+
const diskPath = path.resolve(cwd, repo.path);
|
|
476
|
+
if (fs.existsSync(diskPath)) {
|
|
477
|
+
valid.push(repo);
|
|
478
|
+
} else {
|
|
479
|
+
missing.push(repo);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { valid, missing };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── Path Conflict Detection ─────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Check if a new repo path conflicts with existing repo paths.
|
|
490
|
+
*
|
|
491
|
+
* A conflict exists when one path is a prefix of another at a directory
|
|
492
|
+
* boundary (trailing slash). This prevents ambiguous file-to-repo resolution.
|
|
493
|
+
*
|
|
494
|
+
* @param {string} newPath - New repo path to check (e.g., './services')
|
|
495
|
+
* @param {Array<{name: string, path: string}>} existingRepos - Current repos
|
|
496
|
+
* @returns {{ conflict: boolean, conflicting_repo?: Object }}
|
|
497
|
+
*/
|
|
498
|
+
function hasPathConflict(newPath, existingRepos) {
|
|
499
|
+
// Normalize: strip ./ and ../ prefixes, remove trailing /, add /
|
|
500
|
+
const newNorm = normalizeForMatch(newPath) + '/';
|
|
501
|
+
|
|
502
|
+
for (const repo of existingRepos) {
|
|
503
|
+
const existNorm = normalizeForMatch(repo.path) + '/';
|
|
504
|
+
|
|
505
|
+
if (newNorm.startsWith(existNorm) || existNorm.startsWith(newNorm)) {
|
|
506
|
+
return { conflict: true, conflicting_repo: repo };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return { conflict: false };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ─── PROJECTS.md Scaffold ────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Write .planning/PROJECTS.md with standard scaffold format.
|
|
517
|
+
*
|
|
518
|
+
* Creates the '# Projects' header (v2 marker), Active and Completed
|
|
519
|
+
* sections with table headers.
|
|
520
|
+
*
|
|
521
|
+
* @param {string} cwd - Working directory (product root)
|
|
522
|
+
* @param {Array} projects - Array of project objects (for future use)
|
|
523
|
+
*/
|
|
524
|
+
function writeProjectsMd(cwd, projects) {
|
|
525
|
+
let content = '# Projects\n\n';
|
|
526
|
+
content += '## Active\n\n';
|
|
527
|
+
content += '| Project | Status | Repos Touched | Current Phase |\n';
|
|
528
|
+
content += '|---------|--------|---------------|---------------|\n';
|
|
529
|
+
|
|
530
|
+
// Add active project rows
|
|
531
|
+
const active = (projects || []).filter(p => p.status !== 'completed');
|
|
532
|
+
for (const proj of active) {
|
|
533
|
+
content += `| ${proj.name || ''} | ${proj.status || ''} | ${proj.repos_touched || ''} | ${proj.current_phase || ''} |\n`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
content += '\n## Completed\n\n';
|
|
537
|
+
content += '| Project | Completed | Duration |\n';
|
|
538
|
+
content += '|---------|-----------|----------|\n';
|
|
539
|
+
|
|
540
|
+
// Add completed project rows
|
|
541
|
+
const completed = (projects || []).filter(p => p.status === 'completed');
|
|
542
|
+
for (const proj of completed) {
|
|
543
|
+
content += `| ${proj.name || ''} | ${proj.completed || ''} | ${proj.duration || ''} |\n`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
fs.writeFileSync(path.join(getPlanningRoot(cwd), 'PROJECTS.md'), content);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ─── Plan Tag Auto-Population & Consistency Validation ──────────────────────
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Auto-populate <repos> tags in plan content from <files> path prefixes.
|
|
553
|
+
*
|
|
554
|
+
* For each <task> block containing a <files> tag, resolves file paths to
|
|
555
|
+
* owning repos via resolveFileToRepo (longest-prefix-match). Inserts or
|
|
556
|
+
* replaces <repos> tags with sorted, deduplicated repo names.
|
|
557
|
+
*
|
|
558
|
+
* @param {string} planContent - Raw PLAN.md content string
|
|
559
|
+
* @param {Array<{name: string, path: string}>} repos - Repos array (from parseReposMd)
|
|
560
|
+
* @returns {{ content: string, warnings: string[] }}
|
|
561
|
+
*/
|
|
562
|
+
function autoPopulateReposTags(planContent, repos) {
|
|
563
|
+
const warnings = [];
|
|
564
|
+
|
|
565
|
+
// Match each <task ...>...</task> block (non-greedy, dotall)
|
|
566
|
+
const taskPattern = /<task\b[^>]*>([\s\S]*?)<\/task>/g;
|
|
567
|
+
|
|
568
|
+
const updatedContent = planContent.replace(taskPattern, (fullMatch, taskBody) => {
|
|
569
|
+
// Extract <files> content if present
|
|
570
|
+
const filesMatch = taskBody.match(/<files>([\s\S]*?)<\/files>/);
|
|
571
|
+
if (!filesMatch) return fullMatch;
|
|
572
|
+
|
|
573
|
+
const filesContent = filesMatch[1];
|
|
574
|
+
const fileList = filesContent
|
|
575
|
+
.split(/[\n,]/)
|
|
576
|
+
.map(f => f.trim())
|
|
577
|
+
.filter(f => f);
|
|
578
|
+
|
|
579
|
+
// Resolve each file to its repo
|
|
580
|
+
const repoSet = new Set();
|
|
581
|
+
const unresolvedFiles = [];
|
|
582
|
+
for (const filePath of fileList) {
|
|
583
|
+
const resolved = resolveFileToRepo(filePath, repos);
|
|
584
|
+
if (resolved) {
|
|
585
|
+
repoSet.add(resolved.name);
|
|
586
|
+
} else {
|
|
587
|
+
unresolvedFiles.push(filePath);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Handle repo-relative paths: if no repos could be derived from ANY files
|
|
592
|
+
// AND there are actual files to resolve, the files are likely repo-relative
|
|
593
|
+
if (repoSet.size === 0 && fileList.length > 0) {
|
|
594
|
+
// Files are repo-relative (can't derive repo from path alone)
|
|
595
|
+
// Preserve existing <repos> tag if present
|
|
596
|
+
if (/<repos>[\s\S]*?<\/repos>/.test(taskBody)) {
|
|
597
|
+
return fullMatch; // Leave unchanged
|
|
598
|
+
}
|
|
599
|
+
// No repos tag and no resolvable files — warn
|
|
600
|
+
warnings.push(`Cannot auto-populate <repos> for task — files appear repo-relative. Add <repos> tag manually.`);
|
|
601
|
+
return fullMatch;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Warn about unresolved files when some files DID resolve (likely invalid paths)
|
|
605
|
+
for (const filePath of unresolvedFiles) {
|
|
606
|
+
warnings.push(`File '${filePath}' does not match any registered repo`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Build sorted repos tag content
|
|
610
|
+
const reposTagContent = Array.from(repoSet).sort().join(', ');
|
|
611
|
+
const newReposTag = `<repos>${reposTagContent}</repos>`;
|
|
612
|
+
|
|
613
|
+
// Check if <repos> tag already exists in this task
|
|
614
|
+
let updatedBody;
|
|
615
|
+
if (/<repos>[\s\S]*?<\/repos>/.test(taskBody)) {
|
|
616
|
+
// Replace existing <repos> tag
|
|
617
|
+
updatedBody = taskBody.replace(/<repos>[\s\S]*?<\/repos>/, newReposTag);
|
|
618
|
+
} else {
|
|
619
|
+
// Insert <repos> tag after </files>
|
|
620
|
+
updatedBody = taskBody.replace(/<\/files>/, `</files>\n ${newReposTag}`);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return fullMatch.replace(taskBody, updatedBody);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
return { content: updatedContent, warnings };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Validate consistency between <repos> and <files> tags in plan content.
|
|
631
|
+
*
|
|
632
|
+
* For each <task> block: extracts <repos> tag (listed repos) and <files> tag
|
|
633
|
+
* (derives repos via resolveFileToRepo). Compares to find extra (listed but
|
|
634
|
+
* not derived) and missing (derived but not listed) repos.
|
|
635
|
+
*
|
|
636
|
+
* @param {string} planContent - Raw PLAN.md content string
|
|
637
|
+
* @param {Array<{name: string, path: string}>} repos - Repos array (from parseReposMd)
|
|
638
|
+
* @returns {{ valid: boolean, mismatches: Array<{task: string, repos_listed: string[], repos_derived: string[], extra: string[], missing: string[]}> }}
|
|
639
|
+
*/
|
|
640
|
+
function validateReposConsistency(planContent, repos) {
|
|
641
|
+
const mismatches = [];
|
|
642
|
+
const taskPattern = /<task\b[^>]*>([\s\S]*?)<\/task>/g;
|
|
643
|
+
let match;
|
|
644
|
+
|
|
645
|
+
while ((match = taskPattern.exec(planContent)) !== null) {
|
|
646
|
+
const taskBody = match[1];
|
|
647
|
+
|
|
648
|
+
// Extract task name
|
|
649
|
+
const nameMatch = taskBody.match(/<name>([\s\S]*?)<\/name>/);
|
|
650
|
+
const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed';
|
|
651
|
+
|
|
652
|
+
// Extract <files> content and derive repos
|
|
653
|
+
const filesMatch = taskBody.match(/<files>([\s\S]*?)<\/files>/);
|
|
654
|
+
const fileList = filesMatch
|
|
655
|
+
? filesMatch[1].split(/[\n,]/).map(f => f.trim()).filter(f => f)
|
|
656
|
+
: [];
|
|
657
|
+
|
|
658
|
+
const derivedSet = new Set();
|
|
659
|
+
for (const filePath of fileList) {
|
|
660
|
+
const resolved = resolveFileToRepo(filePath, repos);
|
|
661
|
+
if (resolved) derivedSet.add(resolved.name);
|
|
662
|
+
}
|
|
663
|
+
const repos_derived = Array.from(derivedSet).sort();
|
|
664
|
+
|
|
665
|
+
// Extract <repos> content (listed repos)
|
|
666
|
+
const reposMatch = taskBody.match(/<repos>([\s\S]*?)<\/repos>/);
|
|
667
|
+
const repos_listed = reposMatch
|
|
668
|
+
? reposMatch[1].split(',').map(r => r.trim()).filter(r => r).sort()
|
|
669
|
+
: [];
|
|
670
|
+
|
|
671
|
+
// If no repos could be derived from files (repo-relative paths),
|
|
672
|
+
// trust the explicit <repos> tag — no mismatch
|
|
673
|
+
if (repos_derived.length === 0 && repos_listed.length > 0) {
|
|
674
|
+
continue; // repo-relative files with explicit repos tag — valid
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Compare
|
|
678
|
+
const extra = repos_listed.filter(r => !repos_derived.includes(r));
|
|
679
|
+
const missing = repos_derived.filter(r => !repos_listed.includes(r));
|
|
680
|
+
|
|
681
|
+
if (extra.length > 0 || missing.length > 0) {
|
|
682
|
+
mismatches.push({ task: taskName, repos_listed, repos_derived, extra, missing });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return { valid: mismatches.length === 0, mismatches };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ─── Layout Detection ─────────────────────────────────────────────────────────
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Detect the suggested layout mode for a repo based on its contents.
|
|
693
|
+
*
|
|
694
|
+
* Heuristic (checked in order):
|
|
695
|
+
* 1. .planning/ with config files -> dotplanning (existing setup)
|
|
696
|
+
* 2. dgs.config.json at root with planningRoot:'.' -> root (already root setup)
|
|
697
|
+
* 3. Repo is "empty-ish" (no source code indicators) AND no .planning/ -> root
|
|
698
|
+
* 4. Planning artifacts at root (PROJECT.md, ROADMAP.md) without .planning/ -> root
|
|
699
|
+
* 5. Default -> dotplanning (safer default)
|
|
700
|
+
*
|
|
701
|
+
* @param {string} cwd - Working directory (repo root)
|
|
702
|
+
* @param {boolean} raw - Raw output mode
|
|
703
|
+
*/
|
|
704
|
+
function cmdDetectLayout(cwd, raw) {
|
|
705
|
+
const signals = [];
|
|
706
|
+
// Pre-init detection: must check .planning/ directly (not via getPlanningRoot)
|
|
707
|
+
const DOT_PLANNING_DIR = '.planning';
|
|
708
|
+
const dotPlanning = path.join(cwd, DOT_PLANNING_DIR);
|
|
709
|
+
|
|
710
|
+
// Signal 1: .planning/ directory with config files
|
|
711
|
+
if (
|
|
712
|
+
fs.existsSync(path.join(dotPlanning, 'dgs.config.json')) ||
|
|
713
|
+
fs.existsSync(path.join(dotPlanning, 'config.json'))
|
|
714
|
+
) {
|
|
715
|
+
signals.push('existing .planning/ directory with config files');
|
|
716
|
+
output({ suggested: 'dotplanning', signals }, raw);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Signal 2: dgs.config.json at root with planningRoot: '.'
|
|
721
|
+
const rootConfigPath = path.join(cwd, 'dgs.config.json');
|
|
722
|
+
if (fs.existsSync(rootConfigPath)) {
|
|
723
|
+
try {
|
|
724
|
+
const config = JSON.parse(fs.readFileSync(rootConfigPath, 'utf-8'));
|
|
725
|
+
if (config.planningRoot === '.') {
|
|
726
|
+
signals.push('dgs.config.json at root with planningRoot: "."');
|
|
727
|
+
output({ suggested: 'root', signals }, raw);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
} catch { /* malformed config — continue detection */ }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Signal 3: Empty-ish repo (no source code indicators at depth 1)
|
|
734
|
+
const hasDotPlanning = fs.existsSync(dotPlanning);
|
|
735
|
+
if (!hasDotPlanning) {
|
|
736
|
+
let hasSourceIndicators = false;
|
|
737
|
+
try {
|
|
738
|
+
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
739
|
+
const SOURCE_EXTENSIONS = ['.ts', '.js', '.py', '.go', '.rs', '.java'];
|
|
740
|
+
|
|
741
|
+
for (const entry of entries) {
|
|
742
|
+
const name = entry.name;
|
|
743
|
+
// Skip hidden files/dirs
|
|
744
|
+
if (name.startsWith('.')) continue;
|
|
745
|
+
|
|
746
|
+
// Check for source code directories
|
|
747
|
+
if (entry.isDirectory() && (name === 'src' || name === 'lib')) {
|
|
748
|
+
hasSourceIndicators = true;
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Check for package.json
|
|
753
|
+
if (name === 'package.json') {
|
|
754
|
+
hasSourceIndicators = true;
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Check for source code files at depth 1
|
|
759
|
+
if (entry.isFile()) {
|
|
760
|
+
const ext = path.extname(name);
|
|
761
|
+
if (SOURCE_EXTENSIONS.includes(ext)) {
|
|
762
|
+
hasSourceIndicators = true;
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
} catch { /* can't read dir — treat as not empty-ish */ hasSourceIndicators = true; }
|
|
768
|
+
|
|
769
|
+
if (!hasSourceIndicators) {
|
|
770
|
+
signals.push('repo is empty-ish (no source code indicators at depth 1)');
|
|
771
|
+
output({ suggested: 'root', signals }, raw);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Signal 4: Planning artifacts at root without .planning/
|
|
777
|
+
if (!hasDotPlanning) {
|
|
778
|
+
const hasPlanningArtifacts =
|
|
779
|
+
fs.existsSync(path.join(cwd, 'PROJECT.md')) ||
|
|
780
|
+
fs.existsSync(path.join(cwd, 'ROADMAP.md'));
|
|
781
|
+
if (hasPlanningArtifacts) {
|
|
782
|
+
signals.push('planning artifacts (PROJECT.md or ROADMAP.md) at root without .planning/');
|
|
783
|
+
output({ suggested: 'root', signals }, raw);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Signal 5: .planning/ directory exists (even without config files)
|
|
789
|
+
if (hasDotPlanning) {
|
|
790
|
+
signals.push('existing .planning/ directory');
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Default: dotplanning (safer default)
|
|
794
|
+
signals.push('default (no root-mode signals detected)');
|
|
795
|
+
output({ suggested: 'dotplanning', signals }, raw);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ─── Root-Layout .gitignore ───────────────────────────────────────────────────
|
|
799
|
+
|
|
800
|
+
const DGS_ROOT_MARKER_START = '# DGS - Root Layout';
|
|
801
|
+
const DGS_ROOT_MARKER_END = '# end DGS root layout';
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Generate or append a root-layout .gitignore with DGS section.
|
|
805
|
+
*
|
|
806
|
+
* Creates .gitignore if missing, appends DGS section if .gitignore already exists.
|
|
807
|
+
* Uses marker comments for idempotent section replacement on repeated calls.
|
|
808
|
+
*
|
|
809
|
+
* @param {string} cwd - Working directory (repo root)
|
|
810
|
+
*/
|
|
811
|
+
function generateRootGitignore(cwd) {
|
|
812
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
813
|
+
|
|
814
|
+
// Build DGS root-layout section
|
|
815
|
+
const dgsSection = [
|
|
816
|
+
DGS_ROOT_MARKER_START,
|
|
817
|
+
'# This repo is a dedicated DGS planning repository.',
|
|
818
|
+
'# Planning artifacts are tracked; transient files are ignored.',
|
|
819
|
+
'',
|
|
820
|
+
'# Transient artifacts',
|
|
821
|
+
'node_modules/',
|
|
822
|
+
'*.log',
|
|
823
|
+
'*.tmp',
|
|
824
|
+
'.cache/',
|
|
825
|
+
'',
|
|
826
|
+
'# OS/Editor files',
|
|
827
|
+
'.DS_Store',
|
|
828
|
+
'Thumbs.db',
|
|
829
|
+
'.vscode/',
|
|
830
|
+
'.idea/',
|
|
831
|
+
'*.swp',
|
|
832
|
+
'*.swo',
|
|
833
|
+
'*~',
|
|
834
|
+
'',
|
|
835
|
+
'# DGS config (may contain API keys)',
|
|
836
|
+
'dgs.config.json',
|
|
837
|
+
'',
|
|
838
|
+
'# DGS review keys (contains API keys)',
|
|
839
|
+
'review-keys.json',
|
|
840
|
+
DGS_ROOT_MARKER_END,
|
|
841
|
+
].join('\n');
|
|
842
|
+
|
|
843
|
+
// Read existing content
|
|
844
|
+
let existing = '';
|
|
845
|
+
try {
|
|
846
|
+
existing = fs.readFileSync(gitignorePath, 'utf-8');
|
|
847
|
+
} catch { /* file doesn't exist yet */ }
|
|
848
|
+
|
|
849
|
+
// Find existing marker positions
|
|
850
|
+
const startIdx = existing.indexOf(DGS_ROOT_MARKER_START);
|
|
851
|
+
const endIdx = existing.indexOf(DGS_ROOT_MARKER_END);
|
|
852
|
+
|
|
853
|
+
let newContent;
|
|
854
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
855
|
+
// Replace existing DGS section
|
|
856
|
+
const prefix = existing.slice(0, startIdx).replace(/\n+$/, '');
|
|
857
|
+
const suffix = existing.slice(endIdx + DGS_ROOT_MARKER_END.length).replace(/^\n+/, '');
|
|
858
|
+
const parts = [prefix, dgsSection, suffix].filter(p => p.length > 0);
|
|
859
|
+
newContent = parts.join('\n\n') + '\n';
|
|
860
|
+
} else if (existing.trim()) {
|
|
861
|
+
// Append to existing content
|
|
862
|
+
newContent = existing.replace(/\n+$/, '') + '\n\n' + dgsSection + '\n';
|
|
863
|
+
} else {
|
|
864
|
+
// Create new file
|
|
865
|
+
newContent = dgsSection + '\n';
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
fs.writeFileSync(gitignorePath, newContent);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ─── CLI Command Functions ───────────────────────────────────────────────────
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* List all registered repos from REPOS.md.
|
|
875
|
+
*
|
|
876
|
+
* @param {string} cwd - Working directory
|
|
877
|
+
* @param {boolean} raw - Raw output mode
|
|
878
|
+
*/
|
|
879
|
+
function cmdReposList(cwd, raw) {
|
|
880
|
+
const result = parseReposMd(cwd);
|
|
881
|
+
if (!result) {
|
|
882
|
+
error('No REPOS.md found. Run /dgs:init-product first.');
|
|
883
|
+
}
|
|
884
|
+
output({ repos: result.repos }, raw);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Register a new repo in REPOS.md.
|
|
889
|
+
* Validates: path exists, has .git/, no name duplicate, no path conflict.
|
|
890
|
+
* Updates .gitignore after adding.
|
|
891
|
+
*
|
|
892
|
+
* @param {string} cwd - Working directory
|
|
893
|
+
* @param {string} repoPath - Path to repo (e.g., './new-repo')
|
|
894
|
+
* @param {Object} options - { name?: string, desc?: string }
|
|
895
|
+
* @param {boolean} raw - Raw output mode
|
|
896
|
+
*/
|
|
897
|
+
function cmdReposAdd(cwd, repoPath, options, raw) {
|
|
898
|
+
if (!repoPath) {
|
|
899
|
+
error('Usage: repos add <path> [--name N] [--desc D]');
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Enforce ../name format (breaking change: only sibling repos accepted)
|
|
903
|
+
if (!repoPath.startsWith('../')) {
|
|
904
|
+
error('Repo paths must use ../name format.');
|
|
905
|
+
}
|
|
906
|
+
// Reject deeper paths like ../../shared-libs/common
|
|
907
|
+
if (repoPath.startsWith('../../') || repoPath.replace(/^\.\.\//, '').includes('../')) {
|
|
908
|
+
error('Repo paths must use ../name format.');
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const normalized = repoPath.replace(/\/+$/, '');
|
|
912
|
+
|
|
913
|
+
// Validate path exists on disk using path.resolve
|
|
914
|
+
const diskPath = path.resolve(cwd, normalized);
|
|
915
|
+
if (!fs.existsSync(diskPath)) {
|
|
916
|
+
const repoName = options.name || path.basename(normalized);
|
|
917
|
+
error(`Repo '${repoName}' not found at ${normalized} -- check the path in REPOS.md or clone the repo there.`);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Validate .git/ exists
|
|
921
|
+
if (!fs.existsSync(path.join(diskPath, '.git'))) {
|
|
922
|
+
const repoName = options.name || path.basename(normalized);
|
|
923
|
+
error(`Repo '${repoName}' at ${normalized} is not a git repository (no .git/).`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Parse existing REPOS.md
|
|
927
|
+
const existing = parseReposMd(cwd);
|
|
928
|
+
if (!existing) {
|
|
929
|
+
error('No REPOS.md found. Run /dgs:init-product first.');
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Derive repo name
|
|
933
|
+
const repoName = options.name || path.basename(normalized);
|
|
934
|
+
|
|
935
|
+
// Check duplicate name
|
|
936
|
+
if (existing.repos.some(r => r.name === repoName)) {
|
|
937
|
+
error(`Repo '${repoName}' already exists in REPOS.md. Use --name to provide a different name.`);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Check path conflict
|
|
941
|
+
const conflict = hasPathConflict(normalized, existing.repos);
|
|
942
|
+
if (conflict.conflict) {
|
|
943
|
+
error(`Path conflict: '${normalized}' conflicts with existing repo '${conflict.conflicting_repo.name}' at '${conflict.conflicting_repo.path}'. One path is a prefix of the other.`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Extract GitHub URL
|
|
947
|
+
let url = '';
|
|
948
|
+
try {
|
|
949
|
+
const gitResult = execGit(diskPath, ['config', '--get', 'remote.origin.url']);
|
|
950
|
+
if (gitResult.exitCode === 0 && gitResult.stdout) {
|
|
951
|
+
url = gitResult.stdout;
|
|
952
|
+
}
|
|
953
|
+
} catch {}
|
|
954
|
+
|
|
955
|
+
// Extract description
|
|
956
|
+
const description = options.desc || extractRepoDescription(diskPath);
|
|
957
|
+
|
|
958
|
+
// Build new repo entry
|
|
959
|
+
const newRepo = { name: repoName, path: normalized, url, description };
|
|
960
|
+
|
|
961
|
+
// Append and write
|
|
962
|
+
const repos = [...existing.repos, newRepo];
|
|
963
|
+
writeReposMd(cwd, repos);
|
|
964
|
+
|
|
965
|
+
// Sync .gitignore only for ./ paths (../repo paths are outside product root)
|
|
966
|
+
const localPaths = repos.map(r => r.path).filter(p => p.startsWith('./'));
|
|
967
|
+
if (localPaths.length > 0) {
|
|
968
|
+
syncGitignore(cwd, localPaths);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
output({ added: true, repo: newRepo }, raw);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Unregister a repo from REPOS.md.
|
|
976
|
+
* Checks active project references unless --force is used.
|
|
977
|
+
*
|
|
978
|
+
* @param {string} cwd - Working directory
|
|
979
|
+
* @param {string} repoName - Name of repo to remove
|
|
980
|
+
* @param {Object} options - { force?: boolean }
|
|
981
|
+
* @param {boolean} raw - Raw output mode
|
|
982
|
+
*/
|
|
983
|
+
function cmdReposRemove(cwd, repoName, options, raw) {
|
|
984
|
+
if (!repoName) {
|
|
985
|
+
error('Usage: repos remove <name> [--force]');
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const existing = parseReposMd(cwd);
|
|
989
|
+
if (!existing) {
|
|
990
|
+
error('No REPOS.md found. Run /dgs:init-product first.');
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const repo = existing.repos.find(r => r.name === repoName);
|
|
994
|
+
if (!repo) {
|
|
995
|
+
error(`Repo '${repoName}' not found in REPOS.md.`);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Check active project references (unless --force)
|
|
999
|
+
if (!options.force) {
|
|
1000
|
+
const referencingProjects = scanRepoReferences(cwd, repoName);
|
|
1001
|
+
if (referencingProjects.length > 0) {
|
|
1002
|
+
output({
|
|
1003
|
+
removed: false,
|
|
1004
|
+
warning: 'Active projects reference this repo',
|
|
1005
|
+
projects: referencingProjects,
|
|
1006
|
+
repo: repoName,
|
|
1007
|
+
}, raw);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Remove and write
|
|
1013
|
+
const repos = existing.repos.filter(r => r.name !== repoName);
|
|
1014
|
+
writeReposMd(cwd, repos);
|
|
1015
|
+
|
|
1016
|
+
// Sync .gitignore only for ./ paths (../repo paths are outside product root)
|
|
1017
|
+
const localPaths = repos.map(r => r.path).filter(p => p.startsWith('./'));
|
|
1018
|
+
if (localPaths.length > 0) {
|
|
1019
|
+
syncGitignore(cwd, localPaths);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
output({ removed: true, repo: repoName }, raw);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Scan plan files for references to a repo name in <repos> tags.
|
|
1027
|
+
* Returns array of project/phase identifiers that reference the repo.
|
|
1028
|
+
*
|
|
1029
|
+
* @param {string} cwd - Working directory
|
|
1030
|
+
* @param {string} repoName - Repo name to search for
|
|
1031
|
+
* @returns {string[]} List of referencing locations
|
|
1032
|
+
*/
|
|
1033
|
+
function scanRepoReferences(cwd, repoName) {
|
|
1034
|
+
const references = [];
|
|
1035
|
+
const phasesDir = path.join(getPlanningRoot(cwd), 'phases');
|
|
1036
|
+
|
|
1037
|
+
try {
|
|
1038
|
+
const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
1039
|
+
.filter(e => e.isDirectory())
|
|
1040
|
+
.map(e => e.name);
|
|
1041
|
+
|
|
1042
|
+
for (const phaseDir of phaseDirs) {
|
|
1043
|
+
const fullPhaseDir = path.join(phasesDir, phaseDir);
|
|
1044
|
+
const planFiles = fs.readdirSync(fullPhaseDir)
|
|
1045
|
+
.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
1046
|
+
|
|
1047
|
+
for (const planFile of planFiles) {
|
|
1048
|
+
const content = safeReadFile(path.join(fullPhaseDir, planFile));
|
|
1049
|
+
if (!content) continue;
|
|
1050
|
+
|
|
1051
|
+
const reposMatches = content.match(/<repos>([^<]+)<\/repos>/g);
|
|
1052
|
+
if (!reposMatches) continue;
|
|
1053
|
+
|
|
1054
|
+
for (const match of reposMatches) {
|
|
1055
|
+
const inner = match.replace(/<\/?repos>/g, '');
|
|
1056
|
+
const names = inner.split(',').map(n => n.trim());
|
|
1057
|
+
if (names.includes(repoName)) {
|
|
1058
|
+
references.push(`${phaseDir}/${planFile}`);
|
|
1059
|
+
break;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
} catch {}
|
|
1065
|
+
|
|
1066
|
+
return references;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Validate all registered repo paths exist on disk.
|
|
1071
|
+
*
|
|
1072
|
+
* @param {string} cwd - Working directory
|
|
1073
|
+
* @param {boolean} raw - Raw output mode
|
|
1074
|
+
*/
|
|
1075
|
+
function cmdReposValidate(cwd, raw) {
|
|
1076
|
+
const existing = parseReposMd(cwd);
|
|
1077
|
+
if (!existing) {
|
|
1078
|
+
error('No REPOS.md found. Run /dgs:init-product first.');
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const result = validateRepoPaths(cwd, existing.repos);
|
|
1082
|
+
output({
|
|
1083
|
+
valid: result.valid,
|
|
1084
|
+
missing: result.missing,
|
|
1085
|
+
total: existing.repos.length,
|
|
1086
|
+
}, raw);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Resolve a file path to its owning repo.
|
|
1091
|
+
*
|
|
1092
|
+
* @param {string} cwd - Working directory
|
|
1093
|
+
* @param {string} filePath - File path to resolve
|
|
1094
|
+
* @param {boolean} raw - Raw output mode
|
|
1095
|
+
*/
|
|
1096
|
+
function cmdReposResolve(cwd, filePath, raw) {
|
|
1097
|
+
if (!filePath) {
|
|
1098
|
+
error('Usage: repos resolve <filepath>');
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const existing = parseReposMd(cwd);
|
|
1102
|
+
if (!existing) {
|
|
1103
|
+
error('No REPOS.md found. Run /dgs:init-product first.');
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const match = resolveFileToRepo(filePath, existing.repos);
|
|
1107
|
+
if (match) {
|
|
1108
|
+
output({ resolved: true, repo: match.name, path: match.path }, raw);
|
|
1109
|
+
} else {
|
|
1110
|
+
output({ resolved: false, repo: null, location: 'product' }, raw);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Initialize a product folder structure (v2 mode).
|
|
1116
|
+
* Creates REPOS.md, PROJECTS.md, syncs .gitignore, updates config.
|
|
1117
|
+
*
|
|
1118
|
+
* Supports layout option:
|
|
1119
|
+
* - layout='root': Config-first pattern — writes dgs.config.json at repo root
|
|
1120
|
+
* with planningRoot:'.', then creates all artifacts at repo root (no .planning/).
|
|
1121
|
+
* - layout=null (default): Standard .planning/ layout (unchanged).
|
|
1122
|
+
*
|
|
1123
|
+
* @param {string} cwd - Working directory
|
|
1124
|
+
* @param {Object} options - { productName?: string, layout?: string }
|
|
1125
|
+
* @param {boolean} raw - Raw output mode
|
|
1126
|
+
*/
|
|
1127
|
+
function cmdReposInitProduct(cwd, options, raw) {
|
|
1128
|
+
const isRootMode = options.layout === 'root';
|
|
1129
|
+
|
|
1130
|
+
// ── Root-mode: Config-first pattern ───────────────────────────────────────
|
|
1131
|
+
if (isRootMode) {
|
|
1132
|
+
// Write dgs.config.json FIRST so getPlanningRoot returns repo root
|
|
1133
|
+
const rootConfigPath = path.join(cwd, 'dgs.config.json');
|
|
1134
|
+
fs.writeFileSync(rootConfigPath, JSON.stringify({ planningRoot: '.' }, null, 2));
|
|
1135
|
+
resetPaths(); // Clear stale cache so getPlanningRoot resolves to cwd
|
|
1136
|
+
|
|
1137
|
+
// Check if already initialized (idempotency)
|
|
1138
|
+
if (isV2Install(cwd)) {
|
|
1139
|
+
// Silently ensure v3.0 directories exist (backfill for upgrades)
|
|
1140
|
+
const ideasStates = ['pending', 'rejected', 'done'];
|
|
1141
|
+
for (const st of ideasStates) {
|
|
1142
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'ideas', st), { recursive: true });
|
|
1143
|
+
}
|
|
1144
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'specs'), { recursive: true });
|
|
1145
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'docs', 'product'), { recursive: true });
|
|
1146
|
+
error('Product already initialized. Use /dgs:progress to see status.');
|
|
1147
|
+
}
|
|
1148
|
+
} else {
|
|
1149
|
+
// ── Standard layout: existing v1/v2 checks ────────────────────────────
|
|
1150
|
+
// Check if user previously declined v1-to-v2 migration
|
|
1151
|
+
const configPath = path.join(getPlanningRoot(cwd), 'config.json');
|
|
1152
|
+
let existingConfig = {};
|
|
1153
|
+
try {
|
|
1154
|
+
existingConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1155
|
+
} catch { /* no config yet */ }
|
|
1156
|
+
if (existingConfig.v1_decline_migration) {
|
|
1157
|
+
output({
|
|
1158
|
+
v1_declined: true,
|
|
1159
|
+
message: 'v1 mode preserved (migration was previously declined). Your v1 setup continues to work unchanged.',
|
|
1160
|
+
}, raw);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Check for v1 install (existing .planning/PROJECT.md without v2 markers)
|
|
1165
|
+
const v1Check = migration().detectV1Install(cwd);
|
|
1166
|
+
if (v1Check.isV1) {
|
|
1167
|
+
// Return v1 detection info — workflow handles the user prompt
|
|
1168
|
+
output({
|
|
1169
|
+
v1_detected: true,
|
|
1170
|
+
project_name: v1Check.projectName,
|
|
1171
|
+
slug: v1Check.slug,
|
|
1172
|
+
}, raw);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Check if already v2
|
|
1177
|
+
if (isV2Install(cwd)) {
|
|
1178
|
+
// Silently ensure v3.0 directories exist (backfill for upgrades)
|
|
1179
|
+
const ideasStates = ['pending', 'rejected', 'done'];
|
|
1180
|
+
for (const st of ideasStates) {
|
|
1181
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'ideas', st), { recursive: true });
|
|
1182
|
+
}
|
|
1183
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'specs'), { recursive: true });
|
|
1184
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'docs', 'product'), { recursive: true });
|
|
1185
|
+
// Add .gitkeep files if directories are empty
|
|
1186
|
+
const dirsToKeep = [
|
|
1187
|
+
path.join(getPlanningRoot(cwd), 'ideas', 'pending', '.gitkeep'),
|
|
1188
|
+
path.join(getPlanningRoot(cwd), 'ideas', 'rejected', '.gitkeep'),
|
|
1189
|
+
path.join(getPlanningRoot(cwd), 'ideas', 'done', '.gitkeep'),
|
|
1190
|
+
path.join(getPlanningRoot(cwd), 'specs', '.gitkeep'),
|
|
1191
|
+
path.join(getPlanningRoot(cwd), 'docs', 'product', '.gitkeep'),
|
|
1192
|
+
];
|
|
1193
|
+
for (const gk of dirsToKeep) {
|
|
1194
|
+
if (!fs.existsSync(gk)) {
|
|
1195
|
+
fs.writeFileSync(gk, '', 'utf-8');
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
error('Product already initialized. Use /dgs:progress to see status.');
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// ── Shared scaffolding (layout-aware via getPlanningRoot) ─────────────────
|
|
1203
|
+
|
|
1204
|
+
// Ensure planning root exists (for standard layout creates .planning/; for root it's cwd)
|
|
1205
|
+
fs.mkdirSync(getPlanningRoot(cwd), { recursive: true });
|
|
1206
|
+
|
|
1207
|
+
// Create ideas, specs, and docs directories for v3.0 features
|
|
1208
|
+
const ideasStates = ['pending', 'rejected', 'done'];
|
|
1209
|
+
for (const state of ideasStates) {
|
|
1210
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'ideas', state), { recursive: true });
|
|
1211
|
+
}
|
|
1212
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'specs'), { recursive: true });
|
|
1213
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'docs', 'product'), { recursive: true });
|
|
1214
|
+
|
|
1215
|
+
// Add .gitkeep files to empty directories so they survive git commit
|
|
1216
|
+
const gitkeepPaths = [
|
|
1217
|
+
path.join(getPlanningRoot(cwd), 'ideas', 'pending', '.gitkeep'),
|
|
1218
|
+
path.join(getPlanningRoot(cwd), 'ideas', 'rejected', '.gitkeep'),
|
|
1219
|
+
path.join(getPlanningRoot(cwd), 'ideas', 'done', '.gitkeep'),
|
|
1220
|
+
path.join(getPlanningRoot(cwd), 'specs', '.gitkeep'),
|
|
1221
|
+
path.join(getPlanningRoot(cwd), 'docs', 'product', '.gitkeep'),
|
|
1222
|
+
];
|
|
1223
|
+
for (const gk of gitkeepPaths) {
|
|
1224
|
+
if (!fs.existsSync(gk)) {
|
|
1225
|
+
fs.writeFileSync(gk, '', 'utf-8');
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Scaffold review-keys.json with empty template
|
|
1230
|
+
const reviewKeysPath = path.join(getPlanningRoot(cwd), 'review-keys.json');
|
|
1231
|
+
if (!fs.existsSync(reviewKeysPath)) {
|
|
1232
|
+
const reviewKeysTemplate = {
|
|
1233
|
+
openai: { api_key: "", model: "gpt-5-mini" },
|
|
1234
|
+
gemini: { api_key: "", model: "gemini-2.5-flash" },
|
|
1235
|
+
max_rounds: 3
|
|
1236
|
+
};
|
|
1237
|
+
fs.writeFileSync(reviewKeysPath, JSON.stringify(reviewKeysTemplate, null, 2), 'utf-8');
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Discover repos (local children + sibling repos)
|
|
1241
|
+
const discoveredRepos = discoverRepos(cwd);
|
|
1242
|
+
const siblingRepos = discoverSiblingRepos(cwd);
|
|
1243
|
+
const allRepos = [...discoveredRepos, ...siblingRepos];
|
|
1244
|
+
|
|
1245
|
+
// Derive product name
|
|
1246
|
+
const productName = options.productName || path.basename(cwd);
|
|
1247
|
+
|
|
1248
|
+
// Check if .git/ exists at cwd root
|
|
1249
|
+
const needsGitInit = !fs.existsSync(path.join(cwd, '.git'));
|
|
1250
|
+
|
|
1251
|
+
// Write REPOS.md
|
|
1252
|
+
writeReposMd(cwd, allRepos);
|
|
1253
|
+
|
|
1254
|
+
// Write PROJECTS.md (empty — no projects yet)
|
|
1255
|
+
writeProjectsMd(cwd, []);
|
|
1256
|
+
|
|
1257
|
+
if (isRootMode) {
|
|
1258
|
+
// Root-mode: generate .gitignore for root-layout repo
|
|
1259
|
+
generateRootGitignore(cwd);
|
|
1260
|
+
|
|
1261
|
+
// Update config with product_name (config already exists from config-first step)
|
|
1262
|
+
writeConfigField(cwd, 'product_name', productName);
|
|
1263
|
+
|
|
1264
|
+
output({
|
|
1265
|
+
initialized: true,
|
|
1266
|
+
layout: 'root',
|
|
1267
|
+
product_name: productName,
|
|
1268
|
+
repos_found: allRepos.length,
|
|
1269
|
+
repos: allRepos,
|
|
1270
|
+
needs_git_init: needsGitInit,
|
|
1271
|
+
ideas_dirs_created: true,
|
|
1272
|
+
specs_dir_created: true,
|
|
1273
|
+
docs_dir_created: true,
|
|
1274
|
+
files_created: ['dgs.config.json', 'REPOS.md', 'PROJECTS.md', 'ideas/', 'specs/', 'docs/', '.gitignore', 'review-keys.json'],
|
|
1275
|
+
}, raw);
|
|
1276
|
+
} else {
|
|
1277
|
+
// Standard layout: sync .gitignore for local repo paths
|
|
1278
|
+
const localPaths = allRepos.map(r => r.path).filter(p => p.startsWith('./'));
|
|
1279
|
+
if (localPaths.length > 0) {
|
|
1280
|
+
syncGitignore(cwd, localPaths);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Update config with product_name
|
|
1284
|
+
writeConfigField(cwd, 'product_name', productName);
|
|
1285
|
+
|
|
1286
|
+
output({
|
|
1287
|
+
initialized: true,
|
|
1288
|
+
layout: 'dotplanning',
|
|
1289
|
+
product_name: productName,
|
|
1290
|
+
repos_found: allRepos.length,
|
|
1291
|
+
repos: allRepos,
|
|
1292
|
+
needs_git_init: needsGitInit,
|
|
1293
|
+
gitignore_synced: localPaths.length > 0,
|
|
1294
|
+
ideas_dirs_created: true,
|
|
1295
|
+
specs_dir_created: true,
|
|
1296
|
+
docs_dir_created: true,
|
|
1297
|
+
files_created: ['.planning/', '.planning/REPOS.md', '.planning/PROJECTS.md', '.planning/ideas/', '.planning/specs/', '.planning/docs/', '.planning/review-keys.json'],
|
|
1298
|
+
}, raw);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Scan plan files for <repos> tags and check against registered repos.
|
|
1304
|
+
* Detects unknown repos per REPO-06.
|
|
1305
|
+
*
|
|
1306
|
+
* @param {string} cwd - Working directory
|
|
1307
|
+
* @param {string} phaseDir - Phase directory to scan (relative to cwd)
|
|
1308
|
+
* @param {boolean} raw - Raw output mode
|
|
1309
|
+
*/
|
|
1310
|
+
function cmdReposScanTags(cwd, phaseDir, raw) {
|
|
1311
|
+
if (!phaseDir) {
|
|
1312
|
+
error('Usage: repos scan-tags <phase-dir>');
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const dir = path.join(cwd, phaseDir);
|
|
1316
|
+
let planFiles;
|
|
1317
|
+
try {
|
|
1318
|
+
planFiles = fs.readdirSync(dir).filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
1319
|
+
} catch {
|
|
1320
|
+
error(`Cannot read phase directory: ${phaseDir}`);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Collect all repo names from <repos> tags
|
|
1324
|
+
const allReferenced = new Set();
|
|
1325
|
+
for (const planFile of planFiles) {
|
|
1326
|
+
const content = safeReadFile(path.join(dir, planFile));
|
|
1327
|
+
if (!content) continue;
|
|
1328
|
+
|
|
1329
|
+
const matches = content.match(/<repos>([^<]+)<\/repos>/g);
|
|
1330
|
+
if (!matches) continue;
|
|
1331
|
+
|
|
1332
|
+
for (const match of matches) {
|
|
1333
|
+
const inner = match.replace(/<\/?repos>/g, '');
|
|
1334
|
+
const names = inner.split(',').map(n => n.trim()).filter(n => n);
|
|
1335
|
+
names.forEach(n => allReferenced.add(n));
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const referenced = Array.from(allReferenced);
|
|
1340
|
+
|
|
1341
|
+
// Check against REPOS.md
|
|
1342
|
+
const existing = parseReposMd(cwd);
|
|
1343
|
+
const knownNames = existing ? new Set(existing.repos.map(r => r.name)) : new Set();
|
|
1344
|
+
|
|
1345
|
+
const known = referenced.filter(n => knownNames.has(n));
|
|
1346
|
+
const unknown = referenced.filter(n => !knownNames.has(n));
|
|
1347
|
+
|
|
1348
|
+
output({ referenced, known, unknown }, raw);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* CLI: Auto-populate <repos> tags in a plan file from <files> paths.
|
|
1353
|
+
* Reads REPOS.md, reads plan file, runs autoPopulateReposTags, writes result.
|
|
1354
|
+
*
|
|
1355
|
+
* @param {string} cwd - Working directory
|
|
1356
|
+
* @param {string} planFile - Path to plan file (relative to cwd)
|
|
1357
|
+
* @param {boolean} raw - Raw output mode
|
|
1358
|
+
*/
|
|
1359
|
+
function cmdReposAutoPopulate(cwd, planFile, raw) {
|
|
1360
|
+
if (!planFile) {
|
|
1361
|
+
error('Usage: repos auto-populate <plan-file>');
|
|
1362
|
+
}
|
|
1363
|
+
const fullPath = path.join(cwd, planFile);
|
|
1364
|
+
let content;
|
|
1365
|
+
try {
|
|
1366
|
+
content = fs.readFileSync(fullPath, 'utf-8');
|
|
1367
|
+
} catch {
|
|
1368
|
+
error(`Cannot read plan file: ${planFile}`);
|
|
1369
|
+
}
|
|
1370
|
+
const reposData = parseReposMd(cwd);
|
|
1371
|
+
if (!reposData || reposData.repos.length === 0) {
|
|
1372
|
+
error('No repos found in REPOS.md. Run /dgs:init-product first.');
|
|
1373
|
+
}
|
|
1374
|
+
const result = autoPopulateReposTags(content, reposData.repos);
|
|
1375
|
+
// Write updated content back to file
|
|
1376
|
+
fs.writeFileSync(fullPath, result.content, 'utf-8');
|
|
1377
|
+
output({ updated: true, file: planFile, warnings: result.warnings }, raw);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* CLI: Validate <repos>/<files> consistency in a plan file.
|
|
1382
|
+
*
|
|
1383
|
+
* @param {string} cwd - Working directory
|
|
1384
|
+
* @param {string} planFile - Path to plan file (relative to cwd)
|
|
1385
|
+
* @param {boolean} raw - Raw output mode
|
|
1386
|
+
*/
|
|
1387
|
+
function cmdReposValidateConsistency(cwd, planFile, raw) {
|
|
1388
|
+
if (!planFile) {
|
|
1389
|
+
error('Usage: repos validate-consistency <plan-file>');
|
|
1390
|
+
}
|
|
1391
|
+
const fullPath = path.join(cwd, planFile);
|
|
1392
|
+
let content;
|
|
1393
|
+
try {
|
|
1394
|
+
content = fs.readFileSync(fullPath, 'utf-8');
|
|
1395
|
+
} catch {
|
|
1396
|
+
error(`Cannot read plan file: ${planFile}`);
|
|
1397
|
+
}
|
|
1398
|
+
const reposData = parseReposMd(cwd);
|
|
1399
|
+
if (!reposData || reposData.repos.length === 0) {
|
|
1400
|
+
error('No repos found in REPOS.md. Run /dgs:init-product first.');
|
|
1401
|
+
}
|
|
1402
|
+
const result = validateReposConsistency(content, reposData.repos);
|
|
1403
|
+
output(result, raw);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
1407
|
+
|
|
1408
|
+
module.exports = {
|
|
1409
|
+
parseReposMd,
|
|
1410
|
+
writeReposMd,
|
|
1411
|
+
validateReposMdEager,
|
|
1412
|
+
resolveFileToRepo,
|
|
1413
|
+
discoverRepos,
|
|
1414
|
+
discoverSiblingRepos,
|
|
1415
|
+
syncGitignore,
|
|
1416
|
+
validateRepoPaths,
|
|
1417
|
+
hasPathConflict,
|
|
1418
|
+
writeProjectsMd,
|
|
1419
|
+
autoPopulateReposTags,
|
|
1420
|
+
validateReposConsistency,
|
|
1421
|
+
cmdDetectLayout,
|
|
1422
|
+
generateRootGitignore,
|
|
1423
|
+
cmdReposList,
|
|
1424
|
+
cmdReposAdd,
|
|
1425
|
+
cmdReposRemove,
|
|
1426
|
+
cmdReposValidate,
|
|
1427
|
+
cmdReposResolve,
|
|
1428
|
+
cmdReposInitProduct,
|
|
1429
|
+
cmdReposScanTags,
|
|
1430
|
+
cmdReposAutoPopulate,
|
|
1431
|
+
cmdReposValidateConsistency,
|
|
1432
|
+
};
|