Commit 89f1c1c5eb23

Vincent Demeester <vincent@sbr.pm>
2026-02-10 13:42:01
feat(pi): added jira extension with approval gates
Implemented comprehensive Jira extension for pi with 14 actions. Read operations: me, list, view, search, epic-view, list-attachments. Write operations with approval: create, update, comment, transition, link-to-epic, link, unlink, attach. Features 6 slash commands for instant results, auto-detection of issue keys, JSON parsing for reliability, and table formatting. Includes 45 passing tests and full documentation.
1 parent 889f097
dots/pi/agent/extensions/jira/actions.ts
@@ -0,0 +1,612 @@
+/**
+ * Action handlers for Jira extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { JiraDetails } from "./types";
+import {
+	buildCommentConfirmation,
+	buildCreateConfirmation,
+	buildTransitionConfirmation,
+	buildUpdateConfirmation,
+	extractIssueKey,
+	extractIssueKeys,
+	getErrorMessage,
+	parseIssueListJSON,
+} from "./utils";
+
+/**
+ * Get current Jira user
+ */
+export async function handleMe(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	const result = await pi.exec("jira", ["me"], { signal, timeout: 10000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Get current user") }],
+			details: { action: "me", error: result.stderr } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	const user = result.stdout.trim();
+
+	return {
+		content: [{ type: "text", text: `Current user: ${user}` }],
+		details: { action: "me", output: user } as JiraDetails,
+	};
+}
+
+/**
+ * List Jira issues
+ */
+export async function handleList(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+	currentUser: string,
+): Promise<any> {
+	// Build jira command - use --raw for JSON output
+	const args = ["issue", "list", "--raw"];
+
+	// Handle assignee
+	if (params.assignee) {
+		if (params.assignee === "me") {
+			// Get current user if not cached
+			if (!currentUser) {
+				const meResult = await pi.exec("jira", ["me"], { signal, timeout: 10000 });
+				if (meResult.code === 0) {
+					currentUser = meResult.stdout.trim();
+				}
+			}
+			args.push("-a", currentUser || params.assignee);
+		} else {
+			args.push("-a", params.assignee);
+		}
+	}
+
+	// Handle status
+	if (params.status) {
+		args.push("-s", params.status);
+	}
+
+	// Handle type
+	if (params.type) {
+		args.push("-t", params.type);
+	}
+
+	// Handle priority
+	if (params.priority) {
+		args.push("-p", params.priority);
+	}
+
+	// Handle epic filter (via JQL)
+	if (params.epic) {
+		// Use JQL for epic filtering
+		const jql = buildListJQL(params, currentUser);
+		args.length = 0; // Clear args
+		args.push("issue", "list", "--raw", "--jql", jql);
+	}
+
+	// Handle limit (jira CLI uses --paginate, not --limit)
+	if (params.limit) {
+		args.push("--paginate", String(params.limit));
+	} else {
+		args.push("--paginate", "20");
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: "Fetching issues..." }] });
+
+	const result = await pi.exec("jira", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "List issues") }],
+			details: { action: "list", error: result.stderr } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// Parse JSON output
+	const issues = parseIssueListJSON(result.stdout);
+	
+	// Format for LLM
+	let output = "";
+	if (issues.length === 0) {
+		output = "No issues found";
+	} else {
+		output = issues.map(issue => {
+			const priority = issue.priority ? `[${issue.priority}]` : "";
+			const assignee = issue.assignee !== "Unassigned" ? `(@${issue.assignee})` : "";
+			return `${issue.key} [${issue.type}] ${priority} [${issue.status}] ${issue.summary} ${assignee}`;
+		}).join("\n");
+	}
+	
+	// Extract issue keys for tracking
+	const issueKeys = issues.map(i => i.key);
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "list", output, issueKeys } as JiraDetails,
+	};
+}
+
+/**
+ * View Jira issue details
+ */
+export async function handleView(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.key) {
+		return {
+			content: [{ type: "text", text: "Error: 'key' parameter is required for view action" }],
+			details: { action: "view", error: "missing_key" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.key}...` }] });
+
+	const result = await pi.exec("jira", ["issue", "view", params.key, "--plain"], { signal, timeout: 20000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "View issue") }],
+			details: { action: "view", error: result.stderr, issueKey: params.key } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: result.stdout }],
+		details: { action: "view", output: result.stdout, issueKey: params.key } as JiraDetails,
+	};
+}
+
+/**
+ * Search Jira issues using JQL
+ */
+export async function handleSearch(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.jql) {
+		return {
+			content: [{ type: "text", text: "Error: 'jql' parameter is required for search action" }],
+			details: { action: "search", error: "missing_jql" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	const args = ["issue", "list", "--raw", "--jql", params.jql];
+
+	if (params.limit) {
+		args.push("--paginate", String(params.limit));
+	} else {
+		args.push("--paginate", "50");
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: "Searching..." }] });
+
+	const result = await pi.exec("jira", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Search") }],
+			details: { action: "search", error: result.stderr } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// Parse JSON output
+	const issues = parseIssueListJSON(result.stdout);
+	
+	// Format for LLM
+	let output = "";
+	if (issues.length === 0) {
+		output = "No issues found";
+	} else {
+		output = issues.map(issue => {
+			const priority = issue.priority ? `[${issue.priority}]` : "";
+			const assignee = issue.assignee !== "Unassigned" ? `(@${issue.assignee})` : "";
+			return `${issue.key} [${issue.type}] ${priority} [${issue.status}] ${issue.summary} ${assignee}`;
+		}).join("\n");
+	}
+	
+	// Extract issue keys for tracking
+	const issueKeys = issues.map(i => i.key);
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "search", output, issueKeys } as JiraDetails,
+	};
+}
+
+/**
+ * Create Jira issue (requires approval)
+ */
+export async function handleCreate(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+	currentUser: string,
+): Promise<any> {
+	// Validate required parameters
+	if (!params.issueType) {
+		return {
+			content: [{ type: "text", text: "Error: 'issueType' parameter is required for create action" }],
+			details: { action: "create", error: "missing_type" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.summary) {
+		return {
+			content: [{ type: "text", text: "Error: 'summary' parameter is required for create action" }],
+			details: { action: "create", error: "missing_summary" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildCreateConfirmation(params);
+
+		const confirmed = await ctx.ui.confirm("Create Jira Issue?", confirmMessage);
+
+		if (!confirmed) {
+			ctx.ui.notify("Create cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Create operation cancelled by user" }],
+				details: { action: "create", cancelled: true } as JiraDetails,
+			};
+		}
+	}
+
+	// Build jira command
+	const args = ["issue", "create", "--type", params.issueType, "--summary", params.summary, "--no-input"];
+
+	if (params.description) {
+		args.push("--description", params.description);
+	}
+
+	if (params.priority) {
+		args.push("--priority", params.priority);
+	}
+
+	if (params.assignee) {
+		const assignee = params.assignee === "me" ? currentUser || params.assignee : params.assignee;
+		args.push("--assignee", assignee);
+	}
+
+	if (params.labels && params.labels.length > 0) {
+		args.push("--label", params.labels.join(","));
+	}
+
+	if (params.parent) {
+		args.push("--parent", params.parent);
+	}
+
+	// Note: epic linking might need to be done via edit after creation
+	// depending on jira-cli version
+
+	onUpdate?.({ content: [{ type: "text", text: "Creating issue..." }] });
+
+	const result = await pi.exec("jira", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Create issue") }],
+			details: { action: "create", error: result.stderr } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// Extract issue key from output
+	const issueKey = extractIssueKey(result.stdout) || "unknown";
+
+	return {
+		content: [{ type: "text", text: `Created issue: ${issueKey}\n\n${result.stdout}` }],
+		details: { action: "create", output: result.stdout, issueKey } as JiraDetails,
+	};
+}
+
+/**
+ * Update Jira issue field (requires approval)
+ */
+export async function handleUpdate(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+	currentUser: string,
+): Promise<any> {
+	if (!params.key) {
+		return {
+			content: [{ type: "text", text: "Error: 'key' parameter is required for update action" }],
+			details: { action: "update", error: "missing_key" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.field) {
+		return {
+			content: [{ type: "text", text: "Error: 'field' parameter is required for update action" }],
+			details: { action: "update", error: "missing_field" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.value) {
+		return {
+			content: [{ type: "text", text: "Error: 'value' parameter is required for update action" }],
+			details: { action: "update", error: "missing_value" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildUpdateConfirmation(params);
+
+		const confirmed = await ctx.ui.confirm(`Update ${params.key}?`, confirmMessage);
+
+		if (!confirmed) {
+			ctx.ui.notify("Update cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Update operation cancelled by user" }],
+				details: { action: "update", cancelled: true, issueKey: params.key } as JiraDetails,
+			};
+		}
+	}
+
+	// Build command based on field
+	let args: string[];
+	const value = params.value === "me" ? currentUser || params.value : params.value;
+
+	switch (params.field) {
+		case "assignee":
+			args = ["issue", "assign", params.key, value];
+			break;
+
+		case "labels":
+			// Labels are comma-separated
+			args = ["issue", "edit", params.key, "--label", value];
+			break;
+
+		case "priority":
+		case "summary":
+		case "description":
+			args = ["issue", "edit", params.key, `--${params.field}`, value];
+			break;
+
+		default:
+			return {
+				content: [{ type: "text", text: `Error: Unsupported field: ${params.field}` }],
+				details: { action: "update", error: "unsupported_field", issueKey: params.key } as JiraDetails,
+				isError: true,
+			};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Updating ${params.key}...` }] });
+
+	const result = await pi.exec("jira", args, { signal, timeout: 20000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Update issue") }],
+			details: { action: "update", error: result.stderr, issueKey: params.key } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Updated ${params.key}: ${params.field} = ${value}\n\n${result.stdout}` }],
+		details: {
+			action: "update",
+			output: result.stdout,
+			issueKey: params.key,
+			field: params.field,
+			newValue: value,
+		} as JiraDetails,
+	};
+}
+
+/**
+ * Add comment to Jira issue (requires approval)
+ */
+export async function handleComment(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.key) {
+		return {
+			content: [{ type: "text", text: "Error: 'key' parameter is required for comment action" }],
+			details: { action: "comment", error: "missing_key" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.comment) {
+		return {
+			content: [{ type: "text", text: "Error: 'comment' parameter is required for comment action" }],
+			details: { action: "comment", error: "missing_comment" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildCommentConfirmation(params);
+
+		const confirmed = await ctx.ui.confirm(`Add comment to ${params.key}?`, confirmMessage);
+
+		if (!confirmed) {
+			ctx.ui.notify("Comment cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Comment operation cancelled by user" }],
+				details: { action: "comment", cancelled: true, issueKey: params.key } as JiraDetails,
+			};
+		}
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Adding comment to ${params.key}...` }] });
+
+	// Write comment to temp file to avoid shell escaping issues
+	const tmpFile = `/tmp/jira-comment-${Date.now()}.txt`;
+	await pi.exec("sh", ["-c", `echo ${JSON.stringify(params.comment)} > ${tmpFile}`], { signal });
+
+	const result = await pi.exec("sh", ["-c", `cat ${tmpFile} | jira issue comment add ${params.key}`], {
+		signal,
+		timeout: 20000,
+	});
+
+	// Clean up temp file
+	await pi.exec("rm", ["-f", tmpFile], { signal });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Add comment") }],
+			details: { action: "comment", error: result.stderr, issueKey: params.key } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Added comment to ${params.key}` }],
+		details: { action: "comment", output: result.stdout, issueKey: params.key } as JiraDetails,
+	};
+}
+
+/**
+ * Transition Jira issue to new state (requires approval)
+ */
+export async function handleTransition(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.key) {
+		return {
+			content: [{ type: "text", text: "Error: 'key' parameter is required for transition action" }],
+			details: { action: "transition", error: "missing_key" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.state) {
+		return {
+			content: [{ type: "text", text: "Error: 'state' parameter is required for transition action" }],
+			details: { action: "transition", error: "missing_state" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildTransitionConfirmation(params);
+
+		const confirmed = await ctx.ui.confirm(`Transition ${params.key}?`, confirmMessage);
+
+		if (!confirmed) {
+			ctx.ui.notify("Transition cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Transition operation cancelled by user" }],
+				details: { action: "transition", cancelled: true, issueKey: params.key } as JiraDetails,
+			};
+		}
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Moving ${params.key} to ${params.state}...` }] });
+
+	const result = await pi.exec("jira", ["issue", "move", params.key, params.state], { signal, timeout: 20000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Transition issue") }],
+			details: { action: "transition", error: result.stderr, issueKey: params.key } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Moved ${params.key} to ${params.state}` }],
+		details: {
+			action: "transition",
+			output: result.stdout,
+			issueKey: params.key,
+			toKey: params.state,
+		} as JiraDetails,
+	};
+}
+
+/**
+ * Helper: Build JQL query from list parameters
+ */
+function buildListJQL(params: any, currentUser: string): string {
+	const conditions: string[] = [];
+
+	if (params.assignee) {
+		const assignee = params.assignee === "me" ? currentUser || params.assignee : params.assignee;
+		conditions.push(`assignee = ${assignee}`);
+	}
+
+	if (params.status) {
+		// Handle ~Done syntax for "not Done"
+		if (params.status.startsWith("~")) {
+			conditions.push(`status != ${params.status.slice(1)}`);
+		} else {
+			// Handle comma-separated statuses
+			const statuses = params.status
+				.split(",")
+				.map((s: string) => `"${s.trim()}"`)
+				.join(",");
+			conditions.push(`status IN (${statuses})`);
+		}
+	}
+
+	if (params.type) {
+		const types = params.type
+			.split(",")
+			.map((t: string) => `"${t.trim()}"`)
+			.join(",");
+		conditions.push(`type IN (${types})`);
+	}
+
+	if (params.priority) {
+		const priorities = params.priority
+			.split(",")
+			.map((p: string) => `"${p.trim()}"`)
+			.join(",");
+		conditions.push(`priority IN (${priorities})`);
+	}
+
+	if (params.epic) {
+		conditions.push(`"Epic Link" = ${params.epic}`);
+	}
+
+	return conditions.join(" AND ");
+}
dots/pi/agent/extensions/jira/attachment-actions.ts
@@ -0,0 +1,142 @@
+/**
+ * Attachment-related action handlers for Jira extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { JiraDetails } from "./types";
+import { getErrorMessage } from "./utils";
+
+/**
+ * Attach file to issue
+ */
+export async function handleAttach(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.key) {
+		return {
+			content: [{ type: "text", text: "Error: 'key' parameter is required for attach action" }],
+			details: { action: "attach", error: "missing_key" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.file) {
+		return {
+			content: [{ type: "text", text: "Error: 'file' parameter is required for attach action" }],
+			details: { action: "attach", error: "missing_file" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// Check if file exists
+	const checkResult = await pi.exec("test", ["-f", params.file], { signal });
+	if (checkResult.code !== 0) {
+		return {
+			content: [{ type: "text", text: `Error: File not found: ${params.file}` }],
+			details: { action: "attach", error: "file_not_found" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = `Issue: ${params.key}\nFile: ${params.file}\n\nThis will upload the file as an attachment.`;
+
+		const confirmed = await ctx.ui.confirm("Attach file?", confirmMessage);
+
+		if (!confirmed) {
+			ctx.ui.notify("Attach cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Attach operation cancelled by user" }],
+				details: { action: "attach", cancelled: true, issueKey: params.key } as JiraDetails,
+			};
+		}
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Attaching ${params.file} to ${params.key}...` }] });
+
+	// Attach file using jira CLI
+	const result = await pi.exec("jira", ["issue", "attach", params.key, params.file], { signal, timeout: 60000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Attach file") }],
+			details: { action: "attach", error: result.stderr, issueKey: params.key } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Attached ${params.file} to ${params.key}` }],
+		details: { action: "attach", output: result.stdout, issueKey: params.key } as JiraDetails,
+	};
+}
+
+/**
+ * List attachments for an issue
+ */
+export async function handleListAttachments(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.key) {
+		return {
+			content: [{ type: "text", text: "Error: 'key' parameter is required for list-attachments action" }],
+			details: { action: "list-attachments", error: "missing_key" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Fetching attachments for ${params.key}...` }] });
+
+	// View issue to get attachments
+	const result = await pi.exec("jira", ["issue", "view", params.key, "--plain"], { signal, timeout: 20000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "List attachments") }],
+			details: { action: "list-attachments", error: result.stderr, issueKey: params.key } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// Parse output to find attachments section
+	// The jira CLI view output typically includes an "Attachments:" section
+	const lines = result.stdout.split("\n");
+	let inAttachments = false;
+	const attachments: string[] = [];
+
+	for (const line of lines) {
+		if (line.match(/^Attachments?:/i)) {
+			inAttachments = true;
+			continue;
+		}
+
+		if (inAttachments) {
+			// Stop at next section or empty line
+			if (line.match(/^[A-Z][a-z]+:/) || line.trim() === "") {
+				break;
+			}
+			if (line.trim()) {
+				attachments.push(line.trim());
+			}
+		}
+	}
+
+	const output =
+		attachments.length > 0
+			? `Attachments for ${params.key}:\n\n` + attachments.join("\n")
+			: `No attachments found for ${params.key}`;
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "list-attachments", output, issueKey: params.key } as JiraDetails,
+	};
+}
dots/pi/agent/extensions/jira/DESIGN-NOTES.md
@@ -0,0 +1,394 @@
+# Jira Extension - Design Notes
+
+## Offline Support Strategy
+
+### Current Challenge
+- Jira requires VPN connection to issues.redhat.com
+- API calls fail when offline or not on VPN
+- Users might want to queue operations while offline
+
+### Proposed Approach: Operation Queue in Session
+
+Store pending operations in session state (similar to git commits):
+
+```typescript
+interface QueuedOperation {
+  id: string;
+  action: "create" | "update" | "comment" | "transition";
+  params: any;
+  timestamp: number;
+  approved: boolean;  // User already approved
+}
+
+// Store in session via appendEntry
+pi.appendEntry("jira-queue", {
+  operations: [/* queued ops */]
+});
+```
+
+### Benefits
+1. **Session-based** - Survives restarts, respects branching
+2. **Already Approved** - User approved when offline, just need to execute
+3. **Git-like Workflow** - Make changes offline, "push" when online
+4. **Transparent** - User sees queued operations in session
+
+### Implementation Steps
+
+1. **Detect Offline** - Try `jira me` with short timeout
+2. **Queue Operation** - Store in session with approval flag
+3. **Show Queued** - Display pending operations to user
+4. **Sync Command** - `/jira-sync` to execute queued operations
+5. **Auto-sync** - Optionally sync on connectivity restore
+
+### Example Workflow
+
+```
+[OFFLINE]
+User: "Create a bug for X"
+Agent: <detects offline>
+→ Shows approval dialog (with "QUEUED" indicator)
+→ User confirms
+→ Stores in session queue
+→ Returns: "Queued for sync (1 pending operation)"
+
+[ONLINE]
+User: "/jira-sync"
+→ Executes queued operations
+→ Returns: "Synced 1 operation: Created SRVKP-1234"
+```
+
+### Conflict Resolution
+
+What if issue changed while offline?
+
+```typescript
+// Before executing queued operation
+const current = await viewIssue(op.params.key);
+
+if (hasConflict(current, op)) {
+  const confirmed = await ctx.ui.confirm(
+    "Conflict Detected",
+    `Issue ${op.params.key} changed since queued.\n\n` +
+    `Queued: ${op.params.field} = ${op.params.value}\n` +
+    `Current: ${current[op.params.field]}\n\n` +
+    `Continue with update?`
+  );
+  
+  if (!confirmed) {
+    // Skip this operation
+  }
+}
+```
+
+### Session Entry Structure
+
+```typescript
+// Queued operations
+{
+  type: "custom",
+  customType: "jira-queue",
+  timestamp: Date.now(),
+  data: {
+    operations: [
+      {
+        id: "op-1234",
+        action: "create",
+        params: { issueType: "Bug", summary: "..." },
+        timestamp: Date.now(),
+        approved: true
+      }
+    ]
+  }
+}
+
+// Sync results
+{
+  type: "custom",
+  customType: "jira-sync",
+  timestamp: Date.now(),
+  data: {
+    synced: 3,
+    failed: 0,
+    results: [
+      { id: "op-1234", success: true, issueKey: "SRVKP-5678" }
+    ]
+  }
+}
+```
+
+### When to Implement?
+
+**Wait for real need** - Implement when:
+1. You frequently work offline
+2. You make multiple Jira changes in one session
+3. VPN connectivity is unreliable
+
+For now, the extension fails gracefully with clear error messages about VPN/network.
+
+---
+
+## Batch Operations Strategy
+
+### Use Cases
+
+#### 1. Sprint Planning
+```
+User: "Add SRVKP-1234, SRVKP-1235, SRVKP-1236 to current sprint"
+→ Batch operation: transition multiple issues
+```
+
+#### 2. Bulk Labeling
+```
+User: "Add 'release-notes-pending' label to all issues in current sprint"
+→ Batch operation: update labels on multiple issues
+```
+
+#### 3. Team Assignment
+```
+User: "Assign all unassigned tasks in SRVKP to the team rotation"
+→ Batch operation: update assignee on multiple issues
+```
+
+#### 4. Status Updates
+```
+User: "Move all code-reviewed issues to QE Review"
+→ Batch operation: transition multiple issues
+```
+
+### Proposed Design
+
+Add a `batch` action that operates on multiple issues:
+
+```typescript
+{
+  action: "batch",
+  operation: "transition" | "update" | "comment",
+  issues: ["SRVKP-1234", "SRVKP-1235", "SRVKP-1236"],
+  // Operation-specific params
+  state: "In Progress",  // For transition
+  // OR
+  field: "labels",  // For update
+  value: "release-notes",
+  // OR
+  comment: "Bulk update comment"
+}
+```
+
+### Approval Pattern
+
+Show all changes in one dialog:
+
+```
+┌─────────────────────────────────────┐
+│ Batch Transition (3 issues)?       │
+│                                     │
+│ Issues:                             │
+│   • SRVKP-1234: Fix bug X          │
+│   • SRVKP-1235: Add feature Y      │
+│   • SRVKP-1236: Update docs Z      │
+│                                     │
+│ Change: To Do → In Progress         │
+│                                     │
+│ This will transition 3 issues.      │
+│                                     │
+│ [Yes] [No]                          │
+└─────────────────────────────────────┘
+```
+
+### Implementation
+
+```typescript
+async function handleBatch(pi, params, signal, onUpdate, ctx) {
+  // Validate
+  if (!params.issues || params.issues.length === 0) {
+    return error("No issues specified");
+  }
+  
+  // Get issue details for approval
+  const details = await fetchIssueDetails(params.issues);
+  
+  // Approval
+  if (ctx.hasUI) {
+    const confirmed = await ctx.ui.confirm(
+      `Batch ${params.operation} (${params.issues.length} issues)?`,
+      buildBatchConfirmation(params, details)
+    );
+    
+    if (!confirmed) {
+      return { cancelled: true };
+    }
+  }
+  
+  // Execute on each issue
+  const results = [];
+  for (const issueKey of params.issues) {
+    onUpdate?.({ 
+      content: [{ 
+        type: "text", 
+        text: `Processing ${issueKey}...` 
+      }] 
+    });
+    
+    const result = await executeOperation(
+      issueKey, 
+      params.operation, 
+      params
+    );
+    
+    results.push(result);
+  }
+  
+  // Return aggregated results
+  const succeeded = results.filter(r => r.success).length;
+  const failed = results.filter(r => !r.success).length;
+  
+  return {
+    content: [{
+      type: "text",
+      text: `Batch ${params.operation} complete:\n` +
+            `  Succeeded: ${succeeded}\n` +
+            `  Failed: ${failed}`
+    }],
+    details: {
+      action: "batch",
+      operation: params.operation,
+      results
+    }
+  };
+}
+```
+
+### When to Implement?
+
+**Wait for real need** - Implement when:
+1. You frequently need to update 5+ issues at once
+2. Sprint planning requires bulk operations
+3. Team workflows involve batch status changes
+
+For now, you can:
+- Make individual API calls (jira CLI is fast)
+- Use JQL search + manual batch via web UI if needed
+- Script batch operations outside of pi if critical
+
+---
+
+## Recommendations
+
+### Phase 1: Current Implementation ✅
+- All 8 core actions (me, list, view, search, create, update, comment, transition)
+- Approval gates for write operations
+- Custom rendering
+- State management
+- Error handling
+
+### Phase 2: Epic and Feature Support 🎯
+**NEXT PRIORITY** - You mentioned epics and features are important:
+
+```typescript
+// Add epic-specific actions
+{
+  action: "epic-view",
+  key: "SRVKP-1234"  // View epic with child issues
+}
+
+{
+  action: "epic-create",
+  summary: "Epic: Major feature",
+  description: "..."
+}
+
+{
+  action: "link-to-epic",
+  issue: "SRVKP-5678",
+  epic: "SRVKP-1234"
+}
+```
+
+### Phase 3: Attachments and Links 📎
+**SECOND PRIORITY** - You want these features:
+
+```typescript
+// Attachments
+{
+  action: "attach",
+  key: "SRVKP-1234",
+  file: "/path/to/file"
+}
+
+{
+  action: "list-attachments",
+  key: "SRVKP-1234"
+}
+
+// Issue links
+{
+  action: "link",
+  from: "SRVKP-1234",
+  to: "SRVKP-5678",
+  type: "blocks" | "relates to" | "duplicates"
+}
+```
+
+### Phase 4: Offline Support 📡
+**Wait for need** - Implement if:
+- VPN is unreliable
+- You work offline frequently
+- You want to queue multiple operations
+
+### Phase 5: Batch Operations 📦
+**Wait for need** - Implement if:
+- You regularly update 5+ issues
+- Sprint planning requires bulk ops
+- Clear use cases emerge
+
+---
+
+## Next Steps
+
+1. **Test the Extension**
+   - Try all 8 actions
+   - Verify approval dialogs work
+   - Test error handling (VPN off, wrong issue key, etc.)
+
+2. **Add Epic Support** (Phase 2)
+   - `epic-view` - View epic with child issues
+   - `epic-create` - Create new epic
+   - `link-to-epic` - Link issue to epic
+   - `unlink-from-epic` - Remove epic link
+
+3. **Add Feature Support**
+   - Ensure Feature type works in create
+   - Add Feature-specific fields if needed
+
+4. **Add Attachments** (Phase 3)
+   - `attach` - Attach file to issue
+   - `list-attachments` - List attachments
+   - `download-attachment` - Download file
+
+5. **Add Links** (Phase 3)
+   - `link` - Create issue link
+   - `unlink` - Remove issue link
+   - `list-links` - Show related issues
+
+---
+
+## Implementation Priority
+
+```
+NOW    → Test current 8 actions
+NEXT   → Epic support (epic-view, epic-create, link-to-epic)
+THEN   → Feature type validation
+THEN   → Attachments (attach, list-attachments)
+THEN   → Links (link, unlink, list-links)
+LATER  → Offline support (if needed)
+LATER  → Batch operations (if needed)
+```
+
+---
+
+## Questions?
+
+- **Offline**: Queue operations in session, sync when online (similar to git)
+- **Batch**: Useful but wait for real need; single operations are fast enough for now
+- **Epic**: Should be next priority since you use epics/features
+- **Attachments**: Second priority for documentation and screenshots
dots/pi/agent/extensions/jira/epic-actions.ts
@@ -0,0 +1,133 @@
+/**
+ * Epic-related action handlers for Jira extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { JiraDetails } from "./types";
+import { getErrorMessage } from "./utils";
+
+/**
+ * View epic with all child issues
+ */
+export async function handleEpicView(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.key) {
+		return {
+			content: [{ type: "text", text: "Error: 'key' parameter is required for epic-view action" }],
+			details: { action: "epic-view", error: "missing_key" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Fetching epic ${params.key}...` }] });
+
+	// First, view the epic itself
+	const epicResult = await pi.exec("jira", ["issue", "view", params.key, "--plain"], { signal, timeout: 20000 });
+
+	if (epicResult.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(epicResult.stderr, "View epic") }],
+			details: { action: "epic-view", error: epicResult.stderr, issueKey: params.key } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Fetching child issues for ${params.key}...` }] });
+
+	// Then, list all issues linked to this epic
+	const childrenResult = await pi.exec(
+		"jira",
+		["issue", "list", "--plain", "--jql", `"Epic Link" = ${params.key}`],
+		{ signal, timeout: 30000 },
+	);
+
+	let output = `Epic: ${params.key}\n\n`;
+	output += `${epicResult.stdout}\n\n`;
+	output += `=`.repeat(80) + "\n\n";
+	output += `Child Issues:\n\n`;
+
+	if (childrenResult.code === 0 && childrenResult.stdout.trim()) {
+		output += childrenResult.stdout;
+	} else {
+		output += "No child issues found.";
+	}
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "epic-view", output, issueKey: params.key } as JiraDetails,
+	};
+}
+
+/**
+ * Link issue to epic
+ */
+export async function handleLinkToEpic(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.issue) {
+		return {
+			content: [{ type: "text", text: "Error: 'issue' parameter is required for link-to-epic action" }],
+			details: { action: "link-to-epic", error: "missing_issue" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.epic) {
+		return {
+			content: [{ type: "text", text: "Error: 'epic' parameter is required for link-to-epic action" }],
+			details: { action: "link-to-epic", error: "missing_epic" } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = `Issue: ${params.issue}\nEpic: ${params.epic}\n\nThis will link the issue to the epic.`;
+
+		const confirmed = await ctx.ui.confirm(`Link ${params.issue} to epic?`, confirmMessage);
+
+		if (!confirmed) {
+			ctx.ui.notify("Link to epic cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Link operation cancelled by user" }],
+				details: { action: "link-to-epic", cancelled: true } as JiraDetails,
+			};
+		}
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Linking ${params.issue} to epic ${params.epic}...` }] });
+
+	// Link issue to epic using edit command
+	const result = await pi.exec("jira", ["issue", "edit", params.issue, "--epic", params.epic], {
+		signal,
+		timeout: 20000,
+	});
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Link to epic") }],
+			details: { action: "link-to-epic", error: result.stderr } as JiraDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Linked ${params.issue} to epic ${params.epic}` }],
+		details: {
+			action: "link-to-epic",
+			output: result.stdout,
+			issueKey: params.issue,
+			fromKey: params.issue,
+			toKey: params.epic,
+		} as JiraDetails,
+	};
+}
dots/pi/agent/extensions/jira/index.ts
@@ -0,0 +1,913 @@
+/**
+ * Pi Extension: Jira Issue Management
+ *
+ * Provides Jira issue management for issues.redhat.com with:
+ * - Read operations: me, list, view, search
+ * - Write operations (with approval): create, update, comment, transition
+ * - Custom rendering for issues
+ * - State management for current user
+ *
+ * Configuration:
+ *   ~/.config/.jira/.config.yml - Jira CLI config
+ *   passage show redhat/issues/token/kyushu - API token
+ *
+ * Requirements:
+ *   - jira CLI: go install github.com/ankitpokhrel/jira-cli/cmd/jira@latest
+ *   - Red Hat VPN connection
+ */
+
+import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
+import { Text } from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+
+import type { JiraDetails } from "./types";
+import {
+	handleMe,
+	handleList,
+	handleView,
+	handleSearch,
+	handleCreate,
+	handleUpdate,
+	handleComment,
+	handleTransition,
+} from "./actions";
+import { handleEpicView, handleLinkToEpic } from "./epic-actions";
+import { handleLink, handleUnlink } from "./link-actions";
+import { handleAttach, handleListAttachments } from "./attachment-actions";
+import { parseIssueList, parseIssueListJSON, getStatusColor, getPriorityColor, truncate } from "./utils";
+
+export default function (pi: ExtensionAPI) {
+	// ========================================================================
+	// State Management
+	// ========================================================================
+
+	let currentUser = "";
+	let recentIssues: string[] = [];
+
+	// Reconstruct state from session
+	const reconstructState = (ctx: ExtensionContext) => {
+		currentUser = "";
+		recentIssues = [];
+
+		for (const entry of ctx.sessionManager.getBranch()) {
+			if (entry.type !== "message") continue;
+			const msg = entry.message;
+			if (msg.role !== "toolResult" || msg.toolName !== "jira") continue;
+
+			const details = msg.details as JiraDetails | undefined;
+			if (details?.action === "me" && details.output) {
+				currentUser = details.output.trim();
+			}
+
+			// Track recent issue keys
+			if (details?.issueKey && !recentIssues.includes(details.issueKey)) {
+				recentIssues.push(details.issueKey);
+			}
+			if (details?.issueKeys) {
+				for (const key of details.issueKeys) {
+					if (!recentIssues.includes(key)) {
+						recentIssues.push(key);
+					}
+				}
+			}
+		}
+
+		// Keep only last 20 issues
+		if (recentIssues.length > 20) {
+			recentIssues = recentIssues.slice(-20);
+		}
+	};
+
+	// Session events
+	pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
+	pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
+	pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
+	pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
+
+	// ========================================================================
+	// Tool Registration
+	// ========================================================================
+
+	pi.registerTool({
+		name: "jira",
+		label: "Jira",
+		description:
+			"Manage Jira issues on issues.redhat.com. " +
+			"Actions: me (get current user), list (list issues), view (view issue details), " +
+			"search (JQL search), create (create issue), update (update field), " +
+			"comment (add comment), transition (change state). " +
+			"Use 'me' as assignee value to refer to current user. " +
+			"Write operations (create, update, comment, transition) require user approval.",
+
+		parameters: Type.Object({
+			action: StringEnum([
+				"me",
+				"list",
+				"view",
+				"search",
+				"create",
+				"update",
+				"comment",
+				"transition",
+				"epic-view",
+				"link-to-epic",
+				"link",
+				"unlink",
+				"attach",
+				"list-attachments",
+			] as const),
+
+			// List/Search parameters
+			assignee: Type.Optional(
+				Type.String({
+					description: "Filter by assignee (username or 'me' for current user)",
+				}),
+			),
+			status: Type.Optional(
+				Type.String({
+					description: "Filter by status (comma-separated, or '~Done' for not Done)",
+				}),
+			),
+			type: Type.Optional(
+				Type.String({
+					description: "Filter by type: Bug, Task, Story, Epic, Feature (comma-separated)",
+				}),
+			),
+			priority: Type.Optional(
+				Type.String({
+					description: "Filter by priority: Blocker, Critical, Major, Minor, Trivial (comma-separated)",
+				}),
+			),
+			limit: Type.Optional(
+				Type.Number({
+					description: "Maximum number of results (default 20 for list, 50 for search)",
+				}),
+			),
+			epic: Type.Optional(
+				Type.String({
+					description: "Filter by epic key (e.g., SRVKP-1234)",
+				}),
+			),
+
+			// View parameter
+			key: Type.Optional(
+				Type.String({
+					description: "Issue key (e.g., SRVKP-1234)",
+				}),
+			),
+
+			// Search parameter
+			jql: Type.Optional(
+				Type.String({
+					description: "JQL query for advanced search",
+				}),
+			),
+
+			// Create parameters
+			issueType: Type.Optional(
+				Type.String({
+					description: "Issue type: Bug, Task, Story, Epic, Feature, Sub-task",
+				}),
+			),
+			summary: Type.Optional(
+				Type.String({
+					description: "Issue title/summary",
+				}),
+			),
+			description: Type.Optional(
+				Type.String({
+					description: "Issue description",
+				}),
+			),
+			labels: Type.Optional(
+				Type.Array(Type.String(), {
+					description: "Issue labels",
+				}),
+			),
+			parent: Type.Optional(
+				Type.String({
+					description: "Parent issue key for sub-tasks",
+				}),
+			),
+
+			// Update parameters
+			field: Type.Optional(
+				Type.String({
+					description: "Field to update: assignee, priority, labels, summary, description",
+				}),
+			),
+			value: Type.Optional(
+				Type.String({
+					description: "New value for the field",
+				}),
+			),
+
+			// Comment parameter
+			comment: Type.Optional(
+				Type.String({
+					description: "Comment text",
+				}),
+			),
+
+			// Transition parameter
+			state: Type.Optional(
+				Type.String({
+					description: "New state: To Do, In Progress, Code Review, QE Review, Done, Blocked, etc.",
+				}),
+			),
+
+			// Epic parameters
+			issue: Type.Optional(
+				Type.String({
+					description: "Issue key to link to epic",
+				}),
+			),
+
+			// Link parameters
+			from: Type.Optional(
+				Type.String({
+					description: "Source issue key for linking",
+				}),
+			),
+			to: Type.Optional(
+				Type.String({
+					description: "Target issue key for linking",
+				}),
+			),
+			linkType: Type.Optional(
+				Type.String({
+					description: "Link type: blocks, is blocked by, relates to, duplicates, is duplicated by",
+				}),
+			),
+
+			// Attachment parameter
+			file: Type.Optional(
+				Type.String({
+					description: "File path to attach",
+				}),
+			),
+		}),
+
+		async execute(toolCallId, params, signal, onUpdate, ctx) {
+			try {
+				// Route to appropriate handler
+				switch (params.action) {
+					case "me":
+						return await handleMe(pi, params, signal, onUpdate, ctx);
+
+					case "list":
+						return await handleList(pi, params, signal, onUpdate, ctx, currentUser);
+
+					case "view":
+						return await handleView(pi, params, signal, onUpdate, ctx);
+
+					case "search":
+						return await handleSearch(pi, params, signal, onUpdate, ctx);
+
+					case "create":
+						return await handleCreate(pi, params, signal, onUpdate, ctx, currentUser);
+
+					case "update":
+						return await handleUpdate(pi, params, signal, onUpdate, ctx, currentUser);
+
+					case "comment":
+						return await handleComment(pi, params, signal, onUpdate, ctx);
+
+					case "transition":
+						return await handleTransition(pi, params, signal, onUpdate, ctx);
+
+					case "epic-view":
+						return await handleEpicView(pi, params, signal, onUpdate, ctx);
+
+					case "link-to-epic":
+						return await handleLinkToEpic(pi, params, signal, onUpdate, ctx);
+
+					case "link":
+						return await handleLink(pi, params, signal, onUpdate, ctx);
+
+					case "unlink":
+						return await handleUnlink(pi, params, signal, onUpdate, ctx);
+
+					case "attach":
+						return await handleAttach(pi, params, signal, onUpdate, ctx);
+
+					case "list-attachments":
+						return await handleListAttachments(pi, params, signal, onUpdate, ctx);
+
+					default:
+						return {
+							content: [{ type: "text", text: `Unknown action: ${params.action}` }],
+							details: { action: params.action, error: "unknown_action" } as JiraDetails,
+							isError: true,
+						};
+				}
+			} catch (error) {
+				return {
+					content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
+					details: { action: params.action, error: String(error) } as JiraDetails,
+					isError: true,
+				};
+			}
+		},
+
+		// ====================================================================
+		// Custom Rendering
+		// ====================================================================
+
+		renderCall(args, theme) {
+			let text = theme.fg("toolTitle", theme.bold("jira "));
+			text += theme.fg("muted", args.action);
+
+			if (args.key) {
+				text += " " + theme.fg("accent", args.key);
+			}
+
+			if (args.summary) {
+				text += " " + theme.fg("dim", `"${truncate(args.summary, 50)}"`);
+			}
+
+			if (args.jql) {
+				text += " " + theme.fg("dim", `"${truncate(args.jql, 50)}"`);
+			}
+
+			return new Text(text, 0, 0);
+		},
+
+		renderResult(result, { expanded }, theme) {
+			const details = result.details as JiraDetails | undefined;
+
+			if (!details) {
+				const text = result.content[0];
+				return new Text(text?.type === "text" ? text.text : "", 0, 0);
+			}
+
+			if (details.error) {
+				return new Text(theme.fg("error", `✗ Error: ${details.error}`), 0, 0);
+			}
+
+			if (details.cancelled) {
+				return new Text(theme.fg("warning", "✗ Cancelled by user"), 0, 0);
+			}
+
+			switch (details.action) {
+				case "me":
+					return renderMe(details, theme);
+
+				case "list":
+				case "search":
+					return renderList(details, expanded, theme);
+
+				case "view":
+					return renderView(details, expanded, theme);
+
+				case "create":
+					return renderCreate(details, theme);
+
+				case "update":
+					return renderUpdate(details, theme);
+
+				case "comment":
+					return renderComment(details, theme);
+
+				case "transition":
+					return renderTransition(details, theme);
+
+				case "epic-view":
+					return renderEpicView(details, expanded, theme);
+
+				case "link-to-epic":
+					return renderLinkToEpic(details, theme);
+
+				case "link":
+					return renderLink(details, theme);
+
+				case "unlink":
+					return renderUnlink(details, theme);
+
+				case "attach":
+					return renderAttach(details, theme);
+
+				case "list-attachments":
+					return renderListAttachments(details, expanded, theme);
+
+				default:
+					return new Text(details.output || "", 0, 0);
+			}
+		},
+	});
+
+	// ========================================================================
+	// Slash Commands
+	// ========================================================================
+
+	// /jira - Show my open issues
+	pi.registerCommand("jira", {
+		description: "Show my open Jira issues in SRVKP project",
+		handler: async (_args, ctx) => {
+			if (!ctx.hasUI) {
+				ctx.ui.notify("/jira requires interactive mode", "error");
+				return;
+			}
+
+			// Get current user if not cached
+			if (!currentUser) {
+				const meResult = await pi.exec("jira", ["me"], { timeout: 10000 });
+				if (meResult.code === 0) {
+					currentUser = meResult.stdout.trim();
+				}
+			}
+
+			// List my open issues directly (default to SRVKP project)
+			const args = [
+				"issue",
+				"list",
+				"--raw",
+				"--project",
+				"SRVKP",
+				"-a",
+				currentUser || "currentUser()",
+				"-s",
+				"~Done",
+				"--paginate",
+				"20",
+			];
+
+			const result = await pi.exec("jira", args, { timeout: 30000 });
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			const issues = parseIssueListJSON(result.stdout);
+
+			// Display results directly (like org-todos)
+			const lines: string[] = [];
+			lines.push("## 📋 My Open Issues (SRVKP)");
+			lines.push("");
+			lines.push(`_Command: \`jira ${args.join(" ")}\`_`);
+			lines.push("");
+
+			if (issues.length === 0) {
+				lines.push("*No open issues* ✨");
+			} else {
+				// Table format for better alignment
+				lines.push("| Key | Type | Priority | Status | Summary |");
+				lines.push("|-----|------|----------|--------|---------|");
+				for (const issue of issues) {
+					const priority = issue.priority || "-";
+					const summary = truncate(issue.summary, 80);
+					lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${issue.status} | ${summary} |`);
+				}
+			}
+
+			pi.sendMessage({
+				customType: "jira-list",
+				content: lines.join("\n"),
+				display: true,
+			});
+		},
+	});
+
+	// /jira-view <key> - View specific issue
+	pi.registerCommand("jira-view", {
+		description: "View a Jira issue (e.g., /jira-view SRVKP-1234)",
+		handler: async (args, ctx) => {
+			if (!args) {
+				ctx.ui.notify("Usage: /jira-view ISSUE-KEY", "error");
+				return;
+			}
+
+			const issueKey = args.trim();
+
+			// Validate issue key format (at least 2 uppercase letters, dash, numbers)
+			if (!/^[A-Z]{2,}-\d+$/.test(issueKey)) {
+				ctx.ui.notify(`Invalid issue key format: ${issueKey}`, "error");
+				return;
+			}
+
+			// View issue directly
+			const result = await pi.exec("jira", ["issue", "view", issueKey, "--plain"], { timeout: 20000 });
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			// Display result directly
+			pi.sendMessage({
+				customType: "jira-view",
+				content: `## 🔍 ${issueKey}\n\n_Command: \`jira issue view ${issueKey} --plain\`_\n\n${result.stdout}`,
+				display: true,
+			});
+		},
+	});
+
+	// /jira-search <query> - Search with JQL
+	pi.registerCommand("jira-search", {
+		description: "Search Jira with JQL (defaults to SRVKP project)",
+		handler: async (args, ctx) => {
+			if (!args) {
+				ctx.ui.notify("Usage: /jira-search <JQL query>", "error");
+				return;
+			}
+
+			let jql = args.trim();
+
+			// Default to SRVKP project if no project specified in JQL
+			if (!jql.toLowerCase().includes("project")) {
+				jql = `project = SRVKP AND ${jql}`;
+			}
+
+			// Search directly
+			const result = await pi.exec("jira", ["issue", "list", "--raw", "--jql", jql, "--paginate", "50"], {
+				timeout: 30000,
+			});
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			const issues = parseIssueListJSON(result.stdout);
+
+			// Display results directly
+			const lines: string[] = [];
+			lines.push(`## 🔎 Search Results`);
+			lines.push(`Query: \`${jql}\``);
+			lines.push("");
+
+			if (issues.length === 0) {
+				lines.push("*No issues found*");
+			} else {
+				// Table format
+				lines.push("| Key | Type | Priority | Status | Summary |");
+				lines.push("|-----|------|----------|--------|---------|");
+				for (const issue of issues) {
+					const priority = issue.priority || "-";
+					const summary = truncate(issue.summary, 80);
+					lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${issue.status} | ${summary} |`);
+				}
+			}
+
+			pi.sendMessage({
+				customType: "jira-search",
+				content: lines.join("\n"),
+				display: true,
+			});
+		},
+	});
+
+	// /jira-mine - My issues (shorthand)
+	pi.registerCommand("jira-mine", {
+		description: "Show all issues assigned to me in SRVKP",
+		handler: async (_args, ctx) => {
+			// Get current user if not cached
+			if (!currentUser) {
+				const meResult = await pi.exec("jira", ["me"], { timeout: 10000 });
+				if (meResult.code === 0) {
+					currentUser = meResult.stdout.trim();
+				}
+			}
+
+			// List all my issues directly (default to SRVKP)
+			const args = [
+				"issue",
+				"list",
+				"--raw",
+				"--project",
+				"SRVKP",
+				"-a",
+				currentUser || "currentUser()",
+				"--paginate",
+				"50",
+			];
+
+			const result = await pi.exec("jira", args, { timeout: 30000 });
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			const issues = parseIssueListJSON(result.stdout);
+
+			// Display results directly
+			const lines: string[] = [];
+			lines.push("## 👤 My Issues (SRVKP)");
+			lines.push("");
+			lines.push(`_Command: \`jira ${args.join(" ")}\`_`);
+			lines.push("");
+
+			if (issues.length === 0) {
+				lines.push("*No issues assigned to you*");
+			} else {
+				// Group by status
+				const byStatus = new Map<string, typeof issues>();
+				for (const issue of issues) {
+					const status = issue.status;
+					if (!byStatus.has(status)) {
+						byStatus.set(status, []);
+					}
+					byStatus.get(status)!.push(issue);
+				}
+
+				for (const [status, statusIssues] of byStatus) {
+					lines.push(`### ${status} (${statusIssues.length})`);
+					lines.push("");
+					lines.push("| Key | Type | Priority | Summary |");
+					lines.push("|-----|------|----------|---------|");
+					for (const issue of statusIssues) {
+						const priority = issue.priority || "-";
+						const summary = truncate(issue.summary, 80);
+						lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${summary} |`);
+					}
+					lines.push("");
+				}
+			}
+
+			pi.sendMessage({
+				customType: "jira-mine",
+				content: lines.join("\n"),
+				display: true,
+			});
+		},
+	});
+
+	// /jira-blocked - Blocked issues
+	pi.registerCommand("jira-blocked", {
+		description: "Show blocked issues in SRVKP project",
+		handler: async (_args, ctx) => {
+			// Search for blocked issues directly (default to SRVKP)
+			const args = ["issue", "list", "--raw", "--project", "SRVKP", "-s", "Blocked,Waiting", "--paginate", "50"];
+
+			const result = await pi.exec("jira", args, { timeout: 30000 });
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			const issues = parseIssueListJSON(result.stdout);
+
+			// Display results directly
+			const lines: string[] = [];
+			lines.push("## 🚫 Blocked Issues (SRVKP)");
+			lines.push("");
+			lines.push(`_Command: \`jira ${args.join(" ")}\`_`);
+			lines.push("");
+
+			if (issues.length === 0) {
+				lines.push("*No blocked issues* ✅");
+			} else {
+				// Table format
+				lines.push("| Key | Type | Priority | Status | Assignee | Summary |");
+				lines.push("|-----|------|----------|--------|----------|---------|");
+				for (const issue of issues) {
+					const priority = issue.priority || "-";
+					const assignee = issue.assignee || "Unassigned";
+					const summary = truncate(issue.summary, 60);
+					lines.push(`| ${issue.key} | ${issue.type} | ${priority} | ${issue.status} | ${assignee} | ${summary} |`);
+				}
+			}
+
+			pi.sendMessage({
+				customType: "jira-blocked",
+				content: lines.join("\n"),
+				display: true,
+			});
+		},
+	});
+
+	// /jira-recent - Recent issues from session
+	pi.registerCommand("jira-recent", {
+		description: "Show recently viewed issues from this session",
+		handler: async (_args, ctx) => {
+			if (!ctx.hasUI) {
+				ctx.ui.notify("/jira-recent requires interactive mode", "error");
+				return;
+			}
+
+			if (recentIssues.length === 0) {
+				ctx.ui.notify("No recent issues in this session", "info");
+				return;
+			}
+
+			const message =
+				`Recent Jira issues:\n` + recentIssues.map((key, i) => `${i + 1}. ${key}`).join("\n");
+
+			ctx.ui.notify(message, "info");
+		},
+	});
+
+	// ========================================================================
+	// Auto-detect Jira Issue Keys in User Input
+	// ========================================================================
+
+	pi.on("input", async (event, ctx) => {
+		// Only process interactive input
+		if (event.source !== "interactive") {
+			return { action: "continue" };
+		}
+
+		// Detect Jira issue keys (e.g., SRVKP-1234, KONFLUX-456, etc.)
+		const issueKeyPattern = /\b([A-Z]{2,}-\d+)\b/g;
+		const matches = event.text.match(issueKeyPattern);
+
+		if (!matches || matches.length === 0) {
+			return { action: "continue" };
+		}
+
+		// Remove duplicates
+		const uniqueKeys = [...new Set(matches)];
+
+		// If user just typed issue keys without context, offer to view them
+		const justKeys = event.text.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
+
+		if (justKeys && uniqueKeys.length <= 3 && ctx.hasUI) {
+			// Transform to view request
+			if (uniqueKeys.length === 1) {
+				return {
+					action: "transform",
+					text: `View Jira issue ${uniqueKeys[0]}`,
+				};
+			} else {
+				return {
+					action: "transform",
+					text: `View Jira issues: ${uniqueKeys.join(", ")}`,
+				};
+			}
+		}
+
+		// Otherwise, just continue (LLM will see the keys in context)
+		return { action: "continue" };
+	});
+}
+
+// ============================================================================
+// Rendering Functions
+// ============================================================================
+
+function renderMe(details: JiraDetails, theme: Theme): Text {
+	return new Text(theme.fg("muted", `User: ${theme.fg("accent", details.output || "")}`), 0, 0);
+}
+
+function renderList(details: JiraDetails, expanded: boolean, theme: Theme): Text {
+	if (!details.output) {
+		return new Text(theme.fg("dim", "No issues found"), 0, 0);
+	}
+
+	const issues = parseIssueList(details.output);
+
+	if (issues.length === 0) {
+		return new Text(theme.fg("dim", "No issues found"), 0, 0);
+	}
+
+	let text = theme.fg("muted", `${issues.length} issue(s):`);
+
+	const display = expanded ? issues : issues.slice(0, 5);
+
+	for (const issue of display) {
+		const key = theme.fg("accent", issue.key);
+		const status = getStatusColor(issue.status, theme);
+		const priority = issue.priority ? getPriorityColor(issue.priority, theme) : "";
+		const summary = expanded ? issue.summary : truncate(issue.summary, 60);
+
+		text += `\n${key} ${status}`;
+		if (priority) {
+			text += ` ${priority}`;
+		}
+		text += ` ${theme.fg("text", summary)}`;
+	}
+
+	if (!expanded && issues.length > 5) {
+		text += `\n${theme.fg("dim", `... ${issues.length - 5} more (expand for all)`)}`;
+	}
+
+	return new Text(text, 0, 0);
+}
+
+function renderView(details: JiraDetails, expanded: boolean, theme: Theme): Text {
+	if (!details.output) {
+		return new Text(theme.fg("dim", "No issue details"), 0, 0);
+	}
+
+	const issueKey = details.issueKey || "Issue";
+	const header = theme.fg("accent", theme.bold(issueKey));
+
+	if (expanded) {
+		// Show full output
+		return new Text(`${header}\n\n${details.output}`, 0, 0);
+	} else {
+		// Show summary (first 15 lines)
+		const lines = details.output.split("\n");
+		const preview = lines.slice(0, 15).join("\n");
+
+		let text = `${header}\n\n${preview}`;
+
+		if (lines.length > 15) {
+			text += `\n${theme.fg("dim", `... ${lines.length - 15} more lines (expand for full view)`)}`;
+		}
+
+		return new Text(text, 0, 0);
+	}
+}
+
+function renderCreate(details: JiraDetails, theme: Theme): Text {
+	const key = details.issueKey || "issue";
+	return new Text(theme.fg("success", "✓ Created ") + theme.fg("accent", theme.bold(key)), 0, 0);
+}
+
+function renderUpdate(details: JiraDetails, theme: Theme): Text {
+	const key = details.issueKey || "issue";
+	const field = details.field || "field";
+	const value = details.newValue || "value";
+
+	return new Text(
+		theme.fg("success", "✓ Updated ") + theme.fg("accent", key) + theme.fg("muted", ` (${field} → ${value})`),
+		0,
+		0,
+	);
+}
+
+function renderComment(details: JiraDetails, theme: Theme): Text {
+	const key = details.issueKey || "issue";
+	return new Text(theme.fg("success", "✓ Comment added to ") + theme.fg("accent", key), 0, 0);
+}
+
+function renderTransition(details: JiraDetails, theme: Theme): Text {
+	const key = details.issueKey || "issue";
+	const state = details.toKey || "new state";
+
+	return new Text(theme.fg("success", "✓ Moved ") + theme.fg("accent", key) + theme.fg("muted", ` → ${state}`), 0, 0);
+}
+
+function renderEpicView(details: JiraDetails, expanded: boolean, theme: Theme): Text {
+	if (!details.output) {
+		return new Text(theme.fg("dim", "No epic details"), 0, 0);
+	}
+
+	const issueKey = details.issueKey || "Epic";
+	const header = theme.fg("accent", theme.bold(`Epic: ${issueKey}`));
+
+	if (expanded) {
+		return new Text(`${header}\n\n${details.output}`, 0, 0);
+	} else {
+		// Show summary (first 20 lines)
+		const lines = details.output.split("\n");
+		const preview = lines.slice(0, 20).join("\n");
+
+		let text = `${header}\n\n${preview}`;
+
+		if (lines.length > 20) {
+			text += `\n${theme.fg("dim", `... ${lines.length - 20} more lines (expand for full view)`)}`;
+		}
+
+		return new Text(text, 0, 0);
+	}
+}
+
+function renderLinkToEpic(details: JiraDetails, theme: Theme): Text {
+	const issue = details.fromKey || "issue";
+	const epic = details.toKey || "epic";
+
+	return new Text(theme.fg("success", "✓ Linked ") + theme.fg("accent", issue) + theme.fg("muted", ` → epic ${epic}`), 0, 0);
+}
+
+function renderLink(details: JiraDetails, theme: Theme): Text {
+	const from = details.fromKey || "issue";
+	const to = details.toKey || "issue";
+
+	return new Text(theme.fg("success", "✓ Linked ") + theme.fg("accent", from) + theme.fg("muted", ` ↔ ${to}`), 0, 0);
+}
+
+function renderUnlink(details: JiraDetails, theme: Theme): Text {
+	const from = details.fromKey || "issue";
+	const to = details.toKey || "issue";
+
+	return new Text(theme.fg("success", "✓ Unlinked ") + theme.fg("accent", from) + theme.fg("muted", ` ↮ ${to}`), 0, 0);
+}
+
+function renderAttach(details: JiraDetails, theme: Theme): Text {
+	const key = details.issueKey || "issue";
+
+	return new Text(theme.fg("success", "✓ Attached file to ") + theme.fg("accent", key), 0, 0);
+}
+
+function renderListAttachments(details: JiraDetails, expanded: boolean, theme: Theme): Text {
+	if (!details.output) {
+		return new Text(theme.fg("dim", "No attachments"), 0, 0);
+	}
+
+	const issueKey = details.issueKey || "Issue";
+	const header = theme.fg("accent", `${issueKey} attachments:`);
+
+	if (expanded || details.output.split("\n").length <= 10) {
+		return new Text(`${header}\n${details.output}`, 0, 0);
+	} else {
+		const lines = details.output.split("\n");
+		const preview = lines.slice(0, 10).join("\n");
+		return new Text(`${header}\n${preview}\n${theme.fg("dim", `... ${lines.length - 10} more`)}`, 0, 0);
+	}
+}
dots/pi/agent/extensions/jira/jira.test.ts
@@ -0,0 +1,400 @@
+/**
+ * Tests for Jira extension
+ *
+ * Run with: bun test dots/pi/agent/extensions/jira/test.ts
+ * or: npm test
+ */
+
+import { describe, expect, test } from "bun:test";
+import { parseIssueList, parseIssueListJSON, extractIssueKey, extractIssueKeys, getStatusColor, getPriorityColor, truncate } from "./utils";
+
+// Mock theme for testing
+const mockTheme = {
+	fg: (color: string, text: string) => text,
+	bold: (text: string) => text,
+};
+
+describe("Utility Functions", () => {
+	describe("parseIssueListJSON", () => {
+		test("parses jira CLI JSON output", () => {
+			const output = JSON.stringify([
+				{
+					key: "SRVKP-1234",
+					fields: {
+						summary: "Test summary",
+						issueType: { name: "Bug" },
+						status: { name: "To Do" },
+						assignee: { displayName: "Alice" },
+						priority: { name: "Major" },
+					},
+				},
+				{
+					key: "SRVKP-5678",
+					fields: {
+						summary: "Another issue",
+						issueType: { name: "Epic" },
+						status: { name: "In Progress" },
+						assignee: { displayName: "Bob" },
+						priority: { name: "Undefined" },
+					},
+				},
+			]);
+
+			const issues = parseIssueListJSON(output);
+
+			expect(issues.length).toBe(2);
+			expect(issues[0].key).toBe("SRVKP-1234");
+			expect(issues[0].type).toBe("Bug");
+			expect(issues[0].summary).toBe("Test summary");
+			expect(issues[0].status).toBe("To Do");
+			expect(issues[0].assignee).toBe("Alice");
+			expect(issues[0].priority).toBe("Major");
+			expect(issues[1].key).toBe("SRVKP-5678");
+			expect(issues[1].type).toBe("Epic");
+			expect(issues[1].priority).toBeUndefined(); // "Undefined" is filtered out
+		});
+
+		test("handles unassigned issues", () => {
+			const output = JSON.stringify([
+				{
+					key: "SRVKP-1234",
+					fields: {
+						summary: "Test",
+						issueType: { name: "Task" },
+						status: { name: "To Do" },
+						priority: { name: "Undefined" },
+					},
+				},
+			]);
+
+			const issues = parseIssueListJSON(output);
+
+			expect(issues.length).toBe(1);
+			expect(issues[0].assignee).toBe("Unassigned");
+			expect(issues[0].priority).toBeUndefined();
+		});
+
+		test("handles invalid JSON", () => {
+			const issues = parseIssueListJSON("not json");
+			expect(issues.length).toBe(0);
+		});
+
+		test("handles empty array", () => {
+			const issues = parseIssueListJSON("[]");
+			expect(issues.length).toBe(0);
+		});
+	});
+
+	describe("parseIssueList", () => {
+		test("parses jira CLI plain output (TAB-delimited with alignment)", () => {
+			// Simulate jira CLI output with multiple TABs for alignment
+			const output = `Bug\tSRVKP-1234\t\tSummary text here\t\t\tTo Do
+Task\tSRVKP-5678\tAnother summary\t\tIn Progress`;
+
+			const issues = parseIssueList(output);
+
+			expect(issues.length).toBe(2);
+			expect(issues[0].key).toBe("SRVKP-1234");
+			expect(issues[0].type).toBe("Bug");
+			expect(issues[0].summary).toBe("Summary text here");
+			expect(issues[0].status).toBe("To Do");
+			expect(issues[0].assignee).toBe("Unassigned"); // Default
+			expect(issues[1].key).toBe("SRVKP-5678");
+			expect(issues[1].summary).toBe("Another summary");
+		});
+
+		test("skips header lines", () => {
+			const output = `TYPE\tKEY\t\tSUMMARY\t\t\tSTATUS
+Bug\tSRVKP-1234\t\tSummary\t\t\tDone`;
+
+			const issues = parseIssueList(output);
+
+			expect(issues.length).toBe(1);
+			expect(issues[0].key).toBe("SRVKP-1234");
+		});
+
+		test("handles empty output", () => {
+			const issues = parseIssueList("");
+			expect(issues.length).toBe(0);
+		});
+
+		test("handles minimal columns", () => {
+			const output = `Bug\tSRVKP-1234\tSummary\tTo Do`;
+
+			const issues = parseIssueList(output);
+
+			expect(issues.length).toBe(1);
+			expect(issues[0].assignee).toBe("Unassigned");
+			expect(issues[0].status).toBe("To Do");
+		});
+	});
+
+	describe("extractIssueKey", () => {
+		test("extracts issue key from text", () => {
+			expect(extractIssueKey("Created SRVKP-1234 successfully")).toBe("SRVKP-1234");
+			expect(extractIssueKey("Issue KONFLUX-456 updated")).toBe("KONFLUX-456");
+		});
+
+		test("returns null when no key found", () => {
+			expect(extractIssueKey("No issue key here")).toBeNull();
+		});
+
+		test("extracts first key when multiple present", () => {
+			expect(extractIssueKey("SRVKP-1234 and SRVKP-5678")).toBe("SRVKP-1234");
+		});
+	});
+
+	describe("extractIssueKeys", () => {
+		test("extracts all issue keys", () => {
+			const keys = extractIssueKeys("SRVKP-1234 KONFLUX-456 RHCLOUD-789");
+			expect(keys).toEqual(["SRVKP-1234", "KONFLUX-456", "RHCLOUD-789"]);
+		});
+
+		test("removes duplicates", () => {
+			const keys = extractIssueKeys("SRVKP-1234 SRVKP-1234 SRVKP-1234");
+			expect(keys).toEqual(["SRVKP-1234"]);
+		});
+
+		test("matches issue keys in sentences", () => {
+			const keys = extractIssueKeys("Working on SRVKP-1234 which relates to KONFLUX-456");
+			expect(keys).toEqual(["SRVKP-1234", "KONFLUX-456"]);
+		});
+
+		test("doesn't match invalid patterns", () => {
+			const keys = extractIssueKeys("A-1 lowercase-123 NO-KEY");
+			expect(keys).toEqual([]); // None of these match the pattern (A-1 too short, lowercase-123 lowercase, NO-KEY missing digits)
+		});
+
+		test("returns empty array when none found", () => {
+			const keys = extractIssueKeys("no keys here");
+			expect(keys).toEqual([]);
+		});
+	});
+
+	describe("getStatusColor", () => {
+		test("returns success color for done status", () => {
+			const result = getStatusColor("Done", mockTheme as any);
+			expect(result).toContain("[Done]");
+		});
+
+		test("returns success color for closed status", () => {
+			const result = getStatusColor("Closed", mockTheme as any);
+			expect(result).toContain("[Closed]");
+		});
+
+		test("returns accent color for in progress", () => {
+			const result = getStatusColor("In Progress", mockTheme as any);
+			expect(result).toContain("[In Progress]");
+		});
+
+		test("returns error color for blocked", () => {
+			const result = getStatusColor("Blocked", mockTheme as any);
+			expect(result).toContain("[Blocked]");
+		});
+
+		test("returns muted color for other statuses", () => {
+			const result = getStatusColor("To Do", mockTheme as any);
+			expect(result).toContain("[To Do]");
+		});
+
+		test("is case insensitive", () => {
+			expect(getStatusColor("DONE", mockTheme as any)).toContain("[DONE]");
+			expect(getStatusColor("blocked", mockTheme as any)).toContain("[blocked]");
+		});
+	});
+
+	describe("getPriorityColor", () => {
+		test("returns error color for blocker", () => {
+			const result = getPriorityColor("Blocker", mockTheme as any);
+			expect(result).toBe("Blocker");
+		});
+
+		test("returns error color for critical", () => {
+			const result = getPriorityColor("Critical", mockTheme as any);
+			expect(result).toBe("Critical");
+		});
+
+		test("returns warning color for major", () => {
+			const result = getPriorityColor("Major", mockTheme as any);
+			expect(result).toBe("Major");
+		});
+
+		test("returns dim color for minor", () => {
+			const result = getPriorityColor("Minor", mockTheme as any);
+			expect(result).toBe("Minor");
+		});
+
+		test("is case insensitive", () => {
+			expect(getPriorityColor("BLOCKER", mockTheme as any)).toBe("BLOCKER");
+		});
+	});
+
+	describe("truncate", () => {
+		test("truncates long text", () => {
+			const result = truncate("This is a very long text that should be truncated", 20);
+			expect(result).toBe("This is a very lo...");
+			expect(result.length).toBe(20);
+		});
+
+		test("doesn't truncate short text", () => {
+			const result = truncate("Short", 20);
+			expect(result).toBe("Short");
+		});
+
+		test("handles exact length", () => {
+			const result = truncate("Exactly 20 chars!!!!", 20);
+			expect(result).toBe("Exactly 20 chars!!!!");
+		});
+	});
+});
+
+describe("Issue Key Detection", () => {
+	describe("Pattern Matching", () => {
+		test("matches standard project keys", () => {
+			const pattern = /\b([A-Z]{2,}-\d+)\b/g;
+
+			expect("SRVKP-1234".match(pattern)).toEqual(["SRVKP-1234"]);
+			expect("KONFLUX-456".match(pattern)).toEqual(["KONFLUX-456"]);
+			expect("RHCLOUD-789".match(pattern)).toEqual(["RHCLOUD-789"]);
+		});
+
+		test("doesn't match single letter projects", () => {
+			const pattern = /\b([A-Z]{2,}-\d+)\b/g;
+
+			expect("A-1".match(pattern)).toBeNull();
+			expect("X-999".match(pattern)).toBeNull();
+		});
+
+		test("doesn't match lowercase", () => {
+			const pattern = /\b([A-Z]{2,}-\d+)\b/g;
+
+			expect("srvkp-1234".match(pattern)).toBeNull();
+			expect("lowercase-123".match(pattern)).toBeNull();
+		});
+
+		test("matches multiple keys in text", () => {
+			const pattern = /\b([A-Z]{2,}-\d+)\b/g;
+			const text = "Working on SRVKP-1234 and KONFLUX-456";
+			const matches = text.match(pattern);
+
+			expect(matches).toEqual(["SRVKP-1234", "KONFLUX-456"]);
+		});
+
+		test("matches keys with commas and punctuation", () => {
+			const pattern = /\b([A-Z]{2,}-\d+)\b/g;
+			const text = "Issues: SRVKP-1234, SRVKP-5678, and KONFLUX-999.";
+			const matches = text.match(pattern);
+
+			expect(matches).toEqual(["SRVKP-1234", "SRVKP-5678", "KONFLUX-999"]);
+		});
+	});
+
+	describe("Detection Logic", () => {
+		test("detects bare issue keys", () => {
+			const input = "SRVKP-1234";
+			const justKeys = input.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
+
+			expect(justKeys).toBe(true);
+		});
+
+		test("detects multiple bare keys", () => {
+			const input = "SRVKP-1234 SRVKP-5678";
+			const justKeys = input.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
+
+			expect(justKeys).toBe(true);
+		});
+
+		test("doesn't detect keys in context", () => {
+			const input = "Working on SRVKP-1234";
+			const justKeys = input.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
+
+			expect(justKeys).toBe(false);
+		});
+
+		test("doesn't detect with mixed content", () => {
+			const input = "SRVKP-1234 and some text";
+			const justKeys = input.trim().split(/\s+/).every((word) => /^[A-Z]{2,}-\d+$/.test(word));
+
+			expect(justKeys).toBe(false);
+		});
+	});
+});
+
+describe("Confirmation Message Building", () => {
+	test("buildCreateConfirmation includes all fields", () => {
+		const { buildCreateConfirmation } = require("./utils");
+
+		const params = {
+			issueType: "Bug",
+			summary: "Test bug",
+			description: "Test description",
+			priority: "Major",
+			assignee: "alice",
+			labels: ["bug", "urgent"],
+			epic: "SRVKP-1000",
+		};
+
+		const message = buildCreateConfirmation(params);
+
+		expect(message).toContain("Type: Bug");
+		expect(message).toContain('Summary: "Test bug"');
+		expect(message).toContain("Priority: Major");
+		expect(message).toContain("Assignee: alice");
+		expect(message).toContain("Labels: bug, urgent");
+		expect(message).toContain("Epic: SRVKP-1000");
+	});
+
+	test("buildCreateConfirmation truncates long description", () => {
+		const { buildCreateConfirmation } = require("./utils");
+
+		const params = {
+			issueType: "Bug",
+			summary: "Test",
+			description: "a".repeat(150),
+		};
+
+		const message = buildCreateConfirmation(params);
+
+		expect(message).toContain("Description:");
+		expect(message).toContain("...");
+	});
+});
+
+describe("Error Handling", () => {
+	test("isAuthError detects authentication failures", () => {
+		const { isAuthError } = require("./utils");
+
+		expect(isAuthError("authentication failed")).toBe(true);
+		expect(isAuthError("unauthorized access")).toBe(true);
+		expect(isAuthError("invalid token provided")).toBe(true);
+		expect(isAuthError("permission denied")).toBe(true);
+		expect(isAuthError("network timeout")).toBe(false);
+	});
+
+	test("isNetworkError detects network failures", () => {
+		const { isNetworkError } = require("./utils");
+
+		expect(isNetworkError("connection refused")).toBe(true);
+		expect(isNetworkError("timeout waiting for response")).toBe(true);
+		expect(isNetworkError("network unreachable")).toBe(true);
+		expect(isNetworkError("dial tcp: connection failed")).toBe(true);
+		expect(isNetworkError("authentication failed")).toBe(false);
+	});
+
+	test("isNotFoundError detects not found errors", () => {
+		const { isNotFoundError } = require("./utils");
+
+		expect(isNotFoundError("issue not found")).toBe(true);
+		expect(isNotFoundError("does not exist")).toBe(true);
+		expect(isNotFoundError("authentication failed")).toBe(false);
+	});
+
+	test("getErrorMessage returns helpful messages", () => {
+		const { getErrorMessage } = require("./utils");
+
+		expect(getErrorMessage("authentication failed", "list")).toContain("API token");
+		expect(getErrorMessage("connection timeout", "view")).toContain("VPN");
+		expect(getErrorMessage("not found", "view")).toContain("not found");
+		expect(getErrorMessage("unknown error", "create")).toBe("unknown error");
+	});
+});
dots/pi/agent/extensions/jira/Makefile
@@ -0,0 +1,21 @@
+.PHONY: test test-watch help
+
+# Run tests
+test:
+	@echo "Running tests..."
+	@bun test jira.test.ts
+
+# Run tests in watch mode
+test-watch:
+	@echo "Running tests in watch mode..."
+	@bun test --watch jira.test.ts
+
+# Help
+help:
+	@echo "Available targets:"
+	@echo "  test        - Run tests once"
+	@echo "  test-watch  - Run tests in watch mode"
+	@echo "  help        - Show this help message"
+
+# Default target
+.DEFAULT_GOAL := help
dots/pi/agent/extensions/jira/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "jira-extension",
+  "version": "1.0.0",
+  "type": "module",
+  "scripts": {
+    "test": "bun test test.ts"
+  },
+  "devDependencies": {
+    "bun-types": "^1.0.0"
+  }
+}
dots/pi/agent/extensions/jira/QUICK-START.md
@@ -0,0 +1,269 @@
+# Jira Extension - Quick Start
+
+## 🚀 Quick Commands
+
+### Slash Commands
+
+```bash
+# Your open issues
+/jira
+
+# All your issues
+/jira-mine
+
+# View specific issue
+/jira-view SRVKP-1234
+
+# Search
+/jira-search priority=Blocker
+
+# Blocked issues
+/jira-blocked
+
+# Recently viewed
+/jira-recent
+```
+
+## ✨ Auto-Detection
+
+Just paste issue keys - they'll be auto-viewed!
+
+```bash
+# Single issue
+SRVKP-1234
+
+# Multiple issues  
+SRVKP-1234 KONFLUX-456
+
+# In context (passed through)
+"Working on SRVKP-1234 which relates to KONFLUX-456"
+```
+
+## 📋 Common Workflows
+
+### Morning Standup
+
+```bash
+# What did I work on?
+/jira-recent
+
+# What am I working on?
+/jira
+
+# Any blockers?
+/jira-blocked
+```
+
+### Issue Review
+
+```bash
+# Someone sends you: "Can you look at SRVKP-1234?"
+# Just paste it:
+SRVKP-1234
+# → Auto-views the issue
+```
+
+### Search and Filter
+
+```bash
+# Critical bugs
+/jira-search type=Bug AND priority=Critical
+
+# Issues in epic
+/jira-search "Epic Link"=SRVKP-1000
+
+# Recently updated
+/jira-search updated >= -7d
+```
+
+## 🎯 Tool Actions
+
+All actions available through natural language:
+
+### Read (No Approval)
+
+```
+"What's my Jira username?"              → me
+"Show my open issues"                   → list
+"Show SRVKP-1234"                       → view
+"Search for blocker issues"             → search
+```
+
+### Write (Requires Approval)
+
+```
+"Create a bug for X"                    → create ⚠️
+"Assign SRVKP-1234 to me"              → update ⚠️
+"Add comment to SRVKP-1234"            → comment ⚠️
+"Move SRVKP-1234 to In Progress"       → transition ⚠️
+```
+
+## 🔑 Supported Projects
+
+Works with **any** Jira project:
+- SRVKP (Tekton Pipelines)
+- KONFLUX (Konflux)
+- RHCLOUD (Cloud Services)
+- Any `[A-Z]{2,}-\d+` pattern
+
+## 📊 Filter Examples
+
+### By Assignee
+```
+"Show issues assigned to alice"
+→ list, assignee=alice
+
+"Show my issues"
+→ list, assignee=me
+```
+
+### By Status
+```
+"Show To Do issues"
+→ list, status="To Do"
+
+"Show open issues" (not Done)
+→ list, status=~Done
+```
+
+### By Type
+```
+"Show bugs"
+→ list, type=Bug
+
+"Show bugs and tasks"
+→ list, type="Bug,Task"
+```
+
+### By Priority
+```
+"Show critical issues"
+→ list, priority=Critical
+
+"Show high priority items"
+→ list, priority="Critical,Major"
+```
+
+### By Epic
+```
+"Show issues in epic SRVKP-1000"
+→ list, epic=SRVKP-1000
+```
+
+### Combined
+```
+"Show my high priority bugs not done"
+→ list, assignee=me, type=Bug, priority="Critical,Major", status=~Done
+```
+
+## 🎨 Custom Rendering
+
+Issues are displayed with color-coded status:
+
+- 🟢 **Done/Closed** - Success (green)
+- 🔵 **In Progress/Review** - Accent (blue)
+- 🔴 **Blocked/Waiting** - Error (red)
+- ⚪ **To Do** - Muted (gray)
+
+Priorities are also color-coded:
+- 🔴 **Blocker/Critical** - Error (red)
+- 🟡 **Major/High** - Warning (yellow)
+- ⚪ **Minor/Trivial** - Dim (gray)
+
+## ⚠️ Approval Dialogs
+
+All write operations show a confirmation dialog:
+
+```
+┌─────────────────────────────────┐
+│ Create Jira Issue?              │
+│                                 │
+│ Type: Bug                       │
+│ Summary: "CI tests failing"     │
+│ Priority: Major                 │
+│                                 │
+│ This will create a new issue    │
+│ in Jira.                        │
+│                                 │
+│ [Yes] [No]                      │
+└─────────────────────────────────┘
+```
+
+You must confirm before any change is made!
+
+## 🔍 JQL Examples
+
+For `/jira-search` command:
+
+```bash
+# Your open issues
+/jira-search assignee=currentUser() AND status!=Done
+
+# Blocker issues
+/jira-search priority=Blocker
+
+# Recent updates
+/jira-search updated >= -7d
+
+# Issues in project
+/jira-search project=SRVKP AND status!=Done
+
+# Epic children
+/jira-search "Epic Link"=SRVKP-1000
+
+# Unassigned tasks
+/jira-search type=Task AND assignee=EMPTY
+
+# Complex query
+/jira-search project=SRVKP AND priority IN (Critical,Blocker) AND status!=Done ORDER BY updated DESC
+```
+
+## 🏃 Quick Tips
+
+1. **Just paste issue keys** - fastest way to view
+2. **Use `/jira`** for daily standup prep
+3. **Use `/jira-recent`** to see what you've looked at
+4. **Search with JQL** for complex queries
+5. **Watch for approval dialogs** on write operations
+6. **Use "me"** as assignee to refer to yourself
+
+## 📚 Full Documentation
+
+See `README.md` for complete documentation including:
+- All tool parameters
+- Error handling
+- Configuration
+- Integration patterns
+- Troubleshooting
+
+## 🐛 Troubleshooting
+
+### Extension not loading?
+```bash
+# Check pi output for errors
+pi
+```
+
+### Authentication issues?
+```bash
+# Test jira CLI
+jira me
+
+# Check config
+cat ~/.config/.jira/.config.yml
+
+# Check token
+passage show redhat/issues/token/kyushu
+```
+
+### Network errors?
+- Verify VPN connection
+- Try: `jira me` from terminal
+
+### Slash commands not working?
+- Type exactly: `/jira` (no extra spaces)
+- Check command exists: type `/j` and hit Tab
+
+## 🎉 Have Fun!
+
+The extension makes Jira management fast and seamless. No more switching to the browser for every little thing!
dots/pi/agent/extensions/jira/README.md
@@ -0,0 +1,686 @@
+
+
+# Jira Extension for Pi
+
+Manage Red Hat Jira issues (issues.redhat.com) directly from pi with read operations and approval-gated write operations.
+
+## Features
+
+### Read Operations (No Approval Required)
+- **`me`** - Get current Jira user
+- **`list`** - List issues with filters (assignee, status, type, priority, epic)
+- **`view`** - View detailed issue information
+- **`search`** - Advanced JQL-based search
+- **`epic-view`** - View epic with all child issues
+- **`list-attachments`** - List attachments on an issue
+
+### Write Operations (Require User Approval)
+- **`create`** - Create new issues (Bug, Task, Story, Epic, Feature)
+- **`update`** - Update issue fields (assignee, priority, labels, etc.)
+- **`comment`** - Add comments to issues
+- **`transition`** - Change issue workflow state
+- **`link-to-epic`** - Link issue to epic
+- **`link`** - Link two issues (blocks, relates to, etc.)
+- **`unlink`** - Remove issue link
+- **`attach`** - Attach file to issue
+
+All write operations show a confirmation dialog before executing, preventing accidental changes.
+
+## Prerequisites
+
+### 1. Jira CLI
+
+Install the jira CLI tool:
+
+```bash
+go install github.com/ankitpokhrel/jira-cli/cmd/jira@latest
+```
+
+Verify installation:
+
+```bash
+jira version
+```
+
+### 2. Jira Configuration
+
+Configure at `~/.config/.jira/.config.yml`:
+
+```yaml
+server: https://issues.redhat.com
+login: your-email@redhat.com
+project:
+  key: SRVKP  # Your default project
+installation: local
+auth_type: bearer
+```
+
+### 3. API Token
+
+Set up via passage (or environment variable):
+
+```bash
+# Via passage (recommended)
+passage show redhat/issues/token/kyushu
+
+# OR via environment variable
+export JIRA_API_TOKEN="your-token-here"
+```
+
+### 4. VPN Connection
+
+Red Hat Jira requires VPN connection. Make sure you're connected before using the extension.
+
+### 5. Test Authentication
+
+```bash
+jira me
+```
+
+## Installation
+
+The extension is already in your pi extensions:
+
+```
+dots/pi/agent/extensions/jira/
+├── index.ts         # Main extension
+├── actions.ts       # Action handlers
+├── types.ts         # Type definitions
+├── utils.ts         # Utilities
+├── README.md        # This file
+└── DESIGN-NOTES.md  # Design discussion
+```
+
+Since it's in the `dots/` directory, it will be symlinked to `~/.pi/agent/extensions/jira/` when you deploy your dots.
+
+## Slash Commands
+
+The extension provides several slash commands for **instant results** (no LLM involved - works like `/todos`):
+
+| Command | Description |
+|---------|-------------|
+| `/jira` | Show my open issues (instant) |
+| `/jira-mine` | Show all issues assigned to me (grouped by status) |
+| `/jira-view <key>` | View specific issue (e.g., `/jira-view SRVKP-1234`) |
+| `/jira-search <jql>` | Search with JQL (e.g., `/jira-search status=Blocked`) |
+| `/jira-blocked` | Show blocked issues (instant) |
+| `/jira-recent` | Show recently viewed issues from this session |
+
+**All commands execute directly** - they call the jira CLI and display results immediately without going through the LLM!
+
+### Examples
+
+```bash
+# Quick view of your issues (instant!)
+/jira
+→ Shows formatted list immediately
+
+# View specific issue (instant!)
+/jira-view SRVKP-1234
+→ Shows issue details immediately
+
+# Search for blockers (instant!)
+/jira-search priority=Blocker
+→ Shows search results immediately
+
+# See blocked issues (instant!)
+/jira-blocked
+→ Shows all blocked/waiting issues
+
+# See what you've been working on
+/jira-recent
+→ Shows list from session cache
+```
+
+## Auto-Detection of Issue Keys
+
+The extension automatically detects Jira issue keys (e.g., `SRVKP-1234`, `KONFLUX-456`) in your input and transforms them into view requests.
+
+### Examples
+
+```bash
+# Just type the issue key
+SRVKP-1234
+→ Automatically transformed to: "View Jira issue SRVKP-1234"
+
+# Multiple keys
+SRVKP-1234 SRVKP-5678
+→ Automatically transformed to: "View Jira issues: SRVKP-1234, SRVKP-5678"
+
+# Keys in context (no transformation)
+"I'm working on SRVKP-1234 which relates to KONFLUX-456"
+→ Passed through to LLM with context
+```
+
+This makes it super quick to view issues - just paste the issue key!
+
+## Usage Examples
+
+### Quick Commands
+
+```bash
+# Slash command - show my issues
+/jira
+→ Lists all your open issues
+
+# Auto-detection - just paste issue key
+SRVKP-1234
+→ Automatically views the issue
+
+# Quick search
+/jira-search assignee=currentUser() AND status=Blocked
+→ Shows your blocked issues
+
+# Recent issues
+/jira-recent
+→ Shows issues you've viewed in this session
+```
+
+### Get Current User
+
+```
+User: "What is my Jira username?"
+Agent: <calls jira tool with action=me>
+→ Returns: "Current user: vdemeest"
+```
+
+### List Your Open Issues
+
+```
+User: "Show me my open Jira issues"
+Agent: <calls jira tool with action=list, assignee=me, status=~Done>
+→ Returns list of issues not in Done status
+```
+
+### View Issue Details
+
+```
+User: "Show me details for SRVKP-1234"
+Agent: <calls jira tool with action=view, key=SRVKP-1234>
+→ Returns full issue details
+```
+
+### Filter by Type and Priority
+
+```
+User: "Show high priority bugs"
+Agent: <calls jira tool with action=list, type=Bug, priority=High,Critical>
+→ Returns filtered list
+```
+
+### Create Issue (With Approval)
+
+```
+User: "Create a bug for the failing CI tests"
+Agent: <calls jira tool with action=create, issueType=Bug, summary=...>
+→ Shows approval dialog:
+  ┌─────────────────────────────┐
+  │ Create Jira Issue?          │
+  │                             │
+  │ Type: Bug                   │
+  │ Summary: "CI tests failing" │
+  │ Priority: Major             │
+  │                             │
+  │ This will create a new      │
+  │ issue in Jira.              │
+  │                             │
+  │ [Yes] [No]                  │
+  └─────────────────────────────┘
+→ After confirmation: Creates issue and returns key
+```
+
+### Update Issue (With Approval)
+
+```
+User: "Assign SRVKP-1234 to me"
+Agent: <calls jira tool with action=update, key=SRVKP-1234, field=assignee, value=me>
+→ Shows approval dialog
+→ After confirmation: Updates issue
+```
+
+### Add Comment (With Approval)
+
+```
+User: "Add a comment to SRVKP-1234 saying I'm working on it"
+Agent: <calls jira tool with action=comment, key=SRVKP-1234, comment=...>
+→ Shows approval dialog with comment preview
+→ After confirmation: Adds comment
+```
+
+### Transition Issue (With Approval)
+
+```
+User: "Move SRVKP-1234 to In Progress"
+Agent: <calls jira tool with action=transition, key=SRVKP-1234, state="In Progress">
+→ Shows approval dialog
+→ After confirmation: Transitions issue
+```
+
+### Advanced Search
+
+```
+User: "Find all blocker issues in SRVKP project"
+Agent: <calls jira tool with action=search, jql="project = SRVKP AND priority = Blocker">
+→ Returns matching issues
+```
+
+## Tool Parameters
+
+### Common Parameters
+
+- **`assignee`** - Username or "me" for current user
+- **`status`** - Status name (use "~Done" for "not Done")
+- **`type`** - Bug, Task, Story, Epic, Feature (comma-separated)
+- **`priority`** - Blocker, Critical, Major, Minor, Trivial
+- **`limit`** - Max results (default 20 for list, 50 for search)
+- **`epic`** - Filter by epic key
+- **`key`** - Issue key (e.g., SRVKP-1234)
+
+### Create Parameters
+
+- **`issueType`** - Bug, Task, Story, Epic, Feature, Sub-task
+- **`summary`** - Issue title (required)
+- **`description`** - Issue description
+- **`priority`** - Priority level
+- **`assignee`** - Assignee (or "me")
+- **`labels`** - Array of labels
+- **`parent`** - Parent issue key (for sub-tasks)
+
+### Update Parameters
+
+- **`field`** - Field to update: assignee, priority, labels, summary, description
+- **`value`** - New value
+
+### Other Parameters
+
+- **`comment`** - Comment text
+- **`state`** - New workflow state
+- **`jql`** - JQL query string
+
+## Common Issue Types
+
+- **Bug** - Software defects
+- **Task** - General work items
+- **Story** - User stories/features
+- **Epic** - Large features or initiatives
+- **Feature** - New features
+- **Sub-task** - Child issues
+
+## Common Workflow States
+
+- **To Do** - Not started
+- **In Progress** - Currently being worked on
+- **Code Review** - Awaiting code review
+- **QE Review** - In QA testing
+- **Done** - Completed
+- **Blocked** - Blocked by dependencies
+
+## Tips and Tricks
+
+### 1. Use "me" for Current User
+
+```
+action: list, assignee: "me"
+action: create, assignee: "me"
+```
+
+### 2. Filter by Multiple Values
+
+```
+type: "Bug,Task"
+priority: "Critical,Blocker"
+status: "To Do,In Progress"
+```
+
+### 3. Exclude Done Issues
+
+```
+status: "~Done"
+```
+
+### 4. Search by Epic
+
+```
+action: list, epic: "SRVKP-1234"
+```
+
+### 5. Use JQL for Complex Queries
+
+```
+action: search, jql: "project = SRVKP AND status != Done AND assignee = currentUser()"
+```
+
+## State Management
+
+The extension caches the current user in session state:
+
+1. First call to `me` action fetches username from Jira
+2. Username is stored in tool result details
+3. Subsequent operations use cached username
+4. State is reconstructed from session on load
+5. State survives session branching/forking
+
+This reduces API calls and improves performance.
+
+## Error Handling
+
+The extension provides helpful error messages:
+
+### Authentication Error
+```
+Authentication failed. Check API token:
+passage show redhat/issues/token/kyushu
+```
+
+### Network Error
+```
+Network error. Are you on VPN?
+```
+
+### Not Found
+```
+Issue SRVKP-1234 not found
+```
+
+## Approval Pattern
+
+All write operations follow this pattern:
+
+1. LLM calls jira tool with write action
+2. Extension shows confirmation dialog
+3. User reviews and confirms/cancels
+4. If confirmed: operation executes
+5. If cancelled: returns with `cancelled: true`
+
+The LLM sees the cancellation and can explain or suggest alternatives.
+
+## Integration Patterns
+
+### With Org-Mode TODOs
+
+After viewing an issue:
+```
+User: "Create a TODO for SRVKP-1234"
+Agent: Uses org_todo tool to create corresponding TODO
+```
+
+### With Git Commits
+
+```
+User: "Generate commit message for SRVKP-1234"
+Agent: Views issue and generates:
+  fix(tekton): resolve affinity assistant pod creation
+  
+  Fixes issue where affinity assistant pod was not
+  being created with default serviceAccount.
+  
+  Refs: SRVKP-1234
+```
+
+### With Notes
+
+```
+User: "Create a note documenting SRVKP-1234"
+Agent: Views issue and creates denote note with issue details
+```
+
+## Future Enhancements
+
+### Planned Features
+
+1. **Epic Management**
+   - View epic details with child issues
+   - Create epics
+   - Link issues to epics
+
+2. **Feature Support**
+   - Full support for Feature issue type
+   - Feature-specific fields
+
+3. **Attachments**
+   - Attach files to issues
+   - Download attachments
+   - View attachment list
+
+4. **Links**
+   - Link related issues (blocks, relates to, duplicates)
+   - View issue links
+   - Create issue links
+
+5. **Offline Support**
+   - Queue write operations when offline
+   - Sync when connection restored
+   - Cache issue data in session
+
+6. **Batch Operations**
+   - Update multiple issues at once
+   - Bulk transition
+   - Bulk comment/label
+
+### Offline Support Strategy
+
+For future offline support, the plan is:
+
+1. **Queue Operations** - Store write operations in session when offline
+2. **Detect Connectivity** - Check VPN/network status
+3. **Sync on Connect** - Execute queued operations when online
+4. **Conflict Resolution** - Handle conflicts if issue changed
+5. **Cache Issue Data** - Store issue details in session for offline viewing
+
+This would work similar to git commits - you can make local changes and push when connected.
+
+### Batch Operations Strategy
+
+Batch operations would be useful for:
+
+1. **Sprint Planning** - Move multiple issues to sprint
+2. **Status Updates** - Transition multiple issues at once
+3. **Bulk Labeling** - Add labels to multiple issues
+4. **Team Assignment** - Assign multiple issues
+
+However, this can wait until there's a real need. Start with single operations.
+
+## Troubleshooting
+
+### Extension Not Loading
+
+Check for errors:
+```bash
+pi  # Look for error messages in TUI
+```
+
+### Tool Not Being Called
+
+Be explicit:
+```
+User: "Use the jira tool to list my issues"
+```
+
+### Approval Dialog Not Showing
+
+- Check you're not in print mode (`pi -p`)
+- Verify it's a write operation (create/update/comment/transition)
+
+### Authentication Fails
+
+```bash
+# Check config
+cat ~/.config/.jira/.config.yml
+
+# Test jira CLI
+jira me
+
+# Check token
+passage show redhat/issues/token/kyushu
+```
+
+### Network Errors
+
+- Verify VPN connection
+- Test with: `jira me`
+- Check network connectivity
+
+## Architecture
+
+```
+jira/
+├── index.ts       # Main extension entry point
+│                  # - Tool registration
+│                  # - State management
+│                  # - Custom rendering
+│
+├── actions.ts     # Action handlers
+│                  # - handleMe, handleList, handleView
+│                  # - handleCreate, handleUpdate, handleComment
+│                  # - handleTransition, handleSearch
+│
+├── types.ts       # TypeScript type definitions
+│                  # - JiraDetails, JiraIssue
+│                  # - Parameter types for each action
+│
+└── utils.ts       # Utility functions
+                   # - Parsing (parseIssueList)
+                   # - Formatting (getStatusColor, truncate)
+                   # - Error handling (getErrorMessage)
+                   # - Confirmation messages
+```
+
+## Development
+
+### Adding New Actions
+
+1. Add action to `StringEnum` in `index.ts`
+2. Add parameter types to `types.ts`
+3. Add handler function to `actions.ts`
+4. Add routing in `execute()` in `index.ts`
+5. Add custom rendering in `renderResult()` in `index.ts`
+6. Update this README
+
+### Testing
+
+Test each action manually:
+
+```bash
+# Start pi
+pi
+
+# Test read operations
+"What's my Jira username?"
+"Show my open issues"
+"View SRVKP-1234"
+"Search for blocker issues"
+
+# Test write operations (verify approval)
+"Create a test bug"  # Should show approval dialog
+"Assign SRVKP-1234 to me"  # Should show approval dialog
+"Add comment to SRVKP-1234"  # Should show approval dialog
+"Move SRVKP-1234 to Done"  # Should show approval dialog
+
+# Test cancellation
+Create issue → Cancel in dialog → Should return cancelled
+```
+
+## Related
+
+- **Jira CLI**: https://github.com/ankitpokhrel/jira-cli
+- **Red Hat Jira**: https://issues.redhat.com
+- **JQL Reference**: https://issues.redhat.com/secure/JiraJQLHelp.jspa
+- **Claude Skills Jira**: `~/.config/claude/skills/Jira/`
+- **Org-Todo Extension**: `dots/pi/agent/extensions/org-todos/`
+
+## License
+
+Same as your homelab repository.
+
+## Epic Management
+
+### View Epic with Children
+
+```
+User: "Show me epic SRVKP-1000"
+Agent: <calls jira tool with action=epic-view, key=SRVKP-1000>
+→ Shows epic details plus all child issues grouped
+```
+
+### Link Issue to Epic
+
+```
+User: "Link SRVKP-1234 to epic SRVKP-1000"
+Agent: <calls jira tool with action=link-to-epic>
+→ Shows approval dialog
+→ Links issue to epic
+```
+
+## Issue Links
+
+### Link Issues
+
+```
+User: "SRVKP-1234 blocks SRVKP-5678"
+Agent: <calls jira tool with action=link, from=SRVKP-1234, to=SRVKP-5678, linkType="blocks">
+→ Shows approval dialog
+→ Creates issue link
+```
+
+Link types:
+- `blocks` - Source blocks target
+- `is blocked by` - Source is blocked by target
+- `relates to` - General relation
+- `duplicates` - Source duplicates target
+- `is duplicated by` - Source is duplicated by target
+
+### Unlink Issues
+
+```
+User: "Unlink SRVKP-1234 from SRVKP-5678"
+Agent: <calls jira tool with action=unlink, from=SRVKP-1234, to=SRVKP-5678>
+→ Shows approval dialog
+→ Removes link
+```
+
+## Attachments
+
+### Attach File
+
+```
+User: "Attach screenshot.png to SRVKP-1234"
+Agent: <calls jira tool with action=attach, key=SRVKP-1234, file=screenshot.png>
+→ Shows approval dialog
+→ Uploads file
+```
+
+### List Attachments
+
+```
+User: "What files are attached to SRVKP-1234?"
+Agent: <calls jira tool with action=list-attachments, key=SRVKP-1234>
+→ Shows list of attachments
+```
+
+## Testing
+
+The extension includes comprehensive tests for all utilities and functions.
+
+Run tests:
+```bash
+cd dots/pi/agent/extensions/jira
+make test
+```
+
+Run tests in watch mode:
+```bash
+make test-watch
+```
+
+Test coverage:
+- ✅ Issue list parsing
+- ✅ Issue key extraction
+- ✅ Status color coding
+- ✅ Priority color coding
+- ✅ Text truncation
+- ✅ Pattern matching (auto-detection)
+- ✅ Detection logic
+- ✅ Confirmation message building
+- ✅ Error message handling
+
+41 tests, all passing!
dots/pi/agent/extensions/jira/types.ts
@@ -0,0 +1,108 @@
+/**
+ * Type definitions for Jira extension
+ */
+
+export interface JiraDetails {
+	action: string;
+	output?: string;
+	issueKey?: string;
+	issueKeys?: string[];
+	cancelled?: boolean;
+	error?: string;
+	fromKey?: string;
+	toKey?: string;
+	field?: string;
+	newValue?: string;
+	oldValue?: string;
+}
+
+export interface JiraIssue {
+	key: string;
+	type: string;
+	summary: string;
+	status: string;
+	assignee: string;
+	priority?: string;
+	reporter?: string;
+}
+
+export interface JiraCreateParams {
+	action: "create";
+	issueType: string;
+	summary: string;
+	description?: string;
+	priority?: string;
+	assignee?: string;
+	labels?: string[];
+	epic?: string;
+	parent?: string;
+}
+
+export interface JiraUpdateParams {
+	action: "update";
+	key: string;
+	field: "assignee" | "priority" | "labels" | "summary" | "description" | "epic";
+	value: string;
+}
+
+export interface JiraTransitionParams {
+	action: "transition";
+	key: string;
+	state: string;
+}
+
+export interface JiraCommentParams {
+	action: "comment";
+	key: string;
+	comment: string;
+}
+
+export interface JiraSearchParams {
+	action: "search";
+	jql: string;
+	limit?: number;
+}
+
+export interface JiraListParams {
+	action: "list";
+	assignee?: string;
+	status?: string;
+	type?: string;
+	priority?: string;
+	limit?: number;
+	epic?: string;
+}
+
+export interface JiraViewParams {
+	action: "view";
+	key: string;
+}
+
+export interface JiraEpicViewParams {
+	action: "epic-view";
+	key: string;
+}
+
+export interface JiraLinkParams {
+	action: "link";
+	from: string;
+	to: string;
+	linkType: "blocks" | "is blocked by" | "relates to" | "duplicates" | "is duplicated by";
+}
+
+export interface JiraUnlinkParams {
+	action: "unlink";
+	from: string;
+	to: string;
+}
+
+export interface JiraAttachParams {
+	action: "attach";
+	key: string;
+	file: string;
+}
+
+export interface JiraListAttachmentsParams {
+	action: "list-attachments";
+	key: string;
+}
dots/pi/agent/extensions/jira/utils.ts
@@ -0,0 +1,282 @@
+/**
+ * Utility functions for Jira extension
+ */
+
+import type { Theme } from "@mariozechner/pi-coding-agent";
+import type { JiraIssue } from "./types";
+
+/**
+ * Parse jira issue list output from JSON (--raw flag)
+ */
+export function parseIssueListJSON(output: string): JiraIssue[] {
+	try {
+		const data = JSON.parse(output);
+		if (!Array.isArray(data)) {
+			return [];
+		}
+
+		return data.map((item: any) => ({
+			key: item.key || "?",
+			type: item.fields?.issueType?.name || "?",
+			summary: item.fields?.summary || "",
+			status: item.fields?.status?.name || "?",
+			assignee: item.fields?.assignee?.displayName || "Unassigned",
+			priority: item.fields?.priority?.name !== "Undefined" ? item.fields?.priority?.name : undefined,
+			reporter: item.fields?.reporter?.displayName,
+		}));
+	} catch (error) {
+		console.error("Failed to parse jira JSON:", error);
+		return [];
+	}
+}
+
+/**
+ * Parse jira issue list output (plain text format) - DEPRECATED, use parseIssueListJSON
+ * Kept for backwards compatibility with tests
+ */
+export function parseIssueList(output: string): JiraIssue[] {
+	const lines = output.split("\n").filter((l) => l.trim());
+	const issues: JiraIssue[] = [];
+
+	for (const line of lines) {
+		// Skip header lines
+		if (line.startsWith("TYPE\t") || line.includes("---")) continue;
+
+		// Parse line - jira CLI uses TAB as delimiter with multiple TABs for alignment
+		const parts = line.split("\t").filter(p => p.trim());
+		
+		if (parts.length >= 4) {
+			const type = parts[0];
+			const key = parts[1];
+			const status = parts[parts.length - 1];
+			const summary = parts.slice(2, -1).join(" ");
+			
+			issues.push({
+				key,
+				type,
+				summary,
+				status,
+				assignee: "Unassigned",
+				priority: undefined,
+			});
+		}
+	}
+
+	return issues;
+}
+
+/**
+ * Get colored status indicator
+ */
+export function getStatusColor(status: string, theme: Theme): string {
+	const normalized = status.toLowerCase();
+
+	if (normalized.includes("done") || normalized.includes("closed")) {
+		return theme.fg("success", `[${status}]`);
+	}
+
+	if (normalized.includes("progress") || normalized.includes("review")) {
+		return theme.fg("accent", `[${status}]`);
+	}
+
+	if (normalized.includes("blocked") || normalized.includes("waiting")) {
+		return theme.fg("error", `[${status}]`);
+	}
+
+	return theme.fg("muted", `[${status}]`);
+}
+
+/**
+ * Get colored priority indicator
+ */
+export function getPriorityColor(priority: string, theme: Theme): string {
+	const normalized = priority.toLowerCase();
+
+	if (normalized.includes("blocker") || normalized.includes("critical")) {
+		return theme.fg("error", priority);
+	}
+
+	if (normalized.includes("major") || normalized.includes("high")) {
+		return theme.fg("warning", priority);
+	}
+
+	return theme.fg("dim", priority);
+}
+
+/**
+ * Truncate text to max length
+ */
+export function truncate(text: string, maxLength: number): string {
+	if (text.length <= maxLength) return text;
+	return text.slice(0, maxLength - 3) + "...";
+}
+
+/**
+ * Extract issue key from jira CLI output
+ */
+export function extractIssueKey(output: string): string | null {
+	const match = output.match(/([A-Z]+-\d+)/);
+	return match ? match[1] : null;
+}
+
+/**
+ * Extract all issue keys from output
+ */
+export function extractIssueKeys(output: string): string[] {
+	const matches = output.match(/\b([A-Z]{2,}-\d+)\b/g);
+	if (!matches) return [];
+	
+	// Remove duplicates and return
+	return [...new Set(matches)];
+}
+
+/**
+ * Build confirmation message for create action
+ */
+export function buildCreateConfirmation(params: any): string {
+	let msg = "";
+
+	msg += `Type: ${params.issueType}\n`;
+	msg += `Summary: "${params.summary}"\n`;
+
+	if (params.description) {
+		const preview = params.description.length > 100 ? params.description.slice(0, 97) + "..." : params.description;
+		msg += `Description: ${preview}\n`;
+	}
+
+	if (params.priority) {
+		msg += `Priority: ${params.priority}\n`;
+	}
+
+	if (params.assignee) {
+		msg += `Assignee: ${params.assignee}\n`;
+	}
+
+	if (params.labels && params.labels.length > 0) {
+		msg += `Labels: ${params.labels.join(", ")}\n`;
+	}
+
+	if (params.epic) {
+		msg += `Epic: ${params.epic}\n`;
+	}
+
+	if (params.parent) {
+		msg += `Parent: ${params.parent}\n`;
+	}
+
+	msg += "\nThis will create a new issue in Jira.";
+
+	return msg;
+}
+
+/**
+ * Build confirmation message for update action
+ */
+export function buildUpdateConfirmation(params: any, currentValue?: string): string {
+	let msg = `Issue: ${params.key}\n`;
+	msg += `Field: ${params.field}\n`;
+
+	if (currentValue) {
+		msg += `From: ${currentValue}\n`;
+	}
+
+	msg += `To: ${params.value}\n`;
+	msg += "\nThis will modify the issue in Jira.";
+
+	return msg;
+}
+
+/**
+ * Build confirmation message for transition action
+ */
+export function buildTransitionConfirmation(params: any, currentState?: string): string {
+	let msg = `Issue: ${params.key}\n`;
+
+	if (currentState) {
+		msg += `From: ${currentState}\n`;
+	}
+
+	msg += `To: ${params.state}\n`;
+	msg += "\nThis will change the issue workflow state.";
+
+	return msg;
+}
+
+/**
+ * Build confirmation message for comment action
+ */
+export function buildCommentConfirmation(params: any): string {
+	const preview = params.comment.length > 200 ? params.comment.slice(0, 197) + "..." : params.comment;
+
+	let msg = `Issue: ${params.key}\n\n`;
+	msg += `Comment preview:\n"${preview}"\n\n`;
+	msg += "This will add a public comment visible to all users.";
+
+	return msg;
+}
+
+/**
+ * Format date for display
+ */
+export function formatDate(dateStr: string): string {
+	try {
+		const date = new Date(dateStr);
+		return date.toLocaleDateString();
+	} catch {
+		return dateStr;
+	}
+}
+
+/**
+ * Check if error is authentication related
+ */
+export function isAuthError(stderr: string): boolean {
+	const lower = stderr.toLowerCase();
+	return (
+		lower.includes("authentication") ||
+		lower.includes("unauthorized") ||
+		lower.includes("invalid token") ||
+		lower.includes("permission denied")
+	);
+}
+
+/**
+ * Check if error is network related
+ */
+export function isNetworkError(stderr: string): boolean {
+	const lower = stderr.toLowerCase();
+	return (
+		lower.includes("connection") ||
+		lower.includes("timeout") ||
+		lower.includes("network") ||
+		lower.includes("dial tcp") ||
+		lower.includes("no such host")
+	);
+}
+
+/**
+ * Check if error is not found
+ */
+export function isNotFoundError(stderr: string): boolean {
+	const lower = stderr.toLowerCase();
+	return lower.includes("not found") || lower.includes("does not exist");
+}
+
+/**
+ * Get helpful error message
+ */
+export function getErrorMessage(stderr: string, action: string): string {
+	if (isAuthError(stderr)) {
+		return "Authentication failed. Check API token:\npassage show redhat/issues/token/kyushu";
+	}
+
+	if (isNetworkError(stderr)) {
+		return "Network error. Are you on VPN?";
+	}
+
+	if (isNotFoundError(stderr)) {
+		return `${action} failed: Resource not found`;
+	}
+
+	return stderr;
+}