From bc5bf77963b9e9571c8214c1f3599481139d9eb5 Mon Sep 17 00:00:00 2001 From: Tobias Ostner Date: Sat, 18 Jul 2026 19:59:58 +0700 Subject: [PATCH] Add Linear issue worktree workflow --- config/pi/extensions/worktree-agent/guard.ts | 62 +++++++ config/pi/extensions/worktree-agent/index.ts | 75 ++++++++ config/pi/extensions/worktree-agent/types.ts | 25 +++ config/pi/extensions/worktree-agent/worker.ts | 175 ++++++++++++++++++ .../pi/extensions/worktree-agent/worktree.ts | 93 ++++++++++ config/pi/prompts/issue.md | 19 ++ config/pi/skills/linear-issue-worker/SKILL.md | 96 ++++++++++ .../linear-issue-worker/evals/evals.json | 29 +++ module/home-manager.nix | 7 + 9 files changed, 581 insertions(+) create mode 100644 config/pi/extensions/worktree-agent/guard.ts create mode 100644 config/pi/extensions/worktree-agent/index.ts create mode 100644 config/pi/extensions/worktree-agent/types.ts create mode 100644 config/pi/extensions/worktree-agent/worker.ts create mode 100644 config/pi/extensions/worktree-agent/worktree.ts create mode 100644 config/pi/prompts/issue.md create mode 100644 config/pi/skills/linear-issue-worker/SKILL.md create mode 100644 config/pi/skills/linear-issue-worker/evals/evals.json diff --git a/config/pi/extensions/worktree-agent/guard.ts b/config/pi/extensions/worktree-agent/guard.ts new file mode 100644 index 0000000..e137212 --- /dev/null +++ b/config/pi/extensions/worktree-agent/guard.ts @@ -0,0 +1,62 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]); + +function isWithin(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +function canonicalizePotentialPath(inputPath: string, cwd: string): string { + let normalized = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath; + if (normalized === "~") normalized = os.homedir(); + else if (normalized.startsWith(`~${path.sep}`)) normalized = path.join(os.homedir(), normalized.slice(2)); + + const resolved = path.resolve(cwd, normalized); + const missingSegments: string[] = []; + let existing = resolved; + + while (!fs.existsSync(existing)) { + const parent = path.dirname(existing); + if (parent === existing) break; + missingSegments.unshift(path.basename(existing)); + existing = parent; + } + + const canonicalBase = fs.existsSync(existing) ? fs.realpathSync(existing) : existing; + return path.join(canonicalBase, ...missingSegments); +} + +export function registerWorkerGuard(pi: ExtensionAPI, configuredRoot: string): void { + const root = fs.realpathSync(configuredRoot); + + pi.on("tool_call", async (event) => { + if (!PATH_TOOLS.has(event.toolName)) return; + + const input = event.input as { path?: unknown }; + if (input.path === undefined && (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls")) { + return; + } + if (typeof input.path !== "string") { + return { block: true, reason: `${event.toolName} requires a path inside the assigned worktree.` }; + } + + const target = canonicalizePotentialPath(input.path, root); + if (!isWithin(root, target)) { + return { + block: true, + reason: `Worker file tools are restricted to ${root}. Attempted path: ${input.path}`, + }; + } + }); + + pi.on("before_agent_start", async (event) => ({ + systemPrompt: + event.systemPrompt + + `\n\nYou are running as a worktree worker. Your assigned repository root is ${root}. ` + + "Keep all file mutations and shell work inside this worktree. Do not access or modify sibling worktrees.", + })); +} diff --git a/config/pi/extensions/worktree-agent/index.ts b/config/pi/extensions/worktree-agent/index.ts new file mode 100644 index 0000000..4f114d9 --- /dev/null +++ b/config/pi/extensions/worktree-agent/index.ts @@ -0,0 +1,75 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { registerWorkerGuard } from "./guard.ts"; +import { WorktreeAgentParameters, type ProgressReporter } from "./types.ts"; +import { runWorker } from "./worker.ts"; +import { isParentClean, prepareWorktree } from "./worktree.ts"; + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +export default function worktreeAgentExtension(pi: ExtensionAPI) { + const workerRoot = process.env.PI_WORKTREE_ROOT; + if (process.env.PI_WORKTREE_AGENT === "1" && workerRoot) { + registerWorkerGuard(pi, workerRoot); + return; + } + + pi.registerTool({ + name: "run_issue_in_worktree", + label: "Run issue in worktree", + description: + "Create an isolated Git worktree for a retrieved Linear issue and run a child Pi agent there. " + + "Call this only after retrieving and understanding the Linear issue. The call remains active until the child finishes.", + promptSnippet: "Run a retrieved Linear issue in an isolated Git worktree using a child Pi agent", + promptGuidelines: [ + "Use run_issue_in_worktree only after retrieving the Linear issue, then let the child agent perform all repository modifications.", + "When run_issue_in_worktree is active, do not modify files in the parent checkout.", + ], + parameters: WorktreeAgentParameters, + + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const report: ProgressReporter = (message, details) => { + onUpdate?.({ content: [{ type: "text", text: message }], details }); + }; + + const worktree = await prepareWorktree(pi, ctx.cwd, params, report); + const workerReport = await runWorker({ + input: params, + worktree, + model: ctx.model, + thinkingLevel: pi.getThinkingLevel(), + signal, + report, + }); + + const parentClean = await isParentClean(pi, worktree.repoRoot); + const resumeCommand = + `cd ${shellQuote(worktree.worktreePath)} && ` + + 'PI_WORKTREE_AGENT=1 PI_WORKTREE_ROOT="$PWD" pi -c'; + const parentWarning = parentClean + ? "" + : "\n\nWARNING: The parent working tree is no longer clean; inspect it before continuing."; + + return { + content: [ + { + type: "text", + text: + `Child Pi finished in ${worktree.worktreePath}.\n` + + `Branch: ${worktree.branchName}\n` + + `Resume: ${resumeCommand}\n\n${workerReport}${parentWarning}`, + }, + ], + details: { + phase: "complete", + branchName: worktree.branchName, + worktreePath: worktree.worktreePath, + parentClean, + resumeCommand, + workerReport, + }, + }; + }, + }); +} diff --git a/config/pi/extensions/worktree-agent/types.ts b/config/pi/extensions/worktree-agent/types.ts new file mode 100644 index 0000000..b9f2b22 --- /dev/null +++ b/config/pi/extensions/worktree-agent/types.ts @@ -0,0 +1,25 @@ +import { Type, type Static } from "typebox"; + +export const WorktreeAgentParameters = Type.Object({ + issueId: Type.String({ description: "Linear issue identifier, for example ENG-123" }), + title: Type.String({ description: "Linear issue title" }), + description: Type.String({ description: "Linear issue description and relevant context" }), + acceptanceCriteria: Type.Optional( + Type.Array(Type.String(), { description: "Explicit or inferred acceptance criteria" }), + ), + issueUrl: Type.Optional(Type.String({ description: "Linear issue URL" })), + baseRef: Type.Optional( + Type.String({ description: "Git ref from which to create the branch. Defaults to the current HEAD." }), + ), +}); + +export type WorktreeAgentInput = Static; + +export interface WorktreeContext { + repoRoot: string; + branchName: string; + worktreePath: string; + baseRef: string; +} + +export type ProgressReporter = (message: string, details: Record) => void; diff --git a/config/pi/extensions/worktree-agent/worker.ts b/config/pi/extensions/worktree-agent/worker.ts new file mode 100644 index 0000000..6f13e7c --- /dev/null +++ b/config/pi/extensions/worktree-agent/worker.ts @@ -0,0 +1,175 @@ +import { spawn } from "node:child_process"; +import type { ProgressReporter, WorktreeAgentInput, WorktreeContext } from "./types.ts"; + +const MAX_CAPTURED_STDERR = 50 * 1024; + +interface WorkerModel { + provider: string; + id: string; +} + +interface RunWorkerOptions { + input: WorktreeAgentInput; + worktree: WorktreeContext; + model?: WorkerModel; + thinkingLevel: string; + signal?: AbortSignal; + report: ProgressReporter; +} + +function assistantText(message: unknown): string { + if (!message || typeof message !== "object") return ""; + const candidate = message as { + role?: string; + content?: Array<{ type?: string; text?: string }>; + }; + if (candidate.role !== "assistant" || !Array.isArray(candidate.content)) return ""; + return candidate.content + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n") + .trim(); +} + +function appendCaptured(current: string, addition: string): string { + const combined = current + addition; + return combined.length <= MAX_CAPTURED_STDERR + ? combined + : combined.slice(combined.length - MAX_CAPTURED_STDERR); +} + +function buildWorkerPrompt(input: WorktreeAgentInput, worktree: WorktreeContext): string { + const criteria = input.acceptanceCriteria?.length + ? input.acceptanceCriteria.map((criterion) => `- ${criterion}`).join("\n") + : "- Derive acceptance criteria from the issue description and state any uncertainty."; + + return [ + "/skill:linear-issue-worker", + "", + `Issue ID: ${input.issueId.trim().toUpperCase()}`, + `Title: ${input.title.trim()}`, + input.issueUrl ? `URL: ${input.issueUrl}` : undefined, + "", + "Description:", + input.description.trim() || "(No description supplied.)", + "", + "Acceptance criteria:", + criteria, + "", + `Assigned branch: ${worktree.branchName}`, + `Assigned worktree: ${worktree.worktreePath}`, + ] + .filter((line): line is string => line !== undefined) + .join("\n"); +} + +export async function runWorker(options: RunWorkerOptions): Promise { + const { input, worktree, model, thinkingLevel, signal, report } = options; + const sessionName = `${input.issueId.trim().toUpperCase()} worker`; + const childArgs = ["--mode", "json", "-p", "--approve", "--name", sessionName]; + if (model) childArgs.push("--model", `${model.provider}/${model.id}`); + childArgs.push("--thinking", thinkingLevel, buildWorkerPrompt(input, worktree)); + + report(`Starting child Pi in ${worktree.worktreePath}`, { + phase: "starting-worker", + branchName: worktree.branchName, + worktreePath: worktree.worktreePath, + }); + + let finalText = ""; + let stderr = ""; + let workerStopReason: string | undefined; + let workerError: string | undefined; + + let exitCode: number; + try { + exitCode = await new Promise((resolve, reject) => { + const child = spawn("pi", childArgs, { + cwd: worktree.worktreePath, + env: { + ...process.env, + PI_WORKTREE_AGENT: "1", + PI_WORKTREE_ROOT: worktree.worktreePath, + }, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdoutBuffer = ""; + let completed = false; + let killTimer: ReturnType | undefined; + + const finish = (callback: () => void) => { + if (completed) return; + completed = true; + if (killTimer) clearTimeout(killTimer); + signal?.removeEventListener("abort", abortChild); + callback(); + }; + + const abortChild = () => { + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000); + }; + + const processLine = (line: string) => { + if (!line.trim()) return; + let event: any; + try { + event = JSON.parse(line); + } catch { + return; + } + + if (event.type === "message_end") { + const text = assistantText(event.message); + if (text) finalText = text; + if (event.message?.role === "assistant") { + workerStopReason = event.message.stopReason; + workerError = event.message.errorMessage; + } + } else if (event.type === "tool_execution_start") { + report(`Worker running: ${event.toolName}`, { + phase: "worker-running", + branchName: worktree.branchName, + worktreePath: worktree.worktreePath, + toolName: event.toolName, + }); + } + }; + + child.stdout.on("data", (chunk) => { + stdoutBuffer += chunk.toString(); + const lines = stdoutBuffer.split("\n"); + stdoutBuffer = lines.pop() ?? ""; + for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line); + }); + child.stderr.on("data", (chunk) => { + stderr = appendCaptured(stderr, chunk.toString()); + }); + child.on("error", (error) => finish(() => reject(error))); + child.on("close", (code) => + finish(() => { + if (stdoutBuffer.trim()) processLine(stdoutBuffer); + resolve(code ?? 1); + }), + ); + + if (signal?.aborted) abortChild(); + else signal?.addEventListener("abort", abortChild, { once: true }); + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to run child Pi: ${detail}\nWorktree retained at ${worktree.worktreePath}`); + } + + if (signal?.aborted) { + throw new Error(`Worker aborted. Worktree retained at ${worktree.worktreePath}`); + } + if (exitCode !== 0 || workerStopReason === "error" || workerStopReason === "aborted") { + const detail = workerError || stderr.trim() || finalText || `exit code ${exitCode}`; + throw new Error(`Worker failed: ${detail}\nWorktree retained at ${worktree.worktreePath}`); + } + + return finalText || "Worker exited successfully without a final text report."; +} diff --git a/config/pi/extensions/worktree-agent/worktree.ts b/config/pi/extensions/worktree-agent/worktree.ts new file mode 100644 index 0000000..ffd9fbe --- /dev/null +++ b/config/pi/extensions/worktree-agent/worktree.ts @@ -0,0 +1,93 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { ProgressReporter, WorktreeAgentInput, WorktreeContext } from "./types.ts"; + +type ExecResult = { stdout: string; stderr: string; code: number | null }; + +async function git( + pi: ExtensionAPI, + cwd: string, + args: string[], + options: { allowFailure?: boolean; timeout?: number } = {}, +): Promise { + const result = await pi.exec("git", ["-C", cwd, ...args], { timeout: options.timeout ?? 30_000 }); + if (!options.allowFailure && result.code !== 0) { + const detail = (result.stderr || result.stdout).trim(); + throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`); + } + return result; +} + +function slugify(title: string): string { + const slug = title + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) + .replace(/-+$/g, ""); + + return slug || "work"; +} + +export async function prepareWorktree( + pi: ExtensionAPI, + cwd: string, + input: WorktreeAgentInput, + report: ProgressReporter, +): Promise { + if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(input.issueId.trim())) { + throw new Error(`Invalid Linear issue identifier: ${input.issueId}`); + } + + const repoResult = await git(pi, cwd, ["rev-parse", "--show-toplevel"]); + const repoRoot = fs.realpathSync(repoResult.stdout.trim()); + const statusBefore = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]); + if (statusBefore.stdout.trim()) { + throw new Error("The parent working tree is not clean. Commit, stash, or remove local changes first."); + } + + const issueId = input.issueId.trim().toLowerCase(); + const branchName = `issue/${issueId}-${slugify(input.title)}`; + const worktreePath = path.resolve(repoRoot, "..", "worktrees", branchName); + const baseRef = input.baseRef?.trim() || "HEAD"; + + const branchExists = await git( + pi, + repoRoot, + ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], + { allowFailure: true }, + ); + if (branchExists.code === 0) { + throw new Error(`Branch already exists: ${branchName}`); + } + if (fs.existsSync(worktreePath)) { + throw new Error(`Worktree path already exists: ${worktreePath}`); + } + + const baseExists = await git(pi, repoRoot, ["rev-parse", "--verify", `${baseRef}^{commit}`], { + allowFailure: true, + }); + if (baseExists.code !== 0) { + throw new Error(`Base ref does not resolve to a commit: ${baseRef}`); + } + + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + report(`Creating ${branchName} at ${worktreePath}`, { + phase: "creating-worktree", + branchName, + worktreePath, + }); + await git(pi, repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseRef], { + timeout: 60_000, + }); + + return { repoRoot, branchName, worktreePath, baseRef }; +} + +export async function isParentClean(pi: ExtensionAPI, repoRoot: string): Promise { + const status = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]); + return !status.stdout.trim(); +} diff --git a/config/pi/prompts/issue.md b/config/pi/prompts/issue.md new file mode 100644 index 0000000..adedd8c --- /dev/null +++ b/config/pi/prompts/issue.md @@ -0,0 +1,19 @@ +--- +description: Implement a Linear issue in an isolated Git worktree using a child Pi agent +argument-hint: "" +--- +Work on Linear issue: $ARGUMENTS + +Act only as the parent orchestrator: + +1. Validate that the argument identifies exactly one Linear issue. +2. Retrieve the issue through the Linear MCP server. Include relations, comments, attachments, or linked context when they affect implementation. +3. Read and understand the issue before starting the worker. +4. Extract its title, description, URL, and explicit or reasonably inferred acceptance criteria. +5. Call `run_issue_in_worktree` exactly once with that structured issue context. +6. Let the child Pi agent perform every repository modification, validation command, and local commit. +7. The child must not push or create a pull request; publication happens only after explicit user review. +8. Do not edit files or run implementation commands in the parent checkout. +9. When the child finishes, summarize its status, branch, worktree path, local commit, checks, blockers, and the command for resuming its Pi session. + +Stop and explain the problem instead of starting the worker when the issue cannot be retrieved or its requirements are too ambiguous to delegate safely. diff --git a/config/pi/skills/linear-issue-worker/SKILL.md b/config/pi/skills/linear-issue-worker/SKILL.md new file mode 100644 index 0000000..f448b6b --- /dev/null +++ b/config/pi/skills/linear-issue-worker/SKILL.md @@ -0,0 +1,96 @@ +--- +name: linear-issue-worker +description: Internal workflow for a child Pi agent assigned to implement one Linear issue inside a pre-created Git worktree. Invoke explicitly from the worktree orchestrator; do not use for ordinary parent-agent tasks. +compatibility: Requires Git, a pre-created worktree, and the PI_WORKTREE_ROOT environment variable. +disable-model-invocation: true +--- + +# Linear issue worker + +You are the implementation worker for one Linear issue. The parent agent has already created the branch and worktree and supplied the issue context. + +## Establish the boundary + +1. Read `PI_WORKTREE_ROOT` and resolve it to an absolute canonical path. +2. Confirm `git rev-parse --show-toplevel` resolves to exactly that path. +3. Confirm the current branch matches the assigned branch in the task. +4. Stop as `blocked` if any boundary check fails. +5. Keep all file and shell operations inside this worktree. Do not inspect or modify sibling worktrees or the parent checkout. +6. Do not create another branch or worktree, and do not remove this worktree. + +The harness blocks Pi's path-based file tools outside the assigned worktree, but shell access is not an OS security boundary. Treat the worktree restriction as an explicit safety requirement. + +## Understand the repository and issue + +1. Read all applicable `AGENTS.md` or `CLAUDE.md` instructions in and above the repository. +2. Inspect repository documentation and manifests to discover its conventions and validation commands. +3. Read the supplied issue description and acceptance criteria carefully. +4. Use the inherited Linear MCP tools if essential issue details, comments, relations, or attachments are missing. +5. Identify uncertainty before changing code. If safe implementation requires user input, stop and report `blocked` rather than guessing. +6. Check that the worktree starts clean. + +## Implement + +1. Inspect the relevant code before editing. +2. State a brief implementation plan in the conversation, then continue without waiting unless blocked. +3. Make the smallest coherent change that satisfies the issue. +4. Add or update tests for changed behavior where appropriate. +5. Avoid unrelated refactors, formatting churn, generated artifacts, and dependency changes. +6. Re-read affected code and consider error handling, compatibility, and edge cases. + +## Validate + +Discover and run the repository's applicable checks, normally including: + +- formatter or formatting check +- linter +- type checker +- focused tests +- broader tests when practical + +Fix failures caused by your changes. Clearly distinguish pre-existing or environmental failures from regressions. Never claim a command passed unless you ran it and observed success. + +Before committing: + +1. Inspect `git status --short`. +2. Review the complete diff against the assigned base. +3. Remove accidental or unrelated changes. +4. Confirm no secrets or local-only files are included. + +## Create a local review checkpoint + +1. Commit the changes locally with a message beginning with the uppercase issue ID, for example `ENG-123: add request throttling`. +2. Record the resulting commit hash. +3. Do not push the branch. +4. Do not create a pull request. +5. Leave the worktree and child Pi session intact so the user can inspect, amend, or extend the implementation. + +Do not amend unrelated commits, rewrite shared history, update the base branch, or clean up the worktree. + +## Final report + +End with this structure: + +```text +Status: ready-for-review | blocked | failed +Issue: +Branch: +Commit: +Worktree: +Resume: cd && PI_WORKTREE_AGENT=1 PI_WORKTREE_ROOT="$PWD" pi -c +Published: no + +Summary: +- + +Checks: +- : passed | failed | not run — + +Risks and follow-up: +- + +Blocker: +- +``` + +Use `ready-for-review` only when the requested implementation is committed locally and ready for user inspection. Use `blocked` when requirements, repository state, validation, or local commit creation prevent safe completion. Use `failed` for an unrecoverable implementation failure. Never describe the work as pushed or published. diff --git a/config/pi/skills/linear-issue-worker/evals/evals.json b/config/pi/skills/linear-issue-worker/evals/evals.json new file mode 100644 index 0000000..7428a43 --- /dev/null +++ b/config/pi/skills/linear-issue-worker/evals/evals.json @@ -0,0 +1,29 @@ +{ + "skill_name": "linear-issue-worker", + "evals": [ + { + "id": 1, + "prompt": "Implement ENG-123 from the supplied issue context in the assigned worktree. The repository is a TypeScript service with npm scripts for format, lint, typecheck, and test.", + "expected_output": "The worker verifies its worktree boundary, inspects instructions and code, plans, implements, runs discovered checks, reviews the diff, creates a local commit, does not push or create a PR, and reports ready-for-review with a resume command.", + "files": [] + }, + { + "id": 2, + "prompt": "Implement ENG-456, but PI_WORKTREE_ROOT does not match the current Git root.", + "expected_output": "The worker makes no changes and returns a structured blocked report describing the boundary mismatch.", + "files": [] + }, + { + "id": 3, + "prompt": "Implement ENG-789 in a valid worktree whose origin push URL is git@codeberg.org:example/service.git. Forgejo CLI authentication is available.", + "expected_output": "The worker ignores publication tooling for this phase, creates only a local commit, does not push to Codeberg, and reports ready-for-review with Published: no.", + "files": [] + }, + { + "id": 4, + "prompt": "Implement ENG-999 in a valid worktree. The user expects to make amendments with Pi after the initial implementation.", + "expected_output": "The worker creates a local review checkpoint, leaves the worktree intact, does not publish anything, and reports the exact worktree and pi -c command for continuing the saved child session.", + "files": [] + } + ] +} diff --git a/module/home-manager.nix b/module/home-manager.nix index 0da79f3..fc1513f 100644 --- a/module/home-manager.nix +++ b/module/home-manager.nix @@ -103,6 +103,13 @@ in enable = true; skills = [ (inputs.anthropic-skills + "/skills/skill-creator") + ../config/pi/skills/linear-issue-worker + ]; + extensions = [ + ../config/pi/extensions/worktree-agent + ]; + promptTemplates = [ + ../config/pi/prompts/issue.md ]; environment = { FIRECRAWL_API_KEY.file = config.sops.secrets.firecrawl_api_key.path;