feat: strengthen task workflow and compaction recovery
This commit is contained in:
+70
-40
@@ -2,24 +2,33 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { Type } from "typebox";
|
||||
import { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
|
||||
import { TaskValidationError } from "./types.ts";
|
||||
import { orderedTreeRows, renderToolResult } from "./ui.ts";
|
||||
import { renderToolResult } from "./ui.ts";
|
||||
|
||||
const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, {
|
||||
description: "Task status. Defaults to pending when omitted during add.",
|
||||
description: "Task status. Defaults to pending.",
|
||||
});
|
||||
|
||||
const SetTaskModeSchema = StringEnum(["append", "replace"] as const);
|
||||
const RemovalReasonSchema = StringEnum(["mistaken", "duplicate", "canceled"] as const);
|
||||
|
||||
const AddTaskSchema = Type.Cyclic(
|
||||
{
|
||||
AddTask: Type.Object(
|
||||
{
|
||||
name: Type.String({ minLength: 1 }),
|
||||
description: Type.String({ minLength: 1 }),
|
||||
description: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description: "Detailed scope, constraints, and done criteria. Include when useful.",
|
||||
}),
|
||||
),
|
||||
status: Type.Optional(TaskStatusSchema),
|
||||
parentId: Type.Optional(
|
||||
Type.Integer({ minimum: 1, description: "Existing parent ID. Allowed only on top-level tasks." }),
|
||||
Type.Integer({ minimum: 1, description: "Existing parent ID; append mode and top-level tasks only." }),
|
||||
),
|
||||
children: Type.Optional(Type.Array(Type.Ref("AddTask"))),
|
||||
},
|
||||
@@ -29,9 +38,15 @@ const AddTaskSchema = Type.Cyclic(
|
||||
"AddTask",
|
||||
);
|
||||
|
||||
export const AddTaskParams = Type.Object(
|
||||
export const SetTaskParams = Type.Object(
|
||||
{
|
||||
tasks: Type.Array(AddTaskSchema, { minItems: 1, description: "Tasks to add in one atomic batch." }),
|
||||
mode: SetTaskModeSchema,
|
||||
tasks: Type.Array(AddTaskSchema, {
|
||||
description: "Tasks to append or install. May be empty only in replace mode.",
|
||||
}),
|
||||
force: Type.Optional(
|
||||
Type.Boolean({ description: "Use only when the user explicitly abandons unfinished tasks." }),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -61,20 +76,27 @@ export const UpdateTaskParams = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const IdsParams = Type.Object(
|
||||
const TaskIdsSchema = Type.Array(Type.Integer({ minimum: 1 }), {
|
||||
minItems: 1,
|
||||
description: "Task IDs to process. Duplicate IDs are deduplicated.",
|
||||
});
|
||||
|
||||
export const RemoveTaskParams = Type.Object(
|
||||
{
|
||||
ids: Type.Array(Type.Integer({ minimum: 1 }), {
|
||||
minItems: 1,
|
||||
description: "Task IDs to process in one call. Duplicate IDs are deduplicated.",
|
||||
}),
|
||||
ids: TaskIdsSchema,
|
||||
reason: RemovalReasonSchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalProperties: false });
|
||||
|
||||
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
interface AddTaskInput {
|
||||
interface SetTaskInput {
|
||||
mode: "append" | "replace";
|
||||
tasks: AddTaskDraft[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface UpdateTaskInput {
|
||||
@@ -85,19 +107,16 @@ interface IdsInput {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
interface RemoveTaskInput extends IdsInput {
|
||||
reason: "mistaken" | "duplicate" | "canceled";
|
||||
}
|
||||
|
||||
type ChangeHandler = (ctx: ExtensionContext) => void;
|
||||
|
||||
function taskIds(tasks: Task[]): string {
|
||||
return tasks.map((task) => task.id).join(", ");
|
||||
}
|
||||
|
||||
function compactTree(tasks: Task[]): string {
|
||||
if (tasks.length === 0) return "No tasks.";
|
||||
return orderedTreeRows(tasks)
|
||||
.map(({ task, prefix }) => `${prefix}${task.id} [${task.status}] ${task.name}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function details(action: TaskToolDetails["action"], state: TaskState, affected: Task[]): TaskToolDetails {
|
||||
return { action, snapshot: state.snapshot(), affected };
|
||||
}
|
||||
@@ -107,6 +126,13 @@ function mutationText(verb: string, affected: Task[], state: TaskState): string
|
||||
return `${verb} ${affected.length} task${affected.length === 1 ? "" : "s"}: ${taskIds(affected)}. ${stats.done}/${stats.total} done.`;
|
||||
}
|
||||
|
||||
function setTaskText(mode: SetTaskInput["mode"], affected: Task[], state: TaskState): string {
|
||||
if (mode === "append") return mutationText("Added", affected, state);
|
||||
if (affected.length === 0) return "Cleared task tree. Task IDs reset to 1.";
|
||||
const stats = state.stats();
|
||||
return `Replaced task tree with ${affected.length} task${affected.length === 1 ? "" : "s"}. ${stats.done}/${stats.total} done. Task IDs restart at 1.`;
|
||||
}
|
||||
|
||||
function throwToolError(label: string, error: unknown): never {
|
||||
if (error instanceof TaskValidationError) {
|
||||
const count = error.errors.length;
|
||||
@@ -132,28 +158,26 @@ function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext): void {
|
||||
|
||||
export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: ChangeHandler): void {
|
||||
pi.registerTool({
|
||||
name: "add_task",
|
||||
label: "Add Tasks",
|
||||
name: "set_tasks",
|
||||
label: "Set Tasks",
|
||||
description:
|
||||
"Atomically add one or more tasks. Use nested children for new descendants and parentId on top-level items for an existing parent.",
|
||||
promptGuidelines: [
|
||||
"Use add_task for complex multi-step work; use update_task immediately when tasks start, block, resume, or finish; skip task tracking for simple one-step work.",
|
||||
],
|
||||
parameters: AddTaskParams,
|
||||
async execute(_toolCallId, params: AddTaskInput, _signal, _onUpdate, ctx) {
|
||||
"Atomically append tasks or replace the task tree. Replace may clear with an empty array; force is required to discard unfinished tasks.",
|
||||
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
|
||||
parameters: SetTaskParams,
|
||||
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.add(params.tasks);
|
||||
const affected = state.set(params.mode, params.tasks, params.force);
|
||||
notifyChange(onChange, ctx);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: mutationText("Added", affected, state) }],
|
||||
details: details("add", state, affected),
|
||||
content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }],
|
||||
details: details(params.mode, state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Add", error);
|
||||
throwToolError("Set", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: AddTaskInput, theme: Theme) {
|
||||
return renderCall("Add Tasks", args.tasks?.length, theme);
|
||||
renderCall(args: SetTaskInput, theme: Theme) {
|
||||
return renderCall(`Set Tasks · ${args.mode}`, args.tasks?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
@@ -189,22 +213,28 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
||||
pi.registerTool({
|
||||
name: "rm_task",
|
||||
label: "Remove Tasks",
|
||||
description: "Atomically remove multiple task IDs. Removing a parent also removes its entire descendant subtree.",
|
||||
parameters: IdsParams,
|
||||
async execute(_toolCallId, params: IdsInput, _signal, _onUpdate, ctx) {
|
||||
description:
|
||||
"Remove tasks only when mistaken, duplicate, or canceled. Removing a parent also removes its descendant subtree.",
|
||||
parameters: RemoveTaskParams,
|
||||
async execute(_toolCallId, params: RemoveTaskInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.remove(params.ids);
|
||||
notifyChange(onChange, ctx);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: mutationText("Removed", affected, state) }],
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `${mutationText("Removed", affected, state)} Reason: ${params.reason}.`,
|
||||
},
|
||||
],
|
||||
details: details("remove", state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Remove", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: IdsInput, theme: Theme) {
|
||||
return renderCall("Remove Tasks", args.ids?.length, theme);
|
||||
renderCall(args: RemoveTaskInput, theme: Theme) {
|
||||
return renderCall(`Remove Tasks · ${args.reason}`, args.ids?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
@@ -219,7 +249,7 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
||||
async execute() {
|
||||
const affected = state.list();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: compactTree(affected) }],
|
||||
content: [{ type: "text" as const, text: formatCompactTaskTree(affected) }],
|
||||
details: details("list", state, affected),
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user