@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.
Files changed (256) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +851 -0
  3. package/agents/dgs-codebase-cross-analyzer.md +183 -0
  4. package/agents/dgs-codebase-mapper.md +782 -0
  5. package/agents/dgs-codebase-synthesizer.md +156 -0
  6. package/agents/dgs-debugger.md +1256 -0
  7. package/agents/dgs-executor.md +550 -0
  8. package/agents/dgs-integration-checker.md +481 -0
  9. package/agents/dgs-nyquist-auditor.md +178 -0
  10. package/agents/dgs-phase-researcher.md +563 -0
  11. package/agents/dgs-phase-verifier.md +450 -0
  12. package/agents/dgs-plan-checker.md +708 -0
  13. package/agents/dgs-planner.md +1324 -0
  14. package/agents/dgs-project-researcher.md +631 -0
  15. package/agents/dgs-research-synthesizer.md +249 -0
  16. package/agents/dgs-roadmapper.md +652 -0
  17. package/agents/dgs-verifier.md +607 -0
  18. package/bin/install.js +2073 -0
  19. package/commands/dgs/add-doc.md +45 -0
  20. package/commands/dgs/add-idea.md +38 -0
  21. package/commands/dgs/add-phase.md +43 -0
  22. package/commands/dgs/add-repo.md +54 -0
  23. package/commands/dgs/add-tests.md +41 -0
  24. package/commands/dgs/add-todo.md +47 -0
  25. package/commands/dgs/approve-spec.md +38 -0
  26. package/commands/dgs/audit-milestone.md +36 -0
  27. package/commands/dgs/audit-phase.md +37 -0
  28. package/commands/dgs/cancel-job.md +23 -0
  29. package/commands/dgs/capture-principle.md +143 -0
  30. package/commands/dgs/check-todos.md +45 -0
  31. package/commands/dgs/cleanup.md +18 -0
  32. package/commands/dgs/complete-milestone.md +136 -0
  33. package/commands/dgs/complete-project.md +70 -0
  34. package/commands/dgs/consolidate-ideas.md +50 -0
  35. package/commands/dgs/create-milestone-job.md +37 -0
  36. package/commands/dgs/debug.md +164 -0
  37. package/commands/dgs/develop-idea.md +53 -0
  38. package/commands/dgs/discuss-idea.md +41 -0
  39. package/commands/dgs/discuss-phase.md +83 -0
  40. package/commands/dgs/execute-phase.md +41 -0
  41. package/commands/dgs/fast.md +38 -0
  42. package/commands/dgs/find-related-ideas.md +43 -0
  43. package/commands/dgs/health.md +28 -0
  44. package/commands/dgs/help.md +22 -0
  45. package/commands/dgs/import-spec.md +36 -0
  46. package/commands/dgs/init-product.md +28 -0
  47. package/commands/dgs/insert-phase.md +32 -0
  48. package/commands/dgs/join-discord.md +18 -0
  49. package/commands/dgs/list-docs.md +40 -0
  50. package/commands/dgs/list-ideas.md +42 -0
  51. package/commands/dgs/list-jobs.md +22 -0
  52. package/commands/dgs/list-phase-assumptions.md +46 -0
  53. package/commands/dgs/list-projects.md +57 -0
  54. package/commands/dgs/list-specs.md +40 -0
  55. package/commands/dgs/map-codebase.md +92 -0
  56. package/commands/dgs/new-milestone.md +44 -0
  57. package/commands/dgs/new-project.md +42 -0
  58. package/commands/dgs/node-repair.md +26 -0
  59. package/commands/dgs/overlap-check.md +20 -0
  60. package/commands/dgs/pause-work.md +38 -0
  61. package/commands/dgs/plan-milestone-gaps.md +34 -0
  62. package/commands/dgs/plan-phase.md +44 -0
  63. package/commands/dgs/progress.md +24 -0
  64. package/commands/dgs/quick.md +41 -0
  65. package/commands/dgs/reactivate-project.md +70 -0
  66. package/commands/dgs/reapply-patches.md +110 -0
  67. package/commands/dgs/refine-spec.md +38 -0
  68. package/commands/dgs/reject-idea.md +43 -0
  69. package/commands/dgs/remove-doc.md +44 -0
  70. package/commands/dgs/remove-phase.md +31 -0
  71. package/commands/dgs/remove-repo.md +69 -0
  72. package/commands/dgs/research-idea.md +43 -0
  73. package/commands/dgs/research-phase.md +189 -0
  74. package/commands/dgs/restore-idea.md +45 -0
  75. package/commands/dgs/resume-work.md +40 -0
  76. package/commands/dgs/rollback-job.md +24 -0
  77. package/commands/dgs/run-job.md +35 -0
  78. package/commands/dgs/search.md +40 -0
  79. package/commands/dgs/set-profile.md +34 -0
  80. package/commands/dgs/settings.md +38 -0
  81. package/commands/dgs/switch-project.md +58 -0
  82. package/commands/dgs/undo-consolidation.md +42 -0
  83. package/commands/dgs/update-idea.md +44 -0
  84. package/commands/dgs/update.md +37 -0
  85. package/commands/dgs/validate-phase.md +35 -0
  86. package/commands/dgs/verify-work.md +39 -0
  87. package/commands/dgs/write-spec.md +49 -0
  88. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-01-SUMMARY.md +84 -0
  89. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-02-SUMMARY.md +86 -0
  90. package/deliver-great-systems/.planning/phases/10-v1-to-v2-migration-flow/10-01-SUMMARY.md +85 -0
  91. package/deliver-great-systems/bin/dgs-tools.cjs +1444 -0
  92. package/deliver-great-systems/bin/lib/auto-test.cjs +1365 -0
  93. package/deliver-great-systems/bin/lib/commands.cjs +570 -0
  94. package/deliver-great-systems/bin/lib/config.cjs +417 -0
  95. package/deliver-great-systems/bin/lib/conflict-agent.cjs +1063 -0
  96. package/deliver-great-systems/bin/lib/conflict-agent.test.cjs +554 -0
  97. package/deliver-great-systems/bin/lib/context.cjs +929 -0
  98. package/deliver-great-systems/bin/lib/context.test.cjs +693 -0
  99. package/deliver-great-systems/bin/lib/core.cjs +744 -0
  100. package/deliver-great-systems/bin/lib/core.test.cjs +822 -0
  101. package/deliver-great-systems/bin/lib/docs.cjs +919 -0
  102. package/deliver-great-systems/bin/lib/docs.test.cjs +211 -0
  103. package/deliver-great-systems/bin/lib/execution.cjs +705 -0
  104. package/deliver-great-systems/bin/lib/execution.test.cjs +1472 -0
  105. package/deliver-great-systems/bin/lib/frontmatter.cjs +324 -0
  106. package/deliver-great-systems/bin/lib/ideas.cjs +1406 -0
  107. package/deliver-great-systems/bin/lib/ideas.test.cjs +1417 -0
  108. package/deliver-great-systems/bin/lib/identity.cjs +125 -0
  109. package/deliver-great-systems/bin/lib/init.cjs +1114 -0
  110. package/deliver-great-systems/bin/lib/init.test.cjs +1271 -0
  111. package/deliver-great-systems/bin/lib/jobs.cjs +2015 -0
  112. package/deliver-great-systems/bin/lib/jobs.test.cjs +2619 -0
  113. package/deliver-great-systems/bin/lib/merge-conflicts.cjs +654 -0
  114. package/deliver-great-systems/bin/lib/merge-conflicts.test.cjs +370 -0
  115. package/deliver-great-systems/bin/lib/migration.cjs +352 -0
  116. package/deliver-great-systems/bin/lib/migration.test.cjs +582 -0
  117. package/deliver-great-systems/bin/lib/milestone.cjs +243 -0
  118. package/deliver-great-systems/bin/lib/overlap.cjs +437 -0
  119. package/deliver-great-systems/bin/lib/overlap.test.cjs +747 -0
  120. package/deliver-great-systems/bin/lib/path-audit.test.cjs +384 -0
  121. package/deliver-great-systems/bin/lib/paths.cjs +144 -0
  122. package/deliver-great-systems/bin/lib/paths.test.cjs +486 -0
  123. package/deliver-great-systems/bin/lib/phase.cjs +910 -0
  124. package/deliver-great-systems/bin/lib/projects.cjs +691 -0
  125. package/deliver-great-systems/bin/lib/projects.test.cjs +871 -0
  126. package/deliver-great-systems/bin/lib/repos.cjs +1432 -0
  127. package/deliver-great-systems/bin/lib/repos.test.cjs +1882 -0
  128. package/deliver-great-systems/bin/lib/roadmap.cjs +305 -0
  129. package/deliver-great-systems/bin/lib/search.cjs +570 -0
  130. package/deliver-great-systems/bin/lib/specs.cjs +1303 -0
  131. package/deliver-great-systems/bin/lib/state.cjs +893 -0
  132. package/deliver-great-systems/bin/lib/template.cjs +228 -0
  133. package/deliver-great-systems/bin/lib/test-helpers.cjs +291 -0
  134. package/deliver-great-systems/bin/lib/verify.cjs +796 -0
  135. package/deliver-great-systems/references/checkpoints.md +776 -0
  136. package/deliver-great-systems/references/conflict-resolution.md +66 -0
  137. package/deliver-great-systems/references/context-tiers.md +166 -0
  138. package/deliver-great-systems/references/continuation-format.md +249 -0
  139. package/deliver-great-systems/references/decimal-phase-calculation.md +67 -0
  140. package/deliver-great-systems/references/git-integration.md +250 -0
  141. package/deliver-great-systems/references/git-planning-commit.md +40 -0
  142. package/deliver-great-systems/references/model-profile-resolution.md +36 -0
  143. package/deliver-great-systems/references/model-profiles.md +95 -0
  144. package/deliver-great-systems/references/phase-argument-parsing.md +61 -0
  145. package/deliver-great-systems/references/planning-config.md +224 -0
  146. package/deliver-great-systems/references/questioning.md +162 -0
  147. package/deliver-great-systems/references/spec-review-loop.md +177 -0
  148. package/deliver-great-systems/references/tdd.md +265 -0
  149. package/deliver-great-systems/references/ui-brand.md +160 -0
  150. package/deliver-great-systems/references/verification-patterns.md +612 -0
  151. package/deliver-great-systems/templates/DEBUG.md +166 -0
  152. package/deliver-great-systems/templates/UAT.md +251 -0
  153. package/deliver-great-systems/templates/VALIDATION.md +95 -0
  154. package/deliver-great-systems/templates/claude-md.md +74 -0
  155. package/deliver-great-systems/templates/codebase/architecture.md +257 -0
  156. package/deliver-great-systems/templates/codebase/concerns.md +312 -0
  157. package/deliver-great-systems/templates/codebase/conventions.md +309 -0
  158. package/deliver-great-systems/templates/codebase/integrations.md +282 -0
  159. package/deliver-great-systems/templates/codebase/stack.md +188 -0
  160. package/deliver-great-systems/templates/codebase/structure.md +287 -0
  161. package/deliver-great-systems/templates/codebase/testing.md +482 -0
  162. package/deliver-great-systems/templates/config.json +38 -0
  163. package/deliver-great-systems/templates/context.md +354 -0
  164. package/deliver-great-systems/templates/continue-here.md +80 -0
  165. package/deliver-great-systems/templates/debug-subagent-prompt.md +93 -0
  166. package/deliver-great-systems/templates/discovery.md +148 -0
  167. package/deliver-great-systems/templates/milestone-archive.md +125 -0
  168. package/deliver-great-systems/templates/milestone.md +117 -0
  169. package/deliver-great-systems/templates/phase-prompt.md +615 -0
  170. package/deliver-great-systems/templates/planner-subagent-prompt.md +119 -0
  171. package/deliver-great-systems/templates/project.md +186 -0
  172. package/deliver-great-systems/templates/requirements.md +233 -0
  173. package/deliver-great-systems/templates/research-project/ARCHITECTURE.md +206 -0
  174. package/deliver-great-systems/templates/research-project/FEATURES.md +149 -0
  175. package/deliver-great-systems/templates/research-project/PITFALLS.md +202 -0
  176. package/deliver-great-systems/templates/research-project/STACK.md +122 -0
  177. package/deliver-great-systems/templates/research-project/SUMMARY.md +172 -0
  178. package/deliver-great-systems/templates/research.md +554 -0
  179. package/deliver-great-systems/templates/retrospective.md +54 -0
  180. package/deliver-great-systems/templates/roadmap.md +204 -0
  181. package/deliver-great-systems/templates/state.md +178 -0
  182. package/deliver-great-systems/templates/summary-complex.md +59 -0
  183. package/deliver-great-systems/templates/summary-minimal.md +41 -0
  184. package/deliver-great-systems/templates/summary-standard.md +48 -0
  185. package/deliver-great-systems/templates/summary.md +253 -0
  186. package/deliver-great-systems/templates/user-setup.md +313 -0
  187. package/deliver-great-systems/templates/verification-report.md +324 -0
  188. package/deliver-great-systems/workflows/add-doc.md +151 -0
  189. package/deliver-great-systems/workflows/add-idea.md +96 -0
  190. package/deliver-great-systems/workflows/add-phase.md +120 -0
  191. package/deliver-great-systems/workflows/add-tests.md +359 -0
  192. package/deliver-great-systems/workflows/add-todo.md +162 -0
  193. package/deliver-great-systems/workflows/approve-spec.md +194 -0
  194. package/deliver-great-systems/workflows/audit-milestone.md +364 -0
  195. package/deliver-great-systems/workflows/audit-phase.md +462 -0
  196. package/deliver-great-systems/workflows/cancel-job.md +108 -0
  197. package/deliver-great-systems/workflows/check-todos.md +181 -0
  198. package/deliver-great-systems/workflows/cleanup.md +247 -0
  199. package/deliver-great-systems/workflows/codereview.md +526 -0
  200. package/deliver-great-systems/workflows/complete-milestone.md +1298 -0
  201. package/deliver-great-systems/workflows/consolidate-ideas.md +365 -0
  202. package/deliver-great-systems/workflows/create-milestone-job.md +177 -0
  203. package/deliver-great-systems/workflows/develop-idea.md +544 -0
  204. package/deliver-great-systems/workflows/diagnose-issues.md +231 -0
  205. package/deliver-great-systems/workflows/discovery-phase.md +301 -0
  206. package/deliver-great-systems/workflows/discuss-idea.md +263 -0
  207. package/deliver-great-systems/workflows/discuss-phase.md +733 -0
  208. package/deliver-great-systems/workflows/execute-phase.md +571 -0
  209. package/deliver-great-systems/workflows/execute-plan.md +592 -0
  210. package/deliver-great-systems/workflows/find-related-ideas.md +271 -0
  211. package/deliver-great-systems/workflows/health.md +173 -0
  212. package/deliver-great-systems/workflows/help.md +997 -0
  213. package/deliver-great-systems/workflows/import-spec.md +381 -0
  214. package/deliver-great-systems/workflows/init-product.md +767 -0
  215. package/deliver-great-systems/workflows/insert-phase.md +138 -0
  216. package/deliver-great-systems/workflows/list-docs.md +119 -0
  217. package/deliver-great-systems/workflows/list-ideas.md +154 -0
  218. package/deliver-great-systems/workflows/list-jobs.md +89 -0
  219. package/deliver-great-systems/workflows/list-phase-assumptions.md +192 -0
  220. package/deliver-great-systems/workflows/list-specs.md +101 -0
  221. package/deliver-great-systems/workflows/map-codebase.md +621 -0
  222. package/deliver-great-systems/workflows/new-milestone.md +591 -0
  223. package/deliver-great-systems/workflows/new-project.md +1113 -0
  224. package/deliver-great-systems/workflows/node-repair.md +94 -0
  225. package/deliver-great-systems/workflows/overlap-check.md +86 -0
  226. package/deliver-great-systems/workflows/pause-work.md +134 -0
  227. package/deliver-great-systems/workflows/plan-milestone-gaps.md +306 -0
  228. package/deliver-great-systems/workflows/plan-phase.md +698 -0
  229. package/deliver-great-systems/workflows/progress.md +386 -0
  230. package/deliver-great-systems/workflows/quick.md +845 -0
  231. package/deliver-great-systems/workflows/refine-spec.md +275 -0
  232. package/deliver-great-systems/workflows/reject-idea.md +109 -0
  233. package/deliver-great-systems/workflows/remove-doc.md +117 -0
  234. package/deliver-great-systems/workflows/remove-phase.md +163 -0
  235. package/deliver-great-systems/workflows/research-idea.md +325 -0
  236. package/deliver-great-systems/workflows/research-phase.md +81 -0
  237. package/deliver-great-systems/workflows/restore-idea.md +101 -0
  238. package/deliver-great-systems/workflows/resume-project.md +311 -0
  239. package/deliver-great-systems/workflows/rollback-job.md +130 -0
  240. package/deliver-great-systems/workflows/run-job.md +498 -0
  241. package/deliver-great-systems/workflows/search.md +130 -0
  242. package/deliver-great-systems/workflows/set-profile.md +83 -0
  243. package/deliver-great-systems/workflows/settings.md +470 -0
  244. package/deliver-great-systems/workflows/transition.md +563 -0
  245. package/deliver-great-systems/workflows/undo-consolidation.md +155 -0
  246. package/deliver-great-systems/workflows/update-idea.md +157 -0
  247. package/deliver-great-systems/workflows/update.md +242 -0
  248. package/deliver-great-systems/workflows/validate-phase.md +177 -0
  249. package/deliver-great-systems/workflows/verify-phase.md +253 -0
  250. package/deliver-great-systems/workflows/verify-work.md +671 -0
  251. package/deliver-great-systems/workflows/write-spec.md +450 -0
  252. package/hooks/dist/dgs-check-update.js +62 -0
  253. package/hooks/dist/dgs-context-monitor.js +141 -0
  254. package/hooks/dist/dgs-statusline.js +115 -0
  255. package/package.json +60 -0
  256. 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
+ };