mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-07-19 11:26:46 +08:00
593 lines
27 KiB
TypeScript
593 lines
27 KiB
TypeScript
/**
|
|
* pi-lgtm — Task tracking with structured human sign-off for pi coding agent.
|
|
*
|
|
* Tools:
|
|
* TaskCreate — Create a task with done_criterion
|
|
* TaskList — List all tasks with status
|
|
* TaskGet — Get full task details
|
|
* TaskUpdate — Update task fields (completion requires /lgtm)
|
|
* lgtm_ask — Present evidence + failure modes for sign-off
|
|
*
|
|
* Commands:
|
|
* /tasks — Interactive task management menu
|
|
* /lgtm <id> — Human signs off on a task (only way to complete)
|
|
*/
|
|
|
|
import { existsSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
import { Type } from "@sinclair/typebox";
|
|
import { AutoClearManager } from "./auto-clear.js";
|
|
import { TaskStore } from "./task-store.js";
|
|
import { loadTasksConfig } from "./tasks-config.js";
|
|
import { TaskWidget, type UICtx } from "./ui/task-widget.js";
|
|
|
|
const DEBUG = !!process.env.PI_TASKS_DEBUG;
|
|
function debug(...args: unknown[]) {
|
|
if (DEBUG) console.error("[pi-lgtm]", ...args);
|
|
}
|
|
|
|
function textResult(msg: string) {
|
|
return { content: [{ type: "text" as const, text: msg }], details: undefined as any };
|
|
}
|
|
|
|
const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "lgtm_ask"]);
|
|
const REMINDER_INTERVAL = 4;
|
|
const AUTO_CLEAR_DELAY = 4;
|
|
|
|
const SYSTEM_REMINDER = `<system-reminder>
|
|
The task tools haven't been used recently. If working on tasks, use TaskCreate (requires done_criterion), TaskUpdate for status, and lgtm_ask when ready for human sign-off. Tasks can only be completed via /lgtm after calling lgtm_ask. Ignore if not applicable. Never mention this reminder to the user.
|
|
</system-reminder>`;
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
const cfg = loadTasksConfig();
|
|
const piTasks = process.env.PI_TASKS;
|
|
const taskScope = cfg.taskScope ?? "session";
|
|
|
|
function resolveStorePath(sessionId?: string): string | undefined {
|
|
if (piTasks === "off") return undefined;
|
|
if (piTasks?.startsWith("/")) return piTasks;
|
|
if (piTasks?.startsWith(".")) return resolve(piTasks);
|
|
if (piTasks) return piTasks;
|
|
if (taskScope === "memory") return undefined;
|
|
if (taskScope === "session" && sessionId) {
|
|
return join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`);
|
|
}
|
|
if (taskScope === "session") return undefined;
|
|
return join(process.cwd(), ".pi", "tasks", "tasks.json");
|
|
}
|
|
|
|
let store = new TaskStore(resolveStorePath());
|
|
const widget = new TaskWidget(store);
|
|
const autoClear = new AutoClearManager(() => store, () => cfg.autoClearCompleted ?? "on_list_complete", AUTO_CLEAR_DELAY);
|
|
|
|
let storeUpgraded = false;
|
|
let persistedTasksShown = false;
|
|
function upgradeStoreIfNeeded(ctx: ExtensionContext) {
|
|
if (storeUpgraded) return;
|
|
if (taskScope === "session" && !piTasks) {
|
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
const path = resolveStorePath(sessionId);
|
|
store = new TaskStore(path);
|
|
widget.setStore(store);
|
|
}
|
|
storeUpgraded = true;
|
|
}
|
|
|
|
function showPersistedTasks(isResume = false) {
|
|
if (persistedTasksShown) return;
|
|
persistedTasksShown = true;
|
|
const tasks = store.list();
|
|
if (tasks.length > 0) {
|
|
if (!isResume && tasks.every(t => t.status === "completed")) {
|
|
store.clearCompleted();
|
|
if (taskScope === "session") store.deleteFileIfEmpty();
|
|
} else {
|
|
widget.update();
|
|
}
|
|
}
|
|
}
|
|
|
|
let currentTurn = 0;
|
|
let lastTaskToolUseTurn = 0;
|
|
let reminderInjectedThisCycle = false;
|
|
|
|
pi.on("turn_start", async (_event, ctx) => {
|
|
currentTurn++;
|
|
widget.setUICtx(ctx.ui as UICtx);
|
|
upgradeStoreIfNeeded(ctx);
|
|
if (autoClear.onTurnStart(currentTurn)) widget.update();
|
|
});
|
|
|
|
pi.on("turn_end", async (event) => {
|
|
const msg = event.message as any;
|
|
if (msg?.role === "assistant" && msg.usage) {
|
|
widget.addTokenUsage(msg.usage.input ?? 0, msg.usage.output ?? 0);
|
|
}
|
|
});
|
|
|
|
pi.on("tool_result", async (event) => {
|
|
if (TASK_TOOL_NAMES.has(event.toolName)) {
|
|
lastTaskToolUseTurn = currentTurn;
|
|
reminderInjectedThisCycle = false;
|
|
return {};
|
|
}
|
|
if (currentTurn - lastTaskToolUseTurn < REMINDER_INTERVAL) return {};
|
|
if (reminderInjectedThisCycle) return {};
|
|
const tasks = store.list();
|
|
if (tasks.length === 0) return {};
|
|
reminderInjectedThisCycle = true;
|
|
lastTaskToolUseTurn = currentTurn;
|
|
return { content: [...event.content, { type: "text" as const, text: SYSTEM_REMINDER }] };
|
|
});
|
|
|
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
widget.setUICtx(ctx.ui as UICtx);
|
|
upgradeStoreIfNeeded(ctx);
|
|
showPersistedTasks();
|
|
});
|
|
|
|
pi.on("session_switch" as any, async (event: any, ctx: ExtensionContext) => {
|
|
widget.setUICtx(ctx.ui as UICtx);
|
|
const isResume = event?.reason === "resume";
|
|
storeUpgraded = false;
|
|
persistedTasksShown = false;
|
|
currentTurn = 0;
|
|
lastTaskToolUseTurn = 0;
|
|
reminderInjectedThisCycle = false;
|
|
autoClear.reset();
|
|
if (!isResume && taskScope === "memory") store.clearAll();
|
|
upgradeStoreIfNeeded(ctx);
|
|
showPersistedTasks(isResume);
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Tool 1: TaskCreate
|
|
// ──────────────────────────────────────────────────
|
|
|
|
pi.registerTool({
|
|
name: "TaskCreate",
|
|
label: "TaskCreate",
|
|
description: `Create a task with a clear done_criterion.
|
|
|
|
## When to Use
|
|
|
|
- Complex multi-step tasks (3+ steps)
|
|
- When user provides a list of things to do
|
|
|
|
## Task Fields
|
|
|
|
- **subject**: Brief actionable title
|
|
- **description**: Detailed description with context
|
|
- **done_criterion**: REQUIRED. Falsifiable observation that distinguishes done from fail/null/incomplete/silent-fail. State expected AND wrong-case observations (e.g., "All 92 tests pass. If wrong: type errors in build or test failures in task-store.test.ts")
|
|
- **activeForm** (optional): Present continuous for spinner
|
|
|
|
Tasks are completed only via /lgtm after calling lgtm_ask with evidence.`,
|
|
promptGuidelines: [
|
|
"Use TaskCreate for complex tasks. Include a specific done_criterion.",
|
|
"Mark tasks in_progress before starting. Use lgtm_ask when done.",
|
|
"Tasks cannot be marked completed directly — human must /lgtm them.",
|
|
],
|
|
parameters: Type.Object({
|
|
subject: Type.String({ description: "Brief task title" }),
|
|
description: Type.String({ description: "Detailed description" }),
|
|
done_criterion: Type.String({ description: "Falsifiable observation that distinguishes DONE from fail, null result, incomplete, or silent failure. State what you expect to see AND what you'd see if it's wrong." }),
|
|
activeForm: Type.Optional(Type.String({ description: "Present continuous for spinner" })),
|
|
metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
}),
|
|
|
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
autoClear.resetBatchCountdown();
|
|
const task = store.create(params.subject, params.description, params.done_criterion, params.activeForm, params.metadata);
|
|
widget.update();
|
|
return Promise.resolve(textResult(`Task #${task.id} created: ${task.subject}\nDone criterion: ${task.done_criterion}`));
|
|
},
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Tool 2: TaskList
|
|
// ──────────────────────────────────────────────────
|
|
|
|
pi.registerTool({
|
|
name: "TaskList",
|
|
label: "TaskList",
|
|
description: `List all tasks. Tasks with 👀 are pending human sign-off via /lgtm.`,
|
|
parameters: Type.Object({}),
|
|
|
|
execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
const tasks = store.list();
|
|
if (tasks.length === 0) return Promise.resolve(textResult("No tasks found"));
|
|
|
|
const statusOrder: Record<string, number> = { pending: 0, in_progress: 1, completed: 2 };
|
|
const sorted = [...tasks].sort((a, b) => {
|
|
const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0);
|
|
if (so !== 0) return so;
|
|
return Number(a.id) - Number(b.id);
|
|
});
|
|
|
|
const lines = sorted.map(task => {
|
|
let line = `#${task.id} [${task.status}] ${task.subject}`;
|
|
if (task.pending_approval && task.status !== "completed") line += " 👀";
|
|
if (task.owner) line += ` (${task.owner})`;
|
|
if (task.blockedBy.length > 0) {
|
|
const openBlockers = task.blockedBy.filter(bid => {
|
|
const blocker = store.get(bid);
|
|
return blocker && blocker.status !== "completed";
|
|
});
|
|
if (openBlockers.length > 0) line += ` [blocked by ${openBlockers.map(id => "#" + id).join(", ")}]`;
|
|
}
|
|
return line;
|
|
});
|
|
|
|
return Promise.resolve(textResult(lines.join("\n")));
|
|
},
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Tool 3: TaskGet
|
|
// ──────────────────────────────────────────────────
|
|
|
|
pi.registerTool({
|
|
name: "TaskGet",
|
|
label: "TaskGet",
|
|
description: `Get full task details including done_criterion and approval state.`,
|
|
parameters: Type.Object({
|
|
taskId: Type.String({ description: "Task ID to retrieve" }),
|
|
}),
|
|
|
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
const task = store.get(params.taskId);
|
|
if (!task) return Promise.resolve(textResult("Task not found"));
|
|
|
|
const desc = task.description.replace(/\\n/g, "\n");
|
|
const lines: string[] = [
|
|
`Task #${task.id}: ${task.subject}`,
|
|
`Status: ${task.status}${task.pending_approval && task.status !== "completed" ? " 👀 (pending sign-off)" : ""}`,
|
|
`Done criterion: ${task.done_criterion}`,
|
|
];
|
|
if (task.owner) lines.push(`Owner: ${task.owner}`);
|
|
lines.push(`Description: ${desc}`);
|
|
if (task.blockedBy.length > 0) {
|
|
const openBlockers = task.blockedBy.filter(bid => {
|
|
const blocker = store.get(bid);
|
|
return blocker && blocker.status !== "completed";
|
|
});
|
|
if (openBlockers.length > 0) lines.push(`Blocked by: ${openBlockers.map(id => "#" + id).join(", ")}`);
|
|
}
|
|
if (task.blocks.length > 0) lines.push(`Blocks: ${task.blocks.map(id => "#" + id).join(", ")}`);
|
|
const metaKeys = Object.keys(task.metadata);
|
|
if (metaKeys.length > 0) lines.push(`Metadata: ${JSON.stringify(task.metadata)}`);
|
|
|
|
return Promise.resolve(textResult(lines.join("\n")));
|
|
},
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Tool 4: TaskUpdate
|
|
// ──────────────────────────────────────────────────
|
|
|
|
pi.registerTool({
|
|
name: "TaskUpdate",
|
|
label: "TaskUpdate",
|
|
description: `Update task fields or status.
|
|
|
|
Status: pending -> in_progress -> (call lgtm_ask) -> /lgtm -> completed
|
|
|
|
Cannot set status=completed here. Use lgtm_ask then /lgtm <id>.`,
|
|
parameters: Type.Object({
|
|
taskId: Type.String({ description: "Task ID to update" }),
|
|
status: Type.Optional(Type.Unsafe<"pending" | "in_progress" | "deleted">({
|
|
anyOf: [
|
|
{ type: "string", enum: ["pending", "in_progress"] },
|
|
{ type: "string", const: "deleted" },
|
|
],
|
|
description: "New status. Cannot set completed — use /lgtm after lgtm_ask.",
|
|
})),
|
|
subject: Type.Optional(Type.String()),
|
|
description: Type.Optional(Type.String()),
|
|
done_criterion: Type.Optional(Type.String()),
|
|
activeForm: Type.Optional(Type.String()),
|
|
owner: Type.Optional(Type.String()),
|
|
metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
addBlocks: Type.Optional(Type.Array(Type.String())),
|
|
addBlockedBy: Type.Optional(Type.Array(Type.String())),
|
|
}),
|
|
|
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
const { taskId, ...fields } = params;
|
|
let task: any, changedFields: string[], warnings: string[];
|
|
try {
|
|
({ task, changedFields, warnings } = store.update(taskId, fields));
|
|
} catch (err: any) {
|
|
return Promise.resolve(textResult(err.message));
|
|
}
|
|
|
|
if (changedFields.length === 0 && !task) {
|
|
return Promise.resolve(textResult(`Task #${taskId} not found`));
|
|
}
|
|
|
|
if (fields.status === "in_progress") {
|
|
widget.setActiveTask(taskId);
|
|
autoClear.resetBatchCountdown();
|
|
} else if (fields.status === "pending") {
|
|
autoClear.resetBatchCountdown();
|
|
} else if (fields.status === "deleted") {
|
|
widget.setActiveTask(taskId, false);
|
|
}
|
|
|
|
widget.update();
|
|
let msg = `Updated task #${taskId}: ${changedFields.join(", ")}`;
|
|
if (warnings.length > 0) msg += ` (warning: ${warnings.join("; ")})`;
|
|
return Promise.resolve(textResult(msg));
|
|
},
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Tool 5: lgtm_ask
|
|
// ──────────────────────────────────────────────────
|
|
|
|
pi.registerTool({
|
|
name: "lgtm_ask",
|
|
label: "lgtm_ask",
|
|
description: `Present evidence that a task meets its done_criterion and request human sign-off.
|
|
|
|
Forces structured thinking about failure modes. All text fields required.
|
|
After this, task enters pending sign-off state — only completable via /lgtm <id>.
|
|
|
|
## Fields
|
|
|
|
- **evidence**: Auditable proof — command output, table, file path, link
|
|
- **failure_mode_1**: Most likely way this could be wrong despite evidence
|
|
- **failure_mode_2**: Most perverse or sneaky failure -- one that looks like success superficially, corrupts silently, or only breaks under specific conditions (scale, time, edge case). E.g. feature active but wrong mechanism, works in tests but degrades in prod, correct output for wrong reason.
|
|
- **falsification_test**: What you ran and what you got -- presented so both you and the human can sanity-check it. State: what you ran (command, experiment, log inspection), the actual output or result, and why that result could not occur if a failure mode were real. Must be traceable: include file paths, log snippets, counts, or commit. Human should be able to verify without re-running anything.
|
|
- **evidence_files** (optional): File paths human should inspect -- must exist
|
|
- **remaining_uncertainty** (optional): What's NOT tested, known limitations, deferred edge cases`,
|
|
parameters: Type.Object({
|
|
taskId: Type.String({ description: "Task ID to submit for sign-off" }),
|
|
evidence: Type.String({ description: "Auditable proof with full reproducibility: exact command run and its output, commit hash, config/seeds used, output file paths. Must be re-runnable by the human. 'I wrote X' is not evidence -- 'I ran X and got Y' is. Include counts, snippets, test output." }),
|
|
failure_mode_1: Type.String({ description: "Most likely way this could be wrong despite evidence" }),
|
|
failure_mode_2: Type.String({ description: "Most perverse or sneaky failure: looks like success superficially, corrupts silently, or only breaks at scale/time/edge case. E.g. correct output for wrong reason, feature active but wrong mechanism, passes tests but degrades in prod." }),
|
|
falsification_test: Type.String({ description: "What you ran and what you got, presented so both you and the human can sanity-check it. State: what you ran (command/experiment/log check), the actual output or result, and why that result could not occur if a failure mode were real. Must be traceable: include file paths, log snippets, counts, or commit. The human should be able to verify without re-running anything." }),
|
|
evidence_files: Type.Optional(Type.Array(Type.String(), { description: "File paths to inspect (must exist)" })),
|
|
remaining_uncertainty: Type.Optional(Type.String({ description: "What's NOT tested, known limitations, edge cases deferred. Be honest about scope boundaries." })),
|
|
}),
|
|
|
|
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
const task = store.get(params.taskId);
|
|
if (!task) return Promise.resolve(textResult(`Task #${params.taskId} not found`));
|
|
if (task.status === "completed") return Promise.resolve(textResult(`Task #${params.taskId} already completed`));
|
|
|
|
if (params.evidence_files?.length) {
|
|
for (const f of params.evidence_files) {
|
|
if (!existsSync(f)) return Promise.resolve(textResult(`Evidence file not found: ${f}`));
|
|
}
|
|
}
|
|
|
|
store.update(params.taskId, {
|
|
pending_approval: true,
|
|
metadata: {
|
|
lgtm_evidence: params.evidence,
|
|
lgtm_failure_mode_1: params.failure_mode_1,
|
|
lgtm_failure_mode_2: params.failure_mode_2,
|
|
lgtm_falsification_test: params.falsification_test,
|
|
lgtm_evidence_files: params.evidence_files ?? [],
|
|
lgtm_remaining_uncertainty: params.remaining_uncertainty ?? "",
|
|
lgtm_submitted_at: new Date().toISOString(),
|
|
},
|
|
});
|
|
widget.update();
|
|
|
|
const filesSection = params.evidence_files?.length
|
|
? `\n### Evidence files\n${params.evidence_files.map(f => `- ${f}`).join("\n")}`
|
|
: "";
|
|
const uncertaintySection = params.remaining_uncertainty
|
|
? `\n### Remaining uncertainty\n${params.remaining_uncertainty}`
|
|
: "";
|
|
|
|
const result =
|
|
`## Task #${task.id}: ${task.subject}\n` +
|
|
`Done criterion: ${task.done_criterion}\n\n` +
|
|
`### Evidence\n${params.evidence}\n\n` +
|
|
`### Failure mode 1\n${params.failure_mode_1}\n\n` +
|
|
`### Failure mode 2\n${params.failure_mode_2}\n\n` +
|
|
`### Falsification test\n${params.falsification_test}` +
|
|
filesSection +
|
|
uncertaintySection +
|
|
`\n\n---\n` +
|
|
`Task #${task.id} is now pending human sign-off via \`/lgtm ${task.id}\`.\n\n` +
|
|
`**Self-check (non-blocking):** Look at this as the human will see it. ` +
|
|
`Does the evidence directly address the done_criterion "${task.done_criterion}"? ` +
|
|
`Would a skeptical reviewer find this convincing, or would they immediately ask ` +
|
|
`"but what about..."? If evidence feels thin, call lgtm_ask again with stronger evidence.`;
|
|
|
|
return Promise.resolve(textResult(result));
|
|
},
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// /tasks command
|
|
// ──────────────────────────────────────────────────
|
|
|
|
pi.registerCommand("tasks", {
|
|
description: "Manage tasks — view, create, clear completed",
|
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
const ui = ctx.ui;
|
|
|
|
const mainMenu = async (): Promise<void> => {
|
|
const tasks = store.list();
|
|
const taskCount = tasks.length;
|
|
const completedCount = tasks.filter(t => t.status === "completed").length;
|
|
|
|
const choices: string[] = [`View all tasks (${taskCount})`, "Create task"];
|
|
if (completedCount > 0) choices.push(`Clear completed (${completedCount})`);
|
|
if (taskCount > 0) choices.push(`Clear all (${taskCount})`);
|
|
|
|
const choice = await ui.select("Tasks", choices);
|
|
if (!choice) return;
|
|
|
|
if (choice.startsWith("View")) await viewTasks();
|
|
else if (choice === "Create task") await createTask();
|
|
else if (choice.startsWith("Clear completed")) {
|
|
store.clearCompleted();
|
|
if (taskScope === "session") store.deleteFileIfEmpty();
|
|
widget.update();
|
|
await mainMenu();
|
|
} else if (choice.startsWith("Clear all")) {
|
|
store.clearAll();
|
|
if (taskScope === "session") store.deleteFileIfEmpty();
|
|
widget.update();
|
|
await mainMenu();
|
|
}
|
|
};
|
|
|
|
const viewTasks = async (): Promise<void> => {
|
|
const tasks = store.list();
|
|
if (tasks.length === 0) {
|
|
await ui.select("No tasks", ["← Back"]);
|
|
return mainMenu();
|
|
}
|
|
|
|
const statusIcon = (t: (typeof tasks)[0]) => {
|
|
if (t.status === "completed") return "✔";
|
|
if (t.pending_approval) return "👀";
|
|
if (t.status === "in_progress") return "◼";
|
|
return "◻";
|
|
};
|
|
|
|
const choices = tasks.map(t => `${statusIcon(t)} #${t.id} [${t.status}] ${t.subject}`);
|
|
choices.push("← Back");
|
|
|
|
const selected = await ui.select("Tasks", choices);
|
|
if (!selected || selected === "← Back") return mainMenu();
|
|
|
|
const match = selected.match(/#(\d+)/);
|
|
if (match) await viewTaskDetail(match[1]);
|
|
else return viewTasks();
|
|
};
|
|
|
|
const viewTaskDetail = async (taskId: string): Promise<void> => {
|
|
const task = store.get(taskId);
|
|
if (!task) return viewTasks();
|
|
|
|
const actions: string[] = [];
|
|
if (task.status === "pending") actions.push("▸ Start (in_progress)");
|
|
if (task.pending_approval && task.status !== "completed") {
|
|
actions.push(`(type /lgtm ${taskId} to sign off)`);
|
|
}
|
|
actions.push("✗ Delete");
|
|
actions.push("← Back");
|
|
|
|
const pendingNote = task.pending_approval && task.status !== "completed" ? "\n👀 Pending /lgtm sign-off" : "";
|
|
const em = task.metadata;
|
|
let evidenceNote = "";
|
|
if (em.lgtm_evidence) {
|
|
const parts = [`\n\nEvidence (${em.lgtm_submitted_at ?? "?"}):\n${em.lgtm_evidence}`];
|
|
parts.push(`FM1 (likely): ${em.lgtm_failure_mode_1}`);
|
|
parts.push(`FM2 (subtle/silent): ${em.lgtm_failure_mode_2}`);
|
|
if (em.lgtm_falsification_test) parts.push(`Falsification test: ${em.lgtm_falsification_test}`);
|
|
if (em.lgtm_remaining_uncertainty) parts.push(`Uncertainty: ${em.lgtm_remaining_uncertainty}`);
|
|
if (em.lgtm_evidence_files?.length) parts.push(`Files: ${em.lgtm_evidence_files.join(", ")}`);
|
|
evidenceNote = parts.join("\n");
|
|
}
|
|
const title = `#${task.id} [${task.status}] ${task.subject}\nDone: ${task.done_criterion}${pendingNote}\n${task.description}${evidenceNote}`;
|
|
const action = await ui.select(title, actions);
|
|
|
|
if (action === "▸ Start (in_progress)") {
|
|
store.update(taskId, { status: "in_progress" });
|
|
widget.setActiveTask(taskId);
|
|
widget.update();
|
|
return viewTasks();
|
|
} else if (action === "✗ Delete") {
|
|
store.update(taskId, { status: "deleted" });
|
|
widget.setActiveTask(taskId, false);
|
|
widget.update();
|
|
return viewTasks();
|
|
}
|
|
return viewTasks();
|
|
};
|
|
|
|
const createTask = async (): Promise<void> => {
|
|
const subject = await ui.input("Task subject");
|
|
if (!subject) return mainMenu();
|
|
const description = await ui.input("Task description");
|
|
if (!description) return mainMenu();
|
|
const done_criterion = await ui.input("Done criterion (what does done look like?)");
|
|
if (!done_criterion) return mainMenu();
|
|
|
|
store.create(subject, description, done_criterion);
|
|
widget.update();
|
|
return mainMenu();
|
|
};
|
|
|
|
await mainMenu();
|
|
},
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// /lgtm command — human sign-off only
|
|
// ──────────────────────────────────────────────────
|
|
|
|
async function signOff(taskId: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
const task = store.get(taskId);
|
|
if (!task) { ctx.ui.notify(`Task #${taskId} not found`, "error"); return; }
|
|
if (task.status === "completed") { ctx.ui.notify(`Task #${taskId} already completed`, "info"); return; }
|
|
if (!task.pending_approval) {
|
|
ctx.ui.notify(`Task #${taskId} not ready. Agent must call lgtm_ask first.`, "error");
|
|
return;
|
|
}
|
|
|
|
// Show stored evidence for review before sign-off
|
|
const m = task.metadata;
|
|
const evidenceParts: string[] = [];
|
|
if (m.lgtm_evidence) {
|
|
evidenceParts.push(`Evidence:\n${m.lgtm_evidence}`);
|
|
evidenceParts.push(`FM1 (likely): ${m.lgtm_failure_mode_1}`);
|
|
evidenceParts.push(`FM2 (subtle/silent): ${m.lgtm_failure_mode_2}`);
|
|
if (m.lgtm_falsification_test) evidenceParts.push(`Falsification test: ${m.lgtm_falsification_test}`);
|
|
if (m.lgtm_remaining_uncertainty) evidenceParts.push(`Remaining uncertainty: ${m.lgtm_remaining_uncertainty}`);
|
|
if (m.lgtm_evidence_files?.length) evidenceParts.push(`Files: ${m.lgtm_evidence_files.join(", ")}`);
|
|
evidenceParts.push(`Submitted: ${m.lgtm_submitted_at}`);
|
|
}
|
|
const evidenceSummary = evidenceParts.length > 0 ? evidenceParts.join("\n\n") : "(no stored evidence)";
|
|
const confirm = await ctx.ui.select(
|
|
`Sign off #${taskId}: ${task.subject}\nDone criterion: ${task.done_criterion}\n\n${evidenceSummary}`,
|
|
["✓ LGTM — sign off", "✗ Cancel"],
|
|
);
|
|
if (confirm !== "✓ LGTM — sign off") return;
|
|
|
|
try {
|
|
store.complete(taskId);
|
|
} catch (err: any) {
|
|
ctx.ui.notify(err.message, "error");
|
|
return;
|
|
}
|
|
autoClear.trackCompletion(taskId, currentTurn);
|
|
widget.setActiveTask(taskId, false);
|
|
widget.update();
|
|
ctx.ui.notify(`Task #${taskId} signed off. ✓`, "info");
|
|
}
|
|
|
|
pi.registerCommand("lgtm", {
|
|
description: "Sign off on a task — /lgtm <id>",
|
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
const taskId = args.trim();
|
|
if (!taskId) {
|
|
const pending = store.list().filter(t => t.pending_approval && t.status !== "completed");
|
|
if (pending.length === 0) {
|
|
ctx.ui.notify("No tasks pending sign-off. Agent must call lgtm_ask first.", "info");
|
|
return;
|
|
}
|
|
const choice = await ctx.ui.select(
|
|
"Sign off on:",
|
|
pending.map(t => `#${t.id} ${t.subject}`).concat(["← Cancel"]),
|
|
);
|
|
if (!choice || choice === "← Cancel") return;
|
|
const match = choice.match(/#(\d+)/);
|
|
if (match) signOff(match[1], ctx);
|
|
return;
|
|
}
|
|
signOff(taskId, ctx);
|
|
},
|
|
});
|
|
}
|