From ddb989fdd126fbfe033f02e270cd1802e5980270 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 25 Aug 2026 17:56:55 -0400 Subject: [PATCH] feat(pi-workflow): add lifecycle slash commands --- docs/pi-workflow/README.md | 25 +- docs/pi-workflow/design.md | 6 +- .../pi-workflow/context-transition.test.ts | 344 +++++++++++++++++- extensions/pi-workflow/context-transition.ts | 193 +++++++++- 4 files changed, 542 insertions(+), 26 deletions(-) diff --git a/docs/pi-workflow/README.md b/docs/pi-workflow/README.md index b230123..9c2b4b3 100644 --- a/docs/pi-workflow/README.md +++ b/docs/pi-workflow/README.md @@ -4,7 +4,15 @@ ## Start a feature -Ask the assistant to use `pi_workflow` with: +Start with: + +```text +/workflow start +``` + +The command sends the message through the assistant, which chooses the descriptive feature name and idempotency key before invoking `pi_workflow` through the normal tool path. In interactive TUI mode, `/workflow start` without a message asks what you want to start working on. Non-interactive modes require the message in the command. + +You can also ask the assistant to use `pi_workflow` with: - `action: "start"` - `workflow: { mode: "feature" }` (or omit `workflow`) @@ -31,13 +39,24 @@ The assistant must inspect the current run before each state-changing call and o Use `/workflow` for the focused-card dashboard. It lists runs attached to the current session and shows persisted status, progress, Gate, plan, report, branch, and worktree information. -Use: +Use `/workflow continue` to select a run with a pending fresh-session handoff or normal-compaction continuation. `/workflow resume` is an alias. A run ID may be supplied directly: ```text /workflow continue +/workflow resume ``` -only when the dashboard or `pi_workflow` reports a pending fresh-session handoff or normal-compaction continuation. +Selection requires interactive TUI mode. In non-interactive modes, provide the run ID. + +## Stop active work + +Use `/workflow stop` to abort an active (`running` or `waiting`) Workflow Run owned by the current parent session. If more than one is active, interactive TUI mode presents a selection list. A run can also be named directly, including in non-interactive modes: + +```text +/workflow stop +``` + +Terminal runs and runs owned by historical parent sessions are not offered. The misspelled `/worlflow` alias is not supported. ## Script modes diff --git a/docs/pi-workflow/design.md b/docs/pi-workflow/design.md index de8b461..66c7847 100644 --- a/docs/pi-workflow/design.md +++ b/docs/pi-workflow/design.md @@ -149,7 +149,9 @@ type GateDecision = Feature and feature-script starts require `featureName` as a concise lowercase kebab-case name that describes what the feature does, such as `searchable-workflow-dashboard`. Analysis starts omit it. The host validates the name before persisting launch intent. -The `pi_workflow` tool calls a single internal `execute(command)` interface. `/workflow` opens the dashboard and may accept a run ID for direct inspection. `/workflow continue ` performs a pending fresh-session handoff or normal-compaction fallback after the run is safely waiting; it cannot approve plans, select prototypes, or make other Decisions. +The `pi_workflow` tool calls a single internal `execute(command)` interface. `/workflow` opens the dashboard and may accept a run ID for direct inspection. `/workflow start ` sends the initial request through the assistant so the normal `pi_workflow` tool path owns feature naming and idempotency; in interactive TUI mode, omitting the message opens an input prompt. `/workflow continue` presents eligible pending context transitions for selection, while `/workflow continue ` continues one directly; `/workflow resume` is an alias for both forms. `/workflow stop [run-id]` aborts a non-terminal run owned by the active parent session and presents a selector when multiple owned runs are active. The misspelled `/worlflow` alias is intentionally unsupported. Continue and stop selectors require interactive TUI mode and provide direct-run-ID guidance otherwise. + +Continuation performs only a pending fresh-session handoff or normal-compaction fallback after the run is safely waiting; it cannot approve plans, select prototypes, or make other Decisions. Stop treats `running` and `waiting` runs as active and excludes terminal runs and runs owned only by historical parent sessions. When a run reaches a Gate, the tool returns a structured waiting view. The parent agent asks the user through its normal interaction tools, then calls `pi_workflow` with `action: "decide"`. Child agents never receive `ask_user`. @@ -614,7 +616,7 @@ Extend the Phase 2 worktree scaffold with the Implementation Context Gate, `/wor - Workspace creation intent is persisted first; creation failure rolls back safely or remains reconcilable after restart. - Every feature run receives a distinct leased worktree and worktrees are retained until explicitly removed. - A versioned `plan.md` is persisted before the Implementation Context Gate and revised with an accepted prototype when applicable. -- Phase 4 registers `/workflow continue`; its `ExtensionCommandContext` performs both fresh-session replacement and approved normal-compaction fallback rather than deferring either path to dashboard integration. +- Phase 4 registers `/workflow start`, `/workflow stop`, `/workflow continue`, and its `/workflow resume` alias. Start routes through the assistant's normal `pi_workflow` tool path, stop is scoped to active runs owned by the current parent session, and continuation without a run ID selects from valid candidates. Its `ExtensionCommandContext` performs both fresh-session replacement and approved normal-compaction fallback rather than deferring either path to dashboard integration. - `fresh-session` persists a Gate-, revision-, plan-, preparation-, and source-session-bound handoff intent before `newSession`; only the replacement `withSession` callback consumes it, attaches the new active parent session, seeds the bounded plan handoff, and resumes. - `smart-compact` uses a Gate-bound `report-context-preparation` transition with distinct `unavailable`, `succeeded`, `no-op`, and `failed` outcomes; `failed` requires a non-empty actionable error. The host validates `unavailable` against `pi.getActiveTools()`; only that outcome permits plan-focused `ctx.compact({ onComplete, onError })`, whose intent-bound callback persists success or failure. - An available-but-failed `smart_compact` call does not silently fall back and keeps the Implementation Context Gate waiting with an actionable error; `current-session` preserves context unchanged. diff --git a/extensions/pi-workflow/context-transition.test.ts b/extensions/pi-workflow/context-transition.test.ts index ce3eb32..a20198d 100644 --- a/extensions/pi-workflow/context-transition.test.ts +++ b/extensions/pi-workflow/context-transition.test.ts @@ -43,10 +43,16 @@ function fakeApi() { let command: CommandHandler | undefined; let shutdown: ShutdownHandler | undefined; let start: StartHandler | undefined; + const commandNames: string[] = []; + const userMessages: string[] = []; const api = { - registerCommand(_name: string, options: { handler: CommandHandler }) { + registerCommand(name: string, options: { handler: CommandHandler }) { + commandNames.push(name); command = options.handler; }, + sendUserMessage(message: string) { + userMessages.push(message); + }, on(event: string, handler: ShutdownHandler | StartHandler) { if (event === "session_shutdown") shutdown = handler as ShutdownHandler; if (event === "session_start") start = handler as StartHandler; @@ -54,6 +60,8 @@ function fakeApi() { } as unknown as ExtensionAPI; return { api, + commandNames, + userMessages, command: () => { assert.ok(command); return command; @@ -117,7 +125,10 @@ function commandContext(options: { sessionFile: string; compact?: ExtensionCommandContext["compact"]; newSession?: ExtensionCommandContext["newSession"]; - sent?: unknown[]; + mode?: ExtensionCommandContext["mode"]; + hasUI?: boolean; + input?: (title: string, placeholder?: string) => Promise; + select?: (title: string, options: string[]) => Promise; }) { const notifications: string[] = []; const context = { @@ -126,10 +137,14 @@ function commandContext(options: { getSessionId: () => options.sessionId, getSessionFile: () => options.sessionFile, }, + mode: options.mode ?? "print", + hasUI: options.hasUI ?? false, ui: { notify(message: string) { notifications.push(message); }, + input: options.input ?? (async () => undefined), + select: options.select ?? (async () => undefined), }, async waitForIdle() {}, compact: options.compact ?? (() => {}), @@ -167,6 +182,331 @@ test("/workflow routes non-continuation arguments to the dashboard", async () => assert.deepEqual(opened, ["run-1"]); }); +test("/workflow start routes the exact request through the assistant", async () => { + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, { + store: { listRuns: async () => [] }, + engineForContext: () => { + throw new Error("start should route through the assistant"); + }, + } as unknown as ContextTransitionRuntime); + await registered.command()( + "start Build searchable workflow cards", + commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + }).context, + ); + assert.equal(registered.userMessages.length, 1); + assert.match(registered.userMessages[0]!, /invoke pi_workflow/); + assert.ok( + registered.userMessages[0]!.endsWith("\n\nBuild searchable workflow cards"), + ); +}); + +test("the misspelled /worlflow alias is not registered", () => { + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, { + store: { listRuns: async () => [] }, + engineForContext: () => { + throw new Error("command registration should not create an engine"); + }, + } as unknown as ContextTransitionRuntime); + assert.deepEqual(registered.commandNames, ["workflow"]); +}); + +test("/workflow start prompts in the TUI and requires a message otherwise", async () => { + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, { + store: { listRuns: async () => [] }, + engineForContext: () => { + throw new Error("start should route through the assistant"); + }, + } as unknown as ContextTransitionRuntime); + let prompt = ""; + await registered.command()( + "start", + commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + mode: "tui", + hasUI: true, + input: async (title) => { + prompt = title; + return "Add workflow lifecycle commands"; + }, + }).context, + ); + assert.equal(prompt, "What do you want to start working on?"); + assert.ok( + registered.userMessages[0]!.endsWith("\n\nAdd workflow lifecycle commands"), + ); + + const fallback = commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + }); + await registered.command()("start", fallback.context); + assert.ok(fallback.notifications.some((message) => message.includes("required"))); +}); + +test("/workflow continue lists only valid runs and resume is an alias", async () => { + const validRun = (runId: string, activeParentSessionId = "session-1") => + ({ + runId, + revision: 10, + status: "waiting", + request: `Request ${runId}`, + parentSessionIds: [activeParentSessionId], + lease: { activeParentSessionId }, + planArtifact: { path: "/plan.md", revision: 1, digest: "digest" }, + implementationContext: { + gateId: `gate-${runId}`, + runRevision: 10, + planRevision: 1, + preparationId: `prep-${runId}`, + activeParentSessionId, + strategy: "fresh-session", + status: "handoff-pending", + method: "fresh-session", + }, + gates: [ + { + id: `gate-${runId}`, + kind: "implementation-context", + status: "open", + revision: 10, + openedAt: 1, + }, + ], + assignments: [], + }) as unknown as WorkflowRun; + const selectedTitles: string[] = []; + const selectedOptions: string[][] = []; + const inspected: string[] = []; + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, { + store: { + listRuns: async () => [ + validRun("run-1"), + validRun("run-2"), + validRun("historical", "session-2"), + ], + }, + engineForContext: () => + ({ + async execute(command: WorkflowCommand) { + if (command.action === "inspect") { + inspected.push(command.runId); + return { + runId: command.runId, + revision: 1, + status: "running", + background: false, + workspace: { state: "ready", sourceRoot: "/repo" }, + stages: [], + assignments: [], + mutatorProfileNames: [], + reviewCycles: 0, + verificationCycles: 0, + uiImpactEvidence: [], + affectedUiSurfaces: [], + } as WorkflowView; + } + throw new Error(`Unexpected command ${command.action}`); + }, + }) as WorkflowEngine, + } as unknown as ContextTransitionRuntime); + const interactive = commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + mode: "tui", + hasUI: true, + select: async (title, options) => { + selectedTitles.push(title); + selectedOptions.push(options); + return undefined; + }, + }); + await registered.command()("continue", interactive.context); + await registered.command()("resume", interactive.context); + assert.deepEqual(selectedTitles, [ + "Continue Workflow Run", + "Continue Workflow Run", + ]); + assert.equal(selectedOptions[0]?.length, 2); + assert.equal(selectedOptions[1]?.length, 2); + assert.ok( + selectedOptions.every((options) => + options.every((label) => !label.includes("historical")), + ), + ); + + await registered.command()("resume run-2", interactive.context); + assert.deepEqual(inspected, ["run-2"]); +}); + +test("/workflow continue without a run ID gives actionable non-UI guidance", async () => { + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, { + store: { listRuns: async () => [] }, + engineForContext: () => { + throw new Error("non-UI fallback should not create an engine"); + }, + } as unknown as ContextTransitionRuntime); + const command = commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + }); + await registered.command()("continue", command.context); + assert.ok( + command.notifications.some((message) => + message.includes("/workflow continue "), + ), + ); +}); + +test("/workflow stop selects among active runs owned by the current session", async () => { + const run = ( + runId: string, + status: WorkflowRun["status"], + activeParentSessionId: string, + ) => + ({ + runId, + revision: 4, + status, + request: `Request ${runId}`, + parentSessionIds: [activeParentSessionId], + lease: { activeParentSessionId }, + }) as unknown as WorkflowRun; + const commands: WorkflowCommand[] = []; + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, { + store: { + listRuns: async () => [ + run("run-1", "running", "session-1"), + run("run-2", "waiting", "session-1"), + run("completed", "completed", "session-1"), + run("historical", "running", "session-2"), + ], + }, + engineForContext: () => + ({ + async execute(command: WorkflowCommand) { + commands.push(command); + if (command.action !== "inspect" && command.action !== "abort") { + throw new Error(`Unexpected command ${command.action}`); + } + return { + runId: command.runId, + revision: 4, + status: command.action === "abort" ? "aborted" : "waiting", + } as WorkflowView; + }, + }) as WorkflowEngine, + } as unknown as ContextTransitionRuntime); + let choices: string[] = []; + const command = commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + mode: "tui", + hasUI: true, + select: async (title, options) => { + assert.equal(title, "Stop Workflow Run"); + choices = options; + return options[1]; + }, + }); + await registered.command()("stop", command.context); + assert.equal(choices.length, 2); + assert.ok(choices.every((label) => !label.includes("completed"))); + assert.deepEqual( + commands.map((entry) => + `${entry.action}:${"runId" in entry ? entry.runId : ""}`, + ), + ["inspect:run-2", "abort:run-2"], + ); + const abort = commands[1]; + assert.match( + abort?.action === "abort" ? abort.idempotencyKey : "", + /^workflow-stop-run-2-4-/, + ); +}); + +test("/workflow stop immediately stops the only active run", async () => { + const commands: WorkflowCommand[] = []; + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, { + store: { + listRuns: async () => [ + { + runId: "run-1", + revision: 2, + status: "running", + request: "Only active run", + lease: { activeParentSessionId: "session-1" }, + } as unknown as WorkflowRun, + ], + }, + engineForContext: () => + ({ + async execute(command: WorkflowCommand) { + commands.push(command); + if (command.action !== "inspect" && command.action !== "abort") { + throw new Error(`Unexpected command ${command.action}`); + } + return { + runId: command.runId, + revision: 2, + status: command.action === "abort" ? "aborted" : "running", + } as WorkflowView; + }, + }) as WorkflowEngine, + } as unknown as ContextTransitionRuntime); + const command = commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + }); + await registered.command()("stop", command.context); + assert.deepEqual( + commands.map((entry) => entry.action), + ["inspect", "abort"], + ); + assert.ok(command.notifications.some((message) => message.includes("stopped"))); +}); + +test("/workflow stop requires a run ID for multiple active runs without UI", async () => { + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, { + store: { + listRuns: async () => + ["run-1", "run-2"].map( + (runId) => + ({ + runId, + status: "running", + request: runId, + lease: { activeParentSessionId: "session-1" }, + }) as unknown as WorkflowRun, + ), + }, + engineForContext: () => { + throw new Error("selection fallback should not create an engine"); + }, + } as unknown as ContextTransitionRuntime); + const command = commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + }); + await registered.command()("stop", command.context); + assert.ok( + command.notifications.some((message) => + message.includes("/workflow stop "), + ), + ); +}); + test("/workflow continue invokes and fences normal compaction callbacks", async () => { await withTempDir(async (directory) => { const planPath = path.join(directory, "plan.md"); diff --git a/extensions/pi-workflow/context-transition.ts b/extensions/pi-workflow/context-transition.ts index 2122320..5472c58 100644 --- a/extensions/pi-workflow/context-transition.ts +++ b/extensions/pi-workflow/context-transition.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { ExtensionAPI, ExtensionCommandContext, @@ -13,6 +14,7 @@ import { type WorkflowEngine, type WorkflowView, } from "./engine.ts"; +import { isValidContextContinuation } from "./continuation.ts"; import { readVerifiedPlanArtifact, type WorkflowStore, @@ -233,19 +235,45 @@ async function continueNormalCompaction( } } -async function handleContinue( +function isInteractive(ctx: ExtensionCommandContext) { + return ctx.hasUI && ctx.mode === "tui"; +} + +function runLabel(run: WorkflowRun) { + const name = run.featureName ?? run.request; + return `${name} · ${run.runId} · ${run.status}`; +} + +function belongsToActiveParent(run: WorkflowRun, sessionId: string) { + return run.lease?.activeParentSessionId === sessionId; +} + +function canContinue(run: WorkflowRun, currentSessionId: string) { + return isValidContextContinuation( + { + ...run, + openGate: run.gates.find((gate) => gate.status === "open"), + }, + currentSessionId, + ); +} + +async function selectRun( + ctx: ExtensionCommandContext, + title: string, + runs: WorkflowRun[], +) { + const labels = runs.map(runLabel); + const selected = await ctx.ui.select(title, labels); + return runs[labels.indexOf(selected ?? "")]; +} + +async function continueRun( runtime: ContextTransitionRuntime, - args: string, + runId: string, ctx: ExtensionCommandContext, ) { - const match = args.trim().match(/^continue\s+(\S+)$/); - if (!match) { - ctx.ui.notify("Usage: /workflow continue ", "error"); - return; - } await ctx.waitForIdle(); - const runId = match[1]; - if (!runId) throw new Error("Workflow Run ID is missing"); const { view } = await inspect(runtime, ctx, runId); const status = view.implementationContext?.status; if (status === "handoff-pending") { @@ -259,6 +287,114 @@ async function handleContinue( throw new Error(`Workflow Run ${runId} has no pending context continuation`); } +async function handleContinue( + runtime: ContextTransitionRuntime, + runId: string | undefined, + ctx: ExtensionCommandContext, +) { + if (runId) { + await continueRun(runtime, runId, ctx); + return; + } + if (!isInteractive(ctx)) { + ctx.ui.notify( + "Selecting a Workflow Run to continue requires interactive UI. Use /workflow continue .", + "error", + ); + return; + } + const currentSessionId = sessionId(ctx); + const runs = (await runtime.store.listRuns()).filter((run) => + canContinue(run, currentSessionId), + ); + if (runs.length === 0) { + ctx.ui.notify("No Workflow Runs are ready to continue.", "info"); + return; + } + const selected = await selectRun(ctx, "Continue Workflow Run", runs); + if (selected) await continueRun(runtime, selected.runId, ctx); +} + +async function handleStart( + pi: ExtensionAPI, + request: string, + ctx: ExtensionCommandContext, +) { + let initialRequest = request.trim(); + if (!initialRequest) { + if (!isInteractive(ctx)) { + ctx.ui.notify( + "A workflow request is required outside interactive UI. Use /workflow start .", + "error", + ); + return; + } + initialRequest = + (await ctx.ui.input( + "What do you want to start working on?", + "Describe the feature request", + ))?.trim() ?? ""; + if (!initialRequest) return; + } + await ctx.waitForIdle(); + await pi.sendUserMessage( + [ + "Start a managed pi-workflow for the request below.", + "Use the request text verbatim as the initial workflow request, choose a descriptive lowercase kebab-case featureName, create a unique idempotencyKey, and invoke pi_workflow with action \"start\" through the normal assistant tool path.", + "", + initialRequest, + ].join("\n"), + ); +} + +async function handleStop( + runtime: ContextTransitionRuntime, + requestedRunId: string | undefined, + ctx: ExtensionCommandContext, +) { + const currentSessionId = sessionId(ctx); + const activeRuns = (await runtime.store.listRuns()).filter( + (run) => + (run.status === "running" || run.status === "waiting") && + belongsToActiveParent(run, currentSessionId), + ); + let selected = requestedRunId + ? activeRuns.find((run) => run.runId === requestedRunId) + : activeRuns.length === 1 + ? activeRuns[0] + : undefined; + if (requestedRunId && !selected) { + ctx.ui.notify( + `Workflow Run ${requestedRunId} is not active for this session.`, + "error", + ); + return; + } + if (!requestedRunId && activeRuns.length === 0) { + ctx.ui.notify("No active Workflow Runs for this session.", "info"); + return; + } + if (!selected && !isInteractive(ctx)) { + ctx.ui.notify( + "Selecting a Workflow Run to stop requires interactive UI. Use /workflow stop .", + "error", + ); + return; + } + selected ??= await selectRun(ctx, "Stop Workflow Run", activeRuns); + if (!selected) return; + await ctx.waitForIdle(); + const engine = await runtime.engineForContext(ctx); + const current = await engine.execute({ action: "inspect", runId: selected.runId }); + await engine.execute({ + action: "abort", + runId: current.runId, + expectedRevision: current.revision, + idempotencyKey: `workflow-stop-${current.runId}-${current.revision}-${randomUUID()}`, + }); + ctx.ui.notify(`Workflow ${current.runId} stopped.`, "info"); +} + export function executeHostDecision( pi: Pick, engine: WorkflowEngine, @@ -281,25 +417,44 @@ export function registerWorkflowContextTransitions( ) => Promise<{ continueRunId?: string }>, ) { pi.registerCommand("workflow", { - description: "Inspect and continue persisted pi-workflow runs", + description: "Start, stop, inspect, or continue persisted pi-workflow runs", handler: async (args, ctx) => { try { - if (/^continue\s+\S+$/.test(args.trim())) { - await handleContinue(runtime, args, ctx); + const input = args.trim(); + const command = input.match(/^(start|stop|continue|resume)(?:\s+([\s\S]*))?$/); + if (command?.[1] === "start") { + await handleStart(pi, command[2] ?? "", ctx); + return; + } + if (command?.[1] === "stop") { + const runId = command[2]?.trim(); + if (runId && /\s/.test(runId)) { + ctx.ui.notify("Usage: /workflow stop [run-id]", "error"); + return; + } + await handleStop(runtime, runId || undefined, ctx); + return; + } + if (command?.[1] === "continue" || command?.[1] === "resume") { + const runId = command[2]?.trim(); + if (runId && /\s/.test(runId)) { + ctx.ui.notify( + `Usage: /workflow ${command[1]} [run-id]`, + "error", + ); + return; + } + await handleContinue(runtime, runId || undefined, ctx); return; } if (openDashboard) { - const result = await openDashboard(args.trim(), ctx); + const result = await openDashboard(input, ctx); if (result.continueRunId) { - await handleContinue( - runtime, - `continue ${result.continueRunId}`, - ctx, - ); + await handleContinue(runtime, result.continueRunId, ctx); } return; } - await handleContinue(runtime, args, ctx); + await handleContinue(runtime, undefined, ctx); } catch (error) { ctx.ui.notify( error instanceof Error ? error.message : String(error),