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..b3d3441b5d 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; @@ -5546,6 +5656,7 @@ function applyDecisionsCommand(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 limit = numberArg(args.limit, 20); const processedLimit = numberArg(args.processed_limit, Math.max(limit * 2, 50)); const minAgeDays = numberArg(args.min_age_days, 0); @@ -5638,6 +5749,11 @@ function applyDecisionsCommand(args: Args): void { if (dryRun) return; ensureDir(closedDir); writeFileSync(path, nextMarkdown, "utf8"); + syncWorkPlanFromReport({ + markdown: nextMarkdown, + reportPath: path, + plansDir, + }); renameSync(path, join(closedDir, file)); }; const markApplySkipped = (actionTaken: ActionTaken, reason: string): boolean => { @@ -5968,6 +6084,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 +6118,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 +6535,7 @@ function moveMarkdownFile(options: { function reconcileFolders(options: { itemsDir: string; closedDir: string; + plansDir?: string; maxPages?: number; dryRun?: boolean; fetchClosedAt?: boolean; @@ -6418,6 +6543,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 +6575,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 +6596,7 @@ function reconcileFolders(options: { } const markdown = markReconciledState(sourceMarkdown, "open"); moveMarkdownFile({ sourcePath, destinationPath, markdown, dryRun }); + syncWorkPlanFromReport({ markdown, reportPath: destinationPath, plansDir, dryRun }); movedToItems += 1; } @@ -6483,10 +6614,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 +6685,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 +6756,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 +6967,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 +7015,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 +7186,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 +7313,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..dcfc83cdb7 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,206 @@ 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("apply-decisions removes archived work plans from the scoped plans directory", () => { + const root = mkdtempSync(tmpPrefix); + const originalPath = process.env.PATH; + const defaultPlanDir = join(process.cwd(), "records", "openclaw-clawsweeper", "plans"); + const defaultPlanPath = join(defaultPlanDir, "321.md"); + try { + const binDir = join(root, "bin"); + const itemsDir = join(root, "items"); + const closedDir = join(root, "closed"); + const plansDir = join(root, "plans"); + mkdirSync(binDir, { recursive: true }); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + mkdirSync(defaultPlanDir, { recursive: true }); + writeFileSync( + join(binDir, "gh"), + `#!/usr/bin/env node +const args = process.argv.slice(2).join(" "); +if (args.includes("/comments")) { + console.log(JSON.stringify([[]])); +} else { + console.log(JSON.stringify({ + number: 321, + title: "Render work plans", + html_url: "https://github.com/openclaw/clawsweeper/issues/321", + created_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + closed_at: "2026-05-02T00:00:00Z", + state: "closed", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "reporter" }, + labels: [], + pull_request: null + })); +} +`, + { mode: 0o755 }, + ); + writeFileSync( + join(itemsDir, "321.md"), + workPlanCandidateReport({ + item_snapshot_hash: "reviewed-snapshot", + item_updated_at: "2026-05-01T00:00:00Z", + }), + "utf8", + ); + writeFileSync(join(plansDir, "321.md"), "scoped generated plan\n", "utf8"); + writeFileSync(defaultPlanPath, "default generated plan\n", "utf8"); + + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + execFileSync(process.execPath, [ + "dist/clawsweeper.js", + "apply-decisions", + "--target-repo", + "openclaw/clawsweeper", + "--items-dir", + itemsDir, + "--closed-dir", + closedDir, + "--plans-dir", + plansDir, + "--limit", + "1", + "--processed-limit", + "1", + "--close-delay-ms", + "0", + ]); + + assert.equal(existsSync(join(plansDir, "321.md")), false); + assert.ok(existsSync(defaultPlanPath)); + assert.ok(existsSync(join(closedDir, "321.md"))); + } finally { + process.env.PATH = originalPath; + rmSync(root, { recursive: true, force: true }); + rmSync(defaultPlanPath, { force: true }); + } +}); + test("security-needs-attention reports block unopted repair and automerge pass markers", () => { const securitySection = ` ## Security Review