From 813fd347d55700ae184d5473425901f3908d7865 Mon Sep 17 00:00:00 2001 From: Christoph Schmatzler Date: Tue, 31 Mar 2026 21:25:31 +0000 Subject: [PATCH] Remove pi agent infrastructure --- .opencode/commands/review.md | 25 - .opencode/plugins/review.ts | 907 +++++-- .pi/todos/95b075f0.md | 12 - .pi/todos/e3f0bbbb.md | 15 - flake.lock | 85 - flake.nix | 20 - modules/_ai-tools/AGENTS.md | 27 - modules/_ai-tools/extensions/no-git.ts | 190 -- modules/_ai-tools/extensions/no-scripting.ts | 28 - modules/_ai-tools/extensions/note-ingest.ts | 687 ----- modules/_ai-tools/extensions/review.ts | 2389 ----------------- modules/_ai-tools/extensions/session-name.ts | 260 -- modules/_ai-tools/mcp.json | 21 - modules/_ai-tools/skills/jujutsu/SKILL.md | 143 - .../skills/notability-normalize/SKILL.md | 36 - .../skills/notability-transcribe/SKILL.md | 38 - modules/_opencode/AGENTS.md | 27 +- modules/_overlays/pi-agent-stuff.nix | 10 - modules/_overlays/pi-harness.nix | 33 - modules/_overlays/pi-mcp-adapter.nix | 10 - modules/ai-tools.nix | 63 - modules/dendritic.nix | 20 - modules/notability.nix | 1 - modules/overlays.nix | 6 - 24 files changed, 709 insertions(+), 4344 deletions(-) delete mode 100644 .opencode/commands/review.md delete mode 100644 .pi/todos/95b075f0.md delete mode 100644 .pi/todos/e3f0bbbb.md delete mode 100644 modules/_ai-tools/AGENTS.md delete mode 100644 modules/_ai-tools/extensions/no-git.ts delete mode 100644 modules/_ai-tools/extensions/no-scripting.ts delete mode 100644 modules/_ai-tools/extensions/note-ingest.ts delete mode 100644 modules/_ai-tools/extensions/review.ts delete mode 100644 modules/_ai-tools/extensions/session-name.ts delete mode 100644 modules/_ai-tools/mcp.json delete mode 100644 modules/_ai-tools/skills/jujutsu/SKILL.md delete mode 100644 modules/_ai-tools/skills/notability-normalize/SKILL.md delete mode 100644 modules/_ai-tools/skills/notability-transcribe/SKILL.md delete mode 100644 modules/_overlays/pi-agent-stuff.nix delete mode 100644 modules/_overlays/pi-harness.nix delete mode 100644 modules/_overlays/pi-mcp-adapter.nix diff --git a/.opencode/commands/review.md b/.opencode/commands/review.md deleted file mode 100644 index 7970871..0000000 --- a/.opencode/commands/review.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -description: Review code changes (working-copy, bookmark, change, PR, or folder) -agent: review -subtask: true ---- - -Review the following code changes. $ARGUMENTS - -Current repository state: - -``` -!`jj log -r '::@ ~ ::trunk()' -n 15 --no-graph -T 'change_id.shortest(8) ++ " " ++ coalesce(bookmarks, "") ++ " " ++ description.first_line() ++ "\n"' 2>/dev/null || echo "Not a jj repository or no divergence from trunk"` -``` - -Working copy status: - -``` -!`jj diff --summary 2>/dev/null || echo "No working-copy changes"` -``` - -Available bookmarks: - -``` -!`jj bookmark list --all-remotes -T 'name ++ if(remote, "@" ++ remote, "") ++ "\n"' 2>/dev/null | head -20 || echo "No bookmarks"` -``` diff --git a/.opencode/plugins/review.ts b/.opencode/plugins/review.ts index f66e8ab..0f59f31 100644 --- a/.opencode/plugins/review.ts +++ b/.opencode/plugins/review.ts @@ -1,4 +1,79 @@ -import { type Plugin, tool } from "@opencode-ai/plugin" +import type { + TuiPlugin, + TuiDialogSelectOption, +} from "@opencode-ai/plugin/tui" + +type BookmarkRef = { name: string; remote?: string } +type Change = { changeId: string; title: string } + +type ReviewTarget = + | { type: "workingCopy" } + | { type: "baseBookmark"; bookmark: string; remote?: string } + | { type: "change"; changeId: string; title?: string } + | { + type: "pullRequest" + prNumber: number + baseBookmark: string + baseRemote?: string + title: string + } + | { type: "folder"; paths: string[] } + +function bookmarkLabel(b: BookmarkRef): string { + return b.remote ? `${b.name}@${b.remote}` : b.name +} + +function bookmarkRevset(b: BookmarkRef): string { + const q = JSON.stringify(b.name) + if (b.remote) { + return `remote_bookmarks(exact:${q}, exact:${JSON.stringify(b.remote)})` + } + return `bookmarks(exact:${q})` +} + +function parseBookmarks(stdout: string): BookmarkRef[] { + const seen = new Set() + return stdout + .trim() + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [name, remote = ""] = line.split("\t") + return { + name: name.trim(), + remote: remote.trim() || undefined, + } + }) + .filter((b) => b.name && b.remote !== "git") + .filter((b) => { + const key = `${b.name}@${b.remote ?? ""}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function parseChanges(stdout: string): Change[] { + return stdout + .trim() + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [changeId, ...rest] = line.split("\t") + return { changeId, title: rest.join(" ") } + }) +} + +function parsePrRef(ref: string): number | null { + const trimmed = ref.trim() + const num = parseInt(trimmed, 10) + if (!isNaN(num) && num > 0) return num + const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/) + if (urlMatch) return parseInt(urlMatch[1], 10) + return null +} function normalizeRemoteUrl(value: string): string { return value @@ -10,228 +85,620 @@ function normalizeRemoteUrl(value: string): string { } function sanitizeRemoteName(value: string): string { - const sanitized = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") - return sanitized || "gh-pr" + return ( + value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || + "gh-pr" + ) } -export const ReviewPlugin: Plugin = async ({ $ }) => { - return { - tool: { - review_materialize_pr: tool({ - description: - "Materialize a GitHub pull request locally using jj for code review. " + - "Fetches the PR branch, creates a new jj change on top of it, and returns " + - "metadata needed for the review. Handles cross-repository (forked) PRs. " + - "Call this before reviewing a PR to set up the local state.", - args: { - prNumber: tool.schema - .number() - .describe("The PR number to materialize (e.g. 123)"), - }, - async execute(args, context) { - const prNumber = args.prNumber +export const tui: TuiPlugin = async (api) => { + const cwd = api.state.path.directory - // Check for pending working-copy changes - const statusResult = - await $`jj diff --summary 2>/dev/null`.nothrow().quiet() - if ( - statusResult.exitCode === 0 && - statusResult.stdout.toString().trim().length > 0 - ) { - return JSON.stringify({ - success: false, - error: - "Cannot materialize PR: you have local jj changes. Please snapshot or discard them first.", - }) - } + // -- shell helpers ------------------------------------------------------- - // Save current position for later restoration - const currentChangeResult = - await $`jj log -r @ --no-graph -T 'change_id.shortest(8)'` - .nothrow() - .quiet() - const savedChangeId = currentChangeResult.stdout.toString().trim() - - // Get PR info from GitHub CLI - const prInfoResult = - await $`gh pr view ${prNumber} --json baseRefName,title,headRefName,isCrossRepository,headRepository,headRepositoryOwner` - .nothrow() - .quiet() - if (prInfoResult.exitCode !== 0) { - return JSON.stringify({ - success: false, - error: `Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, - }) - } - - let prInfo: { - baseRefName: string - title: string - headRefName: string - isCrossRepository: boolean - headRepository?: { name: string; url: string } - headRepositoryOwner?: { login: string } - } - try { - prInfo = JSON.parse(prInfoResult.stdout.toString()) - } catch { - return JSON.stringify({ - success: false, - error: "Failed to parse PR info from gh CLI", - }) - } - - // Determine the remote to use - const remotesResult = - await $`jj git remote list`.nothrow().quiet() - const remotes = remotesResult.stdout - .toString() - .trim() - .split("\n") - .filter(Boolean) - .map((line: string) => { - const [name, ...urlParts] = line.split(/\s+/) - return { name, url: urlParts.join(" ") } - }) - .filter( - (r: { name: string; url: string }) => r.name && r.url, - ) - - const defaultRemote = - remotes.find( - (r: { name: string; url: string }) => - r.name === "origin", - ) ?? remotes[0] - if (!defaultRemote) { - return JSON.stringify({ - success: false, - error: "No jj remotes are configured for this repository", - }) - } - - let remoteName = defaultRemote.name - let addedTemporaryRemote = false - - if (prInfo.isCrossRepository) { - const repoSlug = - prInfo.headRepositoryOwner?.login && - prInfo.headRepository?.name - ? `${prInfo.headRepositoryOwner.login}/${prInfo.headRepository.name}`.toLowerCase() - : undefined - const forkUrl = prInfo.headRepository?.url - - // Check if we already have a remote for this fork - const existingRemote = remotes.find( - (r: { name: string; url: string }) => { - if ( - forkUrl && - normalizeRemoteUrl(r.url) === - normalizeRemoteUrl(forkUrl) - ) { - return true - } - return repoSlug - ? normalizeRemoteUrl(r.url).includes( - `github.com/${repoSlug}`, - ) - : false - }, - ) - - if (existingRemote) { - remoteName = existingRemote.name - } else if (forkUrl) { - const remoteBaseName = sanitizeRemoteName( - `gh-pr-${prInfo.headRepositoryOwner?.login ?? "remote"}-${prInfo.headRepository?.name ?? prNumber}`, - ) - const existingNames = new Set( - remotes.map( - (r: { name: string; url: string }) => - r.name, - ), - ) - remoteName = remoteBaseName - let suffix = 2 - while (existingNames.has(remoteName)) { - remoteName = `${remoteBaseName}-${suffix}` - suffix += 1 - } - const addResult = - await $`jj git remote add ${remoteName} ${forkUrl}` - .nothrow() - .quiet() - if (addResult.exitCode !== 0) { - return JSON.stringify({ - success: false, - error: - addResult.stderr.toString() || - "Failed to add PR remote", - }) - } - addedTemporaryRemote = true - } else { - return JSON.stringify({ - success: false, - error: "PR head repository URL is unavailable", - }) - } - } - - // Fetch the PR branch - const fetchResult = - await $`jj git fetch --remote ${remoteName} --branch ${prInfo.headRefName}` - .nothrow() - .quiet() - if (fetchResult.exitCode !== 0) { - if (addedTemporaryRemote) { - await $`jj git remote remove ${remoteName}` - .nothrow() - .quiet() - } - return JSON.stringify({ - success: false, - error: - fetchResult.stderr.toString() || - "Failed to fetch PR branch", - }) - } - - // Create a new change on top of the PR branch - const bookmarkRevset = `remote_bookmarks(exact:"${prInfo.headRefName}", exact:"${remoteName}")` - const editResult = - await $`jj new ${bookmarkRevset}`.nothrow().quiet() - if (editResult.exitCode !== 0) { - if (addedTemporaryRemote) { - await $`jj git remote remove ${remoteName}` - .nothrow() - .quiet() - } - return JSON.stringify({ - success: false, - error: - editResult.stderr.toString() || - "Failed to create change on PR branch", - }) - } - - // Clean up temporary remote - if (addedTemporaryRemote) { - await $`jj git remote remove ${remoteName}` - .nothrow() - .quiet() - } - - return JSON.stringify({ - success: true, - prNumber, - title: prInfo.title, - baseBookmark: prInfo.baseRefName, - headBookmark: prInfo.headRefName, - remote: remoteName, - savedChangeId, - }) - }, - }), - }, + async function exec( + cmd: string, + args: string[], + ): Promise<{ stdout: string; exitCode: number; stderr: string }> { + const proc = Bun.spawn([cmd, ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + const exitCode = await proc.exited + return { stdout, exitCode, stderr } } + + async function jj( + ...args: string[] + ): Promise<{ stdout: string; ok: boolean }> { + const r = await exec("jj", args) + return { stdout: r.stdout, ok: r.exitCode === 0 } + } + + async function gh( + ...args: string[] + ): Promise<{ stdout: string; ok: boolean; stderr: string }> { + const r = await exec("gh", args) + return { stdout: r.stdout, ok: r.exitCode === 0, stderr: r.stderr } + } + + // -- jj helpers ---------------------------------------------------------- + + async function isJjRepo(): Promise { + return (await jj("root")).ok + } + + async function hasWorkingCopyChanges(): Promise { + const r = await jj("diff", "--summary") + return r.ok && r.stdout.trim().length > 0 + } + + async function getBookmarks(): Promise { + const r = await jj( + "bookmark", + "list", + "--all-remotes", + "-T", + 'name ++ "\\t" ++ remote ++ "\\n"', + ) + if (!r.ok) return [] + return parseBookmarks(r.stdout) + } + + async function getCurrentBookmarks(): Promise { + const headRevset = (await hasWorkingCopyChanges()) ? "@" : "@-" + const r = await jj( + "bookmark", + "list", + "--all-remotes", + "-r", + headRevset, + "-T", + 'name ++ "\\t" ++ remote ++ "\\n"', + ) + if (!r.ok) return [] + return parseBookmarks(r.stdout) + } + + async function getDefaultBookmark(): Promise { + const trunkR = await jj( + "bookmark", + "list", + "--all-remotes", + "-r", + "trunk()", + "-T", + 'name ++ "\\t" ++ remote ++ "\\n"', + ) + if (trunkR.ok) { + const bookmarks = parseBookmarks(trunkR.stdout) + if (bookmarks.length > 0) return bookmarks[0] + } + const all = await getBookmarks() + return ( + all.find((b) => !b.remote && b.name === "main") ?? + all.find((b) => !b.remote && b.name === "master") ?? + all[0] ?? + null + ) + } + + async function getRecentChanges(limit = 20): Promise { + const r = await jj( + "log", + "-n", + String(limit), + "--no-graph", + "-T", + 'change_id.shortest(8) ++ "\\t" ++ description.first_line() ++ "\\n"', + ) + if (!r.ok) return [] + return parseChanges(r.stdout) + } + + async function getMergeBase( + bookmark: string, + remote?: string, + ): Promise { + const ref: BookmarkRef = { name: bookmark, remote } + const r = await jj( + "log", + "-r", + `heads(::@ & ::${bookmarkRevset(ref)})`, + "--no-graph", + "-T", + 'change_id.shortest(8) ++ "\\n"', + ) + if (!r.ok) return null + const lines = r.stdout + .trim() + .split("\n") + .filter((l) => l.trim()) + return lines.length === 1 ? lines[0].trim() : null + } + + // -- PR materialization -------------------------------------------------- + + async function materializePr(prNumber: number): Promise< + | { + ok: true + title: string + baseBookmark: string + baseRemote?: string + savedChangeId: string + } + | { ok: false; error: string } + > { + if (await hasWorkingCopyChanges()) { + return { + ok: false, + error: "You have local jj changes. Snapshot or discard them first.", + } + } + + const savedR = await jj( + "log", + "-r", + "@", + "--no-graph", + "-T", + "change_id.shortest(8)", + ) + const savedChangeId = savedR.stdout.trim() + + const prR = await gh( + "pr", + "view", + String(prNumber), + "--json", + "baseRefName,title,headRefName,isCrossRepository,headRepository,headRepositoryOwner", + ) + if (!prR.ok) { + return { + ok: false, + error: `Could not find PR #${prNumber}. Check gh auth and that the PR exists.`, + } + } + + let prInfo: { + baseRefName: string + title: string + headRefName: string + isCrossRepository: boolean + headRepository?: { name: string; url: string } + headRepositoryOwner?: { login: string } + } + try { + prInfo = JSON.parse(prR.stdout) + } catch { + return { ok: false, error: "Failed to parse PR info" } + } + + const remotesR = await jj("git", "remote", "list") + const remotes = remotesR.stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const [name, ...urlParts] = line.split(/\s+/) + return { name, url: urlParts.join(" ") } + }) + .filter((r) => r.name && r.url) + + const defaultRemote = + remotes.find((r) => r.name === "origin") ?? remotes[0] + if (!defaultRemote) { + return { ok: false, error: "No jj remotes configured" } + } + + let remoteName = defaultRemote.name + let addedTempRemote = false + + if (prInfo.isCrossRepository) { + const repoSlug = + prInfo.headRepositoryOwner?.login && prInfo.headRepository?.name + ? `${prInfo.headRepositoryOwner.login}/${prInfo.headRepository.name}`.toLowerCase() + : undefined + const forkUrl = prInfo.headRepository?.url + + const existingRemote = remotes.find((r) => { + if ( + forkUrl && + normalizeRemoteUrl(r.url) === normalizeRemoteUrl(forkUrl) + ) + return true + return repoSlug + ? normalizeRemoteUrl(r.url).includes( + `github.com/${repoSlug}`, + ) + : false + }) + + if (existingRemote) { + remoteName = existingRemote.name + } else if (forkUrl) { + const baseName = sanitizeRemoteName( + `gh-pr-${prInfo.headRepositoryOwner?.login ?? "remote"}-${prInfo.headRepository?.name ?? prNumber}`, + ) + const names = new Set(remotes.map((r) => r.name)) + remoteName = baseName + let suffix = 2 + while (names.has(remoteName)) { + remoteName = `${baseName}-${suffix++}` + } + const addR = await jj( + "git", + "remote", + "add", + remoteName, + forkUrl, + ) + if (!addR.ok) return { ok: false, error: "Failed to add PR remote" } + addedTempRemote = true + } else { + return { ok: false, error: "PR fork URL is unavailable" } + } + } + + const fetchR = await jj( + "git", + "fetch", + "--remote", + remoteName, + "--branch", + prInfo.headRefName, + ) + if (!fetchR.ok) { + if (addedTempRemote) + await jj("git", "remote", "remove", remoteName) + return { ok: false, error: "Failed to fetch PR branch" } + } + + const revset = `remote_bookmarks(exact:${JSON.stringify(prInfo.headRefName)}, exact:${JSON.stringify(remoteName)})` + const newR = await jj("new", revset) + if (!newR.ok) { + if (addedTempRemote) + await jj("git", "remote", "remove", remoteName) + return { ok: false, error: "Failed to create change on PR branch" } + } + + if (addedTempRemote) await jj("git", "remote", "remove", remoteName) + + // Resolve base bookmark remote + const baseBms = await getBookmarks() + const baseRef = baseBms.find((b) => b.name === prInfo.baseRefName) + + return { + ok: true, + title: prInfo.title, + baseBookmark: prInfo.baseRefName, + baseRemote: baseRef?.remote, + savedChangeId, + } + } + + // -- prompt building ----------------------------------------------------- + + async function buildPrompt(target: ReviewTarget): Promise { + switch (target.type) { + case "workingCopy": + return "Review the current working-copy changes (including new files). Use `jj status`, `jj diff --summary`, and `jj diff` to inspect." + + case "baseBookmark": { + const label = bookmarkLabel({ + name: target.bookmark, + remote: target.remote, + }) + const mergeBase = await getMergeBase( + target.bookmark, + target.remote, + ) + if (mergeBase) { + return `Review code changes against the base bookmark '${label}'. The merge-base change is ${mergeBase}. Run \`jj diff --from ${mergeBase} --to @\` to inspect the changes. Also check for local working-copy changes with \`jj diff --summary\`.` + } + return `Review code changes against the base bookmark '${label}'. Find the merge-base between @ and ${label}, then run \`jj diff --from --to @\`. Also check for local working-copy changes.` + } + + case "change": + return target.title + ? `Review the code changes introduced by change ${target.changeId} ("${target.title}"). Use \`jj show ${target.changeId}\` to inspect.` + : `Review the code changes introduced by change ${target.changeId}. Use \`jj show ${target.changeId}\` to inspect.` + + case "pullRequest": { + const label = bookmarkLabel({ + name: target.baseBookmark, + remote: target.baseRemote, + }) + const mergeBase = await getMergeBase( + target.baseBookmark, + target.baseRemote, + ) + if (mergeBase) { + return `Review pull request #${target.prNumber} ("${target.title}") against '${label}'. Merge-base is ${mergeBase}. Run \`jj diff --from ${mergeBase} --to @\` to inspect.` + } + return `Review pull request #${target.prNumber} ("${target.title}") against '${label}'. Find the merge-base and run \`jj diff --from --to @\`.` + } + + case "folder": + return `Review the code in the following paths: ${target.paths.join(", ")}. This is a snapshot review (not a diff). Read the files directly.` + } + } + + // -- review execution ---------------------------------------------------- + + async function startReview(target: ReviewTarget): Promise { + const prompt = await buildPrompt(target) + await api.client.tui.clearPrompt() + await api.client.tui.appendPrompt({ + body: { text: `@review ${prompt}` }, + }) + await api.client.tui.submitPrompt() + } + + // -- dialogs ------------------------------------------------------------- + + function showReviewSelector(): void { + const options: TuiDialogSelectOption[] = [ + { + title: "Working-copy changes", + value: "workingCopy", + description: "Review uncommitted changes", + }, + { + title: "Against a bookmark", + value: "baseBookmark", + description: "PR-style review against a base", + }, + { + title: "A specific change", + value: "change", + description: "Review a single jj change", + }, + { + title: "A pull request", + value: "pullRequest", + description: "Materialize and review a GitHub PR", + }, + { + title: "A folder (snapshot)", + value: "folder", + description: "Review files directly, no diff", + }, + ] + + api.ui.dialog.replace( + () => + api.ui.DialogSelect({ + title: "Review", + options, + onSelect: (option) => { + api.ui.dialog.clear() + switch (option.value) { + case "workingCopy": + void startReview({ type: "workingCopy" }) + break + case "baseBookmark": + void showBookmarkSelector() + break + case "change": + void showChangeSelector() + break + case "pullRequest": + void showPrInput() + break + case "folder": + showFolderInput() + break + } + }, + }), + () => api.ui.dialog.clear(), + ) + } + + async function showBookmarkSelector(): Promise { + api.ui.toast({ message: "Loading bookmarks...", variant: "info" }) + + const allBookmarks = await getBookmarks() + const currentBookmarks = await getCurrentBookmarks() + const defaultBookmark = await getDefaultBookmark() + + const currentKeys = new Set( + currentBookmarks.map((b) => `${b.name}@${b.remote ?? ""}`), + ) + const candidates = allBookmarks.filter( + (b) => !currentKeys.has(`${b.name}@${b.remote ?? ""}`), + ) + + if (candidates.length === 0) { + api.ui.toast({ + message: "No other bookmarks found", + variant: "error", + }) + return + } + + // Sort: default first, then local before remote + const defaultKey = defaultBookmark + ? `${defaultBookmark.name}@${defaultBookmark.remote ?? ""}` + : null + const sorted = candidates.sort((a, b) => { + const aKey = `${a.name}@${a.remote ?? ""}` + const bKey = `${b.name}@${b.remote ?? ""}` + if (aKey === defaultKey) return -1 + if (bKey === defaultKey) return 1 + if (!!a.remote !== !!b.remote) return a.remote ? 1 : -1 + return bookmarkLabel(a).localeCompare(bookmarkLabel(b)) + }) + + const options: TuiDialogSelectOption[] = sorted.map( + (b) => ({ + title: bookmarkLabel(b), + value: b, + description: + `${b.name}@${b.remote ?? ""}` === defaultKey + ? "(default)" + : b.remote + ? `remote: ${b.remote}` + : undefined, + }), + ) + + api.ui.dialog.replace( + () => + api.ui.DialogSelect({ + title: "Base bookmark", + placeholder: "Filter bookmarks...", + options, + onSelect: (option) => { + api.ui.dialog.clear() + void startReview({ + type: "baseBookmark", + bookmark: option.value.name, + remote: option.value.remote, + }) + }, + }), + () => api.ui.dialog.clear(), + ) + } + + async function showChangeSelector(): Promise { + api.ui.toast({ message: "Loading changes...", variant: "info" }) + + const changes = await getRecentChanges() + if (changes.length === 0) { + api.ui.toast({ message: "No changes found", variant: "error" }) + return + } + + const options: TuiDialogSelectOption[] = changes.map((c) => ({ + title: `${c.changeId} ${c.title}`, + value: c, + })) + + api.ui.dialog.replace( + () => + api.ui.DialogSelect({ + title: "Change to review", + placeholder: "Filter changes...", + options, + onSelect: (option) => { + api.ui.dialog.clear() + void startReview({ + type: "change", + changeId: option.value.changeId, + title: option.value.title, + }) + }, + }), + () => api.ui.dialog.clear(), + ) + } + + function showPrInput(): void { + api.ui.dialog.replace( + () => + api.ui.DialogPrompt({ + title: "PR number or URL", + placeholder: + "123 or https://github.com/owner/repo/pull/123", + onConfirm: (value) => { + const prNumber = parsePrRef(value) + if (!prNumber) { + api.ui.toast({ + message: + "Invalid PR reference. Enter a number or GitHub PR URL.", + variant: "error", + }) + return + } + api.ui.dialog.clear() + void handlePrReview(prNumber) + }, + onCancel: () => api.ui.dialog.clear(), + }), + () => api.ui.dialog.clear(), + ) + } + + async function handlePrReview(prNumber: number): Promise { + api.ui.toast({ + message: `Materializing PR #${prNumber}...`, + variant: "info", + duration: 10000, + }) + + const result = await materializePr(prNumber) + if (!result.ok) { + api.ui.toast({ message: result.error, variant: "error" }) + return + } + + api.ui.toast({ + message: `PR #${prNumber} materialized: ${result.title}`, + variant: "success", + }) + + await startReview({ + type: "pullRequest", + prNumber, + baseBookmark: result.baseBookmark, + baseRemote: result.baseRemote, + title: result.title, + }) + } + + function showFolderInput(): void { + api.ui.dialog.replace( + () => + api.ui.DialogPrompt({ + title: "Paths to review", + placeholder: "src docs lib/utils.ts", + onConfirm: (value) => { + const paths = value + .split(/\s+/) + .map((p) => p.trim()) + .filter(Boolean) + if (paths.length === 0) { + api.ui.toast({ + message: "No paths provided", + variant: "error", + }) + return + } + api.ui.dialog.clear() + void startReview({ type: "folder", paths }) + }, + onCancel: () => api.ui.dialog.clear(), + }), + () => api.ui.dialog.clear(), + ) + } + + // -- jj repo check ------------------------------------------------------- + + const inJjRepo = await isJjRepo() + + // -- command registration ------------------------------------------------ + + api.command.register(() => + inJjRepo + ? [ + { + title: "Review code changes", + value: "review", + description: + "Working-copy, bookmark, change, PR, or folder", + slash: { name: "review" }, + onSelect: () => showReviewSelector(), + }, + ] + : [], + ) } diff --git a/.pi/todos/95b075f0.md b/.pi/todos/95b075f0.md deleted file mode 100644 index 9e25f3e..0000000 --- a/.pi/todos/95b075f0.md +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "95b075f0", - "title": "Fix Wipr 2 mas installation failure in nixos-config", - "tags": [ - "bugfix", - "mas", - "nix-darwin" - ], - "status": "in_progress", - "created_at": "2026-03-29T18:55:14.812Z", - "assigned_to_session": "8318f7d4-ccd1-4467-b7c9-fb05e53e4a1d" -} diff --git a/.pi/todos/e3f0bbbb.md b/.pi/todos/e3f0bbbb.md deleted file mode 100644 index f653a68..0000000 --- a/.pi/todos/e3f0bbbb.md +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "e3f0bbbb", - "title": "Restore opencode alongside pi in nixos-config", - "tags": [ - "history", - "nix", - "pi", - "opencode" - ], - "status": "in_progress", - "created_at": "2026-03-31T20:49:44.402Z", - "assigned_to_session": "5607879f-b81a-4343-aa36-826e15630afc" -} - -Find the repository change that replaced opencode with pi, then restore opencode while keeping pi side-by-side. Validate formatting/build/checks as required by AGENTS.md. diff --git a/flake.lock b/flake.lock index 48326e8..42cdd59 100644 --- a/flake.lock +++ b/flake.lock @@ -850,86 +850,6 @@ "type": "github" } }, - "pi-agent-stuff": { - "flake": false, - "locked": { - "lastModified": 1774868285, - "narHash": "sha256-JKMqt5ionfF/aBFTSQe9BD49wAErNtEnf3Mnekk3nzk=", - "owner": "mitsuhiko", - "repo": "agent-stuff", - "rev": "80e1e96fa563ffc0c9d60422eac6dc9e67440385", - "type": "github" - }, - "original": { - "owner": "mitsuhiko", - "repo": "agent-stuff", - "type": "github" - } - }, - "pi-elixir": { - "flake": false, - "locked": { - "lastModified": 1772900407, - "narHash": "sha256-QoCPVdN5CYGe5288cJQmB10ds/UOucHIyG9z9E/4hsw=", - "owner": "dannote", - "repo": "pi-elixir", - "rev": "3b8f667beb696ce6ed456e762bfcf61e7326f5c4", - "type": "github" - }, - "original": { - "owner": "dannote", - "repo": "pi-elixir", - "type": "github" - } - }, - "pi-harness": { - "flake": false, - "locked": { - "lastModified": 1774881866, - "narHash": "sha256-d92ZkKIDQuI8a6WVTIedusmANn0nSQ2iteg8EQkdHmI=", - "owner": "aliou", - "repo": "pi-harness", - "rev": "ea8a2be4156f16761ee508fd538d526d2fca674f", - "type": "github" - }, - "original": { - "owner": "aliou", - "repo": "pi-harness", - "type": "github" - } - }, - "pi-mcp-adapter": { - "flake": false, - "locked": { - "lastModified": 1774247177, - "narHash": "sha256-HTexm+b+UUbJD4qwIqlNcVPhF/G7/MtBtXa0AdeztbY=", - "owner": "nicobailon", - "repo": "pi-mcp-adapter", - "rev": "c0919a29d263c2058c302641ddb04769c21be262", - "type": "github" - }, - "original": { - "owner": "nicobailon", - "repo": "pi-mcp-adapter", - "type": "github" - } - }, - "pi-rose-pine": { - "flake": false, - "locked": { - "lastModified": 1770936151, - "narHash": "sha256-6TzuWJPAn8zz+lUjZ3slFCNdPVd/Z2C+WoXFsLopk1g=", - "owner": "zenobi-us", - "repo": "pi-rose-pine", - "rev": "9b342f6e16d6b28c00c2f888ba2f050273981bdb", - "type": "github" - }, - "original": { - "owner": "zenobi-us", - "repo": "pi-rose-pine", - "type": "github" - } - }, "pimalaya": { "flake": false, "locked": { @@ -994,11 +914,6 @@ "nixpkgs" ], "nixvim": "nixvim", - "pi-agent-stuff": "pi-agent-stuff", - "pi-elixir": "pi-elixir", - "pi-harness": "pi-harness", - "pi-mcp-adapter": "pi-mcp-adapter", - "pi-rose-pine": "pi-rose-pine", "qmd": "qmd", "sops-nix": "sops-nix", "zjstatus": "zjstatus" diff --git a/flake.nix b/flake.nix index 8fd6bf3..544fe9e 100644 --- a/flake.nix +++ b/flake.nix @@ -68,26 +68,6 @@ nixpkgs.url = "github:nixos/nixpkgs/master"; nixpkgs-lib.follows = "nixpkgs"; nixvim.url = "github:nix-community/nixvim"; - pi-agent-stuff = { - url = "github:mitsuhiko/agent-stuff"; - flake = false; - }; - pi-elixir = { - url = "github:dannote/pi-elixir"; - flake = false; - }; - pi-harness = { - url = "github:aliou/pi-harness"; - flake = false; - }; - pi-mcp-adapter = { - url = "github:nicobailon/pi-mcp-adapter"; - flake = false; - }; - pi-rose-pine = { - url = "github:zenobi-us/pi-rose-pine"; - flake = false; - }; qmd.url = "github:tobi/qmd"; sops-nix = { url = "github:Mic92/sops-nix"; diff --git a/modules/_ai-tools/AGENTS.md b/modules/_ai-tools/AGENTS.md deleted file mode 100644 index b4ebc26..0000000 --- a/modules/_ai-tools/AGENTS.md +++ /dev/null @@ -1,27 +0,0 @@ -# AGENTS.md - -## Version Control - -- Use `jj` for version control, not `git`. -- `jj tug` is an alias for `jj bookmark move --from closest_bookmark(@-) --to @-`. -- Never attempt historically destructive Git commands. -- Make small, frequent commits. - -## Scripting - -- Use Nushell (`nu`) for scripting. -- Do not use Python, Perl, Lua, awk, or any other scripting language. You are programatically blocked from doing so. - -## Workflow - -- Always complete the requested work. -- If there is any ambiguity about what to do next, do NOT make a decision yourself. Stop your work and ask. -- Do not end with “If you want me to…” or “I can…”; take the next necessary step and finish the job without waiting for additional confirmation. -- Do not future-proof things. Stick to the original plan. -- Do not add fallbacks or backward compatibility unless explicitly required by the user. By default, replace the previous implementation with the new one entirely. - -## Validation - -- Do not ignore failing tests or checks, even if they appear unrelated to your changes. -- After completing and validating your work, the final step is to run the project's full validation and test commands and ensure they all pass. - diff --git a/modules/_ai-tools/extensions/no-git.ts b/modules/_ai-tools/extensions/no-git.ts deleted file mode 100644 index a9bad2b..0000000 --- a/modules/_ai-tools/extensions/no-git.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * No Git Extension - * - * Blocks direct git invocations and tells the LLM to use jj (Jujutsu) instead. - * Mentions of the word "git" in search patterns, strings, comments, etc. are allowed. - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; - -type ShellToken = - | { type: "word"; value: string } - | { type: "operator"; value: string }; - -const COMMAND_PREFIXES = new Set(["env", "command", "builtin", "time", "sudo", "nohup", "nice"]); -const SHELL_KEYWORDS = new Set(["if", "then", "elif", "else", "do", "while", "until", "case", "in"]); -const SHELL_INTERPRETERS = new Set(["bash", "sh", "zsh", "fish", "nu"]); - -function isAssignmentWord(value: string): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(value); -} - -function tokenizeShell(command: string): ShellToken[] { - const tokens: ShellToken[] = []; - let current = ""; - let quote: "'" | '"' | null = null; - - const pushWord = () => { - if (!current) return; - tokens.push({ type: "word", value: current }); - current = ""; - }; - - for (let i = 0; i < command.length; i++) { - const char = command[i]; - - if (quote) { - if (quote === "'") { - if (char === "'") { - quote = null; - } else { - current += char; - } - continue; - } - - if (char === '"') { - quote = null; - continue; - } - - if (char === "\\") { - if (i + 1 < command.length) { - current += command[i + 1]; - i += 1; - } - continue; - } - - current += char; - continue; - } - - if (char === "'" || char === '"') { - quote = char; - continue; - } - - if (char === "\\") { - if (i + 1 < command.length) { - current += command[i + 1]; - i += 1; - } - continue; - } - - if (/\s/.test(char)) { - pushWord(); - if (char === "\n") { - tokens.push({ type: "operator", value: "\n" }); - } - continue; - } - - const twoCharOperator = command.slice(i, i + 2); - if (twoCharOperator === "&&" || twoCharOperator === "||") { - pushWord(); - tokens.push({ type: "operator", value: twoCharOperator }); - i += 1; - continue; - } - - if (char === ";" || char === "|" || char === "(" || char === ")") { - pushWord(); - tokens.push({ type: "operator", value: char }); - continue; - } - - current += char; - } - - pushWord(); - return tokens; -} - -function findCommandWord(words: string[]): { word?: string; index: number } { - for (let i = 0; i < words.length; i++) { - const word = words[i]; - if (SHELL_KEYWORDS.has(word)) { - continue; - } - if (isAssignmentWord(word)) { - continue; - } - if (COMMAND_PREFIXES.has(word)) { - continue; - } - - return { word, index: i }; - } - - return { index: words.length }; -} - -function getInlineShellCommand(words: string[], commandIndex: number): string | null { - for (let i = commandIndex + 1; i < words.length; i++) { - const word = words[i]; - if (/^(?:-[A-Za-z]*c[A-Za-z]*|--command)$/.test(word)) { - return words[i + 1] ?? null; - } - } - - return null; -} - -function segmentContainsBlockedGit(words: string[]): boolean { - const { word, index } = findCommandWord(words); - if (!word) { - return false; - } - - if (word === "git") { - return true; - } - - if (word === "jj") { - return false; - } - - if (SHELL_INTERPRETERS.has(word)) { - const inlineCommand = getInlineShellCommand(words, index); - return inlineCommand ? containsBlockedGitInvocation(inlineCommand) : false; - } - - return false; -} - -function containsBlockedGitInvocation(command: string): boolean { - const tokens = tokenizeShell(command); - let words: string[] = []; - - for (const token of tokens) { - if (token.type === "operator") { - if (segmentContainsBlockedGit(words)) { - return true; - } - words = []; - continue; - } - - words.push(token.value); - } - - return segmentContainsBlockedGit(words); -} - -export default function (pi: ExtensionAPI) { - pi.on("tool_call", async (event, _ctx) => { - if (!isToolCallEventType("bash", event)) return; - - const command = event.input.command.trim(); - - if (containsBlockedGitInvocation(command)) { - return { - block: true, - reason: "git is not used in this project. Use jj (Jujutsu) instead.", - }; - } - }); -} diff --git a/modules/_ai-tools/extensions/no-scripting.ts b/modules/_ai-tools/extensions/no-scripting.ts deleted file mode 100644 index 79b6c1f..0000000 --- a/modules/_ai-tools/extensions/no-scripting.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * No Scripting Extension - * - * Blocks python, perl, ruby, php, lua, node -e, and inline bash/sh scripts. - * Tells the LLM to use `nu -c` instead. - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; - -const SCRIPTING_PATTERN = - /(?:^|[;&|]\s*|&&\s*|\|\|\s*|\$\(\s*|`\s*)(?:python[23]?|perl|ruby|php|lua|node\s+-e|bash\s+-c|sh\s+-c)\s/; - -export default function (pi: ExtensionAPI) { - pi.on("tool_call", async (event, _ctx) => { - if (!isToolCallEventType("bash", event)) return; - - const command = event.input.command.trim(); - - if (SCRIPTING_PATTERN.test(command)) { - return { - block: true, - reason: - "Do not use python, perl, ruby, php, lua, node -e, or inline bash/sh for scripting. Use `nu -c` instead.", - }; - } - }); -} diff --git a/modules/_ai-tools/extensions/note-ingest.ts b/modules/_ai-tools/extensions/note-ingest.ts deleted file mode 100644 index 83644d6..0000000 --- a/modules/_ai-tools/extensions/note-ingest.ts +++ /dev/null @@ -1,687 +0,0 @@ -import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import * as crypto from "node:crypto"; -import { Box, Text } from "@mariozechner/pi-tui"; -import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, Model } from "@mariozechner/pi-coding-agent"; -import { - createAgentSession, - DefaultResourceLoader, - getAgentDir, - SessionManager, - SettingsManager, -} from "@mariozechner/pi-coding-agent"; - -interface IngestManifest { - version: number; - job_id: string; - note_id: string; - operation: string; - requested_at: string; - title: string; - source_relpath: string; - source_path: string; - input_path: string; - archive_path: string; - output_path: string; - transcript_path: string; - result_path: string; - session_dir: string; - source_hash: string; - last_generated_output_hash?: string | null; - force_overwrite_generated?: boolean; - source_transport?: string; -} - -interface IngestResult { - success: boolean; - job_id: string; - note_id: string; - archive_path: string; - source_hash: string; - session_dir: string; - output_path?: string; - output_hash?: string; - conflict_path?: string; - write_mode?: "create" | "overwrite" | "force-overwrite" | "conflict"; - updated_main_output?: boolean; - transcript_path?: string; - error?: string; -} - -interface FrontmatterInfo { - values: Record; - body: string; -} - -interface RenderedPage { - path: string; - image: { - type: "image"; - source: { - type: "base64"; - mediaType: string; - data: string; - }; - }; -} - -const TRANSCRIBE_SKILL = "notability-transcribe"; -const NORMALIZE_SKILL = "notability-normalize"; -const STATUS_TYPE = "notability-status"; -const DEFAULT_TRANSCRIBE_THINKING = "low" as const; -const DEFAULT_NORMALIZE_THINKING = "off" as const; -const PREFERRED_VISION_MODEL: [string, string] = ["openai-codex", "gpt-5.4"]; - -function getNotesRoot(): string { - return process.env.NOTABILITY_NOTES_DIR ?? path.join(os.homedir(), "Notes"); -} - -function getDataRoot(): string { - return process.env.NOTABILITY_DATA_ROOT ?? path.join(os.homedir(), ".local", "share", "notability-ingest"); -} - -function getRenderRoot(): string { - return process.env.NOTABILITY_RENDER_ROOT ?? path.join(getDataRoot(), "rendered-pages"); -} - -function getNotabilityScriptDir(): string { - return path.join(getAgentDir(), "notability"); -} - -function getSkillPath(skillName: string): string { - return path.join(getAgentDir(), "skills", skillName, "SKILL.md"); -} - -function stripFrontmatterBlock(text: string): string { - const trimmed = text.trim(); - if (!trimmed.startsWith("---\n")) return trimmed; - const end = trimmed.indexOf("\n---\n", 4); - if (end === -1) return trimmed; - return trimmed.slice(end + 5).trim(); -} - -function stripCodeFence(text: string): string { - const trimmed = text.trim(); - const match = trimmed.match(/^```(?:markdown|md)?\n([\s\S]*?)\n```$/i); - return match ? match[1].trim() : trimmed; -} - -function parseFrontmatter(text: string): FrontmatterInfo { - const trimmed = stripCodeFence(text); - if (!trimmed.startsWith("---\n")) { - return { values: {}, body: trimmed }; - } - - const end = trimmed.indexOf("\n---\n", 4); - if (end === -1) { - return { values: {}, body: trimmed }; - } - - const block = trimmed.slice(4, end); - const body = trimmed.slice(end + 5).trim(); - const values: Record = {}; - for (const line of block.split("\n")) { - const idx = line.indexOf(":"); - if (idx === -1) continue; - const key = line.slice(0, idx).trim(); - const value = line.slice(idx + 1).trim(); - values[key] = value; - } - return { values, body }; -} - -function quoteYaml(value: string): string { - return JSON.stringify(value); -} - -function sha256(content: string | Buffer): string { - return crypto.createHash("sha256").update(content).digest("hex"); -} - -async function sha256File(filePath: string): Promise { - const buffer = await readFile(filePath); - return sha256(buffer); -} - -function extractTitle(normalized: string, fallbackTitle: string): string { - const parsed = parseFrontmatter(normalized); - const frontmatterTitle = parsed.values.title?.replace(/^['"]|['"]$/g, "").trim(); - if (frontmatterTitle) return frontmatterTitle; - const heading = parsed.body - .split("\n") - .map((line) => line.trim()) - .find((line) => line.startsWith("# ")); - if (heading) return heading.replace(/^#\s+/, "").trim(); - return fallbackTitle; -} - -function sourceFormat(filePath: string): string { - const extension = path.extname(filePath).toLowerCase(); - if (extension === ".pdf") return "pdf"; - if (extension === ".png") return "png"; - return extension.replace(/^\./, "") || "unknown"; -} - -function buildMarkdown(manifest: IngestManifest, normalized: string): string { - const parsed = parseFrontmatter(normalized); - const title = extractTitle(normalized, manifest.title); - const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); - const created = manifest.requested_at.slice(0, 10); - const body = parsed.body.trim(); - const outputBody = body.length > 0 ? body : `# ${title}\n`; - - return [ - "---", - `title: ${quoteYaml(title)}`, - `created: ${quoteYaml(created)}`, - `updated: ${quoteYaml(now.slice(0, 10))}`, - `source: ${quoteYaml("notability")}`, - `source_transport: ${quoteYaml(manifest.source_transport ?? "webdav")}`, - `source_relpath: ${quoteYaml(manifest.source_relpath)}`, - `note_id: ${quoteYaml(manifest.note_id)}`, - `managed_by: ${quoteYaml("notability-ingest")}`, - `source_file: ${quoteYaml(manifest.archive_path)}`, - `source_file_hash: ${quoteYaml(`sha256:${manifest.source_hash}`)}`, - `source_format: ${quoteYaml(sourceFormat(manifest.archive_path))}`, - `status: ${quoteYaml("active")}`, - "tags:", - " - handwritten", - " - notability", - "---", - "", - outputBody, - "", - ].join("\n"); -} - -function conflictPathFor(outputPath: string): string { - const parsed = path.parse(outputPath); - const stamp = new Date().toISOString().replace(/[:]/g, "-").replace(/\.\d{3}Z$/, "Z"); - return path.join(parsed.dir, `${parsed.name}.conflict-${stamp}${parsed.ext}`); -} - -async function ensureParent(filePath: string): Promise { - await mkdir(path.dirname(filePath), { recursive: true }); -} - -async function loadSkillText(skillName: string): Promise { - const raw = await readFile(getSkillPath(skillName), "utf8"); - return stripFrontmatterBlock(raw).trim(); -} - -function normalizePathArg(arg: string): string { - return arg.startsWith("@") ? arg.slice(1) : arg; -} - -function resolveModel(ctx: ExtensionContext, requireImage = false): Model { - const available = ctx.modelRegistry.getAvailable(); - const matching = requireImage ? available.filter((model) => model.input.includes("image")) : available; - - if (matching.length === 0) { - throw new Error( - requireImage - ? "No image-capable model configured for pi note ingestion" - : "No available model configured for pi note ingestion", - ); - } - - if (ctx.model && (!requireImage || ctx.model.input.includes("image"))) { - if (!requireImage) return ctx.model; - } - - if (requireImage) { - const [provider, id] = PREFERRED_VISION_MODEL; - const preferred = matching.find((model) => model.provider === provider && model.id === id); - if (preferred) return preferred; - - const subscriptionModel = matching.find( - (model) => model.provider !== "opencode" && model.provider !== "opencode-go", - ); - if (subscriptionModel) return subscriptionModel; - } - - if (ctx.model && (!requireImage || ctx.model.input.includes("image"))) { - return ctx.model; - } - - return matching[0]; -} - -async function runSkillPrompt( - ctx: ExtensionContext, - systemPrompt: string, - prompt: string, - images: RenderedPage[] = [], - thinkingLevel: "off" | "low" = "off", -): Promise { - if (images.length > 0) { - const model = resolveModel(ctx, true); - const { execFile } = await import("node:child_process"); - const promptPath = path.join(os.tmpdir(), `pi-note-ingest-${crypto.randomUUID()}.md`); - await writeFile(promptPath, `${prompt}\n`); - const args = [ - "45s", - "pi", - "--model", - `${model.provider}/${model.id}`, - "--thinking", - thinkingLevel, - "--no-tools", - "--no-session", - "-p", - ...images.map((page) => `@${page.path}`), - `@${promptPath}`, - ]; - - try { - const output = await new Promise((resolve, reject) => { - execFile("timeout", args, { cwd: ctx.cwd, env: process.env, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { - if ((stdout ?? "").trim().length > 0) { - resolve(stdout); - return; - } - if (error) { - reject(new Error(stderr || stdout || error.message)); - return; - } - resolve(stdout); - }); - }); - - return stripCodeFence(output).trim(); - } finally { - try { - fs.unlinkSync(promptPath); - } catch { - // Ignore temp file cleanup failures. - } - } - } - - const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(ctx.cwd, agentDir); - const resourceLoader = new DefaultResourceLoader({ - cwd: ctx.cwd, - agentDir, - settingsManager, - noExtensions: true, - noPromptTemplates: true, - noThemes: true, - noSkills: true, - systemPromptOverride: () => systemPrompt, - appendSystemPromptOverride: () => [], - agentsFilesOverride: () => ({ agentsFiles: [] }), - }); - await resourceLoader.reload(); - - const { session } = await createAgentSession({ - model: resolveModel(ctx, images.length > 0), - thinkingLevel, - sessionManager: SessionManager.inMemory(), - modelRegistry: ctx.modelRegistry, - resourceLoader, - tools: [], - }); - - let output = ""; - const unsubscribe = session.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - output += event.assistantMessageEvent.delta; - } - }); - - try { - await session.prompt(prompt, { - images: images.map((page) => page.image), - }); - } finally { - unsubscribe(); - } - - if (!output.trim()) { - const assistantMessages = session.messages.filter((message) => message.role === "assistant"); - const lastAssistant = assistantMessages.at(-1); - if (lastAssistant && Array.isArray(lastAssistant.content)) { - output = lastAssistant.content - .filter((part) => part.type === "text") - .map((part) => part.text) - .join(""); - } - } - - session.dispose(); - return stripCodeFence(output).trim(); -} - -async function renderPdfPages(pdfPath: string, jobId: string): Promise { - const renderDir = path.join(getRenderRoot(), jobId); - await mkdir(renderDir, { recursive: true }); - const prefix = path.join(renderDir, "page"); - const args = ["-png", "-r", "200", pdfPath, prefix]; - const { execFile } = await import("node:child_process"); - await new Promise((resolve, reject) => { - execFile("pdftoppm", args, (error) => { - if (error) reject(error); - else resolve(); - }); - }); - - const entries = await readdir(renderDir); - const pngs = entries - .filter((entry) => entry.endsWith(".png")) - .sort((left, right) => left.localeCompare(right, undefined, { numeric: true })); - if (pngs.length === 0) { - throw new Error(`No rendered pages produced for ${pdfPath}`); - } - - const pages: RenderedPage[] = []; - for (const entry of pngs) { - const pagePath = path.join(renderDir, entry); - const buffer = await readFile(pagePath); - pages.push({ - path: pagePath, - image: { - type: "image", - source: { - type: "base64", - mediaType: "image/png", - data: buffer.toString("base64"), - }, - }, - }); - } - return pages; -} - -async function loadImagePage(imagePath: string): Promise { - const extension = path.extname(imagePath).toLowerCase(); - const mediaType = extension === ".png" ? "image/png" : undefined; - if (!mediaType) { - throw new Error(`Unsupported image input format for ${imagePath}`); - } - - const buffer = await readFile(imagePath); - return { - path: imagePath, - image: { - type: "image", - source: { - type: "base64", - mediaType, - data: buffer.toString("base64"), - }, - }, - }; -} - -async function renderInputPages(inputPath: string, jobId: string): Promise { - const extension = path.extname(inputPath).toLowerCase(); - if (extension === ".pdf") { - return await renderPdfPages(inputPath, jobId); - } - if (extension === ".png") { - return [await loadImagePage(inputPath)]; - } - throw new Error(`Unsupported Notability input format: ${inputPath}`); -} - -async function findManagedOutputs(noteId: string): Promise { - const matches: string[] = []; - const stack = [getNotesRoot()]; - - while (stack.length > 0) { - const currentDir = stack.pop(); - if (!currentDir || !fs.existsSync(currentDir)) continue; - - const entries = await readdir(currentDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name.startsWith(".")) continue; - const fullPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - stack.push(fullPath); - continue; - } - if (!entry.isFile() || !entry.name.endsWith(".md")) continue; - - try { - const parsed = parseFrontmatter(await readFile(fullPath, "utf8")); - const managedBy = parsed.values.managed_by?.replace(/^['"]|['"]$/g, ""); - const frontmatterNoteId = parsed.values.note_id?.replace(/^['"]|['"]$/g, ""); - if (managedBy === "notability-ingest" && frontmatterNoteId === noteId) { - matches.push(fullPath); - } - } catch { - // Ignore unreadable or malformed files while scanning the notebook. - } - } - } - - return matches.sort(); -} - -async function resolveManagedOutputPath(noteId: string, configuredOutputPath: string): Promise { - if (fs.existsSync(configuredOutputPath)) { - const parsed = parseFrontmatter(await readFile(configuredOutputPath, "utf8")); - const managedBy = parsed.values.managed_by?.replace(/^['"]|['"]$/g, ""); - const frontmatterNoteId = parsed.values.note_id?.replace(/^['"]|['"]$/g, ""); - if (managedBy === "notability-ingest" && frontmatterNoteId === noteId) { - return configuredOutputPath; - } - } - - const discovered = await findManagedOutputs(noteId); - if (discovered.length === 0) return configuredOutputPath; - if (discovered.length === 1) return discovered[0]; - - throw new Error( - `Multiple managed note files found for ${noteId}: ${discovered.join(", ")}`, - ); -} - -async function determineWriteTarget(manifest: IngestManifest, markdown: string): Promise<{ - outputPath: string; - writePath: string; - writeMode: "create" | "overwrite" | "force-overwrite" | "conflict"; - updatedMainOutput: boolean; -}> { - const outputPath = await resolveManagedOutputPath(manifest.note_id, manifest.output_path); - if (!fs.existsSync(outputPath)) { - return { outputPath, writePath: outputPath, writeMode: "create", updatedMainOutput: true }; - } - - const existing = await readFile(outputPath, "utf8"); - const existingHash = sha256(existing); - const parsed = parseFrontmatter(existing); - const isManaged = parsed.values.managed_by?.replace(/^['"]|['"]$/g, "") === "notability-ingest"; - const sameNoteId = parsed.values.note_id?.replace(/^['"]|['"]$/g, "") === manifest.note_id; - - if (manifest.last_generated_output_hash && existingHash === manifest.last_generated_output_hash) { - return { outputPath, writePath: outputPath, writeMode: "overwrite", updatedMainOutput: true }; - } - - if (manifest.force_overwrite_generated && isManaged && sameNoteId) { - return { outputPath, writePath: outputPath, writeMode: "force-overwrite", updatedMainOutput: true }; - } - - return { - outputPath, - writePath: conflictPathFor(outputPath), - writeMode: "conflict", - updatedMainOutput: false, - }; -} - -async function writeIngestResult(resultPath: string, payload: IngestResult): Promise { - await ensureParent(resultPath); - await writeFile(resultPath, JSON.stringify(payload, null, 2)); -} - -async function ingestManifest(manifestPath: string, ctx: ExtensionContext): Promise { - const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as IngestManifest; - await ensureParent(manifest.transcript_path); - await ensureParent(manifest.result_path); - await mkdir(manifest.session_dir, { recursive: true }); - - const normalizeSkill = await loadSkillText(NORMALIZE_SKILL); - const pages = await renderInputPages(manifest.input_path, manifest.job_id); - const pageSummary = pages.map((page, index) => `- page ${index + 1}: ${page.path}`).join("\n"); - const transcriptPrompt = [ - "Transcribe this note into clean Markdown.", - "Read it like a human and preserve the intended reading order and visible structure.", - "Keep headings, lists, and paragraphs when they are visible.", - "Do not summarize. Do not add commentary. Return Markdown only.", - "Rendered pages:", - pageSummary, - ].join("\n\n"); - let transcript = await runSkillPrompt( - ctx, - "", - transcriptPrompt, - pages, - DEFAULT_TRANSCRIBE_THINKING, - ); - if (!transcript.trim()) { - throw new Error("Transcription skill returned empty output"); - } - await writeFile(manifest.transcript_path, `${transcript.trim()}\n`); - - const normalizePrompt = [ - `Note ID: ${manifest.note_id}`, - `Source path: ${manifest.source_relpath}`, - `Preferred output path: ${manifest.output_path}`, - "Normalize the following transcription into clean Markdown.", - "Restore natural prose formatting and intended reading order when the transcription contains OCR or layout artifacts.", - "If words are split across separate lines but clearly belong to the same phrase or sentence, merge them.", - "Return only Markdown. No code fences.", - "", - "", - transcript.trim(), - "", - ].join("\n"); - const normalized = await runSkillPrompt( - ctx, - normalizeSkill, - normalizePrompt, - [], - DEFAULT_NORMALIZE_THINKING, - ); - if (!normalized.trim()) { - throw new Error("Normalization skill returned empty output"); - } - - const markdown = buildMarkdown(manifest, normalized); - const target = await determineWriteTarget(manifest, markdown); - await ensureParent(target.writePath); - await writeFile(target.writePath, markdown); - - const result: IngestResult = { - success: true, - job_id: manifest.job_id, - note_id: manifest.note_id, - archive_path: manifest.archive_path, - source_hash: manifest.source_hash, - session_dir: manifest.session_dir, - output_path: target.outputPath, - output_hash: target.updatedMainOutput ? await sha256File(target.writePath) : undefined, - conflict_path: target.writeMode === "conflict" ? target.writePath : undefined, - write_mode: target.writeMode, - updated_main_output: target.updatedMainOutput, - transcript_path: manifest.transcript_path, - }; - await writeIngestResult(manifest.result_path, result); - return result; -} - -async function runScript(scriptName: string, args: string[]): Promise { - const { execFile } = await import("node:child_process"); - const scriptPath = path.join(getNotabilityScriptDir(), scriptName); - return await new Promise((resolve, reject) => { - execFile("nu", [scriptPath, ...args], (error, stdout, stderr) => { - if (error) { - reject(new Error(stderr || stdout || error.message)); - return; - } - resolve(stdout.trim()); - }); - }); -} - -function splitArgs(input: string): string[] { - return input - .trim() - .split(/\s+/) - .filter((part) => part.length > 0); -} - -function postStatus(pi: ExtensionAPI, content: string): void { - pi.sendMessage({ - customType: STATUS_TYPE, - content, - display: true, - }); -} - -export default function noteIngestExtension(pi: ExtensionAPI) { - pi.registerMessageRenderer(STATUS_TYPE, (message, _options, theme) => { - const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); - box.addChild(new Text(message.content, 0, 0)); - return box; - }); - - pi.registerCommand("note-status", { - description: "Show Notability ingest status", - handler: async (args, _ctx) => { - const output = await runScript("status.nu", splitArgs(args)); - postStatus(pi, output.length > 0 ? output : "No status output"); - }, - }); - - pi.registerCommand("note-reingest", { - description: "Enqueue a note for reingestion", - handler: async (args, _ctx) => { - const trimmed = args.trim(); - if (!trimmed) { - postStatus(pi, "Usage: /note-reingest [--latest-source|--latest-archive] [--force-overwrite-generated]"); - return; - } - const output = await runScript("reingest.nu", splitArgs(trimmed)); - postStatus(pi, output.length > 0 ? output : "Reingest enqueued"); - }, - }); - - pi.registerCommand("note-ingest", { - description: "Ingest a queued Notability job manifest", - handler: async (args, ctx: ExtensionCommandContext) => { - const manifestPath = normalizePathArg(args.trim()); - if (!manifestPath) { - throw new Error("Usage: /note-ingest "); - } - - let resultPath = ""; - try { - const raw = await readFile(manifestPath, "utf8"); - const manifest = JSON.parse(raw) as IngestManifest; - resultPath = manifest.result_path; - const result = await ingestManifest(manifestPath, ctx); - postStatus(pi, `Ingested ${result.note_id} (${result.write_mode})`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (resultPath) { - const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as IngestManifest; - await writeIngestResult(resultPath, { - success: false, - job_id: manifest.job_id, - note_id: manifest.note_id, - archive_path: manifest.archive_path, - source_hash: manifest.source_hash, - session_dir: manifest.session_dir, - error: message, - }); - } - throw error; - } - }, - }); -} diff --git a/modules/_ai-tools/extensions/review.ts b/modules/_ai-tools/extensions/review.ts deleted file mode 100644 index ce784db..0000000 --- a/modules/_ai-tools/extensions/review.ts +++ /dev/null @@ -1,2389 +0,0 @@ -/** - * Code Review Extension (inspired by Codex's review feature) - * - * Provides a `/review` command that prompts the agent to review code changes. - * Supports multiple review modes: - * - Review a GitHub pull request (materializes the PR locally with jj) - * - Review against a base bookmark (PR style) - * - Review working-copy changes - * - Review a specific change - * - Shared custom review instructions (applied to all review modes when configured) - * - * Usage: - * - `/review` - show interactive selector - * - `/review pr 123` - review PR #123 (materializes it locally with jj) - * - `/review pr https://github.com/owner/repo/pull/123` - review PR from URL - * - `/review working-copy` - review working-copy changes directly - * - `/review bookmark main` - review against the main bookmark - * - `/review change abc123` - review a specific change - * - `/review folder src docs` - review specific folders/files (snapshot, not diff) - * - `/review` selector includes Add/Remove custom review instructions (applies to all modes) - * - `/review --extra "focus on performance regressions"` - add extra review instruction (works with any mode) - * - * Project-specific review guidelines: - * - If a REVIEW_GUIDELINES.md file exists in the same directory as .pi, - * its contents are appended to the review prompt. - * - * Note: PR review requires a clean working copy (no local jj changes). - */ - -import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; -import { DynamicBorder, BorderedLoader } from "@mariozechner/pi-coding-agent"; -import { - Container, - fuzzyFilter, - Input, - type SelectItem, - SelectList, - Spacer, - Text, -} from "@mariozechner/pi-tui"; -import path from "node:path"; -import { promises as fs } from "node:fs"; - -// State to track fresh session review (where we branched from). -// Module-level state means only one review can be active at a time. -// This is intentional - the UI and /end-review command assume a single active review. -let reviewOriginId: string | undefined = undefined; -let endReviewInProgress = false; -let reviewLoopFixingEnabled = false; -let reviewCustomInstructions: string | undefined = undefined; -let reviewLoopInProgress = false; - -const REVIEW_STATE_TYPE = "review-session"; -const REVIEW_ANCHOR_TYPE = "review-anchor"; -const REVIEW_SETTINGS_TYPE = "review-settings"; -const REVIEW_LOOP_MAX_ITERATIONS = 10; -const REVIEW_LOOP_START_TIMEOUT_MS = 15000; -const REVIEW_LOOP_START_POLL_MS = 50; - -type ReviewSessionState = { - active: boolean; - originId?: string; -}; - -type ReviewSettingsState = { - loopFixingEnabled?: boolean; - customInstructions?: string; -}; - -function setReviewWidget(ctx: ExtensionContext, active: boolean) { - if (!ctx.hasUI) return; - if (!active) { - ctx.ui.setWidget("review", undefined); - return; - } - - ctx.ui.setWidget("review", (_tui, theme) => { - const message = reviewLoopInProgress - ? "Review session active (loop fixing running)" - : reviewLoopFixingEnabled - ? "Review session active (loop fixing enabled), return with /end-review" - : "Review session active, return with /end-review"; - const text = new Text(theme.fg("warning", message), 0, 0); - return { - render(width: number) { - return text.render(width); - }, - invalidate() { - text.invalidate(); - }, - }; - }); -} - -function getReviewState(ctx: ExtensionContext): ReviewSessionState | undefined { - let state: ReviewSessionState | undefined; - for (const entry of ctx.sessionManager.getBranch()) { - if (entry.type === "custom" && entry.customType === REVIEW_STATE_TYPE) { - state = entry.data as ReviewSessionState | undefined; - } - } - - return state; -} - -function applyReviewState(ctx: ExtensionContext) { - const state = getReviewState(ctx); - - if (state?.active && state.originId) { - reviewOriginId = state.originId; - setReviewWidget(ctx, true); - return; - } - - reviewOriginId = undefined; - setReviewWidget(ctx, false); -} - -function getReviewSettings(ctx: ExtensionContext): ReviewSettingsState { - let state: ReviewSettingsState | undefined; - for (const entry of ctx.sessionManager.getEntries()) { - if (entry.type === "custom" && entry.customType === REVIEW_SETTINGS_TYPE) { - state = entry.data as ReviewSettingsState | undefined; - } - } - - return { - loopFixingEnabled: state?.loopFixingEnabled === true, - customInstructions: state?.customInstructions?.trim() || undefined, - }; -} - -function applyReviewSettings(ctx: ExtensionContext) { - const state = getReviewSettings(ctx); - reviewLoopFixingEnabled = state.loopFixingEnabled === true; - reviewCustomInstructions = state.customInstructions?.trim() || undefined; -} - -function parseMarkdownHeading(line: string): { level: number; title: string } | null { - const headingMatch = line.match(/^\s*(#{1,6})\s+(.+?)\s*$/); - if (!headingMatch) { - return null; - } - - const rawTitle = headingMatch[2].replace(/\s+#+\s*$/, "").trim(); - return { - level: headingMatch[1].length, - title: rawTitle, - }; -} - -function getFindingsSectionBounds(lines: string[]): { start: number; end: number } | null { - let start = -1; - let findingsHeadingLevel: number | null = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const heading = parseMarkdownHeading(line); - if (heading && /^findings\b/i.test(heading.title)) { - start = i + 1; - findingsHeadingLevel = heading.level; - break; - } - if (/^\s*findings\s*:?\s*$/i.test(line)) { - start = i + 1; - break; - } - } - - if (start < 0) { - return null; - } - - let end = lines.length; - for (let i = start; i < lines.length; i++) { - const line = lines[i]; - const heading = parseMarkdownHeading(line); - if (heading) { - const normalizedTitle = heading.title.replace(/[*_`]/g, "").trim(); - if (/^(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(normalizedTitle)) { - end = i; - break; - } - - if (/\[P[0-3]\]/i.test(heading.title)) { - continue; - } - - if (findingsHeadingLevel !== null && heading.level <= findingsHeadingLevel) { - end = i; - break; - } - } - - if (/^\s*(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(line)) { - end = i; - break; - } - } - - return { start, end }; -} - -function isLikelyFindingLine(line: string): boolean { - if (!/\[P[0-3]\]/i.test(line)) { - return false; - } - - if (/^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+priority\s+tag\b/i.test(line)) { - return false; - } - - if (/^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+\[P[0-3]\]\s*-\s*(?:drop everything|urgent|normal|low|nice to have)\b/i.test(line)) { - return false; - } - - const allPriorityTags = line.match(/\[P[0-3]\]/gi) ?? []; - if (allPriorityTags.length > 1) { - return false; - } - - if (/^\s*(?:[-*+]|(?:\d+)[.)])\s+/.test(line)) { - return true; - } - - if (/^\s*#{1,6}\s+/.test(line)) { - return true; - } - - if (/^\s*(?:\*\*|__)?\[P[0-3]\](?:\*\*|__)?(?=\s|:|-)/i.test(line)) { - return true; - } - - return false; -} - -function normalizeVerdictValue(value: string): string { - return value - .trim() - .replace(/^[-*+]\s*/, "") - .replace(/^['"`]+|['"`]+$/g, "") - .toLowerCase(); -} - -function isNeedsAttentionVerdictValue(value: string): boolean { - const normalized = normalizeVerdictValue(value); - if (!normalized.includes("needs attention")) { - return false; - } - - if (/\bnot\s+needs\s+attention\b/.test(normalized)) { - return false; - } - - // Reject rubric/choice phrasing like "correct or needs attention", but - // keep legitimate verdict text that may contain unrelated "or". - if (/\bcorrect\b/.test(normalized) && /\bor\b/.test(normalized)) { - return false; - } - - return true; -} - -function hasNeedsAttentionVerdict(messageText: string): boolean { - const lines = messageText.split(/\r?\n/); - - for (const line of lines) { - const inlineMatch = line.match(/^\s*(?:[*-+]\s*)?(?:overall\s+)?verdict\s*:\s*(.+)$/i); - if (inlineMatch && isNeedsAttentionVerdictValue(inlineMatch[1])) { - return true; - } - } - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const heading = parseMarkdownHeading(line); - - let verdictLevel: number | null = null; - if (heading) { - const normalizedHeading = heading.title.replace(/[*_`]/g, "").trim(); - if (!/^(?:overall\s+)?verdict\b/i.test(normalizedHeading)) { - continue; - } - verdictLevel = heading.level; - } else if (!/^\s*(?:overall\s+)?verdict\s*:?\s*$/i.test(line)) { - continue; - } - - for (let j = i + 1; j < lines.length; j++) { - const verdictLine = lines[j]; - const nextHeading = parseMarkdownHeading(verdictLine); - if (nextHeading) { - const normalizedNextHeading = nextHeading.title.replace(/[*_`]/g, "").trim(); - if (verdictLevel === null || nextHeading.level <= verdictLevel) { - break; - } - if (/^(review scope|findings|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(normalizedNextHeading)) { - break; - } - } - - const trimmed = verdictLine.trim(); - if (!trimmed) { - continue; - } - - if (isNeedsAttentionVerdictValue(trimmed)) { - return true; - } - - if (/\bcorrect\b/i.test(normalizeVerdictValue(trimmed))) { - break; - } - } - } - - return false; -} - -function hasBlockingReviewFindings(messageText: string): boolean { - const lines = messageText.split(/\r?\n/); - const bounds = getFindingsSectionBounds(lines); - const candidateLines = bounds ? lines.slice(bounds.start, bounds.end) : lines; - - let inCodeFence = false; - let foundTaggedFinding = false; - for (const line of candidateLines) { - if (/^\s*```/.test(line)) { - inCodeFence = !inCodeFence; - continue; - } - if (inCodeFence) { - continue; - } - - if (!isLikelyFindingLine(line)) { - continue; - } - - foundTaggedFinding = true; - if (/\[(P0|P1|P2)\]/i.test(line)) { - return true; - } - } - - if (foundTaggedFinding) { - return false; - } - - return hasNeedsAttentionVerdict(messageText); -} - -// Review target types (matching Codex's approach) -type BookmarkRef = { - name: string; - remote?: string; -}; - -type ReviewTarget = - | { type: "workingCopy" } - | { type: "baseBookmark"; bookmark: string; remote?: string } - | { type: "change"; changeId: string; title?: string } - | { type: "pullRequest"; prNumber: number; baseBookmark: string; baseRemote?: string; title: string } - | { type: "folder"; paths: string[] }; - -// Prompts (adapted from Codex) -const WORKING_COPY_PROMPT = - "Review the current working-copy changes (including new files) and provide prioritized findings."; - -const LOCAL_CHANGES_REVIEW_INSTRUCTIONS = - "Also include local working-copy changes (including new files) on top of this bookmark. Use `jj status`, `jj diff --summary`, and `jj diff` so local fixes are part of this review cycle."; - -const BASE_BOOKMARK_PROMPT_WITH_MERGE_BASE = - "Review the code changes against the base bookmark '{baseBookmark}'. The merge-base change for this comparison is {mergeBaseChangeId}. Run `jj diff --from {mergeBaseChangeId} --to @` to inspect the changes relative to {baseBookmark}. Provide prioritized, actionable findings."; - -const BASE_BOOKMARK_PROMPT_FALLBACK = - "Review the code changes against the base bookmark '{bookmark}'. Start by finding the merge-base revision between the working copy and {bookmark}, then run `jj diff --from --to @` to see what changes would land on the {bookmark} bookmark. Provide prioritized, actionable findings."; - -const CHANGE_PROMPT_WITH_TITLE = - 'Review the code changes introduced by change {changeId} ("{title}"). Provide prioritized, actionable findings.'; - -const CHANGE_PROMPT = "Review the code changes introduced by change {changeId}. Provide prioritized, actionable findings."; - -const PULL_REQUEST_PROMPT = - 'Review pull request #{prNumber} ("{title}") against the base bookmark \'{baseBookmark}\'. The merge-base change for this comparison is {mergeBaseChangeId}. Run `jj diff --from {mergeBaseChangeId} --to @` to inspect the changes that would be merged. Provide prioritized, actionable findings.'; - -const PULL_REQUEST_PROMPT_FALLBACK = - 'Review pull request #{prNumber} ("{title}") against the base bookmark \'{baseBookmark}\'. Start by finding the merge-base revision between the working copy and {baseBookmark}, then run `jj diff --from --to @` to see the changes that would be merged. Provide prioritized, actionable findings.'; - -const FOLDER_REVIEW_PROMPT = - "Review the code in the following paths: {paths}. This is a snapshot review (not a diff). Read the files directly in these paths and provide prioritized, actionable findings."; - -// The detailed review rubric (adapted from Codex's review_prompt.md) -const REVIEW_RUBRIC = `# Review Guidelines - -You are acting as a code reviewer for a proposed code change made by another engineer. - -Below are default guidelines for determining what to flag. These are not the final word — if you encounter more specific guidelines elsewhere (in a developer message, user message, file, or project review guidelines appended below), those override these general instructions. - -## Determining what to flag - -Flag issues that: -1. Meaningfully impact the accuracy, performance, security, or maintainability of the code. -2. Are discrete and actionable (not general issues or multiple combined issues). -3. Don't demand rigor inconsistent with the rest of the codebase. -4. Were introduced in the changes being reviewed (not pre-existing bugs). -5. The author would likely fix if aware of them. -6. Don't rely on unstated assumptions about the codebase or author's intent. -7. Have provable impact on other parts of the code — it is not enough to speculate that a change may disrupt another part, you must identify the parts that are provably affected. -8. Are clearly not intentional changes by the author. -9. Be particularly careful with untrusted user input and follow the specific guidelines to review. -10. Treat silent local error recovery (especially parsing/IO/network fallbacks) as high-signal review candidates unless there is explicit boundary-level justification. - -## Untrusted User Input - -1. Be careful with open redirects, they must always be checked to only go to trusted domains (?next_page=...) -2. Always flag SQL that is not parametrized -3. In systems with user supplied URL input, http fetches always need to be protected against access to local resources (intercept DNS resolver!) -4. Escape, don't sanitize if you have the option (eg: HTML escaping) - -## Comment guidelines - -1. Be clear about why the issue is a problem. -2. Communicate severity appropriately - don't exaggerate. -3. Be brief - at most 1 paragraph. -4. Keep code snippets under 3 lines, wrapped in inline code or code blocks. -5. Use \`\`\`suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block). Preserve the exact leading whitespace of the replaced lines. -6. Explicitly state scenarios/environments where the issue arises. -7. Use a matter-of-fact tone - helpful AI assistant, not accusatory. -8. Write for quick comprehension without close reading. -9. Avoid excessive flattery or unhelpful phrases like "Great job...". - -## Review priorities - -1. Surface critical non-blocking human callouts (migrations, dependency churn, auth/permissions, compatibility, destructive operations) at the end. -2. Prefer simple, direct solutions over wrappers or abstractions without clear value. -3. Treat back pressure handling as critical to system stability. -4. Apply system-level thinking; flag changes that increase operational risk or on-call wakeups. -5. Ensure that errors are always checked against codes or stable identifiers, never error messages. - -## Fail-fast error handling (strict) - -When reviewing added or modified error handling, default to fail-fast behavior. - -1. Evaluate every new or changed \`try/catch\`: identify what can fail and why local handling is correct at that exact layer. -2. Prefer propagation over local recovery. If the current scope cannot fully recover while preserving correctness, rethrow (optionally with context) instead of returning fallbacks. -3. Flag catch blocks that hide failure signals (e.g. returning \`null\`/\`[]\`/\`false\`, swallowing JSON parse failures, logging-and-continue, or “best effort” silent recovery). -4. JSON parsing/decoding should fail loudly by default. Quiet fallback parsing is only acceptable with an explicit compatibility requirement and clear tested behavior. -5. Boundary handlers (HTTP routes, CLI entrypoints, supervisors) may translate errors, but must not pretend success or silently degrade. -6. If a catch exists only to satisfy lint/style without real handling, treat it as a bug. -7. When uncertain, prefer crashing fast over silent degradation. - -## Required human callouts (non-blocking, at the very end) - -After findings/verdict, you MUST append this final section: - -## Human Reviewer Callouts (Non-Blocking) - -Include only applicable callouts (no yes/no lines): - -- **This change adds a database migration:** -- **This change introduces a new dependency:** -- **This change changes a dependency (or the lockfile):** -- **This change modifies auth/permission behavior:** -- **This change introduces backwards-incompatible public schema/API/contract changes:** -- **This change includes irreversible or destructive operations:** - -Rules for this section: -1. These are informational callouts for the human reviewer, not fix items. -2. Do not include them in Findings unless there is an independent defect. -3. These callouts alone must not change the verdict. -4. Only include callouts that apply to the reviewed change. -5. Keep each emitted callout bold exactly as written. -6. If none apply, write "- (none)". - -## Priority levels - -Tag each finding with a priority level in the title: -- [P0] - Drop everything to fix. Blocking release/operations. Only for universal issues that do not depend on assumptions about inputs. -- [P1] - Urgent. Should be addressed in the next cycle. -- [P2] - Normal. To be fixed eventually. -- [P3] - Low. Nice to have. - -## Output format - -Provide your findings in a clear, structured format: -1. List each finding with its priority tag, file location, and explanation. -2. Findings must reference locations that overlap with the actual diff — don't flag pre-existing code. -3. Keep line references as short as possible (avoid ranges over 5-10 lines; pick the most suitable subrange). -4. Provide an overall verdict: "correct" (no blocking issues) or "needs attention" (has blocking issues). -5. Ignore trivial style issues unless they obscure meaning or violate documented standards. -6. Do not generate a full PR fix — only flag issues and optionally provide short suggestion blocks. -7. End with the required "Human Reviewer Callouts (Non-Blocking)" section and all applicable bold callouts (no yes/no). - -Output all findings the author would fix if they knew about them. If there are no qualifying findings, explicitly state the code looks good. Don't stop at the first finding - list every qualifying issue. Then append the required non-blocking callouts section.`; - -async function loadProjectReviewGuidelines(cwd: string): Promise { - let currentDir = path.resolve(cwd); - - while (true) { - const piDir = path.join(currentDir, ".pi"); - const guidelinesPath = path.join(currentDir, "REVIEW_GUIDELINES.md"); - - const piStats = await fs.stat(piDir).catch(() => null); - if (piStats?.isDirectory()) { - const guidelineStats = await fs.stat(guidelinesPath).catch(() => null); - if (guidelineStats?.isFile()) { - try { - const content = await fs.readFile(guidelinesPath, "utf8"); - const trimmed = content.trim(); - return trimmed ? trimmed : null; - } catch { - return null; - } - } - return null; - } - - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - return null; - } - currentDir = parentDir; - } -} - -function parseNonEmptyLines(stdout: string): string[] { - return stdout - .trim() - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); -} - -function quoteRevsetString(value: string): string { - return JSON.stringify(value); -} - -function localBookmarkRevset(bookmark: string): string { - return `bookmarks(exact:${quoteRevsetString(bookmark)})`; -} - -function remoteBookmarkRevset(bookmark: string, remote: string): string { - return `remote_bookmarks(exact:${quoteRevsetString(bookmark)}, exact:${quoteRevsetString(remote)})`; -} - -function bookmarkRefToRevset(bookmark: BookmarkRef): string { - return bookmark.remote ? remoteBookmarkRevset(bookmark.name, bookmark.remote) : localBookmarkRevset(bookmark.name); -} - -function bookmarkRefToLabel(bookmark: BookmarkRef): string { - return bookmark.remote ? `${bookmark.name}@${bookmark.remote}` : bookmark.name; -} - -function bookmarkRefsEqual(left: BookmarkRef, right: BookmarkRef): boolean { - return left.name === right.name && left.remote === right.remote; -} - -function parseBookmarkReference(value: string): BookmarkRef { - const trimmed = value.trim(); - const separatorIndex = trimmed.lastIndexOf("@"); - if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) { - return { name: trimmed }; - } - - return { - name: trimmed.slice(0, separatorIndex), - remote: trimmed.slice(separatorIndex + 1), - }; -} - -function parseBookmarkRefs(stdout: string): BookmarkRef[] { - return parseNonEmptyLines(stdout) - .map((line) => { - const [name, remote = ""] = line.split("\t"); - return { - name: name.trim(), - remote: remote.trim() || undefined, - }; - }) - .filter((bookmark) => bookmark.name && bookmark.remote !== "git"); -} - -function dedupeBookmarkRefs(bookmarks: BookmarkRef[]): BookmarkRef[] { - const seen = new Set(); - const result: BookmarkRef[] = []; - - for (const bookmark of bookmarks) { - const key = `${bookmark.name}@${bookmark.remote ?? ""}`; - if (seen.has(key)) { - continue; - } - - seen.add(key); - result.push(bookmark); - } - - return result; -} - -async function getBookmarkRefs( - pi: ExtensionAPI, - options?: { revset?: string; includeRemotes?: boolean }, -): Promise { - const args = ["bookmark", "list"]; - if (options?.includeRemotes) { - args.push("--all-remotes"); - } - if (options?.revset) { - args.push("-r", options.revset); - } - args.push("-T", 'name ++ "\\t" ++ remote ++ "\\n"'); - - const { stdout, code } = await pi.exec("jj", args); - if (code !== 0) return []; - return dedupeBookmarkRefs(parseBookmarkRefs(stdout)); -} - -async function getSingleRevisionId(pi: ExtensionAPI, revset: string): Promise { - const { stdout, code } = await pi.exec("jj", ["log", "-r", revset, "--no-graph", "-T", 'commit_id ++ "\\n"']); - if (code !== 0) { - return null; - } - - const revisions = parseNonEmptyLines(stdout); - if (revisions.length !== 1) { - return null; - } - - return revisions[0]; -} - -async function getSingleChangeId(pi: ExtensionAPI, revset: string): Promise { - const { stdout, code } = await pi.exec("jj", ["log", "-r", revset, "--no-graph", "-T", 'change_id.shortest(8) ++ "\\n"']); - if (code !== 0) { - return null; - } - - const revisions = parseNonEmptyLines(stdout); - if (revisions.length !== 1) { - return null; - } - - return revisions[0]; -} - -async function getDefaultRemoteName(pi: ExtensionAPI): Promise { - const remotes = await getJjRemotes(pi); - if (remotes.length === 0) { - return null; - } - - return remotes.find((remote) => remote.name === "origin")?.name ?? remotes[0].name; -} - -function preferBookmarkRef(bookmarks: BookmarkRef[], preferredRemote?: string | null): BookmarkRef | null { - if (bookmarks.length === 0) { - return null; - } - - return ( - bookmarks.find((bookmark) => !bookmark.remote) ?? - (preferredRemote ? bookmarks.find((bookmark) => bookmark.remote === preferredRemote) : undefined) ?? - bookmarks[0] - ); -} - -async function resolveBookmarkRef( - pi: ExtensionAPI, - bookmark: string, - remote?: string, -): Promise { - if (remote) { - return { name: bookmark, remote }; - } - - const localBookmark = (await getBookmarkRefs(pi)).find((entry) => entry.name === bookmark); - if (localBookmark) { - return localBookmark; - } - - const matchingRemoteBookmarks = (await getBookmarkRefs(pi, { includeRemotes: true })).filter( - (entry) => entry.remote && entry.name === bookmark, - ); - if (matchingRemoteBookmarks.length === 0) { - return null; - } - - return preferBookmarkRef(matchingRemoteBookmarks, await getDefaultRemoteName(pi)); -} - -async function getReviewBookmarks(pi: ExtensionAPI): Promise { - const localBookmarks = await getBookmarkRefs(pi); - const localNames = new Set(localBookmarks.map((bookmark) => bookmark.name)); - const defaultRemoteName = await getDefaultRemoteName(pi); - const remoteOnlyBookmarks = (await getBookmarkRefs(pi, { includeRemotes: true })) - .filter((bookmark) => bookmark.remote && !localNames.has(bookmark.name)) - .sort((left, right) => { - if (left.name !== right.name) { - return left.name.localeCompare(right.name); - } - if (left.remote === defaultRemoteName) return -1; - if (right.remote === defaultRemoteName) return 1; - return (left.remote ?? "").localeCompare(right.remote ?? ""); - }); - - return dedupeBookmarkRefs([...localBookmarks, ...remoteOnlyBookmarks]); -} - -async function getReviewHeadRevset(pi: ExtensionAPI): Promise { - return (await hasWorkingCopyChanges(pi)) ? "@" : "@-"; -} - -async function getCurrentReviewBookmarks(pi: ExtensionAPI): Promise { - return getBookmarkRefs(pi, { - revset: await getReviewHeadRevset(pi), - includeRemotes: true, - }); -} - -async function getDefaultBookmarkRef(pi: ExtensionAPI): Promise { - const defaultRemoteName = await getDefaultRemoteName(pi); - const trunkBookmarks = await getBookmarkRefs(pi, { revset: "trunk()", includeRemotes: true }); - const trunkBookmark = preferBookmarkRef(trunkBookmarks, defaultRemoteName); - if (trunkBookmark) { - return trunkBookmark; - } - - const bookmarks = await getReviewBookmarks(pi); - const mainBookmark = - bookmarks.find((bookmark) => !bookmark.remote && bookmark.name === "main") ?? - bookmarks.find((bookmark) => !bookmark.remote && bookmark.name === "master") ?? - bookmarks.find((bookmark) => bookmark.remote === defaultRemoteName && bookmark.name === "main") ?? - bookmarks.find((bookmark) => bookmark.remote === defaultRemoteName && bookmark.name === "master"); - if (mainBookmark) { - return mainBookmark; - } - - return bookmarks[0] ?? null; -} - -/** - * Get the merge-base revision between the working copy and a bookmark - */ -async function getMergeBase( - pi: ExtensionAPI, - bookmark: string, - remote?: string, -): Promise { - try { - const bookmarkRef = await resolveBookmarkRef(pi, bookmark, remote); - if (!bookmarkRef) { - return null; - } - - return getSingleChangeId(pi, `heads(::@ & ::${bookmarkRefToRevset(bookmarkRef)})`); - } catch { - return null; - } -} - -/** - * Get list of recent changes - */ -async function getRecentChanges(pi: ExtensionAPI, limit: number = 10): Promise> { - const { stdout, code } = await pi.exec("jj", [ - "log", - "-n", - `${limit}`, - "--no-graph", - "-T", - 'change_id.shortest(8) ++ "\\t" ++ description.first_line() ++ "\\n"', - ]); - if (code !== 0) return []; - - return parseNonEmptyLines(stdout) - .filter((line) => line.trim()) - .map((line) => { - const [changeId, ...rest] = line.trim().split("\t"); - return { changeId, title: rest.join(" ") }; - }); -} - -/** - * Check if there are working-copy changes - */ -async function hasWorkingCopyChanges(pi: ExtensionAPI): Promise { - const { stdout, code } = await pi.exec("jj", ["diff", "--summary"]); - return code === 0 && stdout.trim().length > 0; -} - -/** - * Check if there are local changes that would make switching bookmarks surprising - */ -async function hasPendingChanges(pi: ExtensionAPI): Promise { - return hasWorkingCopyChanges(pi); -} - -/** - * Parse a PR reference (URL or number) and return the PR number - */ -function parsePrReference(ref: string): number | null { - const trimmed = ref.trim(); - - // Try as a number first - const num = parseInt(trimmed, 10); - if (!isNaN(num) && num > 0) { - return num; - } - - // Try to extract from GitHub URL - // Formats: https://github.com/owner/repo/pull/123 - // github.com/owner/repo/pull/123 - const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/); - if (urlMatch) { - return parseInt(urlMatch[1], 10); - } - - return null; -} - -/** - * Get PR information from GitHub CLI - */ -async function getPrInfo( - pi: ExtensionAPI, - prNumber: number, -): Promise<{ - baseBookmark: string; - title: string; - headBookmark: string; - isCrossRepository: boolean; - headRepositoryName?: string; - headRepositoryOwner?: string; - headRepositoryUrl?: string; -} | null> { - const { stdout, code } = await pi.exec("gh", [ - "pr", "view", String(prNumber), - "--json", "baseRefName,title,headRefName,isCrossRepository,headRepository,headRepositoryOwner", - ]); - - if (code !== 0) return null; - - try { - const data = JSON.parse(stdout); - return { - baseBookmark: data.baseRefName, - title: data.title, - headBookmark: data.headRefName, - isCrossRepository: data.isCrossRepository === true, - headRepositoryName: data.headRepository?.name, - headRepositoryOwner: data.headRepositoryOwner?.login, - headRepositoryUrl: data.headRepository?.url, - }; - } catch { - return null; - } -} - -/** - * Get configured jj remotes - */ -async function getJjRemotes(pi: ExtensionAPI): Promise> { - const { stdout, code } = await pi.exec("jj", ["git", "remote", "list"]); - if (code !== 0) return []; - - return parseNonEmptyLines(stdout) - .map((line) => { - const [name, ...urlParts] = line.split(/\s+/); - return { name, url: urlParts.join(" ") }; - }) - .filter((remote) => remote.name && remote.url); -} - -function normalizeRemoteUrl(value: string): string { - return value - .trim() - .replace(/^git@github\.com:/, "https://github.com/") - .replace(/^ssh:\/\/git@github\.com\//, "https://github.com/") - .replace(/\.git$/, "") - .toLowerCase(); -} - -function sanitizeRemoteName(value: string): string { - const sanitized = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); - return sanitized || "gh-pr"; -} - -/** - * Materialize a PR locally with jj - */ -async function materializePr( - pi: ExtensionAPI, - prNumber: number, - prInfo: { - headBookmark: string; - isCrossRepository: boolean; - headRepositoryName?: string; - headRepositoryOwner?: string; - headRepositoryUrl?: string; - }, -): Promise<{ success: boolean; remote?: string; error?: string }> { - const defaultRemoteName = await getDefaultRemoteName(pi); - if (!defaultRemoteName) { - return { success: false, error: "No jj remotes are configured for this repository" }; - } - - const existingRemotes = await getJjRemotes(pi); - let remoteName = defaultRemoteName; - let addedTemporaryRemote = false; - - if (prInfo.isCrossRepository) { - const repoSlug = prInfo.headRepositoryOwner && prInfo.headRepositoryName - ? `${prInfo.headRepositoryOwner}/${prInfo.headRepositoryName}`.toLowerCase() - : undefined; - const existingRemote = existingRemotes.find((remote) => { - if (prInfo.headRepositoryUrl && normalizeRemoteUrl(remote.url) === normalizeRemoteUrl(prInfo.headRepositoryUrl)) { - return true; - } - return repoSlug ? normalizeRemoteUrl(remote.url).includes(`github.com/${repoSlug}`) : false; - }); - - if (existingRemote) { - remoteName = existingRemote.name; - } else if (prInfo.headRepositoryUrl) { - const remoteBaseName = sanitizeRemoteName( - `gh-pr-${prInfo.headRepositoryOwner ?? "remote"}-${prInfo.headRepositoryName ?? prNumber}`, - ); - const existingRemoteNames = new Set(existingRemotes.map((remote) => remote.name)); - remoteName = remoteBaseName; - let suffix = 2; - while (existingRemoteNames.has(remoteName)) { - remoteName = `${remoteBaseName}-${suffix}`; - suffix += 1; - } - const addRemoteResult = await pi.exec("jj", ["git", "remote", "add", remoteName, prInfo.headRepositoryUrl]); - if (addRemoteResult.code !== 0) { - return { success: false, error: addRemoteResult.stderr || addRemoteResult.stdout || "Failed to add PR remote" }; - } - addedTemporaryRemote = true; - } else { - return { success: false, error: "PR head repository URL is unavailable" }; - } - } - - const fetchResult = await pi.exec("jj", ["git", "fetch", "--remote", remoteName, "--branch", prInfo.headBookmark]); - if (fetchResult.code !== 0) { - if (addedTemporaryRemote) { - await pi.exec("jj", ["git", "remote", "remove", remoteName]); - } - return { success: false, error: fetchResult.stderr || fetchResult.stdout || "Failed to fetch PR bookmark" }; - } - - const editResult = await pi.exec("jj", ["new", remoteBookmarkRevset(prInfo.headBookmark, remoteName)]); - if (editResult.code !== 0) { - if (addedTemporaryRemote) { - await pi.exec("jj", ["git", "remote", "remove", remoteName]); - } - return { success: false, error: editResult.stderr || editResult.stdout || "Failed to materialize PR locally" }; - } - - if (addedTemporaryRemote) { - await pi.exec("jj", ["git", "remote", "remove", remoteName]); - } - - return { success: true, remote: remoteName }; -} - -/** - * Build the review prompt based on target - */ -async function buildReviewPrompt( - pi: ExtensionAPI, - target: ReviewTarget, - options?: { includeLocalChanges?: boolean }, -): Promise { - const includeLocalChanges = options?.includeLocalChanges === true; - - switch (target.type) { - case "workingCopy": - return WORKING_COPY_PROMPT; - - case "baseBookmark": { - const bookmarkLabel = bookmarkRefToLabel({ name: target.bookmark, remote: target.remote }); - const mergeBase = await getMergeBase(pi, target.bookmark, target.remote); - const basePrompt = mergeBase - ? BASE_BOOKMARK_PROMPT_WITH_MERGE_BASE - .replace(/{baseBookmark}/g, bookmarkLabel) - .replace(/{mergeBaseChangeId}/g, mergeBase) - : BASE_BOOKMARK_PROMPT_FALLBACK.replace(/{bookmark}/g, bookmarkLabel); - return includeLocalChanges ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}` : basePrompt; - } - - case "change": - if (target.title) { - return CHANGE_PROMPT_WITH_TITLE.replace("{changeId}", target.changeId).replace("{title}", target.title); - } - return CHANGE_PROMPT.replace("{changeId}", target.changeId); - - case "pullRequest": { - const baseBookmarkLabel = bookmarkRefToLabel({ name: target.baseBookmark, remote: target.baseRemote }); - const mergeBase = await getMergeBase(pi, target.baseBookmark, target.baseRemote); - const basePrompt = mergeBase - ? PULL_REQUEST_PROMPT - .replace(/{prNumber}/g, String(target.prNumber)) - .replace(/{title}/g, target.title) - .replace(/{baseBookmark}/g, baseBookmarkLabel) - .replace(/{mergeBaseChangeId}/g, mergeBase) - : PULL_REQUEST_PROMPT_FALLBACK - .replace(/{prNumber}/g, String(target.prNumber)) - .replace(/{title}/g, target.title) - .replace(/{baseBookmark}/g, baseBookmarkLabel); - return includeLocalChanges ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}` : basePrompt; - } - - case "folder": - return FOLDER_REVIEW_PROMPT.replace("{paths}", target.paths.join(", ")); - } -} - -/** - * Get user-facing hint for the review target - */ -function getUserFacingHint(target: ReviewTarget): string { - switch (target.type) { - case "workingCopy": - return "working-copy changes"; - case "baseBookmark": - return `changes against '${bookmarkRefToLabel({ name: target.bookmark, remote: target.remote })}'`; - case "change": { - return target.title ? `change ${target.changeId}: ${target.title}` : `change ${target.changeId}`; - } - - case "pullRequest": { - const shortTitle = target.title.length > 30 ? target.title.slice(0, 27) + "..." : target.title; - return `PR #${target.prNumber}: ${shortTitle}`; - } - - case "folder": { - const joined = target.paths.join(", "); - return joined.length > 40 ? `folders: ${joined.slice(0, 37)}...` : `folders: ${joined}`; - } - } -} - -type AssistantSnapshot = { - id: string; - text: string; - stopReason?: string; -}; - -function extractAssistantTextContent(content: unknown): string { - if (typeof content === "string") { - return content.trim(); - } - - if (!Array.isArray(content)) { - return ""; - } - - const textParts = content - .filter( - (part): part is { type: "text"; text: string } => - Boolean(part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part), - ) - .map((part) => part.text); - return textParts.join("\n").trim(); -} - -function getLastAssistantSnapshot(ctx: ExtensionContext): AssistantSnapshot | null { - const entries = ctx.sessionManager.getBranch(); - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i]; - if (entry.type !== "message" || entry.message.role !== "assistant") { - continue; - } - - const assistantMessage = entry.message as { content?: unknown; stopReason?: string }; - return { - id: entry.id, - text: extractAssistantTextContent(assistantMessage.content), - stopReason: assistantMessage.stopReason, - }; - } - - return null; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitForLoopTurnToStart(ctx: ExtensionContext, previousAssistantId?: string): Promise { - const deadline = Date.now() + REVIEW_LOOP_START_TIMEOUT_MS; - - while (Date.now() < deadline) { - const lastAssistantId = getLastAssistantSnapshot(ctx)?.id; - if (!ctx.isIdle() || ctx.hasPendingMessages() || (lastAssistantId && lastAssistantId !== previousAssistantId)) { - return true; - } - await sleep(REVIEW_LOOP_START_POLL_MS); - } - - return false; -} - -// Review preset options for the selector (keep this order stable) -const REVIEW_PRESETS = [ - { value: "workingCopy", label: "Review working-copy changes", description: "" }, - { value: "baseBookmark", label: "Review against a base bookmark", description: "(local)" }, - { value: "change", label: "Review a change", description: "" }, - { value: "pullRequest", label: "Review a pull request", description: "(GitHub PR)" }, - { value: "folder", label: "Review a folder (or more)", description: "(snapshot, not diff)" }, -] as const; - -const TOGGLE_LOOP_FIXING_VALUE = "toggleLoopFixing" as const; -const TOGGLE_CUSTOM_INSTRUCTIONS_VALUE = "toggleCustomInstructions" as const; -type ReviewPresetValue = - | (typeof REVIEW_PRESETS)[number]["value"] - | typeof TOGGLE_LOOP_FIXING_VALUE - | typeof TOGGLE_CUSTOM_INSTRUCTIONS_VALUE; - -export default function reviewExtension(pi: ExtensionAPI) { - function persistReviewSettings() { - pi.appendEntry(REVIEW_SETTINGS_TYPE, { - loopFixingEnabled: reviewLoopFixingEnabled, - customInstructions: reviewCustomInstructions, - }); - } - - function setReviewLoopFixingEnabled(enabled: boolean) { - reviewLoopFixingEnabled = enabled; - persistReviewSettings(); - } - - function setReviewCustomInstructions(instructions: string | undefined) { - reviewCustomInstructions = instructions?.trim() || undefined; - persistReviewSettings(); - } - - function applyAllReviewState(ctx: ExtensionContext) { - applyReviewSettings(ctx); - applyReviewState(ctx); - } - - pi.on("session_start", (_event, ctx) => { - applyAllReviewState(ctx); - }); - - pi.on("session_switch", (_event, ctx) => { - applyAllReviewState(ctx); - }); - - pi.on("session_tree", (_event, ctx) => { - applyAllReviewState(ctx); - }); - - /** - * Determine the smart default review type based on jj state - */ - async function getSmartDefault(): Promise<"workingCopy" | "baseBookmark" | "change"> { - // Priority 1: If there are working-copy changes, default to reviewing them - if (await hasWorkingCopyChanges(pi)) { - return "workingCopy"; - } - - // Priority 2: If the current review head differs from trunk/default, default to bookmark-style review - const defaultBookmark = await getDefaultBookmarkRef(pi); - if (defaultBookmark) { - const reviewHeadRevision = await getSingleRevisionId(pi, await getReviewHeadRevset(pi)); - const defaultBookmarkRevision = await getSingleRevisionId(pi, bookmarkRefToRevset(defaultBookmark)); - if (reviewHeadRevision && defaultBookmarkRevision && reviewHeadRevision !== defaultBookmarkRevision) { - return "baseBookmark"; - } - } - - // Priority 3: Default to reviewing a specific change - return "change"; - } - - /** - * Show the review preset selector - */ - async function showReviewSelector(ctx: ExtensionContext): Promise { - // Determine smart default (but keep the list order stable) - const smartDefault = await getSmartDefault(); - const presetItems: SelectItem[] = REVIEW_PRESETS.map((preset) => ({ - value: preset.value, - label: preset.label, - description: preset.description, - })); - const smartDefaultIndex = presetItems.findIndex((item) => item.value === smartDefault); - - while (true) { - const customInstructionsLabel = reviewCustomInstructions - ? "Remove custom review instructions" - : "Add custom review instructions"; - const customInstructionsDescription = reviewCustomInstructions - ? "(currently set)" - : "(applies to all review modes)"; - const loopToggleLabel = reviewLoopFixingEnabled ? "Disable Loop Fixing" : "Enable Loop Fixing"; - const loopToggleDescription = reviewLoopFixingEnabled ? "(currently on)" : "(currently off)"; - const items: SelectItem[] = [ - ...presetItems, - { - value: TOGGLE_CUSTOM_INSTRUCTIONS_VALUE, - label: customInstructionsLabel, - description: customInstructionsDescription, - }, - { value: TOGGLE_LOOP_FIXING_VALUE, label: loopToggleLabel, description: loopToggleDescription }, - ]; - - const result = await ctx.ui.custom((tui, theme, _kb, done) => { - const container = new Container(); - container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); - container.addChild(new Text(theme.fg("accent", theme.bold("Select a review preset")))); - - const selectList = new SelectList(items, Math.min(items.length, 10), { - selectedPrefix: (text) => theme.fg("accent", text), - selectedText: (text) => theme.fg("accent", text), - description: (text) => theme.fg("muted", text), - scrollInfo: (text) => theme.fg("dim", text), - noMatch: (text) => theme.fg("warning", text), - }); - - // Preselect the smart default without reordering the list - if (smartDefaultIndex >= 0) { - selectList.setSelectedIndex(smartDefaultIndex); - } - - selectList.onSelect = (item) => done(item.value as ReviewPresetValue); - selectList.onCancel = () => done(null); - - container.addChild(selectList); - container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back"))); - container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); - - return { - render(width: number) { - return container.render(width); - }, - invalidate() { - container.invalidate(); - }, - handleInput(data: string) { - selectList.handleInput(data); - tui.requestRender(); - }, - }; - }); - - if (!result) return null; - - if (result === TOGGLE_LOOP_FIXING_VALUE) { - const nextEnabled = !reviewLoopFixingEnabled; - setReviewLoopFixingEnabled(nextEnabled); - ctx.ui.notify(nextEnabled ? "Loop fixing enabled" : "Loop fixing disabled", "info"); - continue; - } - - if (result === TOGGLE_CUSTOM_INSTRUCTIONS_VALUE) { - if (reviewCustomInstructions) { - setReviewCustomInstructions(undefined); - ctx.ui.notify("Custom review instructions removed", "info"); - continue; - } - - const customInstructions = await ctx.ui.editor( - "Enter custom review instructions (applies to all review modes):", - "", - ); - - if (!customInstructions?.trim()) { - ctx.ui.notify("Custom review instructions not changed", "info"); - continue; - } - - setReviewCustomInstructions(customInstructions); - ctx.ui.notify("Custom review instructions saved", "info"); - continue; - } - - // Handle each preset type - switch (result) { - case "workingCopy": - return { type: "workingCopy" }; - - case "baseBookmark": { - const target = await showBookmarkSelector(ctx); - if (target) return target; - break; - } - - case "change": { - if (reviewLoopFixingEnabled) { - ctx.ui.notify("Loop mode does not work with change review.", "error"); - break; - } - const target = await showChangeSelector(ctx); - if (target) return target; - break; - } - - case "folder": { - const target = await showFolderInput(ctx); - if (target) return target; - break; - } - - case "pullRequest": { - const target = await showPrInput(ctx); - if (target) return target; - break; - } - - default: - return null; - } - } - } - - /** - * Show bookmark selector for base bookmark review - */ - async function showBookmarkSelector(ctx: ExtensionContext): Promise { - const bookmarks = await getReviewBookmarks(pi); - const currentBookmarks = await getCurrentReviewBookmarks(pi); - const defaultBookmark = await getDefaultBookmarkRef(pi); - - // Never offer the current review head's bookmark(s) as the base bookmark. - const candidateBookmarks = bookmarks.filter( - (bookmark) => !currentBookmarks.some((currentBookmark) => bookmarkRefsEqual(bookmark, currentBookmark)), - ); - - if (candidateBookmarks.length === 0) { - const currentLabel = currentBookmarks[0] ? bookmarkRefToLabel(currentBookmarks[0]) : undefined; - ctx.ui.notify( - currentLabel ? `No other bookmarks found (current bookmark: ${currentLabel})` : "No bookmarks found", - "error", - ); - return null; - } - - // Sort bookmarks with the default bookmark first, then local bookmarks before remote-only ones. - const sortedBookmarks = candidateBookmarks.sort((a, b) => { - if (defaultBookmark && bookmarkRefsEqual(a, defaultBookmark)) return -1; - if (defaultBookmark && bookmarkRefsEqual(b, defaultBookmark)) return 1; - if (!!a.remote !== !!b.remote) return a.remote ? 1 : -1; - return bookmarkRefToLabel(a).localeCompare(bookmarkRefToLabel(b)); - }); - - const items: SelectItem[] = sortedBookmarks.map((bookmark) => ({ - value: bookmarkRefToLabel(bookmark), - label: bookmarkRefToLabel(bookmark), - description: defaultBookmark && bookmarkRefsEqual(bookmark, defaultBookmark) - ? "(default)" - : bookmark.remote - ? `(remote ${bookmark.remote})` - : "", - })); - - const result = await ctx.ui.custom((tui, theme, keybindings, done) => { - const container = new Container(); - container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); - container.addChild(new Text(theme.fg("accent", theme.bold("Select base bookmark")))); - - const searchInput = new Input(); - container.addChild(searchInput); - container.addChild(new Spacer(1)); - - const listContainer = new Container(); - container.addChild(listContainer); - container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel"))); - container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); - - let filteredItems = items; - let selectList: SelectList | null = null; - - const updateList = () => { - listContainer.clear(); - if (filteredItems.length === 0) { - listContainer.addChild(new Text(theme.fg("warning", " No matching bookmarks"))); - selectList = null; - return; - } - - selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 10), { - selectedPrefix: (text) => theme.fg("accent", text), - selectedText: (text) => theme.fg("accent", text), - description: (text) => theme.fg("muted", text), - scrollInfo: (text) => theme.fg("dim", text), - noMatch: (text) => theme.fg("warning", text), - }); - - selectList.onSelect = (item) => done(item.value); - selectList.onCancel = () => done(null); - listContainer.addChild(selectList); - }; - - const applyFilter = () => { - const query = searchInput.getValue(); - filteredItems = query - ? fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`) - : items; - updateList(); - }; - - applyFilter(); - - return { - render(width: number) { - return container.render(width); - }, - invalidate() { - container.invalidate(); - }, - handleInput(data: string) { - if ( - keybindings.matches(data, "tui.select.up") || - keybindings.matches(data, "tui.select.down") || - keybindings.matches(data, "tui.select.confirm") || - keybindings.matches(data, "tui.select.cancel") - ) { - if (selectList) { - selectList.handleInput(data); - } else if (keybindings.matches(data, "tui.select.cancel")) { - done(null); - } - tui.requestRender(); - return; - } - - searchInput.handleInput(data); - applyFilter(); - tui.requestRender(); - }, - }; - }); - - if (!result) return null; - const bookmark = parseBookmarkReference(result); - return { type: "baseBookmark", bookmark: bookmark.name, remote: bookmark.remote }; - } - - /** - * Show change selector - */ - async function showChangeSelector(ctx: ExtensionContext): Promise { - const changes = await getRecentChanges(pi, 20); - - if (changes.length === 0) { - ctx.ui.notify("No changes found", "error"); - return null; - } - - const items: SelectItem[] = changes.map((change) => ({ - value: change.changeId, - label: `${change.changeId} ${change.title}`, - description: "", - })); - - const result = await ctx.ui.custom<{ changeId: string; title: string } | null>((tui, theme, keybindings, done) => { - const container = new Container(); - container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); - container.addChild(new Text(theme.fg("accent", theme.bold("Select change to review")))); - - const searchInput = new Input(); - container.addChild(searchInput); - container.addChild(new Spacer(1)); - - const listContainer = new Container(); - container.addChild(listContainer); - container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel"))); - container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); - - let filteredItems = items; - let selectList: SelectList | null = null; - - const updateList = () => { - listContainer.clear(); - if (filteredItems.length === 0) { - listContainer.addChild(new Text(theme.fg("warning", " No matching changes"))); - selectList = null; - return; - } - - selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 10), { - selectedPrefix: (text) => theme.fg("accent", text), - selectedText: (text) => theme.fg("accent", text), - description: (text) => theme.fg("muted", text), - scrollInfo: (text) => theme.fg("dim", text), - noMatch: (text) => theme.fg("warning", text), - }); - - selectList.onSelect = (item) => { - const change = changes.find((c) => c.changeId === item.value); - if (change) { - done(change); - } else { - done(null); - } - }; - selectList.onCancel = () => done(null); - listContainer.addChild(selectList); - }; - - const applyFilter = () => { - const query = searchInput.getValue(); - filteredItems = query - ? fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`) - : items; - updateList(); - }; - - applyFilter(); - - return { - render(width: number) { - return container.render(width); - }, - invalidate() { - container.invalidate(); - }, - handleInput(data: string) { - if ( - keybindings.matches(data, "tui.select.up") || - keybindings.matches(data, "tui.select.down") || - keybindings.matches(data, "tui.select.confirm") || - keybindings.matches(data, "tui.select.cancel") - ) { - if (selectList) { - selectList.handleInput(data); - } else if (keybindings.matches(data, "tui.select.cancel")) { - done(null); - } - tui.requestRender(); - return; - } - - searchInput.handleInput(data); - applyFilter(); - tui.requestRender(); - }, - }; - }); - - if (!result) return null; - return { type: "change", changeId: result.changeId, title: result.title }; - } - - - function parseReviewPaths(value: string): string[] { - return value - .split(/\s+/) - .map((item) => item.trim()) - .filter((item) => item.length > 0); - } - - /** - * Show folder input - */ - async function showFolderInput(ctx: ExtensionContext): Promise { - const result = await ctx.ui.editor( - "Enter folders/files to review (space-separated or one per line):", - ".", - ); - - if (!result?.trim()) return null; - const paths = parseReviewPaths(result); - if (paths.length === 0) return null; - - return { type: "folder", paths }; - } - - /** - * Show PR input and materialize the PR locally - */ - async function showPrInput(ctx: ExtensionContext): Promise { - // First check for pending changes that would make bookmark switching surprising - if (await hasPendingChanges(pi)) { - ctx.ui.notify("Cannot materialize PR: you have local jj changes. Please snapshot or discard them first.", "error"); - return null; - } - - // Get PR reference from user - const prRef = await ctx.ui.editor( - "Enter PR number or URL (e.g. 123 or https://github.com/owner/repo/pull/123):", - "", - ); - - if (!prRef?.trim()) return null; - - const prNumber = parsePrReference(prRef); - if (!prNumber) { - ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error"); - return null; - } - - // Get PR info from GitHub - ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info"); - const prInfo = await getPrInfo(pi, prNumber); - - if (!prInfo) { - ctx.ui.notify(`Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, "error"); - return null; - } - - // Check again for pending changes (in case something changed) - if (await hasPendingChanges(pi)) { - ctx.ui.notify("Cannot materialize PR: you have local jj changes. Please snapshot or discard them first.", "error"); - return null; - } - - // Materialize the PR locally with jj - ctx.ui.notify(`Materializing PR #${prNumber} with jj...`, "info"); - const materializeResult = await materializePr(pi, prNumber, prInfo); - - if (!materializeResult.success) { - ctx.ui.notify(`Failed to materialize PR: ${materializeResult.error}`, "error"); - return null; - } - - ctx.ui.notify(`Materialized PR #${prNumber} (${prInfo.headBookmark}@${materializeResult.remote ?? "origin"})`, "info"); - - const baseBookmarkRef = await resolveBookmarkRef(pi, prInfo.baseBookmark); - - return { - type: "pullRequest", - prNumber, - baseBookmark: prInfo.baseBookmark, - baseRemote: baseBookmarkRef?.remote, - title: prInfo.title, - }; - } - - /** - * Execute the review - */ - async function executeReview( - ctx: ExtensionCommandContext, - target: ReviewTarget, - useFreshSession: boolean, - options?: { includeLocalChanges?: boolean; extraInstruction?: string }, - ): Promise { - // Check if we're already in a review - if (reviewOriginId) { - ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning"); - return false; - } - - // Handle fresh session mode - if (useFreshSession) { - // Store current position (where we'll return to). - // In an empty session there is no leaf yet, so create a lightweight anchor first. - let originId = ctx.sessionManager.getLeafId() ?? undefined; - if (!originId) { - pi.appendEntry(REVIEW_ANCHOR_TYPE, { createdAt: new Date().toISOString() }); - originId = ctx.sessionManager.getLeafId() ?? undefined; - } - if (!originId) { - ctx.ui.notify("Failed to determine review origin.", "error"); - return false; - } - reviewOriginId = originId; - - // Keep a local copy so session_tree events during navigation don't wipe it - const lockedOriginId = originId; - - // Find the first user message in the session. - // If none exists (e.g. brand-new session), we'll stay on the current leaf. - const entries = ctx.sessionManager.getEntries(); - const firstUserMessage = entries.find( - (e) => e.type === "message" && e.message.role === "user", - ); - - if (firstUserMessage) { - // Navigate to first user message to create a new branch from that point - // Label it as "code-review" so it's visible in the tree - try { - const result = await ctx.navigateTree(firstUserMessage.id, { summarize: false, label: "code-review" }); - if (result.cancelled) { - reviewOriginId = undefined; - return false; - } - } catch (error) { - // Clean up state if navigation fails - reviewOriginId = undefined; - ctx.ui.notify(`Failed to start review: ${error instanceof Error ? error.message : String(error)}`, "error"); - return false; - } - - // Clear the editor (navigating to user message fills it with the message text) - ctx.ui.setEditorText(""); - } - - // Restore origin after navigation events (session_tree can reset it) - reviewOriginId = lockedOriginId; - - // Show widget indicating review is active - setReviewWidget(ctx, true); - - // Persist review state so tree navigation can restore/reset it - pi.appendEntry(REVIEW_STATE_TYPE, { active: true, originId: lockedOriginId }); - } - - const prompt = await buildReviewPrompt(pi, target, { - includeLocalChanges: options?.includeLocalChanges === true, - }); - const hint = getUserFacingHint(target); - const projectGuidelines = await loadProjectReviewGuidelines(ctx.cwd); - - // Combine the review rubric with the specific prompt - let fullPrompt = `${REVIEW_RUBRIC}\n\n---\n\nPlease perform a code review with the following focus:\n\n${prompt}`; - - if (reviewCustomInstructions) { - fullPrompt += `\n\nShared custom review instructions (applies to all reviews):\n\n${reviewCustomInstructions}`; - } - - if (options?.extraInstruction?.trim()) { - fullPrompt += `\n\nAdditional user-provided review instruction:\n\n${options.extraInstruction.trim()}`; - } - - if (projectGuidelines) { - fullPrompt += `\n\nThis project has additional instructions for code reviews:\n\n${projectGuidelines}`; - } - - const modeHint = useFreshSession ? " (fresh session)" : ""; - ctx.ui.notify(`Starting review: ${hint}${modeHint}`, "info"); - - // Send as a user message that triggers a turn - pi.sendUserMessage(fullPrompt); - return true; - } - - /** - * Parse command arguments for direct invocation - * Returns the target or a special marker for PR that needs async handling - */ - type ParsedReviewArgs = { - target: ReviewTarget | { type: "pr"; ref: string } | null; - extraInstruction?: string; - error?: string; - }; - - function tokenizeArgs(value: string): string[] { - const tokens: string[] = []; - let current = ""; - let quote: '"' | "'" | null = null; - - for (let i = 0; i < value.length; i++) { - const char = value[i]; - - if (quote) { - if (char === "\\" && i + 1 < value.length) { - current += value[i + 1]; - i += 1; - continue; - } - if (char === quote) { - quote = null; - continue; - } - current += char; - continue; - } - - if (char === '"' || char === "'") { - quote = char; - continue; - } - - if (/\s/.test(char)) { - if (current.length > 0) { - tokens.push(current); - current = ""; - } - continue; - } - - current += char; - } - - if (current.length > 0) { - tokens.push(current); - } - - return tokens; - } - - function parseArgs(args: string | undefined): ParsedReviewArgs { - if (!args?.trim()) return { target: null }; - - const rawParts = tokenizeArgs(args.trim()); - const parts: string[] = []; - let extraInstruction: string | undefined; - - for (let i = 0; i < rawParts.length; i++) { - const part = rawParts[i]; - if (part === "--extra") { - const next = rawParts[i + 1]; - if (!next) { - return { target: null, error: "Missing value for --extra" }; - } - extraInstruction = next; - i += 1; - continue; - } - - if (part.startsWith("--extra=")) { - extraInstruction = part.slice("--extra=".length); - continue; - } - - parts.push(part); - } - - if (parts.length === 0) { - return { target: null, extraInstruction }; - } - - const subcommand = parts[0]?.toLowerCase(); - - switch (subcommand) { - case "working-copy": - return { target: { type: "workingCopy" }, extraInstruction }; - - case "bookmark": { - const bookmark = parts[1]; - if (!bookmark) return { target: null, extraInstruction }; - const bookmarkRef = parseBookmarkReference(bookmark); - return { - target: { type: "baseBookmark", bookmark: bookmarkRef.name, remote: bookmarkRef.remote }, - extraInstruction, - }; - } - - case "change": { - const changeId = parts[1]; - if (!changeId) return { target: null, extraInstruction }; - const title = parts.slice(2).join(" ") || undefined; - return { target: { type: "change", changeId, title }, extraInstruction }; - } - - - case "folder": { - const paths = parseReviewPaths(parts.slice(1).join(" ")); - if (paths.length === 0) return { target: null, extraInstruction }; - return { target: { type: "folder", paths }, extraInstruction }; - } - - case "pr": { - const ref = parts[1]; - if (!ref) return { target: null, extraInstruction }; - return { target: { type: "pr", ref }, extraInstruction }; - } - - default: - return { target: null, extraInstruction }; - } - } - - /** - * Materialize a PR locally and return a ReviewTarget (or null on failure) - */ - async function handlePrCheckout(ctx: ExtensionContext, ref: string): Promise { - // First check for pending changes - if (await hasPendingChanges(pi)) { - ctx.ui.notify("Cannot materialize PR: you have local jj changes. Please snapshot or discard them first.", "error"); - return null; - } - - const prNumber = parsePrReference(ref); - if (!prNumber) { - ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error"); - return null; - } - - // Get PR info - ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info"); - const prInfo = await getPrInfo(pi, prNumber); - - if (!prInfo) { - ctx.ui.notify(`Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, "error"); - return null; - } - - // Materialize the PR locally with jj - ctx.ui.notify(`Materializing PR #${prNumber} with jj...`, "info"); - const materializeResult = await materializePr(pi, prNumber, prInfo); - - if (!materializeResult.success) { - ctx.ui.notify(`Failed to materialize PR: ${materializeResult.error}`, "error"); - return null; - } - - ctx.ui.notify(`Materialized PR #${prNumber} (${prInfo.headBookmark}@${materializeResult.remote ?? "origin"})`, "info"); - - const baseBookmarkRef = await resolveBookmarkRef(pi, prInfo.baseBookmark); - - return { - type: "pullRequest", - prNumber, - baseBookmark: prInfo.baseBookmark, - baseRemote: baseBookmarkRef?.remote, - title: prInfo.title, - }; - } - - function isLoopCompatibleTarget(target: ReviewTarget): boolean { - if (target.type !== "change") { - return true; - } - - return false; - } - - async function runLoopFixingReview( - ctx: ExtensionCommandContext, - target: ReviewTarget, - extraInstruction?: string, - ): Promise { - if (reviewLoopInProgress) { - ctx.ui.notify("Loop fixing review is already running.", "warning"); - return; - } - - reviewLoopInProgress = true; - setReviewWidget(ctx, Boolean(reviewOriginId)); - try { - ctx.ui.notify( - "Loop fixing enabled: using Empty branch mode and cycling until no blocking findings remain.", - "info", - ); - - for (let pass = 1; pass <= REVIEW_LOOP_MAX_ITERATIONS; pass++) { - const reviewBaselineAssistantId = getLastAssistantSnapshot(ctx)?.id; - const started = await executeReview(ctx, target, true, { - includeLocalChanges: true, - extraInstruction, - }); - if (!started) { - ctx.ui.notify("Loop fixing stopped before starting the review pass.", "warning"); - return; - } - - const reviewTurnStarted = await waitForLoopTurnToStart(ctx, reviewBaselineAssistantId); - if (!reviewTurnStarted) { - ctx.ui.notify("Loop fixing stopped: review pass did not start in time.", "error"); - return; - } - - await ctx.waitForIdle(); - - const reviewSnapshot = getLastAssistantSnapshot(ctx); - if (!reviewSnapshot || reviewSnapshot.id === reviewBaselineAssistantId) { - ctx.ui.notify("Loop fixing stopped: could not read the review result.", "warning"); - return; - } - - if (reviewSnapshot.stopReason === "aborted") { - ctx.ui.notify("Loop fixing stopped: review was aborted.", "warning"); - return; - } - - if (reviewSnapshot.stopReason === "error") { - ctx.ui.notify("Loop fixing stopped: review failed with an error.", "error"); - return; - } - - if (reviewSnapshot.stopReason === "length") { - ctx.ui.notify("Loop fixing stopped: review output was truncated (stopReason=length).", "warning"); - return; - } - - if (!hasBlockingReviewFindings(reviewSnapshot.text)) { - const finalized = await executeEndReviewAction(ctx, "returnAndSummarize", { - showSummaryLoader: true, - notifySuccess: false, - }); - if (finalized !== "ok") { - return; - } - - ctx.ui.notify("Loop fixing complete: no blocking findings remain.", "info"); - return; - } - - ctx.ui.notify(`Loop fixing pass ${pass}: found blocking findings, returning to fix them...`, "info"); - - const fixBaselineAssistantId = getLastAssistantSnapshot(ctx)?.id; - const sentFixPrompt = await executeEndReviewAction(ctx, "returnAndFix", { - showSummaryLoader: true, - notifySuccess: false, - }); - if (sentFixPrompt !== "ok") { - return; - } - - const fixTurnStarted = await waitForLoopTurnToStart(ctx, fixBaselineAssistantId); - if (!fixTurnStarted) { - ctx.ui.notify("Loop fixing stopped: fix pass did not start in time.", "error"); - return; - } - - await ctx.waitForIdle(); - - const fixSnapshot = getLastAssistantSnapshot(ctx); - if (!fixSnapshot || fixSnapshot.id === fixBaselineAssistantId) { - ctx.ui.notify("Loop fixing stopped: could not read the fix pass result.", "warning"); - return; - } - if (fixSnapshot.stopReason === "aborted") { - ctx.ui.notify("Loop fixing stopped: fix pass was aborted.", "warning"); - return; - } - if (fixSnapshot.stopReason === "error") { - ctx.ui.notify("Loop fixing stopped: fix pass failed with an error.", "error"); - return; - } - if (fixSnapshot.stopReason === "length") { - ctx.ui.notify("Loop fixing stopped: fix pass output was truncated (stopReason=length).", "warning"); - return; - } - } - - ctx.ui.notify( - `Loop fixing stopped after ${REVIEW_LOOP_MAX_ITERATIONS} passes (safety limit reached).`, - "warning", - ); - } finally { - reviewLoopInProgress = false; - setReviewWidget(ctx, Boolean(reviewOriginId)); - } - } - - // Register the /review command - pi.registerCommand("review", { - description: "Review code changes (PR, working copy, bookmark, change, or folder)", - handler: async (args, ctx) => { - if (!ctx.hasUI) { - ctx.ui.notify("Review requires interactive mode", "error"); - return; - } - - if (reviewLoopInProgress) { - ctx.ui.notify("Loop fixing review is already running.", "warning"); - return; - } - - // Check if we're already in a review - if (reviewOriginId) { - ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning"); - return; - } - - // Check if we're in a jj repository - const { code } = await pi.exec("jj", ["root"]); - if (code !== 0) { - ctx.ui.notify("Not a jj repository", "error"); - return; - } - - // Try to parse direct arguments - let target: ReviewTarget | null = null; - let fromSelector = false; - let extraInstruction: string | undefined; - const parsed = parseArgs(args); - if (parsed.error) { - ctx.ui.notify(parsed.error, "error"); - return; - } - extraInstruction = parsed.extraInstruction?.trim() || undefined; - - if (parsed.target) { - if (parsed.target.type === "pr") { - // Materialize the PR locally (async operation) - target = await handlePrCheckout(ctx, parsed.target.ref); - if (!target) { - ctx.ui.notify("PR review failed. Returning to review menu.", "warning"); - } - } else { - target = parsed.target; - } - } - - // If no args or invalid args, show selector - if (!target) { - fromSelector = true; - } - - while (true) { - if (!target && fromSelector) { - target = await showReviewSelector(ctx); - } - - if (!target) { - ctx.ui.notify("Review cancelled", "info"); - return; - } - - if (reviewLoopFixingEnabled && !isLoopCompatibleTarget(target)) { - ctx.ui.notify("Loop mode does not work with change review.", "error"); - if (fromSelector) { - target = null; - continue; - } - return; - } - - if (reviewLoopFixingEnabled) { - await runLoopFixingReview(ctx, target, extraInstruction); - return; - } - - // Determine if we should use fresh session mode - // Check if this is a new session (no messages yet) - const entries = ctx.sessionManager.getEntries(); - const messageCount = entries.filter((e) => e.type === "message").length; - - // In an empty session, default to fresh review mode so /end-review works consistently. - let useFreshSession = messageCount === 0; - - if (messageCount > 0) { - // Existing session - ask user which mode they want - const choice = await ctx.ui.select("Start review in:", ["Empty branch", "Current session"]); - - if (choice === undefined) { - if (fromSelector) { - target = null; - continue; - } - ctx.ui.notify("Review cancelled", "info"); - return; - } - - useFreshSession = choice === "Empty branch"; - } - - await executeReview(ctx, target, useFreshSession, { extraInstruction }); - return; - } - }, - }); - - // Custom prompt for review summaries - focuses on preserving actionable findings - const REVIEW_SUMMARY_PROMPT = `We are leaving a code-review branch and returning to the main coding branch. -Create a structured handoff that can be used immediately to implement fixes. - -You MUST summarize the review that happened in this branch so findings can be acted on. -Do not omit findings: include every actionable issue that was identified. - -Required sections (in order): - -## Review Scope -- What was reviewed (files/paths, changes, and scope) - -## Verdict -- "correct" or "needs attention" - -## Findings -For EACH finding, include: -- Priority tag ([P0]..[P3]) and short title -- File location (\`path/to/file.ext:line\`) -- Why it matters (brief) -- What should change (brief, actionable) - -## Fix Queue -1. Ordered implementation checklist (highest priority first) - -## Constraints & Preferences -- Any constraints or preferences mentioned during review -- Or "(none)" - -## Human Reviewer Callouts (Non-Blocking) -Include only applicable callouts (no yes/no lines): -- **This change adds a database migration:** -- **This change introduces a new dependency:** -- **This change changes a dependency (or the lockfile):** -- **This change modifies auth/permission behavior:** -- **This change introduces backwards-incompatible public schema/API/contract changes:** -- **This change includes irreversible or destructive operations:** - -If none apply, write "- (none)". - -These are informational callouts for humans and are not fix items by themselves. - -Preserve exact file paths, function names, and error messages where available.`; - - const REVIEW_FIX_FINDINGS_PROMPT = `Use the latest review summary in this session and implement the review findings now. - -Instructions: -1. Treat the summary's Findings/Fix Queue as a checklist. -2. Fix in priority order: P0, P1, then P2 (include P3 if quick and safe). -3. If a finding is invalid/already fixed/not possible right now, briefly explain why and continue. -4. Treat "Human Reviewer Callouts (Non-Blocking)" as informational only; do not convert them into fix tasks unless there is a separate explicit finding. -5. Follow fail-fast error handling: do not add local catch/fallback recovery unless this scope is an explicit boundary that can safely translate the failure. -6. If you add or keep a \`try/catch\`, explain the expected failure mode and either rethrow with context or return a boundary-safe error response. -7. JSON parsing/decoding should fail loudly by default; avoid silent fallback parsing. -8. Run relevant tests/checks for touched code where practical. -9. End with: fixed items, deferred/skipped items (with reasons), and verification results.`; - - type EndReviewAction = "returnOnly" | "returnAndFix" | "returnAndSummarize"; - type EndReviewActionResult = "ok" | "cancelled" | "error"; - type EndReviewActionOptions = { - showSummaryLoader?: boolean; - notifySuccess?: boolean; - }; - - function getActiveReviewOrigin(ctx: ExtensionContext): string | undefined { - if (reviewOriginId) { - return reviewOriginId; - } - - const state = getReviewState(ctx); - if (state?.active && state.originId) { - reviewOriginId = state.originId; - return reviewOriginId; - } - - if (state?.active) { - setReviewWidget(ctx, false); - pi.appendEntry(REVIEW_STATE_TYPE, { active: false }); - ctx.ui.notify("Review state was missing origin info; cleared review status.", "warning"); - } - - return undefined; - } - - function clearReviewState(ctx: ExtensionContext) { - setReviewWidget(ctx, false); - reviewOriginId = undefined; - pi.appendEntry(REVIEW_STATE_TYPE, { active: false }); - } - - async function navigateWithSummary( - ctx: ExtensionCommandContext, - originId: string, - showLoader: boolean, - ): Promise<{ cancelled: boolean; error?: string } | null> { - if (showLoader && ctx.hasUI) { - return ctx.ui.custom<{ cancelled: boolean; error?: string } | null>((tui, theme, _kb, done) => { - const loader = new BorderedLoader(tui, theme, "Returning and summarizing review branch..."); - loader.onAbort = () => done(null); - - ctx.navigateTree(originId, { - summarize: true, - customInstructions: REVIEW_SUMMARY_PROMPT, - replaceInstructions: true, - }) - .then(done) - .catch((err) => done({ cancelled: false, error: err instanceof Error ? err.message : String(err) })); - - return loader; - }); - } - - try { - return await ctx.navigateTree(originId, { - summarize: true, - customInstructions: REVIEW_SUMMARY_PROMPT, - replaceInstructions: true, - }); - } catch (error) { - return { cancelled: false, error: error instanceof Error ? error.message : String(error) }; - } - } - - async function executeEndReviewAction( - ctx: ExtensionCommandContext, - action: EndReviewAction, - options: EndReviewActionOptions = {}, - ): Promise { - const originId = getActiveReviewOrigin(ctx); - if (!originId) { - if (!getReviewState(ctx)?.active) { - ctx.ui.notify("Not in a review branch (use /review first, or review was started in current session mode)", "info"); - } - return "error"; - } - - const notifySuccess = options.notifySuccess ?? true; - - if (action === "returnOnly") { - try { - const result = await ctx.navigateTree(originId, { summarize: false }); - if (result.cancelled) { - ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info"); - return "cancelled"; - } - } catch (error) { - ctx.ui.notify(`Failed to return: ${error instanceof Error ? error.message : String(error)}`, "error"); - return "error"; - } - - clearReviewState(ctx); - if (notifySuccess) { - ctx.ui.notify("Review complete! Returned to original position.", "info"); - } - return "ok"; - } - - const summaryResult = await navigateWithSummary(ctx, originId, options.showSummaryLoader ?? false); - if (summaryResult === null) { - ctx.ui.notify("Summarization cancelled. Use /end-review to try again.", "info"); - return "cancelled"; - } - - if (summaryResult.error) { - ctx.ui.notify(`Summarization failed: ${summaryResult.error}`, "error"); - return "error"; - } - - if (summaryResult.cancelled) { - ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info"); - return "cancelled"; - } - - clearReviewState(ctx); - - if (action === "returnAndSummarize") { - if (!ctx.ui.getEditorText().trim()) { - ctx.ui.setEditorText("Act on the review findings"); - } - if (notifySuccess) { - ctx.ui.notify("Review complete! Returned and summarized.", "info"); - } - return "ok"; - } - - pi.sendUserMessage(REVIEW_FIX_FINDINGS_PROMPT, { deliverAs: "followUp" }); - if (notifySuccess) { - ctx.ui.notify("Review complete! Returned and queued a follow-up to fix findings.", "info"); - } - return "ok"; - } - - async function runEndReview(ctx: ExtensionCommandContext): Promise { - if (!ctx.hasUI) { - ctx.ui.notify("End-review requires interactive mode", "error"); - return; - } - - if (reviewLoopInProgress) { - ctx.ui.notify("Loop fixing review is running. Wait for it to finish.", "info"); - return; - } - - if (endReviewInProgress) { - ctx.ui.notify("/end-review is already running", "info"); - return; - } - - endReviewInProgress = true; - try { - const choice = await ctx.ui.select("Finish review:", [ - "Return only", - "Return and fix findings", - "Return and summarize", - ]); - - if (choice === undefined) { - ctx.ui.notify("Cancelled. Use /end-review to try again.", "info"); - return; - } - - const action: EndReviewAction = - choice === "Return and fix findings" - ? "returnAndFix" - : choice === "Return and summarize" - ? "returnAndSummarize" - : "returnOnly"; - - await executeEndReviewAction(ctx, action, { - showSummaryLoader: true, - notifySuccess: true, - }); - } finally { - endReviewInProgress = false; - } - } - - // Register the /end-review command - pi.registerCommand("end-review", { - description: "Complete review and return to original position", - handler: async (_args, ctx) => { - await runEndReview(ctx); - }, - }); -} diff --git a/modules/_ai-tools/extensions/session-name.ts b/modules/_ai-tools/extensions/session-name.ts deleted file mode 100644 index 856b7f4..0000000 --- a/modules/_ai-tools/extensions/session-name.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; -import { - createAgentSession, - DefaultResourceLoader, - getAgentDir, - SessionManager, - SettingsManager, -} from "@mariozechner/pi-coding-agent"; - -interface SessionNameState { - hasAutoNamed: boolean; -} - -const TITLE_MODEL = { - provider: "openai-codex", - id: "gpt-5.4-mini", -} as const; - -const MAX_TITLE_LENGTH = 50; -const MAX_RETRIES = 2; -const FALLBACK_LENGTH = 50; -const TITLE_ENTRY_TYPE = "vendored-session-title"; - -const TITLE_SYSTEM_PROMPT = `You are generating a succinct title for a coding session based on the provided conversation. - -Requirements: -- Maximum 50 characters -- Sentence case (capitalize only first word and proper nouns) -- Capture the main intent or task -- Reuse the user's exact words and technical terms -- Match the user's language -- No quotes, colons, or markdown formatting -- No generic titles like "Coding session" or "Help with code" -- No explanations or commentary - -Output ONLY the title text. Nothing else.`; - -function isTurnCompleted(event: unknown): boolean { - if (!event || typeof event !== "object") return false; - const message = (event as { message?: unknown }).message; - if (!message || typeof message !== "object") return false; - const stopReason = (message as { stopReason?: unknown }).stopReason; - return typeof stopReason === "string" && stopReason.toLowerCase() === "stop"; -} - -function buildFallbackTitle(userText: string): string { - const text = userText.trim(); - if (text.length <= FALLBACK_LENGTH) return text; - const truncated = text.slice(0, FALLBACK_LENGTH - 3); - const lastSpace = truncated.lastIndexOf(" "); - return `${lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated}...`; -} - -function postProcessTitle(raw: string): string { - let title = raw; - - title = title.replace(/\s*/g, ""); - title = title.replace(/^["'`]+|["'`]+$/g, ""); - title = title.replace(/^#+\s*/, ""); - title = title.replace(/\*{1,2}(.*?)\*{1,2}/g, "$1"); - title = title.replace(/_{1,2}(.*?)_{1,2}/g, "$1"); - title = title.replace(/^(Title|Summary|Session)\s*:\s*/i, ""); - title = - title - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0) ?? title; - title = title.trim(); - - if (title.length > MAX_TITLE_LENGTH) { - const truncated = title.slice(0, MAX_TITLE_LENGTH - 3); - const lastSpace = truncated.lastIndexOf(" "); - title = `${lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated}...`; - } - - return title; -} - -function getLatestUserText(ctx: ExtensionContext): string | null { - const entries = ctx.sessionManager.getEntries(); - for (let i = entries.length - 1; i >= 0; i -= 1) { - const entry = entries[i]; - if (!entry || entry.type !== "message") continue; - if (entry.message.role !== "user") continue; - - const { content } = entry.message as { content: unknown }; - if (typeof content === "string") return content; - if (!Array.isArray(content)) return null; - - return content - .filter( - (part): part is { type: string; text?: string } => - typeof part === "object" && part !== null && "type" in part, - ) - .filter((part) => part.type === "text" && typeof part.text === "string") - .map((part) => part.text ?? "") - .join(" "); - } - - return null; -} - -function getLatestAssistantText(ctx: ExtensionContext): string | null { - const entries = ctx.sessionManager.getEntries(); - for (let i = entries.length - 1; i >= 0; i -= 1) { - const entry = entries[i]; - if (!entry || entry.type !== "message") continue; - if (entry.message.role !== "assistant") continue; - - const { content } = entry.message as { content: unknown }; - if (typeof content === "string") return content; - if (!Array.isArray(content)) return null; - - return content - .filter( - (part): part is { type: string; text?: string } => - typeof part === "object" && part !== null && "type" in part, - ) - .filter((part) => part.type === "text" && typeof part.text === "string") - .map((part) => part.text ?? "") - .join("\n"); - } - - return null; -} - -function resolveModel(ctx: ExtensionContext) { - const available = ctx.modelRegistry.getAvailable(); - const model = available.find( - (candidate) => candidate.provider === TITLE_MODEL.provider && candidate.id === TITLE_MODEL.id, - ); - if (model) return model; - - const existsWithoutKey = ctx.modelRegistry - .getAll() - .some((candidate) => candidate.provider === TITLE_MODEL.provider && candidate.id === TITLE_MODEL.id); - if (existsWithoutKey) { - throw new Error( - `Model ${TITLE_MODEL.provider}/${TITLE_MODEL.id} exists but has no configured API key.`, - ); - } - - throw new Error(`Model ${TITLE_MODEL.provider}/${TITLE_MODEL.id} is not available.`); -} - -async function generateTitle(userText: string, assistantText: string, ctx: ExtensionContext): Promise { - const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(ctx.cwd, agentDir); - const resourceLoader = new DefaultResourceLoader({ - cwd: ctx.cwd, - agentDir, - settingsManager, - noExtensions: true, - noPromptTemplates: true, - noThemes: true, - noSkills: true, - systemPromptOverride: () => TITLE_SYSTEM_PROMPT, - appendSystemPromptOverride: () => [], - agentsFilesOverride: () => ({ agentsFiles: [] }), - }); - await resourceLoader.reload(); - - const { session } = await createAgentSession({ - model: resolveModel(ctx), - thinkingLevel: "off", - sessionManager: SessionManager.inMemory(), - modelRegistry: ctx.modelRegistry, - resourceLoader, - }); - - let accumulated = ""; - const unsubscribe = session.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - accumulated += event.assistantMessageEvent.delta; - } - }); - - const description = assistantText - ? `${userText}\n${assistantText}` - : `${userText}`; - const userMessage = `\n${description}\n\n\nGenerate a title:`; - - try { - await session.prompt(userMessage); - } finally { - unsubscribe(); - session.dispose(); - } - - return postProcessTitle(accumulated); -} - -async function generateAndSetTitle(pi: ExtensionAPI, ctx: ExtensionContext): Promise { - const userText = getLatestUserText(ctx); - if (!userText?.trim()) return; - - const assistantText = getLatestAssistantText(ctx) ?? ""; - if (!assistantText.trim()) return; - - let lastError: Error | null = null; - for (let attempt = 1; attempt <= MAX_RETRIES; attempt += 1) { - try { - const title = await generateTitle(userText, assistantText, ctx); - if (!title) continue; - - pi.setSessionName(title); - pi.appendEntry(TITLE_ENTRY_TYPE, { - title, - rawUserText: userText, - rawAssistantText: assistantText, - attempt, - model: `${TITLE_MODEL.provider}/${TITLE_MODEL.id}`, - }); - ctx.ui.notify(`Session: ${title}`, "info"); - return; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - } - } - - const fallback = buildFallbackTitle(userText); - pi.setSessionName(fallback); - pi.appendEntry(TITLE_ENTRY_TYPE, { - title: fallback, - fallback: true, - error: lastError?.message ?? "Unknown error", - rawUserText: userText, - rawAssistantText: assistantText, - model: `${TITLE_MODEL.provider}/${TITLE_MODEL.id}`, - }); - ctx.ui.notify(`Title generation failed, using fallback: ${fallback}`, "warning"); -} - -export default function setupSessionNameHook(pi: ExtensionAPI) { - const state: SessionNameState = { - hasAutoNamed: false, - }; - - pi.on("session_start", async () => { - state.hasAutoNamed = false; - }); - - pi.on("session_switch", async () => { - state.hasAutoNamed = false; - }); - - pi.on("turn_end", async (event, ctx) => { - if (state.hasAutoNamed) return; - - if (pi.getSessionName()) { - state.hasAutoNamed = true; - return; - } - - if (!isTurnCompleted(event)) return; - - await generateAndSetTitle(pi, ctx); - state.hasAutoNamed = true; - }); -} diff --git a/modules/_ai-tools/mcp.json b/modules/_ai-tools/mcp.json deleted file mode 100644 index 70bd451..0000000 --- a/modules/_ai-tools/mcp.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "mcpServers": { - "opensrc": { - "command": "npx", - "args": ["-y", "opensrc-mcp"], - "lifecycle": "eager" - }, - "context7": { - "url": "https://mcp.context7.com/mcp", - "lifecycle": "eager" - }, - "grep_app": { - "url": "https://mcp.grep.app", - "lifecycle": "eager" - }, - "sentry": { - "url": "https://mcp.sentry.dev/mcp", - "auth": "oauth" - } - } -} diff --git a/modules/_ai-tools/skills/jujutsu/SKILL.md b/modules/_ai-tools/skills/jujutsu/SKILL.md deleted file mode 100644 index deec8df..0000000 --- a/modules/_ai-tools/skills/jujutsu/SKILL.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: jujutsu -description: Manages version control with Jujutsu (jj), including rebasing, conflict resolution, and Git interop. Use when tracking changes, navigating history, squashing/splitting commits, or pushing to Git remotes. ---- - -# Jujutsu - -Git-compatible VCS focused on concurrent development and ease of use. - -> ⚠️ **Not Git!** Jujutsu syntax differs from Git: -> -> - Parent: `@-` not `@~1` or `@^` -> - Grandparent: `@--` not `@~2` -> - Child: `@+` not `@~-1` -> - Use `jj log` not `jj changes` - -## Key Commands - -| Command | Description | -| -------------------------- | -------------------------------------------- | -| `jj st` | Show working copy status | -| `jj log` | Show change log | -| `jj diff` | Show changes in working copy | -| `jj new` | Create new change | -| `jj desc` | Edit change description | -| `jj squash` | Move changes to parent | -| `jj split` | Split current change | -| `jj rebase -s src -d dest` | Rebase changes | -| `jj absorb` | Move changes into stack of mutable revisions | -| `jj bisect` | Find bad revision by bisection | -| `jj fix` | Update files with formatting fixes | -| `jj sign` | Cryptographically sign a revision | -| `jj metaedit` | Modify metadata without changing content | - -## Basic Workflow - -```bash -jj new # Create new change -jj desc -m "feat: add feature" # Set description -jj log # View history -jj edit change-id # Switch to change -jj new --before @ # Time travel (create before current) -jj edit @- # Go to parent -``` - -## Time Travel - -```bash -jj edit change-id # Switch to specific change -jj next --edit # Next child change -jj edit @- # Parent change -jj new --before @ -m msg # Insert before current -``` - -## Merging & Rebasing - -```bash -jj new x yz -m msg # Merge changes -jj rebase -s src -d dest # Rebase source onto dest -jj abandon # Delete current change -``` - -## Conflicts - -```bash -jj resolve # Interactive conflict resolution -# Edit files, then continue -``` - -## Revset Syntax - -**Parent/child operators:** - -| Syntax | Meaning | Example | -| ------ | ---------------- | -------------------- | -| `@-` | Parent of @ | `jj diff -r @-` | -| `@--` | Grandparent | `jj log -r @--` | -| `x-` | Parent of x | `jj diff -r abc123-` | -| `@+` | Child of @ | `jj log -r @+` | -| `x::y` | x to y inclusive | `jj log -r main::@` | -| `x..y` | x to y exclusive | `jj log -r main..@` | -| `x\|y` | Union (or) | `jj log -r 'a \| b'` | - -**⚠️ Common mistakes:** - -- ❌ `@~1` → ✅ `@-` (parent) -- ❌ `@^` → ✅ `@-` (parent) -- ❌ `@~-1` → ✅ `@+` (child) -- ❌ `jj changes` → ✅ `jj log` or `jj diff` -- ❌ `a,b,c` → ✅ `a | b | c` (union uses pipe, not comma) - -**Functions:** - -```bash -jj log -r 'heads(all())' # All heads -jj log -r 'remote_bookmarks()..' # Not on remote -jj log -r 'author(name)' # By author -jj log -r 'description(regex)' # By description -jj log -r 'mine()' # My commits -jj log -r 'committer_date(after:"7 days ago")' # Recent commits -jj log -r 'mine() & committer_date(after:"yesterday")' # My recent -``` - -## Templates - -```bash -jj log -T 'commit_id ++ "\n" ++ description' -``` - -## Git Interop - -```bash -jj bookmark create main -r @ # Create bookmark -jj git push --bookmark main # Push bookmark -jj git fetch # Fetch from remote -jj bookmark track main@origin # Track remote -``` - -## Advanced Commands - -```bash -jj absorb # Auto-move changes to relevant commits in stack -jj bisect start # Start bisection -jj bisect good # Mark current as good -jj bisect bad # Mark current as bad -jj fix # Run configured formatters on files -jj sign -r @ # Sign current revision -jj metaedit -r @ -m "new message" # Edit metadata only -``` - -## Tips - -- No staging: changes are immediate -- Use conventional commits: `type(scope): desc` -- `jj undo` to revert operations -- `jj op log` to see operation history -- Bookmarks are like branches -- `jj absorb` is powerful for fixing up commits in a stack - -## Related Skills - -- **gh**: GitHub CLI for PRs and issues -- **review**: Code review before committing diff --git a/modules/_ai-tools/skills/notability-normalize/SKILL.md b/modules/_ai-tools/skills/notability-normalize/SKILL.md deleted file mode 100644 index 2c19966..0000000 --- a/modules/_ai-tools/skills/notability-normalize/SKILL.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: notability-normalize -description: Normalizes an exact Notability transcription into clean, searchable Markdown while preserving all original content and uncertainty markers. Use after a faithful transcription pass. ---- - -# Notability Normalize - -You are doing a **Markdown normalization** pass on a previously transcribed Notability note. - -## Rules - -- Do **not** summarize. -- Do **not** remove uncertainty markers such as `[unclear: ...]`. -- Preserve all substantive content from the transcription. -- Clean up only formatting and Markdown structure. -- Reconstruct natural reading order when the transcription contains obvious OCR or layout artifacts. -- Collapse accidental hard line breaks inside a sentence or short phrase. -- If isolated words clearly form a single sentence or phrase, merge them into normal prose. -- Prefer readable Markdown headings, lists, and tables. -- Keep content in the same overall order as the transcription. -- Do not invent content. -- Do not output code fences. -- Output Markdown only. - -## Output - -- Produce a clean Markdown document. -- Include a top-level `#` heading if the note clearly has a title. -- Use standard Markdown lists and checkboxes. -- Represent tables as Markdown tables when practical. -- Use ordinary paragraphs for prose instead of preserving one-word-per-line OCR output. -- Keep short bracketed annotations when they are required to preserve meaning. - -## Important - -The source PDF remains the ground truth. When in doubt, preserve ambiguity instead of cleaning it away. diff --git a/modules/_ai-tools/skills/notability-transcribe/SKILL.md b/modules/_ai-tools/skills/notability-transcribe/SKILL.md deleted file mode 100644 index 5089c1e..0000000 --- a/modules/_ai-tools/skills/notability-transcribe/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: notability-transcribe -description: Faithfully transcribes handwritten or mixed handwritten/typed Notability note pages into Markdown without summarizing. Use when converting note page images or PDFs into an exact textual transcription. ---- - -# Notability Transcribe - -You are doing a **faithful transcription** pass for handwritten Notability notes. - -## Rules - -- Preserve the original order of content. -- Reconstruct the intended reading order from the page layout. -- Read the page in the order a human would: top-to-bottom and left-to-right, while respecting obvious grouping. -- Do **not** summarize, explain, clean up, or reorganize beyond what is necessary to transcribe faithfully. -- Preserve headings, bullets, numbered items, checkboxes, tables, separators, callouts, and obvious layout structure. -- Do **not** preserve accidental OCR-style hard line breaks when the note is clearly continuous prose or a single phrase. -- If words are staggered on the page but clearly belong to the same sentence, combine them into normal lines. -- If text is uncertain, keep the uncertainty inline as `[unclear: ...]`. -- If a word is partially legible, include the best reading and uncertainty marker. -- If there is a drawing or diagram that cannot be represented exactly, describe it minimally in brackets, for example `[diagram: arrow from A to B]`. -- Preserve language exactly as written. -- Do not invent missing words. -- Do not output code fences. -- Output Markdown only. - -## Output shape - -- Use headings when headings are clearly present. -- Use `- [ ]` or `- [x]` for checkboxes when visible. -- Use bullet lists for bullet lists. -- Use normal paragraphs or single-line phrases for continuous prose instead of one word per line. -- Keep side notes in the position that best preserves reading order. -- Insert blank lines between major sections. - -## Safety - -If a page is partly unreadable, still transcribe everything you can and mark uncertain content with `[unclear: ...]`. diff --git a/modules/_opencode/AGENTS.md b/modules/_opencode/AGENTS.md index 644ab5e..6cbf333 100644 --- a/modules/_opencode/AGENTS.md +++ b/modules/_opencode/AGENTS.md @@ -1,11 +1,28 @@ -# Global AGENTS.md +# AGENTS.md ## Version Control -- Use `jj` for VCS, not `git` -- `jj tug` is an alias for `jj bookmark move --from closest_bookmark(@-) --to @-` +- Use `jj` for version control, not `git`. +- `jj tug` is an alias for `jj bookmark move --from closest_bookmark(@-) --to @-`. +- Never attempt historically destructive Git commands. +- Make small, frequent commits. +- "Commit" means `jj commit`, not `jj desc`; `desc` stays on the same working copy. ## Scripting -- Always use Nushell (`nu`) for scripting -- Never use Python, Perl, Lua, awk, or any other scripting language +- Use Nushell (`nu`) for scripting. +- Do not use Python, Perl, Lua, awk, or any other scripting language. You are programatically blocked from doing so. + +## Workflow + +- Always complete the requested work. +- If there is any ambiguity about what to do next, do NOT make a decision yourself. Stop your work and ask. +- Do not end with “If you want me to…” or “I can…”; take the next necessary step and finish the job without waiting for additional confirmation. +- Do not future-proof things. Stick to the original plan. +- Do not add fallbacks or backward compatibility unless explicitly required by the user. By default, replace the previous implementation with the new one entirely. + +## Validation + +- Do not ignore failing tests or checks, even if they appear unrelated to your changes. +- After completing and validating your work, the final step is to run the project's full validation and test commands and ensure they all pass. + diff --git a/modules/_overlays/pi-agent-stuff.nix b/modules/_overlays/pi-agent-stuff.nix deleted file mode 100644 index 751a49a..0000000 --- a/modules/_overlays/pi-agent-stuff.nix +++ /dev/null @@ -1,10 +0,0 @@ -{inputs, ...}: final: prev: { - pi-agent-stuff = - prev.buildNpmPackage { - pname = "pi-agent-stuff"; - version = "1.5.0"; - src = inputs.pi-agent-stuff; - npmDepsHash = "sha256-pyXMNdlie8vAkhz2f3GUGT3CCYuwt+xkWnsijBajXIo="; - dontNpmBuild = true; - }; -} diff --git a/modules/_overlays/pi-harness.nix b/modules/_overlays/pi-harness.nix deleted file mode 100644 index 8b5a842..0000000 --- a/modules/_overlays/pi-harness.nix +++ /dev/null @@ -1,33 +0,0 @@ -{inputs, ...}: final: prev: { - pi-harness = - prev.stdenvNoCC.mkDerivation { - pname = "pi-harness"; - version = "0.0.0"; - src = inputs.pi-harness; - - pnpmDeps = - prev.fetchPnpmDeps { - pname = "pi-harness"; - version = "0.0.0"; - src = inputs.pi-harness; - pnpm = prev.pnpm_10; - fetcherVersion = 3; - hash = "sha256-lNcZRCmmwq9t05UjVWcuGq+ZzRHuHNmqKQIVPh6DoxQ="; - }; - - nativeBuildInputs = [ - prev.pnpmConfigHook - prev.pnpm_10 - prev.nodejs - ]; - - dontBuild = true; - - installPhase = '' - runHook preInstall - mkdir -p $out/lib/node_modules/@aliou/pi-harness - cp -r . $out/lib/node_modules/@aliou/pi-harness - runHook postInstall - ''; - }; -} diff --git a/modules/_overlays/pi-mcp-adapter.nix b/modules/_overlays/pi-mcp-adapter.nix deleted file mode 100644 index 47d0194..0000000 --- a/modules/_overlays/pi-mcp-adapter.nix +++ /dev/null @@ -1,10 +0,0 @@ -{inputs, ...}: final: prev: { - pi-mcp-adapter = - prev.buildNpmPackage { - pname = "pi-mcp-adapter"; - version = "2.2.0"; - src = inputs.pi-mcp-adapter; - npmDepsHash = "sha256-myJ9h/zC/KDddt8NOVvJjjqbnkdEN4ZR+okCR5nu7hM="; - dontNpmBuild = true; - }; -} diff --git a/modules/ai-tools.nix b/modules/ai-tools.nix index f917f04..2e7c528 100644 --- a/modules/ai-tools.nix +++ b/modules/ai-tools.nix @@ -123,68 +123,5 @@ in { }; "opencode/AGENTS.md".source = ./_opencode/AGENTS.md; }; - - home.file = { - "AGENTS.md".source = ./_ai-tools/AGENTS.md; - ".pi/agent/extensions/pi-elixir" = { - source = inputs.pi-elixir; - recursive = true; - }; - ".pi/agent/extensions/pi-mcp-adapter" = { - source = "${pkgs.pi-mcp-adapter}/lib/node_modules/pi-mcp-adapter"; - recursive = true; - }; - ".pi/agent/extensions/no-git.ts".source = ./_ai-tools/extensions/no-git.ts; - ".pi/agent/extensions/no-scripting.ts".source = ./_ai-tools/extensions/no-scripting.ts; - ".pi/agent/extensions/note-ingest.ts".source = ./_ai-tools/extensions/note-ingest.ts; - ".pi/agent/extensions/review.ts".source = ./_ai-tools/extensions/review.ts; - ".pi/agent/extensions/session-name.ts".source = ./_ai-tools/extensions/session-name.ts; - ".pi/agent/notability" = { - source = ./_notability; - recursive = true; - }; - ".pi/agent/skills/elixir-dev" = { - source = "${inputs.pi-elixir}/skills/elixir-dev"; - recursive = true; - }; - ".pi/agent/skills/jujutsu/SKILL.md".source = ./_ai-tools/skills/jujutsu/SKILL.md; - ".pi/agent/skills/notability-transcribe/SKILL.md".source = ./_ai-tools/skills/notability-transcribe/SKILL.md; - ".pi/agent/skills/notability-normalize/SKILL.md".source = ./_ai-tools/skills/notability-normalize/SKILL.md; - ".pi/agent/themes" = { - source = "${inputs.pi-rose-pine}/themes"; - recursive = true; - }; - ".pi/agent/settings.json".text = - builtins.toJSON { - theme = "rose-pine-dawn"; - quietStartup = true; - hideThinkingBlock = true; - defaultProvider = "openai-codex"; - defaultModel = "gpt-5.4"; - defaultThinkingLevel = "high"; - packages = [ - { - source = "${pkgs.pi-agent-stuff}/lib/node_modules/mitsupi"; - extensions = [ - "pi-extensions/answer.ts" - "pi-extensions/context.ts" - "pi-extensions/multi-edit.ts" - "pi-extensions/todos.ts" - ]; - skills = []; - prompts = []; - themes = []; - } - { - source = "${pkgs.pi-harness}/lib/node_modules/@aliou/pi-harness"; - extensions = ["extensions/breadcrumbs/index.ts"]; - skills = []; - prompts = []; - themes = []; - } - ]; - }; - ".pi/agent/mcp.json".source = ./_ai-tools/mcp.json; - }; }; } diff --git a/modules/dendritic.nix b/modules/dendritic.nix index b79c260..b0f7951 100644 --- a/modules/dendritic.nix +++ b/modules/dendritic.nix @@ -54,26 +54,6 @@ inputs.nixpkgs.follows = "nixpkgs"; }; llm-agents.url = "github:numtide/llm-agents.nix"; - pi-agent-stuff = { - url = "github:mitsuhiko/agent-stuff"; - flake = false; - }; - pi-elixir = { - url = "github:dannote/pi-elixir"; - flake = false; - }; - pi-rose-pine = { - url = "github:zenobi-us/pi-rose-pine"; - flake = false; - }; - pi-harness = { - url = "github:aliou/pi-harness"; - flake = false; - }; - pi-mcp-adapter = { - url = "github:nicobailon/pi-mcp-adapter"; - flake = false; - }; qmd.url = "github:tobi/qmd"; # Overlay inputs himalaya.url = "github:pimalaya/himalaya"; diff --git a/modules/notability.nix b/modules/notability.nix index da84c70..7caa862 100644 --- a/modules/notability.nix +++ b/modules/notability.nix @@ -26,7 +26,6 @@ in { ]; commonPath = with pkgs; [ - inputs'.llm-agents.packages.pi coreutils inotify-tools nushell diff --git a/modules/overlays.nix b/modules/overlays.nix index 8a667da..3a1a030 100644 --- a/modules/overlays.nix +++ b/modules/overlays.nix @@ -20,12 +20,6 @@ (import ./_overlays/jj-ryu.nix {inherit inputs;}) # cog-cli (import ./_overlays/cog-cli.nix {inherit inputs;}) - # pi-agent-stuff (mitsuhiko) - (import ./_overlays/pi-agent-stuff.nix {inherit inputs;}) - # pi-harness (aliou) - (import ./_overlays/pi-harness.nix {inherit inputs;}) - # pi-mcp-adapter - (import ./_overlays/pi-mcp-adapter.nix {inherit inputs;}) # qmd (import ./_overlays/qmd.nix {inherit inputs;}) # jj-starship (passes through upstream overlay)