@ktpartners/dgs-platform 2.9.0 → 3.3.0

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 (166) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +34 -2
  3. package/agents/dgs-executor.md +124 -3
  4. package/agents/dgs-idea-researcher.md +447 -0
  5. package/agents/dgs-plan-checker.md +61 -3
  6. package/agents/dgs-planner.md +51 -8
  7. package/bin/install.js +44 -0
  8. package/commands/dgs/abandon-quick.md +28 -0
  9. package/commands/dgs/add-tests.md +2 -2
  10. package/commands/dgs/audit-milestone.md +4 -3
  11. package/commands/dgs/capture-principle.md +11 -11
  12. package/commands/dgs/cleanup.md +2 -2
  13. package/commands/dgs/complete-milestone.md +11 -11
  14. package/commands/dgs/complete-quick.md +28 -0
  15. package/commands/dgs/create-milestone-job.md +2 -2
  16. package/commands/dgs/debug.md +3 -3
  17. package/commands/dgs/develop-idea.md +1 -1
  18. package/commands/dgs/diff-report.md +124 -0
  19. package/commands/dgs/fast.md +3 -1
  20. package/commands/dgs/health.md +1 -1
  21. package/commands/dgs/map-codebase.md +6 -6
  22. package/commands/dgs/new-milestone.md +5 -5
  23. package/commands/dgs/new-project.md +8 -21
  24. package/commands/dgs/package-scan.md +43 -0
  25. package/commands/dgs/plan-milestone-gaps.md +1 -1
  26. package/commands/dgs/progress.md +3 -3
  27. package/commands/dgs/quick-abandon.md +8 -0
  28. package/commands/dgs/quick-complete.md +8 -0
  29. package/commands/dgs/quick.md +10 -3
  30. package/commands/dgs/research-idea.md +3 -2
  31. package/commands/dgs/research-phase.md +3 -3
  32. package/commands/dgs/switch-project.md +14 -1
  33. package/commands/dgs/write-spec.md +3 -3
  34. package/deliver-great-systems/bin/dgs-tools.cjs +401 -32
  35. package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
  36. package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
  37. package/deliver-great-systems/bin/lib/commands.cjs +626 -46
  38. package/deliver-great-systems/bin/lib/commands.test.cjs +451 -0
  39. package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
  40. package/deliver-great-systems/bin/lib/config.cjs +80 -6
  41. package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
  42. package/deliver-great-systems/bin/lib/context.cjs +120 -0
  43. package/deliver-great-systems/bin/lib/core.cjs +35 -14
  44. package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
  45. package/deliver-great-systems/bin/lib/execution.cjs +49 -17
  46. package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
  47. package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
  48. package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
  49. package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
  50. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
  51. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
  52. package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
  53. package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
  54. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
  55. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
  56. package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
  57. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
  58. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
  59. package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
  60. package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
  61. package/deliver-great-systems/bin/lib/governance.cjs +211 -0
  62. package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
  63. package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
  64. package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
  65. package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
  66. package/deliver-great-systems/bin/lib/init.cjs +357 -61
  67. package/deliver-great-systems/bin/lib/init.test.cjs +625 -8
  68. package/deliver-great-systems/bin/lib/jobs.cjs +131 -25
  69. package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
  70. package/deliver-great-systems/bin/lib/migration.cjs +409 -1
  71. package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
  72. package/deliver-great-systems/bin/lib/milestone.cjs +154 -31
  73. package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
  74. package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
  75. package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
  76. package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
  77. package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
  78. package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
  79. package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
  80. package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
  81. package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
  82. package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
  83. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
  84. package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
  85. package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
  86. package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
  87. package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
  88. package/deliver-great-systems/bin/lib/phase.cjs +146 -3
  89. package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
  90. package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
  91. package/deliver-great-systems/bin/lib/projects.cjs +65 -10
  92. package/deliver-great-systems/bin/lib/projects.test.cjs +198 -2
  93. package/deliver-great-systems/bin/lib/quick.cjs +739 -0
  94. package/deliver-great-systems/bin/lib/quick.test.cjs +730 -0
  95. package/deliver-great-systems/bin/lib/repos.cjs +37 -13
  96. package/deliver-great-systems/bin/lib/review.cjs +1821 -0
  97. package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
  98. package/deliver-great-systems/bin/lib/specs.cjs +3 -81
  99. package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
  100. package/deliver-great-systems/bin/lib/state.cjs +147 -55
  101. package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
  102. package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
  103. package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
  104. package/deliver-great-systems/bin/lib/sync.cjs +75 -0
  105. package/deliver-great-systems/bin/lib/verify.cjs +198 -7
  106. package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
  107. package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
  108. package/deliver-great-systems/bin/lib/worktrees.cjs +790 -0
  109. package/deliver-great-systems/bin/lib/worktrees.test.cjs +963 -0
  110. package/deliver-great-systems/references/agent-step-reliability.md +60 -0
  111. package/deliver-great-systems/references/conflict-resolution.md +4 -0
  112. package/deliver-great-systems/references/context-tiers.md +4 -0
  113. package/deliver-great-systems/references/package-scan-config.md +151 -0
  114. package/deliver-great-systems/references/questioning.md +0 -30
  115. package/deliver-great-systems/references/spec-review-loop.md +1 -2
  116. package/deliver-great-systems/references/workflow-conventions.md +29 -0
  117. package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
  118. package/deliver-great-systems/templates/REVIEW.md +35 -0
  119. package/deliver-great-systems/templates/VALIDATION.md +1 -1
  120. package/deliver-great-systems/templates/claude-md.md +27 -0
  121. package/deliver-great-systems/templates/package-scan-report.md +108 -0
  122. package/deliver-great-systems/templates/project.md +6 -170
  123. package/deliver-great-systems/templates/summary.md +3 -1
  124. package/deliver-great-systems/workflows/abandon-quick.md +89 -0
  125. package/deliver-great-systems/workflows/add-idea.md +3 -3
  126. package/deliver-great-systems/workflows/add-phase.md +5 -0
  127. package/deliver-great-systems/workflows/add-tests.md +14 -0
  128. package/deliver-great-systems/workflows/add-todo.md +1 -0
  129. package/deliver-great-systems/workflows/approve-spec.md +25 -4
  130. package/deliver-great-systems/workflows/audit-milestone.md +66 -10
  131. package/deliver-great-systems/workflows/audit-phase.md +15 -5
  132. package/deliver-great-systems/workflows/cancel-job.md +2 -2
  133. package/deliver-great-systems/workflows/check-todos.md +2 -3
  134. package/deliver-great-systems/workflows/codereview.md +103 -9
  135. package/deliver-great-systems/workflows/complete-milestone.md +218 -24
  136. package/deliver-great-systems/workflows/complete-quick.md +106 -0
  137. package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
  138. package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
  139. package/deliver-great-systems/workflows/develop-idea.md +11 -11
  140. package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
  141. package/deliver-great-systems/workflows/discuss-idea.md +1 -1
  142. package/deliver-great-systems/workflows/discuss-phase.md +3 -2
  143. package/deliver-great-systems/workflows/execute-phase.md +209 -33
  144. package/deliver-great-systems/workflows/execute-plan.md +22 -22
  145. package/deliver-great-systems/workflows/help.md +53 -20
  146. package/deliver-great-systems/workflows/import-spec.md +65 -7
  147. package/deliver-great-systems/workflows/init-product.md +45 -167
  148. package/deliver-great-systems/workflows/new-milestone.md +140 -33
  149. package/deliver-great-systems/workflows/new-project.md +60 -331
  150. package/deliver-great-systems/workflows/package-scan.md +59 -0
  151. package/deliver-great-systems/workflows/plan-phase.md +79 -1
  152. package/deliver-great-systems/workflows/progress-all.md +133 -0
  153. package/deliver-great-systems/workflows/quick-abandon.md +89 -0
  154. package/deliver-great-systems/workflows/quick-complete.md +106 -0
  155. package/deliver-great-systems/workflows/quick.md +328 -26
  156. package/deliver-great-systems/workflows/refine-spec.md +1 -1
  157. package/deliver-great-systems/workflows/research-idea.md +77 -139
  158. package/deliver-great-systems/workflows/resume-project.md +2 -2
  159. package/deliver-great-systems/workflows/run-job.md +29 -43
  160. package/deliver-great-systems/workflows/settings.md +13 -77
  161. package/deliver-great-systems/workflows/validate-phase.md +39 -1
  162. package/deliver-great-systems/workflows/verify-work.md +14 -0
  163. package/deliver-great-systems/workflows/write-spec.md +11 -13
  164. package/hooks/dist/dgs-enforce-discipline.js +196 -0
  165. package/package.json +1 -1
  166. package/scripts/build-hooks.js +1 -0
@@ -0,0 +1,1821 @@
1
+ /**
2
+ * Review — Diff report generation for milestones and quick tasks
3
+ *
4
+ * Generates structured REVIEW.md files from git diff data.
5
+ * Default mode uses git stat + commit log (no LLM calls).
6
+ * Detailed mode uses LLM for per-file summaries and overall narrative.
7
+ *
8
+ * Exports: generateDiffReport, generateDiffReportFromJob,
9
+ * cmdJobsGenerateReview, cmdQuickGenerateReview
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { execSync } = require('child_process');
15
+ const { output, error, safeReadFile, loadConfig } = require('./core.cjs');
16
+ const { extractFrontmatter } = require('./frontmatter.cjs');
17
+ const { getPlanningRoot } = require('./paths.cjs');
18
+
19
+ // ─── Repo Resolution ────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Parse REPOS.md and resolve absolute paths for each registered repo.
23
+ * Reuses the same pattern from recordStartShas in jobs.cjs.
24
+ *
25
+ * @param {string} planningRoot - Absolute path to the planning root
26
+ * @returns {Object} Map of repo name to absolute repo path
27
+ */
28
+ function resolveRepoPathsFromReposMd(planningRoot) {
29
+ const reposPath = path.join(planningRoot, 'REPOS.md');
30
+ const repoMap = {};
31
+
32
+ if (!fs.existsSync(reposPath)) {
33
+ return repoMap;
34
+ }
35
+
36
+ const content = fs.readFileSync(reposPath, 'utf-8');
37
+ const lines = content.split('\n');
38
+
39
+ for (const line of lines) {
40
+ const match = line.match(/^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|/);
41
+ if (!match) continue;
42
+ const name = match[1].trim();
43
+ const repoPath = match[2].trim();
44
+ // Skip header row and separator
45
+ if (name === 'Name' || name.startsWith('-')) continue;
46
+ if (!repoPath || repoPath.startsWith('-')) continue;
47
+
48
+ // Resolve absolute path: paths in REPOS.md are relative to the planning root
49
+ const absRepoPath = path.resolve(planningRoot, repoPath);
50
+ if (fs.existsSync(absRepoPath)) {
51
+ repoMap[name] = absRepoPath;
52
+ }
53
+ }
54
+
55
+ return repoMap;
56
+ }
57
+
58
+ /**
59
+ * Look up the active milestone worktree's repo paths from config.local.json.
60
+ * A milestone worktree exists for the duration of the milestone branch before
61
+ * it is merged back to main. There is at most one active milestone worktree
62
+ * per project. Returns null if no milestone worktree is registered for the
63
+ * current project (typical post-complete-milestone state), in which case
64
+ * callers should fall back to REPOS.md main paths.
65
+ *
66
+ * @param {string} planningRoot - Absolute path to the planning root
67
+ * @returns {Object|null} Map of repo name to worktree absolute path, or null
68
+ */
69
+ function getActiveMilestoneWorktreeRepos(planningRoot) {
70
+ const localConfigPath = path.join(planningRoot, 'config.local.json');
71
+ if (!fs.existsSync(localConfigPath)) return null;
72
+
73
+ let local;
74
+ try {
75
+ local = JSON.parse(fs.readFileSync(localConfigPath, 'utf-8'));
76
+ } catch {
77
+ return null;
78
+ }
79
+
80
+ const currentProject = local.current_project;
81
+ if (!currentProject) return null;
82
+
83
+ const projectEntry = local.projects && local.projects[currentProject];
84
+ if (!projectEntry || !projectEntry.worktrees) return null;
85
+
86
+ // At most one milestone worktree per project — pick the first type:'milestone' entry
87
+ for (const wt of Object.values(projectEntry.worktrees)) {
88
+ if (wt && wt.type === 'milestone' && wt.repos) {
89
+ return wt.repos;
90
+ }
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ // ─── Git Operations ─────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Run a git command in a repo directory, returning stdout or null on failure.
100
+ *
101
+ * @param {string} repoPath - Absolute path to the repo
102
+ * @param {string} cmd - Git command arguments (after 'git')
103
+ * @returns {string|null} Command stdout, or null on error
104
+ */
105
+ function gitCmd(repoPath, cmd) {
106
+ try {
107
+ return execSync(`git ${cmd}`, { cwd: repoPath, stdio: 'pipe' }).toString();
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Gather diff data for a single repo between startSha and HEAD.
115
+ *
116
+ * @param {string} repoPath - Absolute path to the repo
117
+ * @param {string} startSha - Start SHA for the diff range
118
+ * @returns {Object} Structured diff data for the repo
119
+ */
120
+ function gatherRepoDiff(repoPath, startSha) {
121
+ // Get commit log
122
+ const logOutput = gitCmd(repoPath, `log --oneline ${startSha}..HEAD`);
123
+ const logLines = logOutput ? logOutput.trim().split('\n').filter(l => l.trim()) : [];
124
+
125
+ // Get file status (A/M/D/R)
126
+ const nameStatusOutput = gitCmd(repoPath, `diff --name-status -M ${startSha}..HEAD`);
127
+ const nameStatusLines = nameStatusOutput
128
+ ? nameStatusOutput.trim().split('\n').filter(l => l.trim())
129
+ : [];
130
+
131
+ // Get numstat for insertions/deletions per file
132
+ const numstatOutput = gitCmd(repoPath, `diff --numstat ${startSha}..HEAD`);
133
+ const numstatLines = numstatOutput
134
+ ? numstatOutput.trim().split('\n').filter(l => l.trim())
135
+ : [];
136
+
137
+ // Build numstat lookup: filePath -> { insertions, deletions }
138
+ const numstatMap = {};
139
+ for (const line of numstatLines) {
140
+ const parts = line.split('\t');
141
+ if (parts.length >= 3) {
142
+ const ins = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
143
+ const del = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
144
+ const filePath = parts.slice(2).join('\t'); // handle paths with tabs (rare)
145
+ numstatMap[filePath] = { insertions: ins, deletions: del };
146
+ }
147
+ }
148
+
149
+ // Parse name-status into structured file entries
150
+ const files = [];
151
+ let totalInsertions = 0;
152
+ let totalDeletions = 0;
153
+
154
+ for (const line of nameStatusLines) {
155
+ const parts = line.split('\t');
156
+ if (parts.length < 2) continue;
157
+
158
+ const statusChar = parts[0].trim();
159
+ let filePath, oldPath = null, status;
160
+
161
+ if (statusChar.startsWith('R')) {
162
+ // Rename: R{score}\told/path\tnew/path
163
+ oldPath = parts[1];
164
+ filePath = parts[2] || parts[1];
165
+ status = 'moved';
166
+ } else if (statusChar === 'A') {
167
+ filePath = parts[1];
168
+ status = 'new';
169
+ } else if (statusChar === 'D') {
170
+ filePath = parts[1];
171
+ status = 'deleted';
172
+ } else if (statusChar === 'M') {
173
+ filePath = parts[1];
174
+ status = 'modified';
175
+ } else {
176
+ filePath = parts[1];
177
+ status = 'modified'; // fallback for C (copy), T (type change), etc.
178
+ }
179
+
180
+ const stats = numstatMap[filePath] || numstatMap[oldPath] || { insertions: 0, deletions: 0 };
181
+ totalInsertions += stats.insertions;
182
+ totalDeletions += stats.deletions;
183
+
184
+ // Get most recent commit message that touched this file in the range
185
+ const description = getFileDescription(repoPath, startSha, filePath);
186
+
187
+ files.push({
188
+ path: filePath,
189
+ status,
190
+ insertions: stats.insertions,
191
+ deletions: stats.deletions,
192
+ oldPath,
193
+ description,
194
+ });
195
+ }
196
+
197
+ return {
198
+ commitCount: logLines.length,
199
+ stats: {
200
+ filesChanged: files.length,
201
+ insertions: totalInsertions,
202
+ deletions: totalDeletions,
203
+ },
204
+ files,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Get the most recent commit message that touched a file in a given range.
210
+ *
211
+ * @param {string} repoPath - Absolute path to the repo
212
+ * @param {string} startSha - Start SHA for the range
213
+ * @param {string} filePath - Relative file path within the repo
214
+ * @returns {string} Most recent commit subject line, or empty string
215
+ */
216
+ function getFileDescription(repoPath, startSha, filePath) {
217
+ const result = gitCmd(repoPath, `log -1 --format="%s" ${startSha}..HEAD -- "${filePath}"`);
218
+ return result ? result.trim() : '';
219
+ }
220
+
221
+ // ─── Configuration File Detection ──────────────────────────────────────────
222
+
223
+ const CONFIG_PATTERNS = [
224
+ 'package.json',
225
+ 'package-lock.json',
226
+ /\.config\.[jt]s$/,
227
+ /^\.env/,
228
+ /^tsconfig/,
229
+ /^\.github\//,
230
+ 'config.json',
231
+ 'config.local.json',
232
+ 'STATE.md',
233
+ ];
234
+
235
+ // ─── Collapse Thresholds ──────────────────────────────────────────────────
236
+ const COLLAPSE_FILE_THRESHOLD = 50; // repos with more than this many files trigger collapse
237
+ const COLLAPSE_LINE_THRESHOLD = 5; // files with fewer than this many combined lines are "minor"
238
+
239
+ /**
240
+ * Check if a file path matches any known configuration file pattern.
241
+ *
242
+ * @param {string} filePath - Relative file path within the repo
243
+ * @returns {boolean} True if the file is a configuration file
244
+ */
245
+ function isConfigFile(filePath) {
246
+ const basename = path.basename(filePath);
247
+ for (const pattern of CONFIG_PATTERNS) {
248
+ if (typeof pattern === 'string') {
249
+ if (basename === pattern) return true;
250
+ } else if (pattern instanceof RegExp) {
251
+ if (pattern.test(filePath) || pattern.test(basename)) return true;
252
+ }
253
+ }
254
+ return false;
255
+ }
256
+
257
+ /**
258
+ * Generate a bold risk annotation for a configuration file.
259
+ *
260
+ * @param {Object} file - File entry with path, insertions, deletions, status
261
+ * @returns {string} Bold risk annotation string
262
+ */
263
+ function generateConfigRiskAnnotation(file) {
264
+ const basename = path.basename(file.path);
265
+
266
+ if (basename === 'package.json') {
267
+ return '**dependencies changed**';
268
+ }
269
+
270
+ if (basename === 'package-lock.json') {
271
+ return '**lockfile updated**';
272
+ }
273
+
274
+ if (/^\.env/.test(basename)) {
275
+ return '**environment variables changed**';
276
+ }
277
+
278
+ if (/^\.github\//.test(file.path)) {
279
+ return '**CI/CD configuration changed**';
280
+ }
281
+
282
+ if (/^tsconfig/.test(basename)) {
283
+ return '**TypeScript configuration changed**';
284
+ }
285
+
286
+ return '**settings changed**';
287
+ }
288
+
289
+ // ─── Rendering ──────────────────────────────────────────────────────────────
290
+
291
+ /**
292
+ * Build the stats banner line.
293
+ *
294
+ * @param {Object} totals - Aggregated stats across all repos
295
+ * @param {number} repoCount - Number of repos with changes
296
+ * @param {number} [riskFlagCount=0] - Number of risk flags detected
297
+ * @returns {string} Formatted stats banner
298
+ */
299
+ function buildStatsBanner(totals, repoCount, riskFlagCount) {
300
+ let banner = `${totals.commits} commits | ${totals.filesChanged} files changed | +${totals.insertions} -${totals.deletions} | ${repoCount} repos`;
301
+ if (riskFlagCount > 0) {
302
+ banner += ` | ${riskFlagCount} risk flags`;
303
+ }
304
+ return banner;
305
+ }
306
+
307
+ /**
308
+ * Render aggregate statistics across all repos for multi-repo reports.
309
+ * Returns empty string for single-repo reports (caller should omit section).
310
+ *
311
+ * @param {Array} repoResults - Array of { name, diff } objects
312
+ * @param {Object} totals - Aggregated stats { commits, filesChanged, insertions, deletions }
313
+ * @returns {string} Rendered markdown for aggregate statistics section
314
+ */
315
+ function renderAggregateStats(repoResults, totals) {
316
+ // Filter to repos with actual changes
317
+ const activeRepos = repoResults.filter(r => r.diff.files.length > 0);
318
+ if (activeRepos.length <= 1) return '';
319
+
320
+ const lines = [];
321
+
322
+ // Summary totals line
323
+ lines.push(`**Totals:** ${totals.commits} commits | ${totals.filesChanged} files changed | +${totals.insertions} -${totals.deletions}`);
324
+ lines.push('');
325
+
326
+ // Per-repo breakdown table
327
+ lines.push('| Repository | Commits | Files | Insertions | Deletions |');
328
+ lines.push('|------------|---------|-------|------------|-----------|');
329
+ for (const repo of activeRepos) {
330
+ lines.push(`| ${repo.name} | ${repo.diff.commitCount} | ${repo.diff.stats.filesChanged} | +${repo.diff.stats.insertions} | -${repo.diff.stats.deletions} |`);
331
+ }
332
+
333
+ return lines.join('\n');
334
+ }
335
+
336
+ /**
337
+ * Render the Code Changes section with per-repo blocks.
338
+ *
339
+ * @param {Array} repoResults - Array of { name, startSha, diff } objects
340
+ * @returns {string} Rendered markdown for all repos
341
+ */
342
+ function renderCodeChanges(repoResults) {
343
+ const sections = [];
344
+
345
+ for (const repo of repoResults) {
346
+ const { name, diff } = repo;
347
+ if (diff.files.length === 0) continue;
348
+
349
+ const header = `### ${name} (${diff.commitCount} commits, +${diff.stats.insertions} -${diff.stats.deletions})`;
350
+
351
+ // Group files by status and sort alphabetically within each category
352
+ const groups = {
353
+ new: diff.files.filter(f => f.status === 'new').sort((a, b) => a.path.localeCompare(b.path)),
354
+ modified: diff.files.filter(f => f.status === 'modified').sort((a, b) => a.path.localeCompare(b.path)),
355
+ deleted: diff.files.filter(f => f.status === 'deleted').sort((a, b) => a.path.localeCompare(b.path)),
356
+ moved: diff.files.filter(f => f.status === 'moved').sort((a, b) => a.path.localeCompare(b.path)),
357
+ };
358
+
359
+ // Separate config files from regular files
360
+ const configFiles = [];
361
+ for (const key of ['new', 'modified', 'deleted', 'moved']) {
362
+ const regular = [];
363
+ for (const f of groups[key]) {
364
+ if (isConfigFile(f.path)) {
365
+ configFiles.push(f);
366
+ } else {
367
+ regular.push(f);
368
+ }
369
+ }
370
+ groups[key] = regular;
371
+ }
372
+ configFiles.sort((a, b) => a.path.localeCompare(b.path));
373
+
374
+ // Collapse minor files when repo has many changes (TMPL-05)
375
+ // Minor = combined (insertions + deletions) < COLLAPSE_LINE_THRESHOLD
376
+ // Config files are NEVER collapsed (already separated above)
377
+ let collapsedSummary = '';
378
+ if (diff.files.length > COLLAPSE_FILE_THRESHOLD) {
379
+ const minorFiles = { new: 0, modified: 0, deleted: 0, moved: 0 };
380
+ let minorTotal = 0;
381
+
382
+ for (const key of ['new', 'modified', 'deleted', 'moved']) {
383
+ const regular = [];
384
+ for (const f of groups[key]) {
385
+ const totalLines = f.insertions + f.deletions;
386
+ if (totalLines < COLLAPSE_LINE_THRESHOLD) {
387
+ minorFiles[key]++;
388
+ minorTotal++;
389
+ } else {
390
+ regular.push(f);
391
+ }
392
+ }
393
+ groups[key] = regular;
394
+ }
395
+
396
+ if (minorTotal > 0) {
397
+ const breakdown = [];
398
+ if (minorFiles.modified > 0) breakdown.push(`${minorFiles.modified} modified`);
399
+ if (minorFiles.new > 0) breakdown.push(`${minorFiles.new} new`);
400
+ if (minorFiles.deleted > 0) breakdown.push(`${minorFiles.deleted} deleted`);
401
+ if (minorFiles.moved > 0) breakdown.push(`${minorFiles.moved} moved`);
402
+ collapsedSummary = `**Minor changes (${minorTotal} files):** ${breakdown.join(', ')}`;
403
+ }
404
+ }
405
+
406
+ const parts = [header, ''];
407
+
408
+ if (groups.new.length > 0) {
409
+ parts.push(`**New files (${groups.new.length}):**`);
410
+ for (const f of groups.new) {
411
+ const desc = f.description ? ` \u2014 ${f.description}` : '';
412
+ parts.push(`- ${f.path} (+${f.insertions} -${f.deletions})${desc}`);
413
+ }
414
+ parts.push('');
415
+ }
416
+
417
+ if (groups.modified.length > 0) {
418
+ parts.push(`**Modified (${groups.modified.length}):**`);
419
+ for (const f of groups.modified) {
420
+ const desc = f.description ? ` \u2014 ${f.description}` : '';
421
+ parts.push(`- ${f.path} (+${f.insertions} -${f.deletions})${desc}`);
422
+ }
423
+ parts.push('');
424
+ }
425
+
426
+ if (groups.deleted.length > 0) {
427
+ parts.push(`**Deleted (${groups.deleted.length}):**`);
428
+ for (const f of groups.deleted) {
429
+ parts.push(`- ${f.path} (-${f.deletions})`);
430
+ }
431
+ parts.push('');
432
+ }
433
+
434
+ if (groups.moved.length > 0) {
435
+ parts.push(`**Moved (${groups.moved.length}):**`);
436
+ for (const f of groups.moved) {
437
+ // Pure renames (no content changes) show no line counts
438
+ if (f.insertions === 0 && f.deletions === 0) {
439
+ parts.push(`- ${f.oldPath} \u2192 ${f.path}`);
440
+ } else {
441
+ parts.push(`- ${f.oldPath} \u2192 ${f.path} (+${f.insertions} -${f.deletions})`);
442
+ }
443
+ }
444
+ parts.push('');
445
+ }
446
+
447
+ // Configuration Changes subsection
448
+ if (configFiles.length > 0) {
449
+ parts.push(`**Configuration Changes (${configFiles.length}):**`);
450
+ for (const f of configFiles) {
451
+ const risk = generateConfigRiskAnnotation(f);
452
+ const counts = f.status === 'deleted'
453
+ ? `(-${f.deletions})`
454
+ : `(+${f.insertions} -${f.deletions})`;
455
+ parts.push(`- ${f.path} ${counts} \u2014 ${risk}`);
456
+ }
457
+ parts.push('');
458
+ }
459
+
460
+ // Minor changes collapsed summary (after all regular categories)
461
+ if (collapsedSummary) {
462
+ parts.push(collapsedSummary);
463
+ parts.push('');
464
+ }
465
+
466
+ sections.push(parts.join('\n'));
467
+ }
468
+
469
+ return sections.join('\n');
470
+ }
471
+
472
+ /**
473
+ * Remove a section (## Header + placeholder) from template content
474
+ * when the section has no data.
475
+ *
476
+ * @param {string} content - Template content
477
+ * @param {string} header - Section header (e.g., "## Goal")
478
+ * @param {string} placeholder - Placeholder name (e.g., "{goal}")
479
+ * @returns {string} Content with section removed
480
+ */
481
+ function removeSection(content, header, placeholder) {
482
+ // Remove the header line, the placeholder line, and any blank line after
483
+ const headerEscaped = header.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
484
+ const placeholderEscaped = placeholder.replace(/[{}]/g, '\\$&');
485
+ const pattern = new RegExp(
486
+ `${headerEscaped}\\n\\n${placeholderEscaped}\\n?\\n?`,
487
+ 'g'
488
+ );
489
+ return content.replace(pattern, '');
490
+ }
491
+
492
+ // ─── Content Extraction ────────────────────────────────────────────────────
493
+
494
+ /**
495
+ * Extract the milestone goal from ROADMAP.md.
496
+ *
497
+ * @param {string} planningRoot - Absolute path to planning root
498
+ * @param {string} projectRoot - Relative project root (e.g., "projects/gsd")
499
+ * @param {string} milestoneVersion - Milestone version (e.g., "v22.0")
500
+ * @returns {string|null} Goal text or null if not found
501
+ */
502
+ function extractGoalFromRoadmap(planningRoot, projectRoot, milestoneVersion) {
503
+ const roadmapPath = path.join(planningRoot, projectRoot, 'ROADMAP.md');
504
+ const content = safeReadFile(roadmapPath);
505
+ if (!content) return null;
506
+
507
+ // Find milestone section and extract goal
508
+ const milestonePattern = new RegExp(
509
+ `###?\\s+${milestoneVersion.replace(/\./g, '\\.')}\\s+[^\\n]*\\n[\\s\\S]*?\\*\\*Milestone Goal:\\*\\*\\s*([^\\n]+)`,
510
+ 'i'
511
+ );
512
+ const match = content.match(milestonePattern);
513
+ return match ? match[1].trim() : null;
514
+ }
515
+
516
+ /**
517
+ * Extract What Was Built from SUMMARY.md one-liners across all phases.
518
+ *
519
+ * @param {string} planningRoot - Absolute path to planning root
520
+ * @param {string} projectRoot - Relative project root (e.g., "projects/gsd")
521
+ * @param {string} roadmapContent - ROADMAP.md content for fallback goal text
522
+ * @returns {string} Formatted What Was Built markdown section
523
+ */
524
+ function extractWhatWasBuiltFromSummaries(planningRoot, projectRoot, roadmapContent) {
525
+ const phasesDir = path.join(planningRoot, projectRoot, 'phases');
526
+ if (!fs.existsSync(phasesDir)) return '';
527
+
528
+ const phaseDirs = fs.readdirSync(phasesDir)
529
+ .filter(d => /^\d+/.test(d))
530
+ .sort((a, b) => {
531
+ const numA = parseInt(a.match(/^(\d+)/)[1], 10);
532
+ const numB = parseInt(b.match(/^(\d+)/)[1], 10);
533
+ return numA - numB;
534
+ });
535
+
536
+ const lines = [];
537
+ let index = 1;
538
+
539
+ for (const dir of phaseDirs) {
540
+ const phaseNum = dir.match(/^(\d+)/)[1];
541
+ const phaseName = dir.replace(/^\d+-/, '').replace(/-/g, ' ');
542
+ const phaseFullDir = path.join(phasesDir, dir);
543
+
544
+ // Find SUMMARY.md files in this phase directory
545
+ const summaryFiles = fs.readdirSync(phaseFullDir)
546
+ .filter(f => f.endsWith('-SUMMARY.md'))
547
+ .sort();
548
+
549
+ if (summaryFiles.length === 0) {
550
+ // Fallback: use ROADMAP goal text
551
+ const goalMatch = roadmapContent
552
+ ? roadmapContent.match(new RegExp(`Phase ${phaseNum}[^\\n]*\\n\\*\\*Goal\\*\\*:\\s*([^\\n]+)`, 'i'))
553
+ : null;
554
+ const fallback = goalMatch ? goalMatch[1].trim() : phaseName;
555
+ lines.push(`${index}. Phase ${phaseNum}: ${fallback} (no summary available)`);
556
+ index++;
557
+ continue;
558
+ }
559
+
560
+ for (const summaryFile of summaryFiles) {
561
+ const content = safeReadFile(path.join(phaseFullDir, summaryFile));
562
+ if (!content) continue;
563
+ const fm = extractFrontmatter(content);
564
+ const oneLiner = fm['one-liner'] || null;
565
+
566
+ if (oneLiner) {
567
+ lines.push(`${index}. Phase ${phaseNum}: ${oneLiner}`);
568
+ } else {
569
+ // Extract first line of body after frontmatter as fallback
570
+ const bodyMatch = content.match(/---[\s\S]*?---\s*\n#?\s*([^\n]+)/);
571
+ const bodyLine = bodyMatch ? bodyMatch[1].replace(/^#+\s*/, '').trim() : phaseName;
572
+ lines.push(`${index}. Phase ${phaseNum}: ${bodyLine}`);
573
+ }
574
+ index++;
575
+ }
576
+ }
577
+
578
+ return lines.join('\n');
579
+ }
580
+
581
+ /**
582
+ * Extract What Was Built from commit messages (for quick tasks).
583
+ *
584
+ * @param {string} repoPath - Absolute path to the repo
585
+ * @param {string} startSha - Start SHA (merge-base)
586
+ * @returns {string} Formatted bullet list of commit messages
587
+ */
588
+ function extractWhatWasBuiltFromCommits(repoPath, startSha) {
589
+ const logOutput = gitCmd(repoPath, `log --oneline --no-merges ${startSha}..HEAD`);
590
+ if (!logOutput || !logOutput.trim()) return '';
591
+
592
+ const seen = new Set();
593
+ const lines = [];
594
+ for (const line of logOutput.trim().split('\n')) {
595
+ // Remove SHA prefix to get message
596
+ const msg = line.replace(/^[a-f0-9]+\s+/, '').trim();
597
+ if (msg && !seen.has(msg)) {
598
+ seen.add(msg);
599
+ lines.push(`- ${msg}`);
600
+ }
601
+ }
602
+ return lines.join('\n');
603
+ }
604
+
605
+ /**
606
+ * Build an overall narrative describing the architectural shift or nature of changes.
607
+ *
608
+ * @param {Object} options - Report options (title, goal, mode)
609
+ * @param {Object} totals - Aggregated stats { commits, filesChanged, insertions, deletions }
610
+ * @param {number} repoCount - Number of repos with changes
611
+ * @param {Array} repoResults - Array of { name, diff } objects
612
+ * @returns {string} 2-3 sentence narrative
613
+ */
614
+ function buildOverallNarrative(options, totals, repoCount, repoResults) {
615
+ const parts = [];
616
+
617
+ // Sentence 1: What the milestone/task achieved (from goal)
618
+ if (options.goal) {
619
+ parts.push(options.goal.replace(/\.$/, '') + '.');
620
+ }
621
+
622
+ // Sentence 2: Scope summary
623
+ const repoNames = repoResults.filter(r => r.diff.files.length > 0).map(r => r.name);
624
+ if (repoNames.length === 1) {
625
+ parts.push(`Changes span ${totals.filesChanged} files across ${totals.commits} commits in ${repoNames[0]}, with a net delta of +${totals.insertions} -${totals.deletions} lines.`);
626
+ } else if (repoNames.length > 1) {
627
+ parts.push(`Changes span ${totals.filesChanged} files across ${totals.commits} commits in ${repoNames.length} repositories (${repoNames.join(', ')}), with a net delta of +${totals.insertions} -${totals.deletions} lines.`);
628
+ }
629
+
630
+ return parts.join(' ');
631
+ }
632
+
633
+ /**
634
+ * Extract verification data from phase VERIFICATION.md files.
635
+ * Returns tiered format: summary line + collapsible per-phase details.
636
+ *
637
+ * @param {string} planningRoot - Absolute path to planning root
638
+ * @param {string} projectRoot - Relative project root
639
+ * @returns {string|null} Formatted verification section or null if no data
640
+ */
641
+ function extractVerificationData(planningRoot, projectRoot) {
642
+ const phasesDir = path.join(planningRoot, projectRoot, 'phases');
643
+ if (!fs.existsSync(phasesDir)) return null;
644
+
645
+ const phaseDirs = fs.readdirSync(phasesDir)
646
+ .filter(d => /^\d+/.test(d))
647
+ .sort((a, b) => {
648
+ const numA = parseInt(a.match(/^(\d+)/)[1], 10);
649
+ const numB = parseInt(b.match(/^(\d+)/)[1], 10);
650
+ return numA - numB;
651
+ });
652
+
653
+ let totalReqsVerified = 0;
654
+ let totalReqs = 0;
655
+ let totalTestsPassed = 0;
656
+ let totalHumanReview = 0;
657
+ const phaseDetails = [];
658
+
659
+ for (const dir of phaseDirs) {
660
+ const phaseNum = dir.match(/^(\d+)/)[1];
661
+ const phaseFullDir = path.join(phasesDir, dir);
662
+
663
+ // Find VERIFICATION.md files
664
+ const verFiles = fs.readdirSync(phaseFullDir)
665
+ .filter(f => f.endsWith('-VERIFICATION.md'))
666
+ .sort();
667
+
668
+ for (const vf of verFiles) {
669
+ const content = safeReadFile(path.join(phaseFullDir, vf));
670
+ if (!content) continue;
671
+
672
+ // Parse verification counts from content
673
+ // Look for patterns like "X/Y requirements verified" or pass/fail counts
674
+ const reqMatch = content.match(/(\d+)\s*\/\s*(\d+)\s*(?:requirements?\s+)?(?:verified|passed)/i);
675
+ if (reqMatch) {
676
+ totalReqsVerified += parseInt(reqMatch[1], 10);
677
+ totalReqs += parseInt(reqMatch[2], 10);
678
+ }
679
+
680
+ // Look for test pass counts
681
+ const testMatch = content.match(/(\d+)\s+tests?\s+passed/i);
682
+ if (testMatch) {
683
+ totalTestsPassed += parseInt(testMatch[1], 10);
684
+ }
685
+
686
+ // Look for human review needed items
687
+ const humanMatch = content.match(/(\d+)\s+(?:items?\s+)?(?:need|require)\s+human\s+review/i);
688
+ if (humanMatch) {
689
+ totalHumanReview += parseInt(humanMatch[1], 10);
690
+ }
691
+
692
+ phaseDetails.push({ phaseNum, file: vf, content });
693
+ }
694
+ }
695
+
696
+ // If no verification data found, return null (section will be omitted)
697
+ if (phaseDetails.length === 0) return null;
698
+
699
+ // Build tiered output
700
+ const lines = [];
701
+
702
+ // Summary line
703
+ const summaryParts = [];
704
+ if (totalReqs > 0) {
705
+ summaryParts.push(`${totalReqsVerified}/${totalReqs} requirements verified`);
706
+ }
707
+ if (totalTestsPassed > 0) {
708
+ summaryParts.push(`${totalTestsPassed} tests passed`);
709
+ }
710
+ if (totalHumanReview > 0) {
711
+ summaryParts.push(`${totalHumanReview} items need human review`);
712
+ }
713
+ if (summaryParts.length > 0) {
714
+ lines.push(summaryParts.join(', '));
715
+ }
716
+
717
+ // Per-phase collapsible details
718
+ if (phaseDetails.length > 0) {
719
+ lines.push('');
720
+ lines.push('<details>');
721
+ lines.push('<summary>Per-phase verification details</summary>');
722
+ lines.push('');
723
+ for (const pd of phaseDetails) {
724
+ lines.push(`#### Phase ${pd.phaseNum}`);
725
+ // Extract the meaningful content (skip frontmatter)
726
+ const body = pd.content.replace(/^---[\s\S]*?---\s*\n?/, '').trim();
727
+ // Take first meaningful section (up to 20 lines to keep it concise)
728
+ const bodyLines = body.split('\n').slice(0, 20);
729
+ lines.push(bodyLines.join('\n'));
730
+ lines.push('');
731
+ }
732
+ lines.push('</details>');
733
+ }
734
+
735
+ return lines.join('\n');
736
+ }
737
+
738
+ // ─── Risk Detection Engine ─────────────────────────────────────────────────
739
+
740
+ /**
741
+ * Detect files with large changes (>100 lines changed).
742
+ * Excludes moved/renamed files (misleading line counts).
743
+ *
744
+ * @param {string} repoName - Repository name
745
+ * @param {Array} files - Files array from gatherRepoDiff()
746
+ * @returns {Array} Risk flag objects of type 'large_change'
747
+ */
748
+ function detectLargeChanges(repoName, files) {
749
+ const flags = [];
750
+ for (const f of files) {
751
+ if (f.status === 'moved') continue; // exclude renames — misleading line counts
752
+ const total = f.insertions + f.deletions;
753
+ if (total > 100) {
754
+ flags.push({
755
+ type: 'large_change',
756
+ repo: repoName,
757
+ text: `${repoName}: ${f.path} (+${f.insertions} -${f.deletions}, ${total} total)`,
758
+ });
759
+ }
760
+ }
761
+ return flags;
762
+ }
763
+
764
+ /**
765
+ * Detect dependency changes by parsing package.json diffs.
766
+ * Classifies changes as New, Updated, or Removed.
767
+ * Lock file changes do not trigger separate flags.
768
+ *
769
+ * @param {string} repoName - Repository name
770
+ * @param {string} repoPath - Absolute path to the repo
771
+ * @param {string} startSha - Start SHA for diff range
772
+ * @param {Array} files - Files array from gatherRepoDiff()
773
+ * @returns {Array} Risk flag objects of type 'dependency_change'
774
+ */
775
+ function detectDependencyChanges(repoName, repoPath, startSha, files) {
776
+ const flags = [];
777
+
778
+ // Only look at package.json changes (not lock files per CONTEXT.md)
779
+ const pkgFiles = files.filter(f =>
780
+ path.basename(f.path) === 'package.json' && f.status !== 'deleted'
781
+ );
782
+ if (pkgFiles.length === 0) return flags;
783
+
784
+ for (const pkgFile of pkgFiles) {
785
+ const diffOutput = gitCmd(repoPath, `diff ${startSha}..HEAD -- "${pkgFile.path}"`);
786
+ if (!diffOutput) continue;
787
+
788
+ const lines = diffOutput.split('\n');
789
+ // Track added and removed dependency lines
790
+ const added = []; // { name, version }
791
+ const removed = []; // { name, version }
792
+
793
+ // Simple heuristic: lines matching +"name": "version" or -"name": "version"
794
+ // within a dependencies-like section
795
+ const nonDepKeys = /^(name|version|description|main|module|types|scripts|repository|author|license|engines|private|type|files|bin|keywords|homepage|bugs|publishConfig|exports|workspaces|packageManager)$/;
796
+ for (const line of lines) {
797
+ const addMatch = line.match(/^\+\s*"([^"]+)"\s*:\s*"([^"]+)"/);
798
+ const remMatch = line.match(/^-\s*"([^"]+)"\s*:\s*"([^"]+)"/);
799
+ if (addMatch) {
800
+ const name = addMatch[1];
801
+ const ver = addMatch[2];
802
+ if (!nonDepKeys.test(name)) {
803
+ added.push({ name, version: ver });
804
+ }
805
+ }
806
+ if (remMatch) {
807
+ const name = remMatch[1];
808
+ const ver = remMatch[2];
809
+ if (!nonDepKeys.test(name)) {
810
+ removed.push({ name, version: ver });
811
+ }
812
+ }
813
+ }
814
+
815
+ // Classify: New (added but not removed), Updated (both added and removed), Removed (removed but not added)
816
+ const removedNames = new Set(removed.map(r => r.name));
817
+ const addedNames = new Set(added.map(a => a.name));
818
+
819
+ for (const dep of added) {
820
+ if (removedNames.has(dep.name)) {
821
+ // Updated — find old version
822
+ const old = removed.find(r => r.name === dep.name);
823
+ flags.push({
824
+ type: 'dependency_change',
825
+ repo: repoName,
826
+ text: `Updated: ${dep.name} ${old.version}->${dep.version}`,
827
+ });
828
+ } else {
829
+ // New
830
+ flags.push({
831
+ type: 'dependency_change',
832
+ repo: repoName,
833
+ text: `New: ${dep.name}@${dep.version}`,
834
+ });
835
+ }
836
+ }
837
+
838
+ for (const dep of removed) {
839
+ if (!addedNames.has(dep.name)) {
840
+ // Removed
841
+ flags.push({
842
+ type: 'dependency_change',
843
+ repo: repoName,
844
+ text: `Removed: ${dep.name}@${dep.version}`,
845
+ });
846
+ }
847
+ }
848
+ }
849
+
850
+ return flags;
851
+ }
852
+
853
+ /**
854
+ * Detect items marked as needing human review from VERIFICATION.md and UAT files.
855
+ *
856
+ * @param {string} planningRoot - Absolute path to planning root
857
+ * @param {string} projectRoot - Relative project root (e.g., "projects/gsd")
858
+ * @returns {Array} Risk flag objects of type 'human_review'
859
+ */
860
+ function detectHumanReviewNeeded(planningRoot, projectRoot) {
861
+ const flags = [];
862
+ if (!planningRoot || !projectRoot) return flags;
863
+
864
+ const phasesDir = path.join(planningRoot, projectRoot, 'phases');
865
+ if (!fs.existsSync(phasesDir)) return flags;
866
+
867
+ const phaseDirs = fs.readdirSync(phasesDir)
868
+ .filter(d => /^\d+/.test(d))
869
+ .sort();
870
+
871
+ for (const dir of phaseDirs) {
872
+ const phaseFullDir = path.join(phasesDir, dir);
873
+ const phaseNum = dir.match(/^(\d+)/)[1];
874
+
875
+ // Check VERIFICATION.md files
876
+ let dirEntries;
877
+ try { dirEntries = fs.readdirSync(phaseFullDir); } catch { continue; }
878
+ const verFiles = dirEntries.filter(f => f.endsWith('-VERIFICATION.md')).sort();
879
+
880
+ for (const vf of verFiles) {
881
+ const content = safeReadFile(path.join(phaseFullDir, vf));
882
+ if (!content) continue;
883
+
884
+ // Find lines with human_needed marker
885
+ const contentLines = content.split('\n');
886
+ for (const line of contentLines) {
887
+ if (/human_needed|human.review.needed|needs.human/i.test(line)) {
888
+ const cleaned = line.replace(/^[\s*-]+/, '').trim();
889
+ if (cleaned) {
890
+ flags.push({
891
+ type: 'human_review',
892
+ repo: '',
893
+ text: `Phase ${phaseNum}: ${cleaned}`,
894
+ });
895
+ }
896
+ }
897
+ }
898
+ }
899
+
900
+ // Also check UAT files
901
+ const uatFiles = dirEntries.filter(f => f.endsWith('-UAT.md')).sort();
902
+ for (const uf of uatFiles) {
903
+ const content = safeReadFile(path.join(phaseFullDir, uf));
904
+ if (!content) continue;
905
+
906
+ const contentLines = content.split('\n');
907
+ for (const line of contentLines) {
908
+ if (/human_needed|human.review.needed|needs.human/i.test(line)) {
909
+ const cleaned = line.replace(/^[\s*-]+/, '').trim();
910
+ if (cleaned) {
911
+ flags.push({
912
+ type: 'human_review',
913
+ repo: '',
914
+ text: `Phase ${phaseNum}: ${cleaned}`,
915
+ });
916
+ }
917
+ }
918
+ }
919
+ }
920
+ }
921
+
922
+ return flags;
923
+ }
924
+
925
+ /**
926
+ * Detect tech debt indicators: TODO/FIXME/HACK on newly introduced lines
927
+ * and deleted test files.
928
+ *
929
+ * @param {string} repoName - Repository name
930
+ * @param {string} repoPath - Absolute path to the repo
931
+ * @param {string} startSha - Start SHA for diff range
932
+ * @param {Array} files - Files array from gatherRepoDiff()
933
+ * @returns {Array} Risk flag objects of type 'tech_debt'
934
+ */
935
+ function detectTechDebt(repoName, repoPath, startSha, files) {
936
+ const flags = [];
937
+
938
+ // (a) TODO/FIXME/HACK on newly introduced lines
939
+ const diffOutput = gitCmd(repoPath, `diff ${startSha}..HEAD`);
940
+ if (diffOutput) {
941
+ const lines = diffOutput.split('\n');
942
+ let currentFile = null;
943
+ let lineNum = 0;
944
+
945
+ for (const line of lines) {
946
+ // Track current file from +++ b/path header
947
+ const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
948
+ if (fileMatch) {
949
+ currentFile = fileMatch[1];
950
+ continue;
951
+ }
952
+
953
+ // Track line number from @@ hunk header
954
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)/);
955
+ if (hunkMatch) {
956
+ lineNum = parseInt(hunkMatch[1], 10);
957
+ continue;
958
+ }
959
+
960
+ // Only look at added lines (start with + but not +++)
961
+ if (line.startsWith('+') && !line.startsWith('+++')) {
962
+ if (currentFile && /\b(TODO|FIXME|HACK)\b/i.test(line)) {
963
+ const markerMatch = line.match(/\b(TODO|FIXME|HACK)\b[:\s]*(.*)/i);
964
+ const markerText = markerMatch
965
+ ? `${markerMatch[1].toUpperCase()}: ${markerMatch[2].trim()}`.replace(/\s*\*\/\s*$/, '').trim()
966
+ : line.replace(/^\+\s*/, '').trim();
967
+ flags.push({
968
+ type: 'tech_debt',
969
+ repo: repoName,
970
+ text: `${repoName}: ${currentFile}:${lineNum} — ${markerText}`,
971
+ });
972
+ }
973
+ lineNum++;
974
+ } else if (!line.startsWith('-')) {
975
+ // Context line (no prefix) — increment line number
976
+ lineNum++;
977
+ }
978
+ // Removed lines (start with -) don't increment new-side line number
979
+ }
980
+ }
981
+
982
+ // (b) Test file deletions
983
+ const testPatterns = [/\.test\.[^.]+$/, /\.spec\.[^.]+$/, /^__tests__\//, /^test\//];
984
+ for (const f of files) {
985
+ if (f.status !== 'deleted') continue;
986
+ const isTest = testPatterns.some(p => p.test(f.path) || p.test(path.basename(f.path)));
987
+ if (isTest) {
988
+ flags.push({
989
+ type: 'tech_debt',
990
+ repo: repoName,
991
+ text: `${repoName}: ${f.path} — test file deleted`,
992
+ });
993
+ }
994
+ }
995
+
996
+ return flags;
997
+ }
998
+
999
+ /**
1000
+ * Orchestrate all four risk detectors and return a flat array of all risk flags.
1001
+ *
1002
+ * @param {Array} repoResults - Array of { name, startSha, diff, repoPath } objects
1003
+ * @param {Object} options - Report options (planningRoot, projectRoot)
1004
+ * @returns {Array} All risk flag objects
1005
+ */
1006
+ function collectRiskFlags(repoResults, options) {
1007
+ const allFlags = [];
1008
+
1009
+ for (const repo of repoResults) {
1010
+ const { name, startSha, diff, repoPath } = repo;
1011
+
1012
+ // RISK-01: Large changes
1013
+ allFlags.push(...detectLargeChanges(name, diff.files));
1014
+
1015
+ // RISK-02: Dependency changes
1016
+ if (repoPath) {
1017
+ allFlags.push(...detectDependencyChanges(name, repoPath, startSha, diff.files));
1018
+ }
1019
+
1020
+ // RISK-04: Tech debt
1021
+ if (repoPath) {
1022
+ allFlags.push(...detectTechDebt(name, repoPath, startSha, diff.files));
1023
+ }
1024
+ }
1025
+
1026
+ // RISK-03: Human review needed (project-level, not per-repo)
1027
+ // Skip for quick tasks — phase-level human_needed markers belong to milestones
1028
+ if (options.planningRoot && options.projectRoot && !options.isQuickTask) {
1029
+ allFlags.push(...detectHumanReviewNeeded(options.planningRoot, options.projectRoot));
1030
+ }
1031
+
1032
+ return allFlags;
1033
+ }
1034
+
1035
+ /**
1036
+ * Render risk flags as grouped markdown with summary count.
1037
+ * Empty categories are omitted. Returns empty string when no flags.
1038
+ *
1039
+ * @param {Array} flags - Array of risk flag objects
1040
+ * @returns {string} Rendered markdown for the Risk Flags section
1041
+ */
1042
+ function renderRiskFlags(flags) {
1043
+ if (flags.length === 0) return '';
1044
+
1045
+ const groups = {
1046
+ large_change: flags.filter(f => f.type === 'large_change'),
1047
+ dependency_change: flags.filter(f => f.type === 'dependency_change'),
1048
+ human_review: flags.filter(f => f.type === 'human_review'),
1049
+ tech_debt: flags.filter(f => f.type === 'tech_debt'),
1050
+ };
1051
+
1052
+ // Build summary count
1053
+ const counts = [];
1054
+ if (groups.large_change.length > 0) counts.push(`${groups.large_change.length} large changes`);
1055
+ if (groups.dependency_change.length > 0) counts.push(`${groups.dependency_change.length} dependency changes`);
1056
+ if (groups.human_review.length > 0) counts.push(`${groups.human_review.length} human review`);
1057
+ if (groups.tech_debt.length > 0) counts.push(`${groups.tech_debt.length} tech debt`);
1058
+
1059
+ const lines = [];
1060
+ lines.push(`**${flags.length} risk flags:** ${counts.join(', ')}`);
1061
+ lines.push('');
1062
+
1063
+ // Render each non-empty group
1064
+ if (groups.large_change.length > 0) {
1065
+ lines.push('### Large Changes');
1066
+ for (const f of groups.large_change) lines.push(`- ${f.text}`);
1067
+ lines.push('');
1068
+ }
1069
+
1070
+ if (groups.dependency_change.length > 0) {
1071
+ lines.push('### Dependency Changes');
1072
+ for (const f of groups.dependency_change) lines.push(`- ${f.text}`);
1073
+ lines.push('');
1074
+ }
1075
+
1076
+ if (groups.human_review.length > 0) {
1077
+ lines.push('### Human Review Needed');
1078
+ for (const f of groups.human_review) lines.push(`- ${f.text}`);
1079
+ lines.push('');
1080
+ }
1081
+
1082
+ if (groups.tech_debt.length > 0) {
1083
+ lines.push('### Tech Debt');
1084
+ for (const f of groups.tech_debt) lines.push(`- ${f.text}`);
1085
+ lines.push('');
1086
+ }
1087
+
1088
+ return lines.join('\n').trim();
1089
+ }
1090
+
1091
+ // ─── Detailed Mode Functions ────────────────────────────────────────────────
1092
+
1093
+ /**
1094
+ * Analyze diff hunks for all files in a repo using an LLM.
1095
+ * Batches all files into one LLM call per repo for coherent cross-file summaries.
1096
+ *
1097
+ * @param {string} repoPath - Absolute path to the repo
1098
+ * @param {Array} files - Array of file objects with { path, status, insertions, deletions }
1099
+ * @param {string} startSha - Start SHA for diff range
1100
+ * @param {string} model - Model name to use (from resolveModelInternal)
1101
+ * @returns {{ success: boolean, summaries: Object|null, error: string|null }}
1102
+ * summaries maps filePath -> summary string
1103
+ */
1104
+ function analyzeRepoDiffs(repoPath, files, startSha, model) {
1105
+ // 1. Collect diff hunks for all files
1106
+ let diffContent = '';
1107
+ for (const file of files) {
1108
+ if (file.status === 'deleted') continue; // no diff hunks for deleted files
1109
+ try {
1110
+ const fileDiff = execSync(
1111
+ 'git diff ' + startSha + '..HEAD -- "' + file.path + '"',
1112
+ { cwd: repoPath, stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 }
1113
+ ).toString();
1114
+ if (fileDiff.trim()) {
1115
+ diffContent += '\n--- File: ' + file.path + ' (' + file.status + ', +' + file.insertions + ' -' + file.deletions + ') ---\n';
1116
+ diffContent += fileDiff + '\n';
1117
+ }
1118
+ } catch {
1119
+ // Skip files where diff fails
1120
+ }
1121
+ }
1122
+
1123
+ if (!diffContent.trim()) {
1124
+ return { success: true, summaries: {}, error: null };
1125
+ }
1126
+
1127
+ // 2. Check size limit — fall back if too large
1128
+ if (diffContent.length > 80000) {
1129
+ return { success: false, summaries: null, error: 'Diff too large (' + diffContent.length + ' chars)' };
1130
+ }
1131
+
1132
+ // 3. Build LLM prompt
1133
+ const prompt = 'You are a code review analyst. For each file in the diff below, write a ONE sentence summary explaining what changed and why it matters (purpose + impact). Format your response as one line per file:\nFILE: path/to/file — Your summary here\n\nOnly include files that appear in the diff. Do not add commentary or headers.\n\n' + diffContent;
1134
+
1135
+ // 4. Invoke LLM via claude CLI (use input option to pipe prompt via stdin — safe for large/complex prompts)
1136
+ try {
1137
+ const modelArgs = model === 'inherit' ? '' : ' --model ' + model;
1138
+ const result = execSync(
1139
+ 'claude --print' + modelArgs,
1140
+ { input: prompt, cwd: repoPath, stdio: ['pipe', 'pipe', 'pipe'], timeout: 120000, maxBuffer: 10 * 1024 * 1024 }
1141
+ ).toString().trim();
1142
+
1143
+ // 5. Parse response — expect lines like: FILE: path/to/file — summary
1144
+ const summaries = {};
1145
+ const lines = result.split('\n');
1146
+ for (const line of lines) {
1147
+ const match = line.match(/^FILE:\s*(.+?)\s*[—\-]\s*(.+)$/);
1148
+ if (match) {
1149
+ summaries[match[1].trim()] = match[2].trim();
1150
+ }
1151
+ }
1152
+
1153
+ return { success: true, summaries, error: null };
1154
+ } catch (e) {
1155
+ return { success: false, summaries: null, error: e.message || 'LLM call failed' };
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Generate a cross-repo overall narrative from all per-file summaries.
1161
+ *
1162
+ * @param {Object} allSummaries - Map of repoName -> { filePath -> summary }
1163
+ * @param {string} goalText - The report's goal/purpose text
1164
+ * @param {string} model - Model name to use
1165
+ * @returns {{ success: boolean, narrative: string|null, error: string|null }}
1166
+ */
1167
+ function generateDetailedNarrative(allSummaries, goalText, model) {
1168
+ // Build context from all summaries
1169
+ let summaryText = '';
1170
+ for (const [repoName, fileSummaries] of Object.entries(allSummaries)) {
1171
+ summaryText += '\n## ' + repoName + '\n';
1172
+ for (const [filePath, summary] of Object.entries(fileSummaries)) {
1173
+ summaryText += '- ' + filePath + ' \u2014 ' + summary + '\n';
1174
+ }
1175
+ }
1176
+
1177
+ if (!summaryText.trim()) {
1178
+ return { success: true, narrative: null, error: null };
1179
+ }
1180
+
1181
+ const prompt = 'You are a code review analyst. Based on the per-file change summaries below, write a 2-3 sentence overall narrative explaining the architectural shift or key outcome of these changes. Be specific about what was achieved, not just what was touched.\n\nGoal: ' + (goalText || 'Not specified') + '\n\nFile changes:\n' + summaryText + '\n\nRespond with ONLY the narrative text, no headers or formatting.';
1182
+
1183
+ try {
1184
+ const modelArgs = model === 'inherit' ? '' : ' --model ' + model;
1185
+ const result = execSync(
1186
+ 'claude --print' + modelArgs,
1187
+ { input: prompt, cwd: process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], timeout: 120000, maxBuffer: 10 * 1024 * 1024 }
1188
+ ).toString().trim();
1189
+
1190
+ return { success: true, narrative: result, error: null };
1191
+ } catch (e) {
1192
+ return { success: false, narrative: null, error: e.message || 'Narrative LLM call failed' };
1193
+ }
1194
+ }
1195
+
1196
+ // ─── Main Function ──────────────────────────────────────────────────────────
1197
+
1198
+ /**
1199
+ * Generate a diff report from StartShas for all registered repos.
1200
+ *
1201
+ * Runs git diff --stat, git log --oneline, git diff --name-status per repo.
1202
+ * Reads the REVIEW.md template and fills placeholders. Sections with no
1203
+ * content are omitted entirely (header + placeholder removed).
1204
+ *
1205
+ * Default mode uses git stats only. Detailed mode invokes LLM for per-file summaries and narrative.
1206
+ *
1207
+ * @param {string} cwd - Working directory (planning root context)
1208
+ * @param {Object} startShas - Map of repo name to start SHA
1209
+ * (e.g., { "_planning": "abc123", "deliver-great-systems": "def456" })
1210
+ * @param {Object} options - Report options
1211
+ * @param {string} options.outputPath - Where to write the REVIEW.md file
1212
+ * @param {string} options.title - Report title (milestone name or quick task name)
1213
+ * @param {string} [options.mode="default"] - "default" (no LLM) or "detailed" (LLM analysis)
1214
+ * @param {string|null} [options.goal] - Goal text (populated by phase 140)
1215
+ * @param {string|null} [options.whatWasBuilt] - What Was Built text (populated by phase 140)
1216
+ * @param {string|null} [options.verification] - Verification text (populated by phase 140)
1217
+ * @param {string|null} [options.riskFlags] - Risk flags text (populated by phase 142)
1218
+ * @param {string|null} [options.overall] - Overall narrative (populated by phase 140)
1219
+ * @param {string|null} [options.planningRoot] - Absolute path to planning root (enables content extraction)
1220
+ * @param {string|null} [options.projectRoot] - Relative project root path (e.g., "projects/gsd")
1221
+ * @param {string|null} [options.milestoneVersion] - Milestone version (e.g., "v22.0") for ROADMAP goal extraction
1222
+ * @param {boolean} [options.isQuickTask=false] - If true, use quick task content extraction logic
1223
+ * @param {string|null} [options.quickTaskDescription] - Quick task description (used as Goal for quick tasks)
1224
+ * @param {string|null} [options.quickTaskRepoPath] - Repo path for quick task commit extraction
1225
+ * @param {string|null} [options.quickTaskStartSha] - Start SHA for quick task commit extraction
1226
+ * @returns {Object} Result with stats on success, or reason on failure
1227
+ */
1228
+ function generateDiffReport(cwd, startShas, options) {
1229
+ // Validate inputs
1230
+ if (!startShas || typeof startShas !== 'object' || Object.keys(startShas).length === 0) {
1231
+ return { generated: false, reason: 'No start SHAs provided' };
1232
+ }
1233
+
1234
+ if (!options || !options.outputPath) {
1235
+ return { generated: false, reason: 'No outputPath specified in options' };
1236
+ }
1237
+
1238
+ const warnings = [];
1239
+ const planningRoot = getPlanningRoot(cwd);
1240
+ // Repo paths may be supplied by the caller (e.g. active worktree paths for
1241
+ // pre-merge milestone/quick reviews). Fall back to REPOS.md main paths when
1242
+ // no override is provided — that's correct for post-merge milestone reviews
1243
+ // where the worktree has been cleaned up.
1244
+ const repoMap = (options.repoPaths && typeof options.repoPaths === 'object')
1245
+ ? options.repoPaths
1246
+ : resolveRepoPathsFromReposMd(planningRoot);
1247
+
1248
+ // Gather diff data for each repo in startShas (skip _planning key)
1249
+ const repoResults = [];
1250
+ const totals = { commits: 0, filesChanged: 0, insertions: 0, deletions: 0 };
1251
+ let repoCount = 0;
1252
+
1253
+ for (const [repoName, startSha] of Object.entries(startShas)) {
1254
+ if (repoName === '_planning') continue;
1255
+
1256
+ const repoPath = repoMap[repoName];
1257
+ if (!repoPath) {
1258
+ warnings.push(`Repo '${repoName}' not found in REPOS.md — skipped`);
1259
+ continue;
1260
+ }
1261
+
1262
+ if (!fs.existsSync(repoPath)) {
1263
+ warnings.push(`Repo path does not exist: ${repoPath} — skipped`);
1264
+ continue;
1265
+ }
1266
+
1267
+ try {
1268
+ const diff = gatherRepoDiff(repoPath, startSha);
1269
+ repoResults.push({ name: repoName, startSha, diff, repoPath });
1270
+
1271
+ totals.commits += diff.commitCount;
1272
+ totals.filesChanged += diff.stats.filesChanged;
1273
+ totals.insertions += diff.stats.insertions;
1274
+ totals.deletions += diff.stats.deletions;
1275
+
1276
+ if (diff.files.length > 0) {
1277
+ repoCount++;
1278
+ }
1279
+ } catch (err) {
1280
+ warnings.push(`Failed to gather diff for '${repoName}': ${err.message}`);
1281
+ }
1282
+ }
1283
+
1284
+ // ── Auto-populate outcome sections from project artifacts ──
1285
+ // Only when planningRoot and projectRoot are provided (milestone context)
1286
+ if (options.planningRoot && options.projectRoot) {
1287
+ const pr = options.planningRoot;
1288
+ const projRoot = options.projectRoot;
1289
+
1290
+ // Goal section (RCNT-01)
1291
+ if (!options.goal && options.milestoneVersion) {
1292
+ options.goal = extractGoalFromRoadmap(pr, projRoot, options.milestoneVersion);
1293
+ }
1294
+
1295
+ // What Was Built section (RCNT-02)
1296
+ if (!options.whatWasBuilt) {
1297
+ if (options.isQuickTask && options.quickTaskRepoPath && options.quickTaskStartSha) {
1298
+ options.whatWasBuilt = extractWhatWasBuiltFromCommits(options.quickTaskRepoPath, options.quickTaskStartSha);
1299
+ } else {
1300
+ const roadmapPath = path.join(pr, projRoot, 'ROADMAP.md');
1301
+ const roadmapContent = safeReadFile(roadmapPath);
1302
+ options.whatWasBuilt = extractWhatWasBuiltFromSummaries(pr, projRoot, roadmapContent);
1303
+ }
1304
+ }
1305
+
1306
+ // Overall narrative (RCNT-05)
1307
+ if (!options.overall) {
1308
+ options.overall = buildOverallNarrative(options, totals, repoCount, repoResults);
1309
+ }
1310
+
1311
+ // Verification section (RCNT-06) — skip for quick tasks (they don't belong to milestone phases)
1312
+ if (!options.verification && !options.isQuickTask) {
1313
+ options.verification = extractVerificationData(pr, projRoot);
1314
+ }
1315
+ }
1316
+
1317
+ // Quick task Goal fallback (RCNT-01)
1318
+ if (!options.goal && options.isQuickTask && options.quickTaskDescription) {
1319
+ options.goal = options.quickTaskDescription;
1320
+ }
1321
+
1322
+ // ── Risk flag detection (RISK-01 through RISK-04) ──
1323
+ if (!options.riskFlags) {
1324
+ const riskFlags = collectRiskFlags(repoResults, options);
1325
+ if (riskFlags.length > 0) {
1326
+ options.riskFlags = renderRiskFlags(riskFlags);
1327
+ options._riskFlagCount = riskFlags.length;
1328
+ }
1329
+ }
1330
+
1331
+ // ── Detailed mode: LLM analysis ──
1332
+ if (options.mode === 'detailed') {
1333
+ const { resolveModelInternal } = require('./core.cjs');
1334
+ const model = resolveModelInternal(cwd, 'dgs-review-analyst');
1335
+ const allSummaries = {};
1336
+ let detailedFailed = false;
1337
+
1338
+ for (const repoData of repoResults) {
1339
+ if (!repoData.diff.files || repoData.diff.files.length === 0) continue;
1340
+
1341
+ // Exclude collapsed minor files from LLM analysis (Phase 146)
1342
+ let filesToAnalyze = repoData.diff.files;
1343
+ if (repoData.diff.files.length > COLLAPSE_FILE_THRESHOLD) {
1344
+ filesToAnalyze = repoData.diff.files.filter(f => {
1345
+ // Config files are never collapsed — always analyze
1346
+ if (isConfigFile(f.path)) return true;
1347
+ // Minor files (< threshold) are collapsed — skip LLM analysis
1348
+ return (f.insertions + f.deletions) >= COLLAPSE_LINE_THRESHOLD;
1349
+ });
1350
+ }
1351
+
1352
+ const analysis = analyzeRepoDiffs(
1353
+ repoData.repoPath, filesToAnalyze, repoData.startSha, model
1354
+ );
1355
+
1356
+ if (analysis.success && analysis.summaries) {
1357
+ allSummaries[repoData.name] = analysis.summaries;
1358
+ // Replace file descriptions with LLM summaries
1359
+ for (const file of repoData.diff.files) {
1360
+ if (analysis.summaries[file.path]) {
1361
+ file.description = analysis.summaries[file.path];
1362
+ }
1363
+ }
1364
+ } else if (analysis.error && analysis.error.startsWith('Diff too large')) {
1365
+ // Per-repo fallback — keep default descriptions for this repo
1366
+ warnings.push('Detailed analysis skipped for ' + repoData.name + ' \u2014 diff too large');
1367
+ } else {
1368
+ // LLM failure — fall back to default mode for entire report
1369
+ detailedFailed = true;
1370
+ warnings.push('Detailed mode failed \u2014 falling back to default mode');
1371
+ break;
1372
+ }
1373
+ }
1374
+
1375
+ // Generate overall narrative (only if per-file analysis succeeded)
1376
+ if (!detailedFailed && Object.keys(allSummaries).length > 0) {
1377
+ const goalText = options.goal || '';
1378
+ const narrativeResult = generateDetailedNarrative(
1379
+ allSummaries, goalText, model
1380
+ );
1381
+ if (narrativeResult.success && narrativeResult.narrative) {
1382
+ // Replace the overall section content with LLM narrative
1383
+ options.overall = narrativeResult.narrative;
1384
+ } else if (!narrativeResult.success) {
1385
+ // Narrative failure is non-fatal — keep existing overall content
1386
+ warnings.push('Detailed narrative generation failed \u2014 using default overall');
1387
+ }
1388
+ }
1389
+
1390
+ if (detailedFailed) {
1391
+ // Reset mode to default so template shows correct mode
1392
+ options.mode = 'default';
1393
+ }
1394
+ }
1395
+
1396
+ // Read template
1397
+ const templatePath = path.join(__dirname, '../../templates/REVIEW.md');
1398
+ const template = safeReadFile(templatePath);
1399
+ if (!template) {
1400
+ return { generated: false, reason: `Template not found at ${templatePath}` };
1401
+ }
1402
+
1403
+ // Build content
1404
+ const statsBanner = buildStatsBanner(totals, repoCount, options._riskFlagCount || 0);
1405
+ const codeChanges = renderCodeChanges(repoResults);
1406
+ const aggregateStats = renderAggregateStats(repoResults, totals);
1407
+ if (aggregateStats) {
1408
+ options.aggregateStats = aggregateStats;
1409
+ }
1410
+ const mode = options.mode || 'default';
1411
+ const date = new Date().toISOString().split('T')[0];
1412
+
1413
+ // Fill template
1414
+ let rendered = template;
1415
+ rendered = rendered.replace('{title}', options.title || 'Untitled');
1416
+ rendered = rendered.replace('{stats_banner}', statsBanner);
1417
+ rendered = rendered.replace('{date}', date);
1418
+ rendered = rendered.replace('{mode}', mode);
1419
+
1420
+ // Code Changes — always fill (may be empty string if no changes)
1421
+ if (codeChanges.trim()) {
1422
+ rendered = rendered.replace('{code_changes}', codeChanges);
1423
+ } else {
1424
+ rendered = removeSection(rendered, '## Code Changes', '{code_changes}');
1425
+ }
1426
+
1427
+ // Optional sections — omit entirely when null/undefined/empty
1428
+ const optionalSections = [
1429
+ { key: 'goal', header: '## Goal', placeholder: '{goal}' },
1430
+ { key: 'whatWasBuilt', header: '## What Was Built', placeholder: '{what_was_built}' },
1431
+ { key: 'aggregateStats', header: '## Aggregate Statistics', placeholder: '{aggregate_stats}' },
1432
+ { key: 'verification', header: '## Verification', placeholder: '{verification}' },
1433
+ { key: 'riskFlags', header: '## Risk Flags', placeholder: '{risk_flags}' },
1434
+ { key: 'overall', header: '## Overall', placeholder: '{overall}' },
1435
+ ];
1436
+
1437
+ for (const section of optionalSections) {
1438
+ const value = options[section.key];
1439
+ if (value && String(value).trim()) {
1440
+ rendered = rendered.replace(section.placeholder, String(value));
1441
+ } else {
1442
+ rendered = removeSection(rendered, section.header, section.placeholder);
1443
+ }
1444
+ }
1445
+
1446
+ // Write output
1447
+ const outputDir = path.dirname(options.outputPath);
1448
+ if (!fs.existsSync(outputDir)) {
1449
+ fs.mkdirSync(outputDir, { recursive: true });
1450
+ }
1451
+ fs.writeFileSync(options.outputPath, rendered, 'utf-8');
1452
+
1453
+ return {
1454
+ generated: true,
1455
+ path: options.outputPath,
1456
+ warnings,
1457
+ stats: {
1458
+ repos: repoCount,
1459
+ commits: totals.commits,
1460
+ filesChanged: totals.filesChanged,
1461
+ insertions: totals.insertions,
1462
+ deletions: totals.deletions,
1463
+ },
1464
+ };
1465
+ }
1466
+
1467
+ // ─── Job File Integration ───────────────────────────────────────────────────
1468
+
1469
+ /**
1470
+ * Generate a diff report from a job file's StartShas.
1471
+ * Convenience wrapper: reads the job file, extracts StartShas,
1472
+ * auto-detects project context, calls generateDiffReport.
1473
+ *
1474
+ * @param {string} cwd - Working directory
1475
+ * @param {string} jobFilePath - Absolute path to the job file
1476
+ * @param {Object} options - Same options as generateDiffReport (outputPath, title, mode, etc.)
1477
+ * Project context (planningRoot, projectRoot, milestoneVersion) is auto-detected
1478
+ * from the job file and planning root if not already set in options.
1479
+ * @returns {Object} Same return as generateDiffReport
1480
+ */
1481
+ function generateDiffReportFromJob(cwd, jobFilePath, options) {
1482
+ const { parseJobFile } = require('./jobs.cjs');
1483
+ const parsed = parseJobFile(jobFilePath);
1484
+ if (!parsed.startShas) {
1485
+ return { generated: false, reason: 'Job file has no StartShas recorded' };
1486
+ }
1487
+
1488
+ // Auto-detect project context for content extraction
1489
+ const planningRoot = getPlanningRoot(cwd);
1490
+ if (planningRoot && !options.planningRoot) {
1491
+ options.planningRoot = planningRoot;
1492
+ }
1493
+
1494
+ // Detect project root from job file path (jobs live under projects/{name}/)
1495
+ if (options.planningRoot && !options.projectRoot) {
1496
+ const relPath = path.relative(options.planningRoot, jobFilePath);
1497
+ const projectMatch = relPath.match(/^(projects\/[^/]+)\//);
1498
+ if (projectMatch) {
1499
+ options.projectRoot = projectMatch[1];
1500
+ } else {
1501
+ // Flat-layout fallback: job lives at jobs/*.md (not under projects/)
1502
+ // but project content still lives under projects/<current_project>/.
1503
+ // Read current_project from config.local.json and verify the dir exists.
1504
+ const localConfigPath = path.join(options.planningRoot, 'config.local.json');
1505
+ if (fs.existsSync(localConfigPath)) {
1506
+ try {
1507
+ const local = JSON.parse(fs.readFileSync(localConfigPath, 'utf-8'));
1508
+ const cp = local && local.current_project;
1509
+ if (cp && fs.existsSync(path.join(options.planningRoot, 'projects', cp))) {
1510
+ options.projectRoot = 'projects/' + cp;
1511
+ }
1512
+ } catch {
1513
+ // Malformed config.local.json — leave projectRoot undefined
1514
+ }
1515
+ }
1516
+ }
1517
+ }
1518
+
1519
+ // Extract milestone version from job file parsed data
1520
+ if (!options.milestoneVersion && parsed.version) {
1521
+ options.milestoneVersion = parsed.version;
1522
+ }
1523
+
1524
+ // Before the milestone worktree is merged back to main, the commits live
1525
+ // only on the milestone branch inside the worktree — main has nothing.
1526
+ // Prefer the active milestone worktree paths when one is registered;
1527
+ // fall back to REPOS.md main paths otherwise (post-merge case where the
1528
+ // worktree has been cleaned up).
1529
+ if (!options.repoPaths && options.planningRoot) {
1530
+ const milestoneRepos = getActiveMilestoneWorktreeRepos(options.planningRoot);
1531
+ if (milestoneRepos) {
1532
+ options.repoPaths = milestoneRepos;
1533
+ }
1534
+ }
1535
+
1536
+ return generateDiffReport(cwd, parsed.startShas, options);
1537
+ }
1538
+
1539
+ // ─── CLI Commands (stubs) ───────────────────────────────────────────────────
1540
+
1541
+ /**
1542
+ * CLI command: Generate a review report for a milestone job.
1543
+ * Usage: dgs-tools jobs generate-review [version] [--detailed]
1544
+ *
1545
+ * Auto-detects current milestone from active job file if no version specified.
1546
+ * Finds job file, calls generateDiffReportFromJob, auto-commits the result.
1547
+ *
1548
+ * @param {string} cwd - Working directory
1549
+ * @param {string|undefined} version - Milestone version (e.g., "v22.0"), or undefined for auto-detect
1550
+ * @param {boolean} raw - If true, output JSON instead of formatted text
1551
+ * @param {boolean} [detailed=false] - If true, use LLM-powered detailed analysis mode
1552
+ */
1553
+ function cmdJobsGenerateReview(cwd, version, raw, detailed) {
1554
+ const { findJobFile, listJobs } = require('./jobs.cjs');
1555
+
1556
+ // Auto-detect version from active job if not provided
1557
+ let targetVersion = version;
1558
+ if (!targetVersion) {
1559
+ try {
1560
+ const jobList = listJobs(cwd);
1561
+ const activeJobs = jobList.in_progress || [];
1562
+ if (activeJobs.length > 0) {
1563
+ targetVersion = activeJobs[0].version;
1564
+ } else {
1565
+ const completedJobs = jobList.completed || [];
1566
+ if (completedJobs.length > 0) {
1567
+ targetVersion = completedJobs[0].version;
1568
+ }
1569
+ }
1570
+ } catch (_) {
1571
+ // listJobs may fail if no jobs directory exists
1572
+ }
1573
+ if (!targetVersion) {
1574
+ error('No version specified and no active job found. Usage: jobs generate-review [version]');
1575
+ }
1576
+ }
1577
+
1578
+ // Find the job file
1579
+ let jobFile;
1580
+ try {
1581
+ jobFile = findJobFile(cwd, targetVersion);
1582
+ } catch (_) {
1583
+ error(`No job file found for ${targetVersion}. Run a milestone job first.`);
1584
+ }
1585
+
1586
+ // Generate the report
1587
+ const planningRoot = getPlanningRoot(cwd);
1588
+ const milestoneDir = path.join(planningRoot, 'milestones');
1589
+ if (!fs.existsSync(milestoneDir)) {
1590
+ fs.mkdirSync(milestoneDir, { recursive: true });
1591
+ }
1592
+ const outputPath = path.join(milestoneDir, `${targetVersion}-REVIEW.md`);
1593
+
1594
+ const result = generateDiffReportFromJob(cwd, jobFile.path, {
1595
+ outputPath,
1596
+ title: `${targetVersion} Milestone Review`,
1597
+ mode: detailed ? 'detailed' : 'default',
1598
+ });
1599
+
1600
+ if (!result.generated) {
1601
+ error(result.reason || 'Review generation failed');
1602
+ }
1603
+
1604
+ // Auto-commit the generated REVIEW.md
1605
+ try {
1606
+ const commitMsg = `docs(${targetVersion}): generate milestone review report`;
1607
+ execSync(
1608
+ `node "${path.join(__dirname, '..', 'dgs-tools.cjs')}" commit "${commitMsg}" --files "${result.path}"`,
1609
+ { cwd, stdio: 'pipe' }
1610
+ );
1611
+ } catch (_) {
1612
+ // Commit failure is non-fatal — report was still generated
1613
+ }
1614
+
1615
+ // Build output
1616
+ const stats = result.stats || {};
1617
+ const summaryLine = `Generated: ${path.relative(planningRoot, result.path)} (${stats.commits || 0} commits, ${stats.filesChanged || 0} files${stats.riskFlags ? `, ${stats.riskFlags} risk flags` : ''})`;
1618
+
1619
+ if (raw) {
1620
+ output({
1621
+ generated: true,
1622
+ filePath: result.path,
1623
+ relativePath: path.relative(planningRoot, result.path),
1624
+ version: targetVersion,
1625
+ stats: {
1626
+ commits: stats.commits || 0,
1627
+ filesChanged: stats.filesChanged || 0,
1628
+ insertions: stats.insertions || 0,
1629
+ deletions: stats.deletions || 0,
1630
+ },
1631
+ }, raw);
1632
+ } else {
1633
+ output({ message: summaryLine }, raw);
1634
+ }
1635
+ }
1636
+
1637
+ /**
1638
+ * CLI command: Generate a review for a quick task.
1639
+ * Usage: dgs-tools quick generate-review [slug] [--detailed]
1640
+ *
1641
+ * Auto-detects active quick task from local config if no slug specified.
1642
+ * Computes diff range via git merge-base instead of StartShas.
1643
+ * Fast-forwarded tasks (merge-base === HEAD) produce "No code changes detected".
1644
+ *
1645
+ * @param {string} cwd - Working directory
1646
+ * @param {string|undefined} slug - Quick task slug, or undefined for auto-detect
1647
+ * @param {boolean} raw - If true, output JSON instead of formatted text
1648
+ * @param {boolean} [detailed=false] - If true, use LLM-powered detailed analysis mode
1649
+ */
1650
+ function cmdQuickGenerateReview(cwd, slug, raw, detailed) {
1651
+ const { getActiveQuick } = require('./quick.cjs');
1652
+
1653
+ const planningRoot = getPlanningRoot(cwd);
1654
+ const config = loadConfig(cwd);
1655
+ const baseBranch = (config.git && config.git.base_branch) || config.base_branch || 'main';
1656
+
1657
+ // Resolve slug: use argument or auto-detect from active quick
1658
+ let targetSlug = slug;
1659
+ let repoMap = {};
1660
+
1661
+ if (targetSlug) {
1662
+ // If slug provided, find its repos from local config
1663
+ const active = getActiveQuick(cwd);
1664
+ if (active && active.slug === targetSlug) {
1665
+ repoMap = active.entry.repos || {};
1666
+ } else {
1667
+ error('Quick task \'' + targetSlug + '\' is not the active quick. Auto-detection only works for the active task.');
1668
+ }
1669
+ } else {
1670
+ const active = getActiveQuick(cwd);
1671
+ if (!active) {
1672
+ error('No active quick task found. Specify a slug: quick generate-review <slug>');
1673
+ }
1674
+ targetSlug = active.slug;
1675
+ repoMap = active.entry.repos || {};
1676
+ }
1677
+
1678
+ const repoNames = Object.keys(repoMap);
1679
+ if (repoNames.length === 0) {
1680
+ error('Quick task \'' + targetSlug + '\' has no repos tracked');
1681
+ }
1682
+
1683
+ // Compute merge-base for each repo and check for fast-forward
1684
+ const startShas = {};
1685
+ let totalCommits = 0;
1686
+ let fastForward = true;
1687
+ let primaryRepoPath = null;
1688
+ let primaryStartSha = null;
1689
+
1690
+ for (const repoName of repoNames) {
1691
+ const worktreePath = repoMap[repoName];
1692
+ const branchName = 'quick/' + targetSlug;
1693
+
1694
+ // Compute merge-base
1695
+ const mergeBase = gitCmd(worktreePath, 'merge-base ' + baseBranch + ' ' + branchName);
1696
+ if (!mergeBase || !mergeBase.trim()) {
1697
+ // Cannot compute merge-base — skip this repo
1698
+ continue;
1699
+ }
1700
+ const startSha = mergeBase.trim();
1701
+
1702
+ // Count commits on the branch beyond merge-base
1703
+ const commitCount = gitCmd(worktreePath, 'rev-list --count ' + startSha + '..' + branchName);
1704
+ const count = parseInt((commitCount || '0').trim(), 10) || 0;
1705
+ totalCommits += count;
1706
+
1707
+ if (count > 0) {
1708
+ fastForward = false;
1709
+ }
1710
+
1711
+ startShas[repoName] = startSha;
1712
+ if (!primaryRepoPath) {
1713
+ primaryRepoPath = worktreePath;
1714
+ primaryStartSha = startSha;
1715
+ }
1716
+ }
1717
+
1718
+ // Fast-forward detection (RGEN-05): merge-base equals HEAD means zero commits
1719
+ if (fastForward) {
1720
+ if (raw) {
1721
+ output({
1722
+ generated: false,
1723
+ slug: targetSlug,
1724
+ reason: 'No code changes detected',
1725
+ fastForward: true,
1726
+ }, raw);
1727
+ } else {
1728
+ output({ message: 'No code changes detected \u2014 review not generated.' }, raw);
1729
+ }
1730
+ return;
1731
+ }
1732
+
1733
+ // Generate the review report
1734
+ const quickDir = path.join(planningRoot, 'quick', targetSlug);
1735
+ if (!fs.existsSync(quickDir)) {
1736
+ fs.mkdirSync(quickDir, { recursive: true });
1737
+ }
1738
+ const outputPath = path.join(quickDir, 'REVIEW.md');
1739
+
1740
+ // Derive description from slug (convert hyphens to spaces)
1741
+ const description = targetSlug.replace(/-/g, ' ');
1742
+
1743
+ const result = generateDiffReport(cwd, startShas, {
1744
+ outputPath: outputPath,
1745
+ title: 'Quick Task: ' + targetSlug,
1746
+ mode: detailed ? 'detailed' : 'default',
1747
+ isQuickTask: true,
1748
+ quickTaskDescription: description,
1749
+ quickTaskRepoPath: primaryRepoPath,
1750
+ quickTaskStartSha: primaryStartSha,
1751
+ planningRoot: planningRoot,
1752
+ projectRoot: (config.current_project ? 'projects/' + config.current_project : null),
1753
+ // Pass the quick worktree paths through so gatherRepoDiff runs in the
1754
+ // worktree (where the quick/<slug> branch lives), not the main checkout
1755
+ // which hasn't seen the commits yet.
1756
+ repoPaths: repoMap,
1757
+ overall: null, // Omit overall narrative for quick tasks (Goal section is sufficient)
1758
+ });
1759
+
1760
+ if (!result.generated) {
1761
+ error(result.reason || 'Review generation failed for quick task \'' + targetSlug + '\'');
1762
+ }
1763
+
1764
+ // Auto-commit the generated REVIEW.md
1765
+ try {
1766
+ const commitMsg = 'docs(quick/' + targetSlug + '): generate quick task review report';
1767
+ execSync(
1768
+ 'node ' + JSON.stringify(path.join(__dirname, '..', 'dgs-tools.cjs')) + ' commit ' + JSON.stringify(commitMsg) + ' --files ' + JSON.stringify(result.path),
1769
+ { cwd: cwd, stdio: 'pipe' }
1770
+ );
1771
+ } catch (_) {
1772
+ // Commit failure is non-fatal — report was still generated
1773
+ }
1774
+
1775
+ // Build output — count risk flags from REVIEW.md content (since generateDiffReport
1776
+ // doesn't return risk flag count in stats)
1777
+ const stats = result.stats || {};
1778
+ let riskFlagCount = 0;
1779
+ try {
1780
+ const reviewContent = fs.readFileSync(result.path, 'utf-8');
1781
+ const riskMatch = reviewContent.match(/## Risk Flags/);
1782
+ if (riskMatch) {
1783
+ // Count "> **" markers in the risk flags section
1784
+ const riskSection = reviewContent.split('## Risk Flags')[1] || '';
1785
+ const nextSection = riskSection.indexOf('\n## ');
1786
+ const riskText = nextSection > -1 ? riskSection.substring(0, nextSection) : riskSection;
1787
+ const flags = riskText.match(/> \*\*/g);
1788
+ riskFlagCount = flags ? flags.length : 0;
1789
+ }
1790
+ } catch (_) {
1791
+ // Non-fatal — just report 0 flags
1792
+ }
1793
+ const summaryLine = 'Generated: quick/' + targetSlug + '/REVIEW.md (' + (stats.commits || 0) + ' commits, ' + (stats.filesChanged || 0) + ' files' + (riskFlagCount > 0 ? ', ' + riskFlagCount + ' risk flags' : '') + ')';
1794
+
1795
+ if (raw) {
1796
+ output({
1797
+ generated: true,
1798
+ slug: targetSlug,
1799
+ filePath: result.path,
1800
+ relativePath: 'quick/' + targetSlug + '/REVIEW.md',
1801
+ stats: {
1802
+ commits: stats.commits || 0,
1803
+ filesChanged: stats.filesChanged || 0,
1804
+ insertions: stats.insertions || 0,
1805
+ deletions: stats.deletions || 0,
1806
+ riskFlags: riskFlagCount,
1807
+ },
1808
+ }, raw);
1809
+ } else {
1810
+ output({ message: summaryLine }, raw);
1811
+ }
1812
+ }
1813
+
1814
+ // ─── Exports ────────────────────────────────────────────────────────────────
1815
+
1816
+ module.exports = {
1817
+ generateDiffReport,
1818
+ generateDiffReportFromJob,
1819
+ cmdJobsGenerateReview,
1820
+ cmdQuickGenerateReview,
1821
+ };