Skip to content

Commit d8595eb

Browse files
authored
feat: add auto-run continuation with epic context and auto-close (#56)
* feat(ticket): add auto-run continuation with epic context and auto-close * feat(status-bar): add periodic polling to detect pr status changes * docs: update changelog
1 parent f4e19e7 commit d8595eb

6 files changed

Lines changed: 380 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ All notable changes to agent-stuff are documented here.
9595

9696

9797

98+
99+
100+
## feat/ticket-auto-run-continuation
101+
102+
Auto-run continuation for ticket processing (#56): tickets can now be processed sequentially across epic boundaries with automatic context compaction, where the agent receives the next ready task after closing the current one without manual intervention. Status-bar PR polling now refreshes every 60 seconds to detect merge status changes in real-time. Parent epics auto-close when all child tasks are completed, with transition context automatically included when moving between epics to maintain narrative continuity. A new `/ticket-run-stop` command allows halting the auto-run loop gracefully after the current ticket finishes.
98103

99104
## feat/add-pr-extension-command
100105

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ All extensions live in [`pi-extensions/`](pi-extensions). Each file is a self-co
3535
| [`git-rebase-master.ts`](pi-extensions/git-rebase-master.ts) | `/git-rebase-master` command — fetches latest main/master and rebases current branch with automatic LLM conflict resolution |
3636
| [`claude-import.ts`](pi-extensions/claude-import.ts) | Loads commands, skills, and agents from `.claude/` directories (project + global) and registers them as `/claude:*` commands |
3737
| [`kbrainstorm.ts`](pi-extensions/kbrainstorm.ts) | `ask_question` tool — interactive TUI for brainstorming with multiple-choice and freeform answers |
38-
| [`ticket/`](pi-extensions/ticket) | `ticket` tool — git-backed ticket tracker storing tickets as markdown files in `.tickets/` with hierarchy, dependencies, and status workflow |
38+
| [`ticket/`](pi-extensions/ticket) | `ticket` tool — git-backed ticket tracker storing tickets as markdown files in `.tickets/` with hierarchy, dependencies, status workflow, auto-closing of completed epics, and auto-run continuation across epics with context compaction |
3939
| [`loop.ts`](pi-extensions/loop.ts) | `/loop` command — runs a follow-up prompt loop with a breakout condition for iterative coding |
4040
| [`notify.ts`](pi-extensions/notify.ts) | Desktop notifications (OSC 777) when the agent finishes and is waiting for input |
4141
| [`op-timer.ts`](pi-extensions/op-timer.ts) | Live elapsed-time counter above the editor while the agent works — shows total operation and current tool execution duration |

pi-extensions/status-bar.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,11 @@ export default function statusBarExtension(pi: ExtensionAPI): void {
220220
let diffStats: GitDiffStats | null = null;
221221
let diffStatsTimer: ReturnType<typeof setTimeout> | null = null;
222222

223-
// PR status for current branch (refreshed on branch change)
223+
// PR status for current branch (refreshed on branch change + every 60s)
224224
let prStatus: PrStatus | null = null;
225225
let prStatusTimer: ReturnType<typeof setTimeout> | null = null;
226226
let prStatusBranch: string | null = null;
227+
let prPollInterval: ReturnType<typeof setInterval> | null = null;
227228

228229
async function refreshDiffStats(): Promise<void> {
229230
try {
@@ -280,14 +281,14 @@ export default function statusBarExtension(pi: ExtensionAPI): void {
280281
}
281282

282283
/** Fetch PR status for the current branch via gh CLI. */
283-
async function refreshPrStatus(branch?: string | null): Promise<void> {
284+
async function refreshPrStatus(branch?: string | null, force = false): Promise<void> {
284285
const currentBranch = branch ?? prStatusBranch;
285286
if (!currentBranch || currentBranch === "main" || currentBranch === "master") {
286287
prStatus = null;
287288
prStatusBranch = currentBranch ?? null;
288289
return;
289290
}
290-
if (currentBranch === prStatusBranch && prStatus !== null) return;
291+
if (!force && currentBranch === prStatusBranch && prStatus !== null) return;
291292
prStatusBranch = currentBranch;
292293
try {
293294
const result = await pi.exec(
@@ -310,6 +311,23 @@ export default function statusBarExtension(pi: ExtensionAPI): void {
310311
prStatusTimer = setTimeout(() => refreshPrStatus(branch), 600);
311312
}
312313

314+
/** Start a 60s polling interval to keep PR status up-to-date (e.g. detect merges). */
315+
function startPrPoll(): void {
316+
stopPrPoll();
317+
prPollInterval = setInterval(async () => {
318+
if (!prStatusBranch) return;
319+
await refreshPrStatus(prStatusBranch, true);
320+
tuiRef?.requestRender();
321+
}, 60_000);
322+
}
323+
324+
function stopPrPoll(): void {
325+
if (prPollInterval) {
326+
clearInterval(prPollInterval);
327+
prPollInterval = null;
328+
}
329+
}
330+
313331
// ── Event handlers ─────────────────────────────────────────────────────
314332

315333
pi.on("message_start", async (event) => {
@@ -364,14 +382,15 @@ export default function statusBarExtension(pi: ExtensionAPI): void {
364382

365383
ctx.ui.setFooter((tui, theme, footerData) => {
366384
refreshPrStatus(footerData.getGitBranch());
385+
startPrPoll();
367386
tuiRef = tui;
368387
const unsub = footerData.onBranchChange(() => {
369388
schedulePrRefresh(footerData.getGitBranch());
370389
tui.requestRender();
371390
});
372391

373392
return {
374-
dispose() { unsub(); stopRenderTimer(); tuiRef = null; },
393+
dispose() { unsub(); stopRenderTimer(); stopPrPoll(); tuiRef = null; },
375394
invalidate() {},
376395
render(width: number): string[] {
377396
return renderFooter(width, ctx, theme, footerData);
@@ -391,6 +410,7 @@ export default function statusBarExtension(pi: ExtensionAPI): void {
391410
currentBytesPerSec = 0;
392411
isStreaming = false;
393412
stopRenderTimer();
413+
stopPrPoll();
394414
refreshDiffStats();
395415
}
396416
});

pi-extensions/ticket/index.ts

Lines changed: 132 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
* - `ticket` tool with 9 actions (create, show, update, delete, start, close, reopen, list, add-note)
1111
* - `/ticket` TUI browser with fuzzy search and action menu
1212
* - `/ticket-create` prompt injection for epic + task breakdown
13-
* - `/ticket-run-all` automated ticket processing loop with session forking
13+
* - `/ticket-run-all` automated ticket processing with auto-run continuation across epics
14+
* - `/ticket-run-stop` to halt the auto-run loop after the current ticket
15+
* - Auto-close of parent epics when all child tasks are closed
16+
* - Auto-continuation: after closing a task, compacts context and sends next task prompt
17+
* - Epic transition context: mentions the previously completed epic when moving to a new one
1418
* - Widget showing current in-progress ticket
1519
* - Status line with ticket counts
1620
* - Auto-nudge on agent_end when tickets remain in-progress
@@ -56,13 +60,18 @@ import {
5660
LOCK_TTL_MS,
5761
VALID_STATUSES,
5862
VALID_TYPES,
63+
allChildrenClosed,
64+
buildAutoRunPrompt,
65+
buildEpicContextLine,
5966
buildRefinePrompt,
6067
buildWorkPrompt,
68+
clearAutoRun,
6169
ensureDir,
6270
filterTickets,
6371
formatTicketLine,
6472
garbageCollect,
6573
generateId,
74+
getChildren,
6675
getLockPath,
6776
getProjectPrefix,
6877
getReadyTickets,
@@ -71,12 +80,14 @@ import {
7180
isError,
7281
listTickets,
7382
listTicketsSync,
83+
readAutoRun,
7484
readSettings,
7585
readTicketFile,
7686
resolveId,
7787
serializeForAgent,
7888
serializeListForAgent,
7989
statusIcon,
90+
writeAutoRun,
8091
writeTicketFile,
8192
} from "./ticket-core.ts";
8293

@@ -581,12 +592,47 @@ export default function ticketExtension(pi: ExtensionAPI) {
581592
pi.on("session_fork", async (_event, ctx) => refreshUI(ctx));
582593
pi.on("session_tree", async (_event, ctx) => refreshUI(ctx));
583594

584-
// ── Auto-nudge ─────────────────────────────────────────────────────
595+
// ── Auto-run continuation + nudge ──────────────────────────────────
585596

586597
pi.on("agent_end", async (_event, ctx) => {
587598
const dir = getTicketsDir(ctx.cwd);
588599
const tickets = await listTickets(dir);
589600
const inProgress = tickets.filter((t) => t.status === "in_progress");
601+
602+
// Auto-run: when no tickets are in-progress, advance to the next ready task
603+
const autoRun = await readAutoRun(dir);
604+
if (autoRun?.active && !inProgress.length) {
605+
const ready = getReadyTickets(tickets).filter((t) => t.status === "open" && t.type !== "epic");
606+
607+
if (!ready.length) {
608+
await clearAutoRun(dir);
609+
refreshUI(ctx);
610+
pi.sendMessage({
611+
customType: "ticket-run-all-done",
612+
content: "🎉 All tickets processed! Auto-run complete.",
613+
display: true,
614+
});
615+
return;
616+
}
617+
618+
const nextTask = ready[0];
619+
const nextRecord = await readTicketFile(getTicketPath(dir, nextTask.id), nextTask.id);
620+
621+
let prefix = "";
622+
if (autoRun.lastCompletedEpicId) {
623+
prefix += `✅ Previously completed epic ${autoRun.lastCompletedEpicId} "${autoRun.lastCompletedEpicTitle ?? ""}".\n\n`;
624+
}
625+
prefix += buildEpicContextLine(tickets, nextTask);
626+
627+
const prompt = prefix + buildAutoRunPrompt(nextTask, nextRecord, ready.length);
628+
629+
ctx.compact();
630+
refreshUI(ctx);
631+
pi.sendUserMessage(prompt);
632+
return;
633+
}
634+
635+
// Nudge: remind about in-progress tickets
590636
if (!inProgress.length || nudgedThisCycle) return;
591637

592638
nudgedThisCycle = true;
@@ -681,6 +727,50 @@ export default function ticketExtension(pi: ExtensionAPI) {
681727
return resolved;
682728
}
683729

730+
/**
731+
* Auto-close the parent epic when all its children are closed.
732+
* Updates the auto-run state with the completed epic for transition prompts.
733+
* Returns the closed epic and child count, or undefined if nothing was closed.
734+
*/
735+
async function tryAutoCloseEpic(
736+
dir: string,
737+
closedTicket: TicketRecord,
738+
ctx: ExtensionContext,
739+
): Promise<{ epic: TicketRecord; childCount: number } | undefined> {
740+
if (!closedTicket.parent) return undefined;
741+
742+
const allTickets = await listTickets(dir);
743+
const parent = allTickets.find((t) => t.id === closedTicket.parent);
744+
if (parent?.type !== "epic" || parent.status === "closed") return undefined;
745+
if (!allChildrenClosed(allTickets, parent.id)) return undefined;
746+
747+
const epicResult = await withLock(dir, parent.id, ctx, async () => {
748+
const fp = getTicketPath(dir, parent.id);
749+
if (!existsSync(fp)) return { error: "not found" } as const;
750+
const epic = await readTicketFile(fp, parent.id);
751+
epic.status = "closed";
752+
epic.assignee = undefined;
753+
await writeTicketFile(fp, epic);
754+
return epic;
755+
});
756+
if (isError(epicResult)) return undefined;
757+
758+
const epic = epicResult as TicketRecord;
759+
const childCount = getChildren(allTickets, epic.id).length;
760+
761+
// Record completed epic in auto-run state for transition prompts
762+
const autoRun = await readAutoRun(dir);
763+
if (autoRun?.active) {
764+
await writeAutoRun(dir, {
765+
active: true,
766+
lastCompletedEpicId: epic.id,
767+
lastCompletedEpicTitle: epic.title,
768+
});
769+
}
770+
771+
return { epic, childCount };
772+
}
773+
684774
// ── ticket tool ────────────────────────────────────────────────────
685775

686776
pi.registerTool({
@@ -863,8 +953,18 @@ export default function ticketExtension(pi: ExtensionAPI) {
863953
});
864954
if (isError(result)) return errorResult("close", result.error);
865955

956+
const closedTicket = result as TicketRecord;
957+
const epicClose = await tryAutoCloseEpic(dir, closedTicket, ctx);
866958
refreshUI(ctx);
867-
return ticketResult("close", result as TicketRecord);
959+
960+
if (epicClose) {
961+
const extra = `\n\n✅ Epic ${epicClose.epic.id} "${epicClose.epic.title}" auto-closed — all ${epicClose.childCount} tasks complete.`;
962+
return {
963+
content: [{ type: "text" as const, text: serializeForAgent(closedTicket) + extra }],
964+
details: { action: "close", ticket: closedTicket },
965+
};
966+
}
967+
return ticketResult("close", closedTicket);
868968
}
869969

870970
// ── reopen ─────────────────────────────────────────────
@@ -1214,7 +1314,7 @@ export default function ticketExtension(pi: ExtensionAPI) {
12141314
handler: async (_args, ctx) => {
12151315
const dir = getTicketsDir(ctx.cwd);
12161316
const tickets = await listTickets(dir);
1217-
const ready = getReadyTickets(tickets).filter((t) => t.status === "open");
1317+
const ready = getReadyTickets(tickets).filter((t) => t.status === "open" && t.type !== "epic");
12181318

12191319
if (!ready.length) {
12201320
ctx.ui.notify("No ready tickets to process", "info");
@@ -1230,7 +1330,7 @@ export default function ticketExtension(pi: ExtensionAPI) {
12301330

12311331
// Ask user how to proceed
12321332
const forkOptions = [
1233-
"Fork a new session for each ticket",
1333+
"Auto-run: work through all, each task compacted",
12341334
"Work through all in this session",
12351335
"Cancel",
12361336
];
@@ -1239,7 +1339,7 @@ export default function ticketExtension(pi: ExtensionAPI) {
12391339
if (!forkChoice || forkChoice === "Cancel") return;
12401340

12411341
if (forkChoice === "Work through all in this session") {
1242-
// Inject prompt to work through all tickets
1342+
await clearAutoRun(dir);
12431343
const prompt =
12441344
`Work through these tickets in order, one at a time. For each ticket:\n` +
12451345
`1. Use \`ticket start <id>\` to begin\n` +
@@ -1251,29 +1351,43 @@ export default function ticketExtension(pi: ExtensionAPI) {
12511351
return;
12521352
}
12531353

1254-
// Fork-each mode: process first ticket, inject context for next
1255-
const firstTicket = ready[0];
1256-
const record = await readTicketFile(getTicketPath(dir, firstTicket.id), firstTicket.id);
1354+
// Auto-run mode: set marker, send first task, agent_end handles continuation
1355+
await writeAutoRun(dir, { active: true });
1356+
1357+
const firstTask = ready[0];
1358+
const record = await readTicketFile(getTicketPath(dir, firstTask.id), firstTask.id);
12571359

12581360
const prompt =
1259-
`Work on ticket ${firstTicket.id} "${firstTicket.title}".\n\n` +
1260-
(record.description ? `Description: ${record.description}\n\n` : "") +
1261-
(record.design ? `Design: ${record.design}\n\n` : "") +
1262-
(record.acceptance ? `Acceptance: ${record.acceptance}\n\n` : "") +
1263-
(record.tests ? `Tests: ${record.tests}\n\n` : "") +
1264-
`Steps:\n1. \`ticket start ${firstTicket.id}\`\n2. Implement the work\n3. \`ticket close ${firstTicket.id}\`${record.tests ? " (with tests_confirmed=true after verifying tests)" : ""}\n\n` +
1265-
`After closing, there are ${ready.length - 1} more tickets to process.`;
1266-
1267-
// Fork a new session, then send the prompt to trigger execution
1361+
buildEpicContextLine(tickets, firstTask) +
1362+
buildAutoRunPrompt(firstTask, record, ready.length) +
1363+
". Next task will be sent automatically after closing.";
1364+
12681365
const result = await ctx.newSession({
12691366
parentSession: ctx.sessionManager.getSessionFile(),
12701367
});
12711368

12721369
if (result.cancelled) {
1370+
await clearAutoRun(dir);
12731371
ctx.ui.notify("Session creation cancelled", "info");
12741372
} else {
12751373
pi.sendUserMessage(prompt);
12761374
}
12771375
},
12781376
});
1377+
1378+
// ── /ticket-run-stop command ────────────────────────────────────────
1379+
1380+
pi.registerCommand("ticket-run-stop", {
1381+
description: "Stop the auto-run loop after the current ticket",
1382+
handler: async (_args, ctx) => {
1383+
const dir = getTicketsDir(ctx.cwd);
1384+
const autoRun = await readAutoRun(dir);
1385+
if (!autoRun?.active) {
1386+
ctx.ui.notify("Auto-run is not active", "info");
1387+
return;
1388+
}
1389+
await clearAutoRun(dir);
1390+
ctx.ui.notify("Auto-run stopped. Current ticket will finish normally.", "info");
1391+
},
1392+
});
12791393
}

0 commit comments

Comments
 (0)