@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.
- package/CHANGELOG.md +197 -0
- package/README.md +34 -2
- package/agents/dgs-executor.md +124 -3
- package/agents/dgs-idea-researcher.md +447 -0
- package/agents/dgs-plan-checker.md +61 -3
- package/agents/dgs-planner.md +51 -8
- package/bin/install.js +44 -0
- package/commands/dgs/abandon-quick.md +28 -0
- package/commands/dgs/add-tests.md +2 -2
- package/commands/dgs/audit-milestone.md +4 -3
- package/commands/dgs/capture-principle.md +11 -11
- package/commands/dgs/cleanup.md +2 -2
- package/commands/dgs/complete-milestone.md +11 -11
- package/commands/dgs/complete-quick.md +28 -0
- package/commands/dgs/create-milestone-job.md +2 -2
- package/commands/dgs/debug.md +3 -3
- package/commands/dgs/develop-idea.md +1 -1
- package/commands/dgs/diff-report.md +124 -0
- package/commands/dgs/fast.md +3 -1
- package/commands/dgs/health.md +1 -1
- package/commands/dgs/map-codebase.md +6 -6
- package/commands/dgs/new-milestone.md +5 -5
- package/commands/dgs/new-project.md +8 -21
- package/commands/dgs/package-scan.md +43 -0
- package/commands/dgs/plan-milestone-gaps.md +1 -1
- package/commands/dgs/progress.md +3 -3
- package/commands/dgs/quick-abandon.md +8 -0
- package/commands/dgs/quick-complete.md +8 -0
- package/commands/dgs/quick.md +10 -3
- package/commands/dgs/research-idea.md +3 -2
- package/commands/dgs/research-phase.md +3 -3
- package/commands/dgs/switch-project.md +14 -1
- package/commands/dgs/write-spec.md +3 -3
- package/deliver-great-systems/bin/dgs-tools.cjs +401 -32
- package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
- package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
- package/deliver-great-systems/bin/lib/commands.cjs +626 -46
- package/deliver-great-systems/bin/lib/commands.test.cjs +451 -0
- package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
- package/deliver-great-systems/bin/lib/config.cjs +80 -6
- package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
- package/deliver-great-systems/bin/lib/context.cjs +120 -0
- package/deliver-great-systems/bin/lib/core.cjs +35 -14
- package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
- package/deliver-great-systems/bin/lib/execution.cjs +49 -17
- package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
- package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
- package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
- package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
- package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
- package/deliver-great-systems/bin/lib/governance.cjs +211 -0
- package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
- package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
- package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
- package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
- package/deliver-great-systems/bin/lib/init.cjs +357 -61
- package/deliver-great-systems/bin/lib/init.test.cjs +625 -8
- package/deliver-great-systems/bin/lib/jobs.cjs +131 -25
- package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
- package/deliver-great-systems/bin/lib/migration.cjs +409 -1
- package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
- package/deliver-great-systems/bin/lib/milestone.cjs +154 -31
- package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
- package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
- package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
- package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
- package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
- package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
- package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
- package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
- package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
- package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
- package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
- package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
- package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
- package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
- package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
- package/deliver-great-systems/bin/lib/phase.cjs +146 -3
- package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
- package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
- package/deliver-great-systems/bin/lib/projects.cjs +65 -10
- package/deliver-great-systems/bin/lib/projects.test.cjs +198 -2
- package/deliver-great-systems/bin/lib/quick.cjs +739 -0
- package/deliver-great-systems/bin/lib/quick.test.cjs +730 -0
- package/deliver-great-systems/bin/lib/repos.cjs +37 -13
- package/deliver-great-systems/bin/lib/review.cjs +1821 -0
- package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
- package/deliver-great-systems/bin/lib/specs.cjs +3 -81
- package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
- package/deliver-great-systems/bin/lib/state.cjs +147 -55
- package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
- package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
- package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
- package/deliver-great-systems/bin/lib/sync.cjs +75 -0
- package/deliver-great-systems/bin/lib/verify.cjs +198 -7
- package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
- package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
- package/deliver-great-systems/bin/lib/worktrees.cjs +790 -0
- package/deliver-great-systems/bin/lib/worktrees.test.cjs +963 -0
- package/deliver-great-systems/references/agent-step-reliability.md +60 -0
- package/deliver-great-systems/references/conflict-resolution.md +4 -0
- package/deliver-great-systems/references/context-tiers.md +4 -0
- package/deliver-great-systems/references/package-scan-config.md +151 -0
- package/deliver-great-systems/references/questioning.md +0 -30
- package/deliver-great-systems/references/spec-review-loop.md +1 -2
- package/deliver-great-systems/references/workflow-conventions.md +29 -0
- package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
- package/deliver-great-systems/templates/REVIEW.md +35 -0
- package/deliver-great-systems/templates/VALIDATION.md +1 -1
- package/deliver-great-systems/templates/claude-md.md +27 -0
- package/deliver-great-systems/templates/package-scan-report.md +108 -0
- package/deliver-great-systems/templates/project.md +6 -170
- package/deliver-great-systems/templates/summary.md +3 -1
- package/deliver-great-systems/workflows/abandon-quick.md +89 -0
- package/deliver-great-systems/workflows/add-idea.md +3 -3
- package/deliver-great-systems/workflows/add-phase.md +5 -0
- package/deliver-great-systems/workflows/add-tests.md +14 -0
- package/deliver-great-systems/workflows/add-todo.md +1 -0
- package/deliver-great-systems/workflows/approve-spec.md +25 -4
- package/deliver-great-systems/workflows/audit-milestone.md +66 -10
- package/deliver-great-systems/workflows/audit-phase.md +15 -5
- package/deliver-great-systems/workflows/cancel-job.md +2 -2
- package/deliver-great-systems/workflows/check-todos.md +2 -3
- package/deliver-great-systems/workflows/codereview.md +103 -9
- package/deliver-great-systems/workflows/complete-milestone.md +218 -24
- package/deliver-great-systems/workflows/complete-quick.md +106 -0
- package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
- package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
- package/deliver-great-systems/workflows/develop-idea.md +11 -11
- package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
- package/deliver-great-systems/workflows/discuss-idea.md +1 -1
- package/deliver-great-systems/workflows/discuss-phase.md +3 -2
- package/deliver-great-systems/workflows/execute-phase.md +209 -33
- package/deliver-great-systems/workflows/execute-plan.md +22 -22
- package/deliver-great-systems/workflows/help.md +53 -20
- package/deliver-great-systems/workflows/import-spec.md +65 -7
- package/deliver-great-systems/workflows/init-product.md +45 -167
- package/deliver-great-systems/workflows/new-milestone.md +140 -33
- package/deliver-great-systems/workflows/new-project.md +60 -331
- package/deliver-great-systems/workflows/package-scan.md +59 -0
- package/deliver-great-systems/workflows/plan-phase.md +79 -1
- package/deliver-great-systems/workflows/progress-all.md +133 -0
- package/deliver-great-systems/workflows/quick-abandon.md +89 -0
- package/deliver-great-systems/workflows/quick-complete.md +106 -0
- package/deliver-great-systems/workflows/quick.md +328 -26
- package/deliver-great-systems/workflows/refine-spec.md +1 -1
- package/deliver-great-systems/workflows/research-idea.md +77 -139
- package/deliver-great-systems/workflows/resume-project.md +2 -2
- package/deliver-great-systems/workflows/run-job.md +29 -43
- package/deliver-great-systems/workflows/settings.md +13 -77
- package/deliver-great-systems/workflows/validate-phase.md +39 -1
- package/deliver-great-systems/workflows/verify-work.md +14 -0
- package/deliver-great-systems/workflows/write-spec.md +11 -13
- package/hooks/dist/dgs-enforce-discipline.js +196 -0
- package/package.json +1 -1
- 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
|
+
};
|