From ec12de330c0da73f5b391c193f944dc8723c3f5f Mon Sep 17 00:00:00 2001 From: Fer Frau Roca Date: Mon, 4 May 2026 00:26:05 +0200 Subject: [PATCH] feat: render work candidate coding plans --- CHANGELOG.md | 2 + docs/work-lane.md | 11 ++- src/clawsweeper.ts | 170 ++++++++++++++++++++++++++++++++++++--- test/clawsweeper.test.ts | 127 ++++++++++++++++++++++++++++- 4 files changed, 297 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d6e3c432..9ac4a64eba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ checkpoint, and status-only commits are intentionally omitted. ### Added +- Added generated coding-plan artifacts for fresh `queue_fix_pr` work candidates + and linked them from the dashboard work-candidate tables. - Added a generated 1200x630 social preview card plus large-image Open Graph and Twitter metadata for the docs site. diff --git a/docs/work-lane.md b/docs/work-lane.md index 5001924240..553be71011 100644 --- a/docs/work-lane.md +++ b/docs/work-lane.md @@ -16,7 +16,16 @@ Reports store the lane fields in frontmatter: - `work_cluster_refs`, `work_validation`, and `work_likely_files` The dashboard shows fresh `queue_fix_pr` reports whose `work_status` is -`candidate`. This is a manual promotion queue for the repair lane. +`candidate`. This is a manual promotion queue for the repair lane. For each +fresh candidate, apply/reconcile also generates +`records//plans/.md` from the existing report fields. The +dashboard links both the source report and the generated coding plan so +maintainers can promote from a concise implementation view without editing the +durable report. + +Plan artifacts are generated state. They are removed when the item closes, +archives, becomes stale, or is reclassified away from `queue_fix_pr`; regenerate +them from the source report instead of editing them by hand. ## Reproducible Bug Auto-Implementation diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index 757cba29ec..cb0a91b89f 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -314,6 +314,7 @@ interface DashboardItem { action: string; reviewStatus: string; reportPath: string; + planPath?: string | undefined; workCandidate: string; workPriority: string; workStatus: string; @@ -822,6 +823,10 @@ function defaultClosedDir(profile = targetProfile()): string { return join(repoRecordsDir(profile), "closed"); } +function defaultPlansDir(profile = targetProfile()): string { + return join(repoRecordsDir(profile), "plans"); +} + function reportFileName(repo: string, number: number): string { repositoryProfileFor(repo); return `${number}.md`; @@ -4223,6 +4228,111 @@ function reportDecision(markdown: string, closeReason: CloseReason): Decision { }; } +function workPlanPathForReport(file: string, plansDir = defaultPlansDir()): string { + return join(plansDir, basename(file)); +} + +function shouldRenderWorkPlanFromReport(markdown: string): boolean { + return ( + frontMatterValue(markdown, "decision") === "keep_open" && + frontMatterValue(markdown, "action_taken") === "kept_open" && + frontMatterValue(markdown, "work_candidate") === "queue_fix_pr" && + frontMatterValue(markdown, "work_status") === "candidate" && + isFresh({ + reviewedAt: frontMatterValue(markdown, "reviewed_at"), + reviewStatus: effectiveReviewStatus(markdown), + }) + ); +} + +function formattedMarkdownList( + values: readonly string[], + formatter: (value: string) => string, +): string { + return values.length ? values.map((value) => `- ${formatter(value)}`).join("\n") : "- none"; +} + +function inlineCode(value: string): string { + return `\`${value.replaceAll("`", "\\`")}\``; +} + +export function renderWorkPlanFromReport( + markdown: string, + options: { reportPath?: string } = {}, +): string | null { + if (!shouldRenderWorkPlanFromReport(markdown)) return null; + const repo = markdownRepository(markdown); + const number = frontMatterValue(markdown, "number") ?? "unknown"; + const title = frontMatterValue(markdown, "title") ?? "Untitled"; + const reviewedAt = frontMatterValue(markdown, "reviewed_at") ?? "unknown"; + const workPrompt = reviewSectionValue(markdown, "repairWorkPrompt").trim(); + const likelyFiles = frontMatterStringArray(markdown, "work_likely_files"); + const validation = frontMatterStringArray(markdown, "work_validation"); + const clusterRefs = frontMatterStringArray(markdown, "work_cluster_refs"); + const reportPath = options.reportPath ?? "unknown"; + return `--- +number: ${number} +repository: ${repo} +title: ${JSON.stringify(title)} +source_report: ${reportPath} +reviewed_at: ${reviewedAt} +work_candidate: ${frontMatterValue(markdown, "work_candidate") ?? "none"} +work_priority: ${frontMatterValue(markdown, "work_priority") ?? "low"} +work_confidence: ${frontMatterValue(markdown, "work_confidence") ?? "low"} +--- + +# Coding Plan for ${repo}#${number}: ${title} + +Source report: ${reportPath === "unknown" ? "unknown" : markdownLink(reportPath, reportPath)} + +## Summary + +${reviewSectionValue(markdown, "summary") || "No summary provided."} + +## Plan + +${workPrompt || "No repair work prompt provided."} + +## Likely Files + +${formattedMarkdownList(likelyFiles, inlineCode)} + +## Validation + +${formattedMarkdownList(validation, inlineCode)} + +## Cluster References + +${formattedMarkdownList(clusterRefs, (value) => value)} + +## Notes + +- This file is generated dashboard state from the durable review report. +- Regenerate it from the source report instead of editing it by hand. +`; +} + +function syncWorkPlanFromReport(options: { + markdown: string; + reportPath: string; + plansDir: string; + dryRun?: boolean; +}): boolean { + const planPath = workPlanPathForReport(options.reportPath, options.plansDir); + const plan = renderWorkPlanFromReport(options.markdown, { + reportPath: repoRelativePath(options.reportPath), + }); + if (!plan) { + if (!options.dryRun && existsSync(planPath)) unlinkSync(planPath); + return false; + } + if (!options.dryRun) { + ensureDir(dirname(planPath)); + writeFileSync(planPath, plan, "utf8"); + } + return true; +} + function runtimeReviewText(runtime?: { model?: string | undefined; reasoningEffort?: string | undefined; @@ -5638,6 +5748,11 @@ function applyDecisionsCommand(args: Args): void { if (dryRun) return; ensureDir(closedDir); writeFileSync(path, nextMarkdown, "utf8"); + syncWorkPlanFromReport({ + markdown: nextMarkdown, + reportPath: path, + plansDir: defaultPlansDir(), + }); renameSync(path, join(closedDir, file)); }; const markApplySkipped = (actionTaken: ActionTaken, reason: string): boolean => { @@ -5968,6 +6083,7 @@ function applyArtifactsCommand(args: Args): void { const artifactDir = resolve(stringArg(args.artifact_dir, "artifacts")); const itemsDir = resolve(stringArg(args.items_dir, defaultItemsDir())); const closedDir = resolve(stringArg(args.closed_dir, defaultClosedDir())); + const plansDir = resolve(stringArg(args.plans_dir, defaultPlansDir())); const skipReconcile = boolArg(args.skip_reconcile); const replayClosedArtifacts = boolArg(args.replay_closed_artifacts); const maxPages = numberArg(args.max_pages, 250); @@ -6001,14 +6117,21 @@ function applyArtifactsCommand(args: Args): void { const destinationDir = destination === "closed" ? closedDir : itemsDir; const stalePath = join(destinationDir === itemsDir ? closedDir : itemsDir, destinationFile); if (existsSync(stalePath)) unlinkSync(stalePath); - writeFileSync(join(destinationDir, destinationFile), markdown, "utf8"); + const reportPath = join(destinationDir, destinationFile); + writeFileSync(reportPath, markdown, "utf8"); + if (destination === "closed") { + const planPath = workPlanPathForReport(reportPath, plansDir); + if (existsSync(planPath)) unlinkSync(planPath); + } else { + syncWorkPlanFromReport({ markdown, reportPath, plansDir }); + } appliedArtifacts += 1; } } console.error( `[apply-artifacts] applied=${appliedArtifacts} skipped_closed=${skippedClosedArtifacts}`, ); - if (!skipReconcile) reconcileFolders({ itemsDir, closedDir }); + if (!skipReconcile) reconcileFolders({ itemsDir, closedDir, plansDir }); } function artifactTargetIsOpen(number: number, openNumbers: Set | null): boolean { @@ -6411,6 +6534,7 @@ function moveMarkdownFile(options: { function reconcileFolders(options: { itemsDir: string; closedDir: string; + plansDir?: string; maxPages?: number; dryRun?: boolean; fetchClosedAt?: boolean; @@ -6418,6 +6542,7 @@ function reconcileFolders(options: { const maxPages = options.maxPages ?? 250; const dryRun = options.dryRun ?? false; const fetchClosedAt = options.fetchClosedAt ?? true; + const plansDir = options.plansDir ?? defaultPlansDir(); ensureDir(options.itemsDir); ensureDir(options.closedDir); const { numbers: openNumbers, pagesScanned } = fetchOpenItemNumbers(maxPages); @@ -6449,6 +6574,10 @@ function reconcileFolders(options: { } const markdown = markReconciledState(sourceMarkdown, "closed", { closedAt }); moveMarkdownFile({ sourcePath, destinationPath, markdown, dryRun }); + if (!dryRun) { + const planPath = workPlanPathForReport(sourcePath, plansDir); + if (existsSync(planPath)) unlinkSync(planPath); + } movedToClosed += 1; } @@ -6466,6 +6595,7 @@ function reconcileFolders(options: { } const markdown = markReconciledState(sourceMarkdown, "open"); moveMarkdownFile({ sourcePath, destinationPath, markdown, dryRun }); + syncWorkPlanFromReport({ markdown, reportPath: destinationPath, plansDir, dryRun }); movedToItems += 1; } @@ -6483,10 +6613,18 @@ function reconcileCommand(args: Args): void { repoFromArgs(args); const itemsDir = resolve(stringArg(args.items_dir, defaultItemsDir())); const closedDir = resolve(stringArg(args.closed_dir, defaultClosedDir())); + const plansDir = resolve(stringArg(args.plans_dir, defaultPlansDir())); const maxPages = numberArg(args.max_pages, 250); const dryRun = boolArg(args.dry_run); const fetchClosedAt = !boolArg(args.skip_closed_at); - const result = reconcileFolders({ itemsDir, closedDir, maxPages, dryRun, fetchClosedAt }); + const result = reconcileFolders({ + itemsDir, + closedDir, + plansDir, + maxPages, + dryRun, + fetchClosedAt, + }); console.log(JSON.stringify(result, null, 2)); } @@ -6546,6 +6684,7 @@ function dashboardStats( ): DashboardStats { const files = markdownFiles(itemsDir); const closedFiles = markdownFiles(closedDir); + const plansDir = defaultPlansDir(profile); const now = Date.now(); let fresh = 0; let proposedClose = 0; @@ -6616,6 +6755,9 @@ function dashboardStats( action, reviewStatus, reportPath: repoRelativePath(join(itemsDir, file)), + planPath: existsSync(join(plansDir, file)) + ? repoRelativePath(join(plansDir, file)) + : undefined, workCandidate, workPriority, workStatus, @@ -6824,9 +6966,12 @@ function formatWorkQueueRows(items: readonly DashboardItem[], limit = 10): strin const repo = item.repo ?? targetRepo(); const title = markdownTableCell(displayTitle(item.title)); const report = markdownLink(item.reportPath, reportFileUrl(item.number, item.reportPath)); - return `| ${markdownLink(`#${item.number}`, itemUrlFor(repo, item.number, item.kind))} | ${title} | ${item.workPriority} | ${item.workStatus} | ${formatTimestamp(item.reviewedAt)} | ${report} |`; + const plan = item.planPath + ? markdownLink(item.planPath, reportFileUrl(item.number, item.planPath)) + : "_pending_"; + return `| ${markdownLink(`#${item.number}`, itemUrlFor(repo, item.number, item.kind))} | ${title} | ${item.workPriority} | ${item.workStatus} | ${formatTimestamp(item.reviewedAt)} | ${plan} | ${report} |`; }) - .join("\n") || "| _None_ | | | | | |" + .join("\n") || "| _None_ | | | | | | |" ); } @@ -6869,9 +7014,12 @@ function formatFleetWorkQueueRows(items: readonly DashboardItem[], limit = 15): const repo = item.repo ?? targetRepo(); const title = markdownTableCell(displayTitle(item.title)); const report = markdownLink(item.reportPath, reportFileUrl(item.number, item.reportPath)); - return `| ${markdownLink(repo, repoUrlFor(repo))} | ${markdownLink(`#${item.number}`, itemUrlFor(repo, item.number, item.kind))} | ${title} | ${item.workPriority} | ${item.workStatus} | ${formatTimestamp(item.reviewedAt)} | ${report} |`; + const plan = item.planPath + ? markdownLink(item.planPath, reportFileUrl(item.number, item.planPath)) + : "_pending_"; + return `| ${markdownLink(repo, repoUrlFor(repo))} | ${markdownLink(`#${item.number}`, itemUrlFor(repo, item.number, item.kind))} | ${title} | ${item.workPriority} | ${item.workStatus} | ${formatTimestamp(item.reviewedAt)} | ${plan} | ${report} |`; }) - .join("\n") || "| _None_ | | | | | | |" + .join("\n") || "| _None_ | | | | | | | |" ); } @@ -7037,8 +7185,8 @@ ${formatRecentClosedRows(stats.recentClosed)} #### Work Candidates -| Item | Title | Priority | Status | Reviewed | Report | -| --- | --- | --- | --- | --- | --- | +| Item | Title | Priority | Status | Reviewed | Plan | Report | +| --- | --- | --- | --- | --- | --- | --- | ${formatWorkQueueRows(stats.workQueue)} #### Recently Reviewed @@ -7164,8 +7312,8 @@ ${formatFleetRecentClosedRows(recentClosed)} ### Work Candidates Across Repos -| Repository | Item | Title | Priority | Status | Reviewed | Report | -| --- | --- | --- | --- | --- | --- | --- | +| Repository | Item | Title | Priority | Status | Reviewed | Plan | Report | +| --- | --- | --- | --- | --- | --- | --- | --- | ${formatFleetWorkQueueRows(workQueue)}
diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts index 00fc0f5d94..36fb5bba23 100644 --- a/test/clawsweeper.test.ts +++ b/test/clawsweeper.test.ts @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; +const tmpPrefix = join(tmpdir(), "clawsweeper-test-"); + import { applyDecisionPriority, auditFromSnapshot, @@ -35,6 +40,7 @@ import { reviewActionForDecision, reviewPriority, renderReviewCommentFromReport, + renderWorkPlanFromReport, runtimeBudgetExceeded, safeOutputTail, sameAuthorCounterpartApplyReason, @@ -1722,6 +1728,125 @@ Full review comments: assert.doesNotMatch(markers, /clawsweeper-verdict:needs-human/); }); +function workPlanCandidateReport(overrides = {}) { + const frontmatter = { + number: 321, + repository: "openclaw/clawsweeper", + type: "issue", + title: "Render work plans", + reviewed_at: new Date().toISOString(), + review_status: "complete", + local_checkout_access: "verified", + decision: "keep_open", + action_taken: "kept_open", + work_candidate: "queue_fix_pr", + work_status: "candidate", + work_priority: "medium", + work_confidence: "high", + work_likely_files: JSON.stringify(["src/clawsweeper.ts", "test/clawsweeper.test.ts"]), + work_validation: JSON.stringify(["pnpm run check"]), + work_cluster_refs: JSON.stringify(["openclaw/clawsweeper#26"]), + ...overrides, + }; + return `--- +${Object.entries(frontmatter) + .map(([key, value]) => `${key}: ${value}`) + .join("\n")} +--- + +# #321: Render work plans + +## Summary + +The dashboard has queue_fix_pr candidates but no generated coding plan. + +## Repair Work Prompt + +Render generated plan markdown from existing report fields. +`; +} + +test("renderWorkPlanFromReport renders dashboard plan artifacts for fresh queue_fix_pr candidates", () => { + const plan = renderWorkPlanFromReport(workPlanCandidateReport(), { + reportPath: "records/openclaw-clawsweeper/items/321.md", + }); + assert.ok(plan); + assert.match(plan, /# Coding Plan for openclaw\/clawsweeper#321: Render work plans/); + assert.match(plan, /Render generated plan markdown from existing report fields\./); + assert.match(plan, /- `src\/clawsweeper\.ts`/); + assert.match(plan, /- `pnpm run check`/); + assert.match(plan, /openclaw\/clawsweeper#26/); +}); + +test("renderWorkPlanFromReport returns null for stale, reclassified, or non-candidate reports", () => { + assert.equal(renderWorkPlanFromReport(workPlanCandidateReport({ work_candidate: "none" })), null); + assert.equal( + renderWorkPlanFromReport(workPlanCandidateReport({ work_status: "manual_review" })), + null, + ); + assert.equal(renderWorkPlanFromReport(workPlanCandidateReport({ action_taken: "closed" })), null); + assert.equal( + renderWorkPlanFromReport(workPlanCandidateReport({ reviewed_at: "2026-01-01T00:00:00.000Z" })), + null, + ); +}); + +test("apply-artifacts writes and removes generated work plans", () => { + const root = mkdtempSync(tmpPrefix); + try { + const artifactDir = join(root, "artifacts"); + const itemsDir = join(root, "items"); + const closedDir = join(root, "closed"); + const plansDir = join(root, "plans"); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync(join(artifactDir, "321.md"), workPlanCandidateReport(), "utf8"); + execFileSync(process.execPath, [ + "dist/clawsweeper.js", + "apply-artifacts", + "--target-repo", + "openclaw/clawsweeper", + "--artifact-dir", + artifactDir, + "--items-dir", + itemsDir, + "--closed-dir", + closedDir, + "--plans-dir", + plansDir, + "--replay-closed-artifacts", + "--skip-reconcile", + ]); + const planPath = join(plansDir, "321.md"); + assert.ok(existsSync(planPath)); + assert.match(readFileSync(planPath, "utf8"), /## Plan\n\nRender generated plan markdown/); + + writeFileSync( + join(artifactDir, "321.md"), + workPlanCandidateReport({ work_candidate: "none", work_status: "none" }), + "utf8", + ); + execFileSync(process.execPath, [ + "dist/clawsweeper.js", + "apply-artifacts", + "--target-repo", + "openclaw/clawsweeper", + "--artifact-dir", + artifactDir, + "--items-dir", + itemsDir, + "--closed-dir", + closedDir, + "--plans-dir", + plansDir, + "--replay-closed-artifacts", + "--skip-reconcile", + ]); + assert.equal(existsSync(planPath), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("security-needs-attention reports block unopted repair and automerge pass markers", () => { const securitySection = ` ## Security Review