Revert "Add Linear issue worktree workflow"

This reverts commit bc5bf77963.
This commit is contained in:
Tobias Ostner 2026-07-19 15:50:32 +07:00
parent 2ceb720792
commit ba9803ac3c
9 changed files with 0 additions and 581 deletions

View file

@ -1,62 +0,0 @@
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.",
}));
}

View file

@ -1,75 +0,0 @@
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,
},
};
},
});
}

View file

@ -1,25 +0,0 @@
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<typeof WorktreeAgentParameters>;
export interface WorktreeContext {
repoRoot: string;
branchName: string;
worktreePath: string;
baseRef: string;
}
export type ProgressReporter = (message: string, details: Record<string, unknown>) => void;

View file

@ -1,175 +0,0 @@
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<string> {
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<number>((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<typeof setTimeout> | 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.";
}

View file

@ -1,93 +0,0 @@
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<ExecResult> {
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<WorktreeContext> {
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<boolean> {
const status = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
return !status.stdout.trim();
}

View file

@ -1,19 +0,0 @@
---
description: Implement a Linear issue in an isolated Git worktree using a child Pi agent
argument-hint: "<ISSUE-ID>"
---
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.

View file

@ -1,96 +0,0 @@
---
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: <ID>
Branch: <branch>
Commit: <local commit hash or none>
Worktree: <absolute path>
Resume: cd <worktree> && PI_WORKTREE_AGENT=1 PI_WORKTREE_ROOT="$PWD" pi -c
Published: no
Summary:
- <what changed>
Checks:
- <command>: passed | failed | not run — <reason when relevant>
Risks and follow-up:
- <remaining concern or none>
Blocker:
- <reason, or none>
```
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.

View file

@ -1,29 +0,0 @@
{
"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": []
}
]
}

View file

@ -103,13 +103,6 @@ in
enable = true; enable = true;
skills = [ skills = [
(inputs.anthropic-skills + "/skills/skill-creator") (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 = { environment = {
FIRECRAWL_API_KEY.file = config.sops.secrets.firecrawl_api_key.path; FIRECRAWL_API_KEY.file = config.sops.secrets.firecrawl_api_key.path;