Commit 218a2abb80e6
Changed files (3)
dots
config
claude
skills
UsingGitWorktrees
pi
agent
extensions
git
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;
+ }
+}