diff --git a/prompt.md b/prompt.md index d084f77..3f801a7 100644 --- a/prompt.md +++ b/prompt.md @@ -19,31 +19,41 @@ author's push credentials. Read the code there, run its checks and tests when they bear on the task, and push from there. For a `pull_request` event, and for a comment on a pull request that asks you to -review it, invoke the installed `superpowers:requesting-code-review` skill -before inspecting the PR. You are the reviewer that has already been dispatched, -so run the skill's code reviewer template yourself instead of dispatching -another reviewer. Use the pull request and triggering instruction as its -description and requirements, and review the exact base and head SHAs without -changing code. Run the project's required checks on the head and treat a real -failure as at least Important. The bot automation workflow itself is not a -project check: ignore its skipped or canceled duplicate/automatic runs, and -never reject a PR because the current review run is unfinished. Only a failed -required check for the reviewed head blocks approval. Check the whole repository -against the code rules at the end of this prompt, not only the diff; a violation -is at least Important even when the diff did not cause it. Write the complete -review, and nothing else, to the file `${REVIEW_PATH}`: it is posted verbatim as -a pull request review from the bot account, and your final response is not -posted at all. The file's first line must be exactly the verdict and nothing -else: `Approved` when the head is ready to merge, `Changes requested` otherwise. -The mark in front of it is added when posting, so write the words alone; any -other first line is posted as a plain comment, which wastes the run. Minor -issues alone never block, and neither does a finding the author has answered in -the comment history below as intended or a false alarm, once the code or docs -make that clear. When the verdict is `Changes requested`, the second line names -what must change in one line, addressed to the author; the author's own agent -picks the fixes up, so never ask `@bot` to make them. For UI changes, check that -the result is aligned, clean, and pixel-perfect, and that included screenshots -prove the intended result was achieved. +review it, use the installed `superpowers:requesting-code-review` skill before +inspecting the PR. Its exact installed instructions and reviewer template are +included below, so this run fails before reaching you if they could not be +loaded. You are the reviewer that has already been dispatched, so run the +skill's code reviewer template yourself instead of dispatching another reviewer. +Use the pull request and triggering instruction as its description and +requirements, and review the exact base and head SHAs without changing code. Run +the project's required checks on the head and treat a real failure as at least +Important. The bot automation workflow itself is not a project check: ignore its +skipped or canceled duplicate/automatic runs, and never reject a PR because the +current review run is unfinished. Only a failed required check for the reviewed +head blocks approval. Check the whole repository against the code rules at the +end of this prompt, not only the diff; a violation is at least Important even +when the diff did not cause it. Write the complete review, and nothing else, to +the file `${REVIEW_PATH}`: it is posted verbatim as a pull request review from +the bot account, and your final response is not posted at all. The file's first +line must be exactly the verdict and nothing else: `Approved` when the head is +ready to merge, `Changes requested` otherwise. The mark in front of it is added +when posting, so write the words alone; any other first line is posted as a +plain comment, which wastes the run. Minor issues alone never block, and neither +does a finding the author has answered in the comment history below as intended +or a false alarm, once the code or docs make that clear. When the verdict is +`Changes requested`, the second line names what must change in one line, +addressed to the author; the author's own agent picks the fixes up, so never ask +`@bot` to make them. For UI changes, check that the result is aligned, clean, +and pixel-perfect, and that included screenshots prove the intended result was +achieved. + +# Installed Superpowers review skill + +${SUPERPOWERS_REVIEW_SKILL} + +# Installed Superpowers reviewer template + +${SUPERPOWERS_REVIEW_TEMPLATE} Every finding that belongs to one line of the diff goes on that line instead of into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry diff --git a/run.ts b/run.ts index 06d3f66..7d99e66 100644 --- a/run.ts +++ b/run.ts @@ -6,8 +6,10 @@ import { TextLineStream } from "jsr:@std/streams@1/text-line-stream"; import { retryInvalidToken } from "./auth.ts"; import { type BotType, + loadSuperpowersReviewGuide, parseBotType, superpowersInstallCommands, + type SuperpowersReviewGuide, } from "./superpowers.ts"; type GiteaUser = { login: string; email: string }; @@ -160,6 +162,20 @@ async function installSuperpowers(bot: BotType): Promise { } } +async function prepareSuperpowers( + bot: BotType, +): Promise { + await installSuperpowers(bot); + const guide = await loadSuperpowersReviewGuide(bot, env("HOME")); + console.log( + `Loaded Superpowers requesting-code-review ${guide.version} from ${guide.skillPath}`, + ); + console.log( + `Loaded Superpowers reviewer template from ${guide.templatePath}`, + ); + return guide; +} + // Commits belong to the same account as the pull request they end up in. async function configureGitAuthor(): Promise { const user: GiteaUser = await (await gitea(AUTHOR_TOKEN, "user")).json(); @@ -168,7 +184,7 @@ async function configureGitAuthor(): Promise { } } -async function renderPrompt(): Promise { +async function renderPrompt(guide: SuperpowersReviewGuide): Promise { const comments: GiteaComment[] = await (await gitea( REVIEWER_TOKEN, `repos/${REPO}/issues/${INDEX}/comments?limit=100`, @@ -185,6 +201,8 @@ async function renderPrompt(): Promise { ISSUE_INDEX: INDEX, REVIEW_PATH, ANCHORS_PATH, + SUPERPOWERS_REVIEW_SKILL: guide.skill, + SUPERPOWERS_REVIEW_TEMPLATE: guide.template, }; const template = await Deno.readTextFile( new URL("prompt.md", import.meta.url), @@ -195,14 +213,14 @@ async function renderPrompt(): Promise { ); } -async function runClaude(prompt: string): Promise { +async function runClaude(): Promise { const token = Deno.env.get("BOT_TOKEN"); if (!token) { throw new Error( "Run `claude setup-token` locally and set the `bot-token` action input.", ); } - await installSuperpowers("claude"); + const prompt = await renderPrompt(await prepareSuperpowers("claude")); const claude = new Deno.Command("claude", { args: [ "--print", @@ -266,13 +284,13 @@ async function codexDeviceLogin(): Promise { await Promise.all([status, drained]); } -async function runCodex(prompt: string): Promise { +async function runCodex(): Promise { const loggedIn = await new Deno.Command("codex", { args: ["login", "status"], }) .output(); if (!loggedIn.success) await codexDeviceLogin(); - await installSuperpowers("codex"); + const prompt = await renderPrompt(await prepareSuperpowers("codex")); const file = await Deno.makeTempFile(); const attempt = async () => { const codex = new Deno.Command("codex", { @@ -309,10 +327,7 @@ async function runCodex(prompt: string): Promise { try { await configureGitAuthor(); - const prompt = await renderPrompt(); - const result = BOT === "claude" - ? await runClaude(prompt) - : await runCodex(prompt); + const result = BOT === "claude" ? await runClaude() : await runCodex(); await postResult(result); } catch (error) { await postComment(`Bot failed: ${error}`); diff --git a/superpowers.ts b/superpowers.ts index 4093bc3..43de3de 100644 --- a/superpowers.ts +++ b/superpowers.ts @@ -1,3 +1,5 @@ +import { join } from "jsr:@std/path@1"; + export type BotType = "claude" | "codex"; export type InstallCommand = { @@ -5,6 +7,24 @@ export type InstallCommand = { args: string[]; }; +export type SuperpowersReviewGuide = { + version: string; + skillPath: string; + templatePath: string; + skill: string; + template: string; +}; + +export type SuperpowersFileSystem = { + readDir(path: string): AsyncIterable; + readTextFile(path: string): Promise; +}; + +const systemFileSystem: SuperpowersFileSystem = { + readDir: Deno.readDir, + readTextFile: Deno.readTextFile, +}; + export function parseBotType(value: string): BotType { if (value === "claude" || value === "codex") return value; throw new Error(`unsupported bot type: ${value}`); @@ -40,3 +60,88 @@ export function superpowersInstallCommands( args: ["plugin", "add", "superpowers@openai-curated-remote"], }]; } + +type Candidate = SuperpowersReviewGuide & { directory: string }; + +async function readCandidate( + directory: string, + fileSystem: SuperpowersFileSystem, +): Promise { + if (!directory.split(/[\\/]/).includes("superpowers")) return null; + const skillPath = join(directory, "SKILL.md"); + const templatePath = join(directory, "code-reviewer.md"); + try { + const [skill, template] = await Promise.all([ + fileSystem.readTextFile(skillPath), + fileSystem.readTextFile(templatePath), + ]); + return { + directory, + version: directory.split(/[\\/]/).at(-3) ?? "unknown", + skillPath, + templatePath, + skill, + template, + }; + } catch (error) { + if (error instanceof Deno.errors.NotFound) return null; + throw error; + } +} + +async function findCandidates( + directory: string, + candidates: Candidate[], + fileSystem: SuperpowersFileSystem, +): Promise { + let entries: Deno.DirEntry[]; + try { + entries = []; + for await (const entry of fileSystem.readDir(directory)) { + entries.push(entry); + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) return; + throw error; + } + for (const entry of entries) { + if (!entry.isDirectory) continue; + const child = join(directory, entry.name); + if (entry.name === "requesting-code-review") { + const candidate = await readCandidate(child, fileSystem); + if (candidate !== null) candidates.push(candidate); + } else { + await findCandidates(child, candidates, fileSystem); + } + } +} + +// Load the installed files rather than trusting skill discovery in a later +// non-interactive agent process. The newest cached plugin version is the one +// the installers activate, and the exact paths are reported by the caller. +export async function loadSuperpowersReviewGuide( + bot: BotType, + home: string, + fileSystem: SuperpowersFileSystem = systemFileSystem, +): Promise { + const root = join( + home, + bot === "codex" ? ".codex" : ".claude", + "plugins", + "cache", + ); + const candidates: Candidate[] = []; + await findCandidates(root, candidates, fileSystem); + candidates.sort((left, right) => + left.version.localeCompare(right.version, undefined, { numeric: true }) || + left.directory.localeCompare(right.directory) + ); + const guide = candidates.at(-1); + if (guide === undefined) { + throw new Error( + `installed Superpowers requesting-code-review files not found under ${root}`, + ); + } + const { directory: _, ...result } = guide; + return result; +} diff --git a/superpowers_test.ts b/superpowers_test.ts index b6c679c..14a53ff 100644 --- a/superpowers_test.ts +++ b/superpowers_test.ts @@ -1,5 +1,40 @@ -import { assertEquals, assertThrows } from "jsr:@std/assert@1"; -import { parseBotType, superpowersInstallCommands } from "./superpowers.ts"; +import { assertEquals, assertRejects, assertThrows } from "jsr:@std/assert@1"; +import { join } from "jsr:@std/path@1"; +import { + loadSuperpowersReviewGuide, + parseBotType, + type SuperpowersFileSystem, + superpowersInstallCommands, +} from "./superpowers.ts"; + +function fakeFileSystem(files: Record): SuperpowersFileSystem { + return { + readTextFile(path) { + const contents = files[path]; + return contents === undefined + ? Promise.reject(new Deno.errors.NotFound(path)) + : Promise.resolve(contents); + }, + async *readDir(directory) { + const prefix = `${directory}/`; + const children = new Map(); + for (const path of Object.keys(files)) { + if (!path.startsWith(prefix)) continue; + const [name, ...rest] = path.slice(prefix.length).split("/"); + if (name !== "") children.set(name, rest.length > 0); + } + if (children.size === 0) throw new Deno.errors.NotFound(directory); + for (const [name, isDirectory] of children) { + yield { + name, + isDirectory, + isFile: !isDirectory, + isSymlink: false, + }; + } + }, + }; +} Deno.test("installs Superpowers from the Codex marketplace", () => { assertEquals(superpowersInstallCommands("codex"), [{ @@ -38,3 +73,47 @@ Deno.test("rejects an unsupported bot type", () => { "unsupported bot type: other", ); }); + +Deno.test("loads the newest installed Superpowers review guide", async () => { + const home = "/home/bot"; + const older = join( + home, + ".codex/plugins/cache/openai-curated-remote/superpowers/6.3.0/skills/requesting-code-review", + ); + const newer = join( + home, + ".codex/plugins/cache/openai-curated-remote/superpowers/6.10.0/skills/requesting-code-review", + ); + const guide = await loadSuperpowersReviewGuide( + "codex", + home, + fakeFileSystem({ + [join(older, "SKILL.md")]: "old", + [join(older, "code-reviewer.md")]: "old template", + [join(newer, "SKILL.md")]: "new", + [join(newer, "code-reviewer.md")]: "template", + }), + ); + + assertEquals(guide.version, "6.10.0"); + assertEquals(guide.skill, "new"); + assertEquals(guide.template, "template"); + assertEquals(guide.skillPath, join(newer, "SKILL.md")); +}); + +Deno.test("fails when the installed review guide is incomplete", async () => { + const home = "/home/bot"; + const directory = join( + home, + ".claude/plugins/cache/superpowers-marketplace/superpowers/6.3.0/skills/requesting-code-review", + ); + const fileSystem = fakeFileSystem({ + [join(directory, "SKILL.md")]: "skill", + }); + + await assertRejects( + () => loadSuperpowersReviewGuide("claude", home, fileSystem), + Error, + "installed Superpowers requesting-code-review files not found", + ); +});