From a384b3a3d8b50abe2da5d094ca1e3b7e9b1e4762 Mon Sep 17 00:00:00 2001 From: terra tauri Date: Thu, 19 Feb 2026 07:36:47 -0800 Subject: [PATCH] feat: Add worktree isolation, auto-branch, review memory, and verbose tool details - Git worktree lifecycle: implementer runs in isolated worktree under ~/.config/autonav/worktrees/, cleaned up on exit - Auto-branch: generates branch name from first plan summary when --branch is not provided (slugified, e.g. "feat/add-user-auth") - Review loop memory: accumulates ReviewRound history across rounds, passes to reviewer (anti-oscillation) and fixer (anti-revert) - Verbose tool details: shows Bash commands, file paths, search patterns instead of just tool names - Improved uncommitted changes UI: shows file list with status indicators, adds [v] View diff option with re-prompt loop - Fix PR creation: uses activeBranch instead of options.branch so auto-generated branches can create PRs - Show Claude config dir in start panel (respects CLAUDE_CONFIG_DIR) Co-Authored-By: Claude Opus 4.6 --- packages/autonav/src/cli/nav-memento.ts | 3 + .../autonav/src/memento/git-operations.ts | 109 ++++++++++ packages/autonav/src/memento/index.ts | 9 + packages/autonav/src/memento/loop.ts | 205 +++++++++++++++--- packages/autonav/src/memento/prompts.ts | 62 +++++- 5 files changed, 349 insertions(+), 39 deletions(-) diff --git a/packages/autonav/src/cli/nav-memento.ts b/packages/autonav/src/cli/nav-memento.ts index 62f0455..352a89f 100644 --- a/packages/autonav/src/cli/nav-memento.ts +++ b/packages/autonav/src/cli/nav-memento.ts @@ -22,6 +22,7 @@ import { Command } from "commander"; import chalk from "chalk"; import * as fs from "node:fs"; import * as path from "node:path"; +import * as os from "node:os"; import { runMementoLoop } from "../memento/index.js"; import { resolveAndCreateHarness } from "../harness/index.js"; @@ -162,6 +163,8 @@ async function executeMemento( console.log(`${chalk.blue("Code:")} ${resolvedCodeDir}`); console.log(`${chalk.blue("Navigator:")} ${resolvedNavDir}`); console.log(`${chalk.blue("Task:")} ${task.substring(0, 80)}${task.length > 80 ? "..." : ""}`); + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); + console.log(`${chalk.blue("Claude config:")} ${claudeConfigDir}`); if (options.branch) { console.log(`${chalk.blue("Branch:")} ${options.branch}`); } diff --git a/packages/autonav/src/memento/git-operations.ts b/packages/autonav/src/memento/git-operations.ts index a804b4a..2c74697 100644 --- a/packages/autonav/src/memento/git-operations.ts +++ b/packages/autonav/src/memento/git-operations.ts @@ -318,3 +318,112 @@ export function getRemoteUrl(options: GitOptions): string | null { return null; } } + +/** + * Detect the default branch (main or master). + * + * Tries `git symbolic-ref refs/remotes/origin/HEAD` first, then falls back + * to checking if `main` or `master` exist locally. + */ +export function getDefaultBranch(options: GitOptions): string { + // Try remote HEAD symbolic ref + try { + const ref = execGit("git symbolic-ref refs/remotes/origin/HEAD", { ...options, verbose: false }); + // ref looks like "refs/remotes/origin/main" + const branch = ref.split("/").pop(); + if (branch) return branch; + } catch { + // No remote HEAD — fall through to local check + } + + // Check if main exists locally + try { + execGit("git rev-parse --verify main", { ...options, verbose: false }); + return "main"; + } catch { + // fall through + } + + // Check if master exists locally + try { + execGit("git rev-parse --verify master", { ...options, verbose: false }); + return "master"; + } catch { + // fall through + } + + // Default to main + return "main"; +} + +/** + * Create a git worktree checked out on a new branch forked from baseBranch. + * + * Runs: `git worktree add -b ` from repoDir. + */ +export function createWorktree( + repoDir: string, + worktreePath: string, + branch: string, + baseBranch?: string +): void { + const base = baseBranch || getDefaultBranch({ cwd: repoDir }); + execGit( + `git worktree add -b ${branch} "${worktreePath}" ${base}`, + { cwd: repoDir } + ); +} + +/** + * Remove a git worktree and clean up. + */ +export function removeWorktree(repoDir: string, worktreePath: string): void { + try { + execGit(`git worktree remove "${worktreePath}" --force`, { cwd: repoDir }); + } catch { + // If git worktree remove fails, try manual cleanup + } + + // Clean up directory if it lingers + try { + const fs = require("node:fs"); + if (fs.existsSync(worktreePath)) { + fs.rmSync(worktreePath, { recursive: true, force: true }); + } + } catch { + // Best effort cleanup + } +} + +/** + * Convert a plan summary into a git branch name. + * + * "feat: Add user authentication" → "feat/add-user-authentication" + * Lowercase, replace spaces/special chars with hyphens, truncate to ~50 chars. + */ +export function slugifyBranchName(summary: string): string { + return summary + .toLowerCase() + .replace(/:\s*/g, "/") // "feat: ..." → "feat/..." + .replace(/[^a-z0-9/]+/g, "-") // non-alphanumeric → hyphens + .replace(/-+/g, "-") // collapse multiple hyphens + .replace(/^-|-$/g, "") // trim leading/trailing hyphens + .replace(/\/-|-\//g, "/") // clean hyphens around slashes + .substring(0, 50) + .replace(/-$/, ""); // trim trailing hyphen after truncation +} + +/** + * Get list of uncommitted files with their status indicators. + * + * Returns lines like: "M src/foo.ts", "?? new-file.ts", "A added.ts" + */ +export function getUncommittedFiles(options: GitOptions): string[] { + try { + const output = execGit("git status --porcelain", { ...options, verbose: false }); + if (!output) return []; + return output.split("\n").filter(Boolean); + } catch { + return []; + } +} diff --git a/packages/autonav/src/memento/index.ts b/packages/autonav/src/memento/index.ts index 861041c..1b1095d 100644 --- a/packages/autonav/src/memento/index.ts +++ b/packages/autonav/src/memento/index.ts @@ -44,12 +44,17 @@ export { getDiffStats, getLastCommitDiffStats, hasUncommittedChanges, + getUncommittedFiles, stageAllChanges, commitChanges, pushBranch, createPullRequest, isGhAvailable, getRemoteUrl, + getDefaultBranch, + createWorktree, + removeWorktree, + slugifyBranchName, type DiffStats, } from "./git-operations.js"; @@ -66,7 +71,11 @@ export { buildNavSystemPrompt, buildImplementerPrompt, buildImplementerSystemPrompt, + buildReviewPrompt, + buildFixPrompt, + buildFixSystemPrompt, type NavigatorIdentity, + type ReviewRound, } from "./prompts.js"; // Implementer agent diff --git a/packages/autonav/src/memento/loop.ts b/packages/autonav/src/memento/loop.ts index af1ce28..cc39ddc 100644 --- a/packages/autonav/src/memento/loop.ts +++ b/packages/autonav/src/memento/loop.ts @@ -19,18 +19,23 @@ import { loadNavigator } from "../query-engine/index.js"; import chalk from "chalk"; import { ensureGitRepo, - createBranch, getCurrentBranch, getRecentGitLog, getRecentDiff, getLastCommitDiffStats, hasUncommittedChanges, + getUncommittedFiles, stageAllChanges, commitChanges, pushBranch, createPullRequest, isGhAvailable, + getDefaultBranch, + createWorktree, + removeWorktree, + slugifyBranchName, } from "./git-operations.js"; +import { resolveConfigDir } from "../standup/config.js"; import { createNavProtocolTools } from "./nav-protocol.js"; import { buildNavPlanPrompt, @@ -39,6 +44,7 @@ import { buildFixPrompt, buildFixSystemPrompt, type NavigatorIdentity, + type ReviewRound, } from "./prompts.js"; import { MatrixAnimation } from "./matrix-animation.js"; import { @@ -262,6 +268,34 @@ function pickMood( return randomFrom(IMPL_READING); } +// ── Verbose tool formatting ────────────────────────────────────────────────── + +/** + * Format a tool_use event for verbose logging. + * + * Shows contextual details: Bash commands, file paths, search patterns. + */ +function formatToolEvent( + prefix: string, + toolName: string, + input: Record +): string { + switch (toolName) { + case "Bash": + return `[${prefix}] Bash: ${(input.command as string) || ""}`; + case "Read": + case "Write": + case "Edit": + return `[${prefix}] ${toolName}: ${(input.file_path as string) || ""}`; + case "Glob": + return `[${prefix}] Glob: ${(input.pattern as string) || ""}`; + case "Grep": + return `[${prefix}] Grep: ${(input.pattern as string) || ""}`; + default: + return `[${prefix}] Tool: ${toolName}`; + } +} + // ── Rate limit retry wrapper ──────────────────────────────────────────────── /** @@ -657,6 +691,7 @@ async function reviewImplementation( ): Promise<{ lgtm: boolean; fixApplied: boolean }> { const MAX_REVIEW_ROUNDS = options.maxReviewRounds ?? 10; let fixApplied = false; + const reviewHistory: ReviewRound[] = []; for (let round = 1; round <= MAX_REVIEW_ROUNDS; round++) { // Stage and get diff @@ -678,6 +713,7 @@ async function reviewImplementation( } // Ask opus to review the diff (single-turn, no tools) + // Pass review history for anti-oscillation on round 2+ let reviewResult = ""; try { const reviewSession = harness.run( @@ -688,8 +724,8 @@ async function reviewImplementation( "You are a code reviewer. Be concise and actionable. Never use tools — respond directly.", cwd: navDirectory, permissionMode: "bypassPermissions", - }, - buildReviewPrompt(diff) + }, + buildReviewPrompt(diff, reviewHistory.length > 0 ? reviewHistory : undefined) ); for await (const event of reviewSession) { @@ -715,6 +751,14 @@ async function reviewImplementation( return { lgtm: true, fixApplied }; } + // Record this round's issues in history (fixApplied will be set after fix) + const historyEntry: ReviewRound = { + round, + issues: reviewResult.trim(), + fixApplied: "", + }; + reviewHistory.push(historyEntry); + // Show issues found const bulletLines = reviewResult .trim() @@ -751,6 +795,8 @@ async function reviewImplementation( } // Ask haiku to fix the issues + // Pass review history so fixer knows what was already addressed + let fixerText = ""; try { const fixSession = harness.run( { @@ -760,20 +806,26 @@ async function reviewImplementation( cwd: codeDirectory, permissionMode: "bypassPermissions", }, - buildFixPrompt(codeDirectory, reviewResult) + buildFixPrompt(codeDirectory, reviewResult, reviewHistory.length > 1 ? reviewHistory.slice(0, -1) : undefined) ); for await (const event of fixSession) { if (event.type === "tool_use") { if (verbose) { - console.log(`[Fix] Tool: ${event.name}`); + console.log(formatToolEvent("Fix", event.name, event.input)); } animation.setLastTool(event.name); animation.incrementTurns(); + } else if (event.type === "text") { + fixerText += event.text; } } fixApplied = true; + + // Capture fixer summary for history (first ~200 chars of final text) + const fixSummary = fixerText.trim().substring(0, 200) || "fixes applied"; + historyEntry.fixApplied = fixSummary; } catch (err) { if (verbose) { console.log( @@ -820,6 +872,24 @@ async function handleUncommittedChanges( console.log(chalk.dim("The memento loop uses git history as context for the navigator.")); console.log(chalk.dim("Uncommitted changes won't be visible.\n")); + // Show affected files with status indicators + const uncommittedFiles = getUncommittedFiles({ cwd: codeDirectory }); + if (uncommittedFiles.length > 0) { + console.log(chalk.dim(" Affected files:")); + for (const file of uncommittedFiles) { + const status = file.substring(0, 2); + const filePath = file.substring(3); + const statusColor = + status.includes("M") ? chalk.yellow : + status.includes("A") ? chalk.green : + status.includes("D") ? chalk.red : + status.includes("?") ? chalk.blue : + chalk.dim; + console.log(` ${statusColor(status)} ${filePath}`); + } + console.log(""); + } + // Show brief diff summary const diff = getRecentDiff({ cwd: codeDirectory }); if (diff) { @@ -830,12 +900,27 @@ async function handleUncommittedChanges( console.log(""); } - console.log(" [c] Commit (auto-generate message)"); - console.log(` [r] Ask ${navName} to review, fix issues, then commit`); - console.log(" [d] Discard changes"); - console.log(" [q] Quit\n"); + // Prompt loop — [v] returns to the menu after showing diff + let answer = ""; + while (true) { + console.log(" [c] Commit (auto-generate message)"); + console.log(` [r] Ask ${navName} to review, fix issues, then commit`); + console.log(" [v] View diff"); + console.log(" [d] Discard changes"); + console.log(" [q] Quit\n"); - const answer = await promptUser("Choice [c/r/d/q]: "); + answer = await promptUser("Choice [c/r/v/d/q]: "); + + if (answer === "v" || answer === "view") { + if (diff) { + console.log("\n" + diff + "\n"); + } else { + console.log(chalk.dim("\n (no diff available)\n")); + } + continue; + } + break; + } switch (answer) { case "c": @@ -985,7 +1070,7 @@ async function queryNavForPlanWithStats( mood.lastError = false; if (verbose) { - console.log(`[Nav] Tool: ${toolName}`); + console.log(formatToolEvent("Nav", toolName, event.input)); } animation.setMessage(pickMood("nav", toolName, event.input, mood)); @@ -1134,7 +1219,7 @@ async function runImplementerAgentWithStats( mood.lastError = false; if (verbose) { - console.log(`[Implementer] Tool: ${toolName}`); + console.log(formatToolEvent("Implementer", toolName, event.input)); } animation.setMessage(pickMood("impl", toolName, event.input, mood)); @@ -1362,10 +1447,17 @@ export async function runMementoLoop( // Check for uncommitted changes - navigator can't see them! await handleUncommittedChanges(codeDirectory, navDirectory, navSystemPrompt, navIdentity, options, harness); - // Create or switch to branch if specified - if (options.branch) { - createBranch(options.branch, { cwd: codeDirectory, verbose }); - } + // Detect default branch once and compute worktree base path + const defaultBranch = getDefaultBranch({ cwd: codeDirectory }); + const worktreesBase = path.join(resolveConfigDir(), "worktrees"); + + // Track the effective branch and worktree for this run + let activeBranch: string | undefined = options.branch; + let worktreePath: string | null = null; + + // The working directory for the implementer — starts as codeDirectory, + // switches to worktree once created after the first plan + let workDir = codeDirectory; const errors: string[] = []; @@ -1382,8 +1474,8 @@ export async function runMementoLoop( console.log(`\nIteration ${state.iteration}...`); } - // Get git log to show the navigator what the implementer has accomplished - const gitLog = getRecentGitLog({ cwd: codeDirectory, count: 20 }); + // Get git log from the working directory (worktree after iteration 1) + const gitLog = getRecentGitLog({ cwd: workDir, count: 20 }); // Create animation with cumulative stats const animation = new MatrixAnimation({ @@ -1418,13 +1510,14 @@ export async function runMementoLoop( try { // Query navigator for plan (with rate limit retry) + // Navigator always reads from workDir (worktree once created) let navResult: Awaited> | null = null; let navRateLimitAttempt = 0; while (navResult === null) { try { navResult = await queryNavForPlanWithStats( - codeDirectory, + workDir, navDirectory, task, state.iteration, @@ -1463,6 +1556,30 @@ export async function runMementoLoop( // Record plan in memory (for PR body) state.planHistory.push({ iteration: state.iteration, summary: plan.summary }); + // Create worktree after first plan returns + if (state.iteration === 1 && !worktreePath) { + const branchName = activeBranch || slugifyBranchName(plan.summary); + activeBranch = branchName; + worktreePath = path.join(worktreesBase, branchName); + + // Ensure worktrees base directory exists + fs.mkdirSync(worktreesBase, { recursive: true }); + + if (verbose) { + console.log(`[Memento] Creating worktree: ${worktreePath}`); + console.log(`[Memento] Branch: ${branchName} (from ${defaultBranch})`); + } + + createWorktree(codeDirectory, worktreePath, branchName, defaultBranch); + workDir = worktreePath; + + if (!verbose) { + animation.stop(); + console.log(chalk.dim(` └─ Branch: ${branchName}`)); + animation.start(); + } + } + // Show what the implementer will be working on (truncate to ~60 chars) const shortSummary = plan.summary.length > 60 ? plan.summary.substring(0, 57) + "..." @@ -1487,10 +1604,10 @@ export async function runMementoLoop( animation.setTokens(0); animation.resetTurns(); - // Run implementer to implement the plan + // Run implementer to implement the plan (in worktree) // Rate limit retries are handled internally by runImplementerAgentWithStats const implementerResult = await runImplementerAgentWithStats( - { codeDirectory, task }, + { codeDirectory: workDir, task }, plan, { verbose, @@ -1520,23 +1637,23 @@ export async function runMementoLoop( errors.push(`Iteration ${state.iteration}: Implementer failed - ${truncated}`); // Continue to next iteration - navigator can see the state and adjust } - // Phase 3: Review + // Phase 3: Review (in worktree) // Animation is still running — reviewImplementation manages stop/start internally - await reviewImplementation(codeDirectory, navDirectory, options, animation, verbose, harness); + await reviewImplementation(workDir, navDirectory, options, animation, verbose, harness); } finally { if (!verbose) { animation.stop(); } } - // Phase 4: Commit with LLM-generated message - stageAllChanges({ cwd: codeDirectory }); - const commitMessage = await generateCommitMessage(codeDirectory, harness); - const commitHash = commitChanges(commitMessage, { cwd: codeDirectory, verbose }); + // Phase 4: Commit with LLM-generated message (in worktree) + stageAllChanges({ cwd: workDir }); + const commitMessage = await generateCommitMessage(workDir, harness); + const commitHash = commitChanges(commitMessage, { cwd: workDir, verbose }); // Get diff stats from the commit if (commitHash) { - const diffStats = getLastCommitDiffStats({ cwd: codeDirectory }); + const diffStats = getLastCommitDiffStats({ cwd: workDir }); state.stats.linesAdded += diffStats.linesAdded; state.stats.linesRemoved += diffStats.linesRemoved; } @@ -1565,7 +1682,7 @@ export async function runMementoLoop( // Handle PR creation if requested let prUrl: string | undefined; - if (pr && options.branch) { + if (pr && activeBranch) { if (!isGhAvailable()) { console.warn( "\nWarning: gh CLI not available. Cannot create PR. Install and authenticate gh CLI." @@ -1573,9 +1690,9 @@ export async function runMementoLoop( } else { console.log("\nCreating pull request..."); - // Push branch - pushBranch(options.branch, { - cwd: codeDirectory, + // Push branch (from worktree where commits live) + pushBranch(activeBranch, { + cwd: workDir, verbose, setUpstream: true, }); @@ -1593,7 +1710,7 @@ ${state.planHistory.map((h) => `- **${h.iteration}**: ${h.summary}`).join("\n")} *Created by autonav memento loop*`; prUrl = createPullRequest({ - cwd: codeDirectory, + cwd: workDir, verbose, title: task.length > 70 ? `${task.substring(0, 67)}...` : task, body: prBody, @@ -1610,7 +1727,7 @@ ${state.planHistory.map((h) => `- **${h.iteration}**: ${h.summary}`).join("\n")} iterations: state.iteration, completionMessage: state.completionMessage, prUrl, - branch: options.branch || getCurrentBranch({ cwd: codeDirectory }), + branch: activeBranch || getCurrentBranch({ cwd: workDir }), durationMs, errors: errors.length > 0 ? errors : undefined, }; @@ -1622,9 +1739,27 @@ ${state.planHistory.map((h) => `- **${h.iteration}**: ${h.summary}`).join("\n")} return { success: false, iterations: state.iteration, - branch: options.branch || getCurrentBranch({ cwd: codeDirectory }), + branch: activeBranch || getCurrentBranch({ cwd: workDir }), durationMs, errors, }; + } finally { + // Clean up worktree on exit + if (worktreePath) { + if (verbose) { + console.log(`[Memento] Cleaning up worktree: ${worktreePath}`); + } + try { + removeWorktree(codeDirectory, worktreePath); + } catch (err) { + if (verbose) { + console.log( + chalk.yellow( + `[Memento] Worktree cleanup failed: ${err instanceof Error ? err.message : err}` + ) + ); + } + } + } } } diff --git a/packages/autonav/src/memento/prompts.ts b/packages/autonav/src/memento/prompts.ts index ccf3f33..eea57d1 100644 --- a/packages/autonav/src/memento/prompts.ts +++ b/packages/autonav/src/memento/prompts.ts @@ -18,6 +18,15 @@ import type { ImplementationPlan } from "./types.js"; // Re-export for convenience export type { NavigatorIdentity }; +/** + * A single round of review history for anti-oscillation context. + */ +export interface ReviewRound { + round: number; + issues: string; // The reviewer's feedback text + fixApplied: string; // Brief summary of what was fixed (captured from fixer output) +} + /** * Minimal context passed to nav prompt (no persisted state) */ @@ -186,12 +195,35 @@ All file paths are relative to: ${codeDirectory} /** * Build the prompt for the reviewer (navigator/opus) to review a diff. * Single-turn, no tools — just read the diff and respond. + * + * When reviewHistory is provided, previous rounds are included to prevent + * oscillation (reviewer contradicting its own earlier feedback). */ -export function buildReviewPrompt(diff: string): string { +export function buildReviewPrompt(diff: string, reviewHistory?: ReviewRound[]): string { const truncatedDiff = diff.length > 8000 ? diff.substring(0, 8000) + "\n... (truncated)" : diff; - return `Review the following diff for bugs, correctness issues, or missing error handling. Do NOT use any tools — just read the diff and respond. + let historySection = ""; + if (reviewHistory && reviewHistory.length > 0) { + const rounds = reviewHistory + .map( + (r) => + `### Round ${r.round}\nIssues flagged:\n${r.issues}\n${r.fixApplied ? `Fix applied: ${r.fixApplied}` : "(fix pending)"}` + ) + .join("\n\n"); + + historySection = `## Previous Review Rounds + +The following reviews have already been conducted on this code. You MUST maintain consistency with your previous feedback. Do NOT contradict or reverse guidance from earlier rounds. Only flag NEW issues or issues that were not properly fixed. + +${rounds} + +## Current Diff + +`; + } + + return `${historySection}Review the following diff for bugs, correctness issues, or missing error handling. Do NOT use any tools — just read the diff and respond. Respond in EXACTLY one of these formats: @@ -210,14 +242,36 @@ ${truncatedDiff} /** * Build the prompt for the fixer (implementer/haiku) to fix review issues. + * + * When reviewHistory is provided, previous rounds are included so the fixer + * knows what was already addressed and avoids reverting earlier fixes. */ export function buildFixPrompt( codeDirectory: string, - reviewResult: string + reviewResult: string, + reviewHistory?: ReviewRound[] ): string { + let historySection = ""; + if (reviewHistory && reviewHistory.length > 0) { + const rounds = reviewHistory + .map( + (r) => + `### Round ${r.round}\nIssues flagged:\n${r.issues}\n${r.fixApplied ? `Fix applied: ${r.fixApplied}` : "(fix pending)"}` + ) + .join("\n\n"); + + historySection = `## Previous Review History + +These issues were flagged and fixed in earlier rounds. Do NOT undo or revert these fixes: + +${rounds} + +`; + } + return `# Fix Review Issues -Fix the following issues found during code review: +${historySection}Fix the following issues found during code review: ${reviewResult}