Commit 218a2abb80e6

Vincent Demeester <vincent@sbr.pm>
2026-03-17 13:12:03
feat: integrate lazyworktree notes with pi
Added bidirectional sync between lazyworktree notes and pi session tools. Worktree notes serve as working memory while save_session captures durable history. - Added note action to git_worktree tool (read/write) - Enhanced list action to include worktree notes - Auto-inject worktree note as context on session start - Auto-update worktree note on save_session_to_history - Documented worktree notes flow in skill
1 parent a332da8
Changed files (3)
dots
config
claude
skills
UsingGitWorktrees
pi
agent
dots/config/claude/skills/UsingGitWorktrees/SKILL.md
@@ -297,14 +297,75 @@ go test ./...
 
 The pi git extension (`~/.pi/agent/extensions/git/`) provides:
 - **Tool:** `git_worktree` — AI can create/remove/list/exec in worktrees programmatically
-  - `action: "list"` — lists worktrees with dirty/ahead/behind/last_active from lazyworktree
+  - `action: "list"` — lists worktrees with dirty/ahead/behind/last_active + notes from lazyworktree
   - `action: "create"` + `exec: "cmd"` — creates worktree and runs setup command
   - `action: "exec"` + `branch: "<name>"` + `exec: "cmd"` — runs command inside a worktree
   - `action: "remove"` — removes worktree (lazyworktree also cleans up the branch)
+  - `action: "note"` — read/write worktree notes (see below)
 - **Commands:** `/worktree` or `/wt` — interactive worktree management
 
 Both the tool and commands use lazyworktree when available, falling back to raw git.
 
+## Worktree Notes
+
+Worktree notes are short descriptions attached to worktrees via `lazyworktree note`. They serve as **working memory** — ephemeral context tied to a branch's lifecycle — complementing the durable `save_session`/`save_learning`/`save_plan` tools.
+
+### How Notes Flow
+
+| Moment | Direction | What happens |
+|--------|-----------|--------------|
+| Session starts in a worktree | lazyworktree → AI | Note is auto-injected as context on first turn (if a note exists) |
+| AI browses worktrees | lazyworktree → AI | `git_worktree list` includes first line of each note |
+| AI saves a session | AI → lazyworktree | `save_session_to_history` auto-updates the worktree note with session date + description |
+| Worktree is deleted | — | Note dies naturally; session/learning/plan persists in `~/.local/share/ai/` |
+
+### Reading Notes (AI tool)
+
+```
+# Read current worktree's note
+git_worktree({ action: "note" })
+
+# Read a specific worktree's note by name
+git_worktree({ action: "note", branch: "bump-knative-pkg-and-k8s" })
+```
+
+### Writing Notes (AI tool)
+
+```
+# Set a worktree note
+git_worktree({ action: "note", branch: "my-worktree", exec: "Working on auth refactor\nBlocked on upstream PR #123" })
+```
+
+### Auto-Update on save_session
+
+When `save_session_to_history` is called, the extension automatically appends/updates a line in the worktree note:
+
+```
+🤖 pi (2026-03-17): bump-knative-pkg-k8s-035-tekton-pipeline
+```
+
+This preserves any manually written notes and only updates the AI session line. So when you open `lazyworktree` later, you can see at a glance what the AI last worked on in each worktree.
+
+### Best Practices for Notes
+
+- **Keep notes short** — they appear in the lazyworktree TUI, one-liners are best
+- **Use notes for current state** — "3 tests failing, waiting for upstream fix"
+- **Use `save_session` for full story** — the session summary captures the complete narrative
+- **Write a note after creating a worktree** — helps future AI sessions (and you) understand the worktree's purpose
+
+### CLI Usage (for reference)
+
+```bash
+# Read
+lazyworktree note show <worktree-name>
+
+# Write (from stdin)
+echo "Working on feature X" | lazyworktree note edit --input - <worktree-name>
+
+# Write (opens editor)
+lazyworktree note edit <worktree-name>
+```
+
 ## Integration with Other Skills
 
 **Before UsingGitWorktrees:**
dots/pi/agent/extensions/git/index.ts
@@ -11,6 +11,9 @@ import {
 	execInWorktree,
 	pruneWorktrees,
 	getCurrentWorktreeInfo,
+	getWorktreeNote,
+	setWorktreeNote,
+	getCurrentWorktreeContext,
 } from "./worktree.js";
 import { findRepoRoot, hasUncommittedChanges } from "./utils.js";
 
@@ -314,11 +317,12 @@ export default function (pi: ExtensionAPI) {
 				Type.Literal("create"),
 				Type.Literal("remove"),
 				Type.Literal("exec"),
+				Type.Literal("note"),
 			], {
 				description: "The worktree action to perform"
 			}),
 			branch: Type.Optional(Type.String({
-				description: "Branch name (required for create/remove)"
+				description: "Branch name (required for create/remove) or worktree name (for exec/note)"
 			})),
 			path: Type.Optional(Type.String({
 				description: "Custom path for worktree (defaults to ~/.local/share/worktrees/<org>/<repo>/<branch>)"
@@ -327,7 +331,7 @@ export default function (pi: ExtensionAPI) {
 				description: "Force removal even with uncommitted changes"
 			})),
 			exec: Type.Optional(Type.String({
-				description: "Command to run: post-create hook (with create) or command to execute (with exec action, requires branch)"
+				description: "Command to run: post-create hook (with create), command to execute (with exec action), or note content to write (with note action). Omit exec with note action to read the note."
 			})),
 		}),
 		execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
@@ -345,7 +349,10 @@ export default function (pi: ExtensionAPI) {
 							const sync = ((wt as any).ahead || (wt as any).behind)
 								? ` ↑${(wt as any).ahead ?? 0}↓${(wt as any).behind ?? 0}`
 								: "";
-							return `- ${wt.branch}${dirtyMark}: ${wt.path}${commit}${sync}${lastActive}`;
+							const wtName = (wt as any).isMain ? wt.branch : (wt.path.split("/").pop() ?? wt.branch);
+							const note = getWorktreeNote(wtName, ctx.cwd);
+							const noteLine = note ? `\n  Note: ${note.split("\n")[0].substring(0, 100)}` : "";
+							return `- ${wt.branch}${dirtyMark}: ${wt.path}${commit}${sync}${lastActive}${noteLine}`;
 						});
 						
 						return {
@@ -437,6 +444,58 @@ export default function (pi: ExtensionAPI) {
 						};
 					}
 
+					case "note": {
+						if (!branch) {
+							// No branch specified: read the current worktree's note
+							const wtCtx = getCurrentWorktreeContext(ctx.cwd);
+							if (!wtCtx) {
+								return {
+									content: [{
+										type: "text",
+										text: "Error: Not in a lazyworktree-managed worktree, or worktree name required"
+									}],
+									details: { error: true },
+								};
+							}
+
+							return {
+								content: [{
+									type: "text",
+									text: wtCtx.note
+										? `Note for "${wtCtx.name}" (branch: ${wtCtx.branch}):\n${wtCtx.note}`
+										: `No note set for "${wtCtx.name}" (branch: ${wtCtx.branch})`
+								}],
+								details: { name: wtCtx.name, branch: wtCtx.branch },
+							};
+						}
+
+						if (execCmd) {
+							// Write note
+							const success = setWorktreeNote(branch, execCmd, ctx.cwd);
+							return {
+								content: [{
+									type: "text",
+									text: success
+										? `✓ Note updated for "${branch}"`
+										: `Error: Failed to update note for "${branch}"`
+								}],
+								details: { name: branch, written: success },
+							};
+						} else {
+							// Read note
+							const note = getWorktreeNote(branch, ctx.cwd);
+							return {
+								content: [{
+									type: "text",
+									text: note
+										? `Note for "${branch}":\n${note}`
+										: `No note set for "${branch}"`
+								}],
+								details: { name: branch },
+							};
+						}
+					}
+
 					default:
 						return {
 							content: [{
@@ -457,4 +516,86 @@ export default function (pi: ExtensionAPI) {
 			}
 		},
 	});
+
+	// =========================================================================
+	// Worktree Context: Auto-inject note on session start
+	// =========================================================================
+
+	let worktreeContext: {
+		name: string;
+		branch: string;
+		note: string;
+		isMain: boolean;
+	} | null = null;
+	let contextInjected = false;
+
+	pi.on("session_start", async (_event, ctx) => {
+		contextInjected = false;
+		worktreeContext = null;
+
+		try {
+			const wtCtx = getCurrentWorktreeContext(ctx.cwd);
+			if (wtCtx && wtCtx.note) {
+				worktreeContext = {
+					name: wtCtx.name,
+					branch: wtCtx.branch,
+					note: wtCtx.note,
+					isMain: wtCtx.isMain,
+				};
+			}
+		} catch {
+			// Silently ignore — not critical
+		}
+	});
+
+	pi.on("before_agent_start", async (_event, _ctx) => {
+		if (contextInjected || !worktreeContext) return;
+		contextInjected = true;
+
+		const label = worktreeContext.isMain ? "main worktree" : "worktree";
+		return {
+			message: {
+				customType: "worktree-context",
+				content: `📋 ${label} "${worktreeContext.name}" (branch: ${worktreeContext.branch})\nNote: ${worktreeContext.note}`,
+				display: true,
+			},
+		};
+	});
+
+	// =========================================================================
+	// Auto-update worktree note on save_session_to_history
+	// =========================================================================
+
+	pi.on("tool_result", async (event, _ctx) => {
+		if (event.toolName !== "save_session_to_history") return;
+		if (event.isError) return;
+
+		try {
+			const input = event.input as { description?: string; content?: string } | undefined;
+			if (!input?.description) return;
+
+			const wtCtx = getCurrentWorktreeContext(_ctx.cwd);
+			if (!wtCtx) return;
+
+			const date = new Date().toISOString().split("T")[0];
+			const sessionLine = `🤖 pi (${date}): ${input.description}`;
+
+			// Read existing note, update or append the AI session line
+			const existing = wtCtx.note ?? "";
+			const aiLineRegex = /^🤖 pi \(\d{4}-\d{2}-\d{2}\):.*$/m;
+
+			let newNote: string;
+			if (aiLineRegex.test(existing)) {
+				newNote = existing.replace(aiLineRegex, sessionLine);
+			} else if (existing.trim()) {
+				newNote = `${existing.trim()}\n${sessionLine}`;
+			} else {
+				newNote = sessionLine;
+			}
+
+			setWorktreeNote(wtCtx.name, newNote, _ctx.cwd);
+		} catch {
+			// Non-critical, don't break the session
+		}
+	});
 }
dots/pi/agent/extensions/git/worktree.ts
@@ -407,3 +407,93 @@ export function getCurrentWorktreeInfo(cwd: string = process.cwd()): WorktreeInf
 
 	return worktrees.find((wt) => path.resolve(wt.path) === currentPath) ?? null;
 }
+
+// =============================================================================
+// Worktree Notes (requires lazyworktree)
+// =============================================================================
+
+/**
+ * Get the note for a worktree by name.
+ * Returns the note content or null if no note or lazyworktree unavailable.
+ */
+export function getWorktreeNote(worktreeName: string, cwd: string = process.cwd()): string | null {
+	if (!hasLazyworktree()) return null;
+
+	const repoRoot = findRepoRoot(cwd);
+	if (!repoRoot) return null;
+
+	try {
+		const output = execSync(`lazyworktree note show "${worktreeName}"`, {
+			cwd: repoRoot,
+			encoding: "utf-8",
+			stdio: "pipe",
+		}).trim();
+		return output || null;
+	} catch {
+		return null;
+	}
+}
+
+/**
+ * Set the note for a worktree by name.
+ * Writes content via stdin to lazyworktree note edit.
+ */
+export function setWorktreeNote(worktreeName: string, content: string, cwd: string = process.cwd()): boolean {
+	if (!hasLazyworktree()) return false;
+
+	const repoRoot = findRepoRoot(cwd);
+	if (!repoRoot) return false;
+
+	try {
+		execSync(`lazyworktree note edit --input - "${worktreeName}"`, {
+			cwd: repoRoot,
+			encoding: "utf-8",
+			input: content,
+			stdio: ["pipe", "pipe", "pipe"],
+		});
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+/**
+ * Resolve the current directory to a worktree name and its note.
+ * Returns null if not in a lazyworktree-managed worktree or lazyworktree unavailable.
+ */
+export function getCurrentWorktreeContext(cwd: string = process.cwd()): {
+	name: string;
+	branch: string;
+	note: string | null;
+	isMain: boolean;
+} | null {
+	if (!hasLazyworktree()) return null;
+
+	const repoRoot = findRepoRoot(cwd);
+	if (!repoRoot) return null;
+
+	try {
+		const output = execSync("lazyworktree list --json", {
+			cwd: repoRoot,
+			encoding: "utf-8",
+			stdio: "pipe",
+		}).trim();
+
+		const items: LazyworktreeInfo[] = JSON.parse(output);
+		const resolvedCwd = path.resolve(cwd);
+		const match = items.find((wt) => path.resolve(wt.path) === resolvedCwd);
+
+		if (!match) return null;
+
+		const note = getWorktreeNote(match.name, cwd);
+
+		return {
+			name: match.name,
+			branch: match.branch,
+			note,
+			isMain: match.is_main,
+		};
+	} catch {
+		return null;
+	}
+}