feat: render work candidate coding plans
This commit is contained in:
parent
0fefca282d
commit
369c634b3c
@ -16,6 +16,8 @@ checkpoint, and status-only commits are intentionally omitted.
|
||||
checked-in source of truth.
|
||||
- Replaced per-lane capacity config with a single `workers.max` budget and
|
||||
dynamic background lane scheduling.
|
||||
- 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.
|
||||
|
||||
|
||||
@ -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/<repo-slug>/plans/<number>.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
|
||||
|
||||
|
||||
@ -375,6 +375,7 @@ interface DashboardItem {
|
||||
action: string;
|
||||
reviewStatus: string;
|
||||
reportPath: string;
|
||||
planPath?: string | undefined;
|
||||
workCandidate: string;
|
||||
workPriority: string;
|
||||
workStatus: string;
|
||||
@ -911,6 +912,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`;
|
||||
@ -4715,6 +4720,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;
|
||||
@ -6229,6 +6339,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 => {
|
||||
@ -6567,6 +6682,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);
|
||||
@ -6600,14 +6716,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<number> | null): boolean {
|
||||
@ -7010,6 +7133,7 @@ function moveMarkdownFile(options: {
|
||||
function reconcileFolders(options: {
|
||||
itemsDir: string;
|
||||
closedDir: string;
|
||||
plansDir?: string;
|
||||
maxPages?: number;
|
||||
dryRun?: boolean;
|
||||
fetchClosedAt?: boolean;
|
||||
@ -7017,6 +7141,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);
|
||||
@ -7048,6 +7173,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;
|
||||
}
|
||||
|
||||
@ -7065,6 +7194,7 @@ function reconcileFolders(options: {
|
||||
}
|
||||
const markdown = markReconciledState(sourceMarkdown, "open");
|
||||
moveMarkdownFile({ sourcePath, destinationPath, markdown, dryRun });
|
||||
syncWorkPlanFromReport({ markdown, reportPath: destinationPath, plansDir, dryRun });
|
||||
movedToItems += 1;
|
||||
}
|
||||
|
||||
@ -7082,10 +7212,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));
|
||||
}
|
||||
|
||||
@ -7145,6 +7283,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;
|
||||
@ -7215,6 +7354,9 @@ function dashboardStats(
|
||||
action,
|
||||
reviewStatus,
|
||||
reportPath: repoRelativePath(join(itemsDir, file)),
|
||||
planPath: existsSync(join(plansDir, file))
|
||||
? repoRelativePath(join(plansDir, file))
|
||||
: undefined,
|
||||
workCandidate,
|
||||
workPriority,
|
||||
workStatus,
|
||||
@ -7423,9 +7565,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_ | | | | | | |"
|
||||
);
|
||||
}
|
||||
|
||||
@ -7468,9 +7613,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_ | | | | | | | |"
|
||||
);
|
||||
}
|
||||
|
||||
@ -7636,8 +7784,8 @@ ${formatRecentClosedRows(stats.recentClosed)}
|
||||
|
||||
#### Work Candidates
|
||||
|
||||
| Item | Title | Priority | Status | Reviewed | Report |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Item | Title | Priority | Status | Reviewed | Plan | Report |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
${formatWorkQueueRows(stats.workQueue)}
|
||||
|
||||
#### Recently Reviewed
|
||||
@ -7763,8 +7911,8 @@ ${formatFleetRecentClosedRows(recentClosed)}
|
||||
|
||||
### Work Candidates Across Repos
|
||||
|
||||
| Repository | Item | Title | Priority | Status | Reviewed | Report |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Repository | Item | Title | Priority | Status | Reviewed | Plan | Report |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
${formatFleetWorkQueueRows(workQueue)}
|
||||
|
||||
<details>
|
||||
|
||||
@ -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,
|
||||
@ -39,6 +44,7 @@ import {
|
||||
reviewActionForDecision,
|
||||
reviewPriority,
|
||||
renderReviewCommentFromReport,
|
||||
renderWorkPlanFromReport,
|
||||
reviewPromptTelemetryForTest,
|
||||
runtimeBudgetExceeded,
|
||||
safeOutputTail,
|
||||
@ -2100,6 +2106,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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user