Implementeert vier open stories uit PBI 'Veilige Claude-agent-workflow': **Branch per story (cmon11tbe001zbortx35n155c)** - `resolveBranchForJob`: zoek sibling-job in dezelfde story; reuse z'n branch (1 PR per story i.p.v. per task). - Branch-naam: `feat/story-<8-char>` voor nieuwe stories. - `createWorktreeForJob` kent nu `reuseBranch=true`: detecteert stale sibling-worktree die de branch nog vasthoudt en verwijdert die eerst. - `attachWorktreeToJob` neemt `storyId` mee. **PR-hergebruik (zelfde story)** - `maybeCreateAutoPr`: als sibling-job in story al een pr_url heeft, hergebruik die zonder nieuwe `gh pr create`-call. PR-titel komt nu van de story (was task) zodat het als 'story-PR' leest. **Worktree-cleanup uitgesteld bij actieve siblings** - `cleanupWorktreeForTerminalStatus`: count active sibling-jobs in dezelfde story; defer als > 0 (volgende sub-task gebruikt branch). **Worktree-cleanup logging (cmon0jc14001ubortjxf2a2ck)** - Warning bij ontbrekende repoRoot, met productId + jobId in message. - Warning bij removeWorktreeForJob-failure met keepBranch in message. **resolveRepoRoot fallback (cmon0jc14001ubortjxf2a2ck)** - Convention-based fallback: `~/Projects/<repo-name>` afgeleid uit `product.repo_url` als noch env-var noch config-bestand iets oplevert. - `repoNameFromUrl` helper geëxporteerd voor herbruikbaarheid. **Verify EMPTY-detection edge-case (cmon0kdq6001xbort2kgbcqmr)** - `classifyDiffAgainstPlan`: na file-paths-check ook content-lines checken; als alle +/- regels alleen headers of whitespace zijn, return EMPTY met duidelijke reasoning. Tests: 120/120 groen (3 nieuwe), tsc clean, build clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
5.1 KiB
TypeScript
140 lines
5.1 KiB
TypeScript
export type VerifyResultValue = 'ALIGNED' | 'PARTIAL' | 'EMPTY' | 'DIVERGENT'
|
||
|
||
export interface ClassifyResult {
|
||
result: VerifyResultValue
|
||
reasoning: string
|
||
}
|
||
|
||
// Extract changed file paths from a unified diff ("+++ b/<path>" lines).
|
||
function extractDiffPaths(diff: string): string[] {
|
||
const paths = new Set<string>()
|
||
for (const line of diff.split('\n')) {
|
||
const m = line.match(/^\+\+\+ b\/(.+)$/)
|
||
if (m && m[1].trim() !== '/dev/null') paths.add(m[1].trim())
|
||
}
|
||
return [...paths]
|
||
}
|
||
|
||
// Extract file paths mentioned in a plan (backtick-quoted, parenthesised, or bullet-list headings).
|
||
function extractPlanPaths(plan: string): string[] {
|
||
const paths = new Set<string>()
|
||
|
||
const backtickRe = /`([^`\s][^`]*[^`\s]|[^`\s])`/g
|
||
let m: RegExpExecArray | null
|
||
while ((m = backtickRe.exec(plan)) !== null) {
|
||
const p = m[1].trim()
|
||
if ((p.includes('/') || p.includes('.')) && !p.includes(' ') && p.length > 3) paths.add(p)
|
||
}
|
||
|
||
const bulletRe = /^[-*]\s+\*{0,2}([^\s*][^\s]*)\.([a-zA-Z]{1,6})\*{0,2}\s*[:\n]/gm
|
||
while ((m = bulletRe.exec(plan)) !== null) {
|
||
paths.add(`${m[1]}.${m[2]}`)
|
||
}
|
||
|
||
return [...paths]
|
||
}
|
||
|
||
// Path match: exact or suffix match so "classify.ts" matches "src/verify/classify.ts".
|
||
function pathMatches(planPath: string, diffPaths: string[]): boolean {
|
||
const norm = planPath.replace(/\\/g, '/')
|
||
return diffPaths.some((dp) => {
|
||
const ndp = dp.replace(/\\/g, '/')
|
||
return ndp === norm || ndp.endsWith(`/${norm}`) || norm.endsWith(`/${ndp}`)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Classify a unified git diff against an implementation plan.
|
||
* Returns a VerifyResult (ALIGNED|PARTIAL|EMPTY|DIVERGENT) plus human-readable reasoning.
|
||
*
|
||
* v1 heuristic — no LLM required:
|
||
* - No file changes in diff → EMPTY
|
||
* - Plan empty, diff ≤50 changed lines → ALIGNED (targeted fix)
|
||
* - Plan empty, diff >50 changed lines → DIVERGENT (too large to trust)
|
||
* - Plan paths < 100% covered in diff → PARTIAL
|
||
* - Plan paths 100% covered, diff ≥3× more paths → DIVERGENT (scope creep)
|
||
* - Plan paths 100% covered, diff <3× more paths → ALIGNED
|
||
*/
|
||
export function classifyDiffAgainstPlan(opts: {
|
||
diff: string
|
||
plan: string | null
|
||
}): ClassifyResult {
|
||
const { diff, plan } = opts
|
||
|
||
const diffPaths = extractDiffPaths(diff)
|
||
if (diffPaths.length === 0) {
|
||
return { result: 'EMPTY', reasoning: 'Geen bestandswijzigingen in de diff.' }
|
||
}
|
||
|
||
// Whitespace-only / no-content edge case: paths are present but every +/-
|
||
// line is a diff header (---/+++) or whitespace-only. Treat as EMPTY so the
|
||
// gate rejects DONE for tasks that didn't really change anything.
|
||
const meaningfulChange = diff.split('\n').some((l) => {
|
||
if (!/^[+-]/.test(l)) return false
|
||
if (/^[+-]{3}\s/.test(l)) return false // diff header line (--- / +++)
|
||
return l.slice(1).trim().length > 0
|
||
})
|
||
if (!meaningfulChange) {
|
||
return {
|
||
result: 'EMPTY',
|
||
reasoning: 'Diff bevat alleen headers of whitespace — geen daadwerkelijke content-wijzigingen.',
|
||
}
|
||
}
|
||
|
||
const changedLines = diff.split('\n').filter((l) => l.startsWith('+') || l.startsWith('-')).length
|
||
|
||
if (!plan || plan.trim().length === 0) {
|
||
if (changedLines > 50) {
|
||
return {
|
||
result: 'DIVERGENT',
|
||
reasoning: `Geen plan-baseline aanwezig; ${changedLines} gewijzigde regels — te groot om als aligned te bestempelen.`,
|
||
}
|
||
}
|
||
return {
|
||
result: 'ALIGNED',
|
||
reasoning: `Geen plan-baseline; ${diffPaths.length} bestand(en) gewijzigd — kleine gerichte wijziging.`,
|
||
}
|
||
}
|
||
|
||
const planPaths = extractPlanPaths(plan)
|
||
|
||
if (planPaths.length === 0) {
|
||
if (changedLines > 50) {
|
||
return {
|
||
result: 'DIVERGENT',
|
||
reasoning: `Plan vermeldt geen specifieke paden; ${changedLines} gewijzigde regels — te groot om als aligned te bestempelen.`,
|
||
}
|
||
}
|
||
return {
|
||
result: 'ALIGNED',
|
||
reasoning: `Plan vermeldt geen specifieke paden; ${diffPaths.length} bestand(en) gewijzigd.`,
|
||
}
|
||
}
|
||
|
||
const covered = planPaths.filter((pp) => pathMatches(pp, diffPaths))
|
||
const coverage = covered.length / planPaths.length
|
||
const ratio = diffPaths.length / planPaths.length
|
||
|
||
if (coverage < 1) {
|
||
const missing = planPaths.filter((pp) => !pathMatches(pp, diffPaths))
|
||
const missingStr = missing.slice(0, 3).join(', ') + (missing.length > 3 ? ` + ${missing.length - 3} meer` : '')
|
||
return {
|
||
result: 'PARTIAL',
|
||
reasoning: `${covered.length}/${planPaths.length} plan-paden aanwezig in diff. Ontbrekend: ${missingStr}.`,
|
||
}
|
||
}
|
||
|
||
if (ratio >= 3) {
|
||
const extra = diffPaths.filter((dp) => !planPaths.some((pp) => pathMatches(pp, [dp])))
|
||
const extraStr = extra.slice(0, 3).join(', ') + (extra.length > 3 ? ` + ${extra.length - 3} meer` : '')
|
||
return {
|
||
result: 'DIVERGENT',
|
||
reasoning: `Alle ${planPaths.length} plan-paden aanwezig, maar diff bevat ${diffPaths.length} paden (${ratio.toFixed(1)}x). Extra: ${extraStr}.`,
|
||
}
|
||
}
|
||
|
||
return {
|
||
result: 'ALIGNED',
|
||
reasoning: `Alle ${planPaths.length} plan-paden aanwezig in diff (${diffPaths.length} totaal; ${ratio.toFixed(1)}x).`,
|
||
}
|
||
}
|