mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 17:01:35 +08:00
feat: human override for /lgtm + visible [STATE] tag
- /lgtm <id> and /lgtm * no longer hard-error when the agent skipped lgtm_ask; the human is the final gate, so they get a confirm dialog with explicit override copy instead of an error. - /lgtm * now spans every open task (READY + ACTIVE + PENDING) and shows a grouped preview before signing off. - Each task row (widget + TaskList) is prefixed with a coloured [READY]/[ACTIVE]/[PENDING]/[DONE] tag so signoff-readiness is legible at a glance instead of decoded from emoji pipeline + colour.
This commit is contained in:
+75
-36
@@ -17,8 +17,9 @@
|
||||
*
|
||||
* Commands:
|
||||
* /tasks — Interactive task management menu
|
||||
* /lgtm <id...> — Human signs off on one or more tasks
|
||||
* /lgtm * — Sign off all tasks awaiting human review with passing robot review
|
||||
* /lgtm <id...> — Human signs off on one or more tasks (override allowed even without lgtm_ask)
|
||||
* /lgtm * — Sign off ALL open tasks (READY/ACTIVE/PENDING) after a grouped confirm
|
||||
* /lgtm — Pick from any open task (with [READY]/[ACTIVE]/[PENDING] tags)
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
@@ -26,7 +27,7 @@ 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 { type DisplayStatus, getDisplayStatus, getReviewBadges } from "./review-badges.js";
|
||||
import { type DisplayStatus, getDisplayStatus, getReviewBadges, getStateTag } from "./review-badges.js";
|
||||
import {
|
||||
appendRobotReviewMetadata,
|
||||
getLatestRobotReview,
|
||||
@@ -487,7 +488,7 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "TaskList",
|
||||
label: "TaskList",
|
||||
description: `List all tasks grouped by status. Pipeline stages: [🛠🤖👀] = evidence→review→signoff (·=pending).`,
|
||||
description: `List all tasks grouped by status. State tag: [READY] (signoff-ready) [ACTIVE] [PENDING] [DONE]. Pipeline stages: [🛠🤖👀] = evidence→review→signoff (·=pending).`,
|
||||
parameters: Type.Object({}),
|
||||
|
||||
execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
||||
@@ -495,7 +496,7 @@ export default function (pi: ExtensionAPI) {
|
||||
if (tasks.length === 0) return Promise.resolve(textResult("No tasks found"));
|
||||
|
||||
const renderTask = (task: typeof tasks[number]) => {
|
||||
let line = ` #${task.id} ${task.subject} ${getReviewBadges(task)}`;
|
||||
let line = ` [${getStateTag(task).padEnd(7)}] #${task.id} ${task.subject} ${getReviewBadges(task)}`;
|
||||
if (task.blockedBy.length > 0) {
|
||||
const openBlockers = task.blockedBy.filter(bid => {
|
||||
const blocker = store.get(bid);
|
||||
@@ -992,17 +993,14 @@ This appends a new robot-review iteration. If the reviewer marks evidence incomp
|
||||
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;
|
||||
}
|
||||
if (getRobotReviews(task).length > 0 && !latestRobotReviewPasses(task)) {
|
||||
ctx.ui.notify(`Task #${taskId} is blocked by the latest robot review. Strengthen evidence and rerun review first.`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build human-visible state summary (the human is the final gate; we just surface friction).
|
||||
const m = task.metadata;
|
||||
const robotReviews = getRobotReviews(task);
|
||||
const noEvidence = !task.pending_approval && !m.lgtm_evidence;
|
||||
const robotRejected = robotReviews.length > 0 && !latestRobotReviewPasses(task);
|
||||
|
||||
// Print evidence to the conversation so the user can review it there
|
||||
const m = task.metadata;
|
||||
const evidenceParts: string[] = [];
|
||||
if (m.lgtm_evidence) {
|
||||
evidenceParts.push(`**Evidence:**\n${m.lgtm_evidence}`);
|
||||
@@ -1012,24 +1010,32 @@ This appends a new robot-review iteration. If the reviewer marks evidence incomp
|
||||
if (m.lgtm_remaining_uncertainty) evidenceParts.push(`Remaining uncertainty: ${m.lgtm_remaining_uncertainty}`);
|
||||
if (m.lgtm_verification_hints?.length) evidenceParts.push(`Hints: ${m.lgtm_verification_hints.join(", ")}`);
|
||||
evidenceParts.push(`Submitted: ${m.lgtm_submitted_at}`);
|
||||
} else {
|
||||
evidenceParts.push(`(No agent-submitted evidence — agent never called lgtm_ask. Human override.)`);
|
||||
}
|
||||
const robotReviews = getRobotReviews(task);
|
||||
if (robotReviews.length > 0) {
|
||||
evidenceParts.push(
|
||||
`Robot reviews (${robotReviews.length} total):\n${robotReviews.map(formatRobotReview).join("\n\n")}`,
|
||||
);
|
||||
if (!latestRobotReviewPasses(task)) {
|
||||
evidenceParts.push("Latest robot review says the evidence is not yet complete/convincing.");
|
||||
if (robotRejected) {
|
||||
evidenceParts.push("⚠ Latest robot review says the evidence is not yet complete/convincing.");
|
||||
}
|
||||
}
|
||||
if (evidenceParts.length > 0) {
|
||||
ctx.ui.notify(evidenceParts.join("\n\n"), "info");
|
||||
}
|
||||
const confirm = await ctx.ui.select(
|
||||
`Sign off #${taskId}: ${task.subject}\nDone: ${task.done_criterion}`,
|
||||
["✓ LGTM — sign off", "✗ Cancel"],
|
||||
);
|
||||
if (confirm !== "✓ LGTM — sign off") return;
|
||||
|
||||
let title = `Sign off #${taskId}: ${task.subject}\nDone: ${task.done_criterion}`;
|
||||
let signLabel = "✓ LGTM — sign off";
|
||||
if (noEvidence) {
|
||||
title = `⚠ Task #${taskId} has no agent-submitted evidence.\nSign off anyway?\nDone: ${task.done_criterion}`;
|
||||
signLabel = "✓ Override — sign off without evidence";
|
||||
} else if (robotRejected) {
|
||||
title = `⚠ Task #${taskId} robot review rejected the evidence.\nSign off anyway?\nDone: ${task.done_criterion}`;
|
||||
signLabel = "✓ Override — sign off despite rejected review";
|
||||
}
|
||||
const confirm = await ctx.ui.select(title, [signLabel, "✗ Cancel"]);
|
||||
if (confirm !== signLabel) return;
|
||||
|
||||
try {
|
||||
store.complete(taskId);
|
||||
@@ -1044,43 +1050,76 @@ This appends a new robot-review iteration. If the reviewer marks evidence incomp
|
||||
}
|
||||
|
||||
pi.registerCommand("lgtm", {
|
||||
description: "Sign off on tasks — /lgtm <id> [<id> ...] or /lgtm * to sign off all pending",
|
||||
description:
|
||||
"Sign off on tasks. /lgtm <id> [<id>...] signs specific tasks; /lgtm * signs ALL open tasks (READY + ACTIVE + PENDING) after confirmation. Human override allowed even when the agent never called lgtm_ask.",
|
||||
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
||||
const trimmed = args.trim();
|
||||
if (trimmed === "*") {
|
||||
// Sign off all pending tasks at once
|
||||
const pending = store.list().filter(t => t.pending_approval && t.status !== "completed" && latestRobotReviewPasses(t));
|
||||
if (pending.length === 0) {
|
||||
ctx.ui.notify("No tasks pending sign-off with passing robot review.", "info");
|
||||
// Sign off all open (non-completed) tasks at once. Human is the final gate.
|
||||
const open = store.list().filter(t => t.status !== "completed");
|
||||
if (open.length === 0) {
|
||||
ctx.ui.notify("No open tasks to sign off.", "info");
|
||||
return;
|
||||
}
|
||||
const groups: Record<DisplayStatus, typeof open> = {
|
||||
awaiting_signoff: [],
|
||||
in_progress: [],
|
||||
pending: [],
|
||||
completed: [],
|
||||
};
|
||||
for (const t of open) groups[getDisplayStatus(t)].push(t);
|
||||
const groupLabel: Record<DisplayStatus, string> = {
|
||||
awaiting_signoff: "READY (robot ✓)",
|
||||
in_progress: "ACTIVE (no /lgtm evidence)",
|
||||
pending: "PENDING (not started)",
|
||||
completed: "DONE",
|
||||
};
|
||||
const lines: string[] = [];
|
||||
for (const status of ["awaiting_signoff", "in_progress", "pending"] as DisplayStatus[]) {
|
||||
const inBucket = groups[status];
|
||||
if (inBucket.length === 0) continue;
|
||||
lines.push(` ${groupLabel[status]}:`);
|
||||
for (const t of inBucket) {
|
||||
const warn = status === "awaiting_signoff" && !latestRobotReviewPasses(t) ? " ⚠ robot rejected" : "";
|
||||
lines.push(` #${t.id} ${t.subject}${warn}`);
|
||||
}
|
||||
}
|
||||
ctx.ui.notify(`About to sign off ALL ${open.length} open tasks:\n${lines.join("\n")}`, "info");
|
||||
const choice = await ctx.ui.select(
|
||||
`Sign off ALL ${pending.length} pending tasks?`,
|
||||
pending.map(t => `#${t.id} ${t.subject}`).concat(["← Cancel"]),
|
||||
`Sign off ALL ${open.length} open tasks?`,
|
||||
[`✓ Sign off all ${open.length}`, "← Cancel"],
|
||||
);
|
||||
if (!choice || choice === "← Cancel") return;
|
||||
for (const t of pending) {
|
||||
let signed = 0;
|
||||
for (const t of open) {
|
||||
try {
|
||||
store.complete(t.id);
|
||||
autoClear.trackCompletion(t.id, currentTurn);
|
||||
widget.setActiveTask(t.id, false);
|
||||
signed++;
|
||||
} catch (err: any) {
|
||||
ctx.ui.notify(`Failed to sign off #${t.id}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
widget.update();
|
||||
ctx.ui.notify(`Signed off ${pending.length} tasks. ✓`, "info");
|
||||
ctx.ui.notify(`Signed off ${signed}/${open.length} tasks. ✓`, "info");
|
||||
return;
|
||||
}
|
||||
if (!trimmed) {
|
||||
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");
|
||||
const open = store.list().filter(t => t.status !== "completed");
|
||||
if (open.length === 0) {
|
||||
ctx.ui.notify("No open tasks. Use /lgtm * to confirm-clear everything, or /lgtm <id>.", "info");
|
||||
return;
|
||||
}
|
||||
const tag = (t: typeof open[number]) => {
|
||||
const s = getDisplayStatus(t);
|
||||
if (s === "awaiting_signoff") return "[READY] ";
|
||||
if (s === "in_progress") return "[ACTIVE] ";
|
||||
return "[PENDING] ";
|
||||
};
|
||||
const choice = await ctx.ui.select(
|
||||
"Sign off on:",
|
||||
pending.map(t => `#${t.id} ${t.subject}`).concat(["← Cancel"]),
|
||||
"Sign off on (any open task — human override allowed):",
|
||||
open.map(t => `${tag(t)}#${t.id} ${t.subject}`).concat(["← Cancel"]),
|
||||
);
|
||||
if (!choice || choice === "← Cancel") return;
|
||||
const match = choice.match(/#(\d+)/);
|
||||
|
||||
@@ -22,3 +22,22 @@ export function getDisplayStatus(task: Task): DisplayStatus {
|
||||
if (task.pending_approval) return "awaiting_signoff";
|
||||
return task.status;
|
||||
}
|
||||
|
||||
export type StateTag = "READY" | "ACTIVE" | "PENDING" | "DONE";
|
||||
|
||||
/** Short uppercase tag for the human ("can I /lgtm this?" at a glance). */
|
||||
export function getStateTag(task: Task): StateTag {
|
||||
const s = getDisplayStatus(task);
|
||||
if (s === "completed") return "DONE";
|
||||
if (s === "awaiting_signoff") return "READY";
|
||||
if (s === "in_progress") return "ACTIVE";
|
||||
return "PENDING";
|
||||
}
|
||||
|
||||
/** Theme colour key for each state tag (only theme colours present in pi-tui are used). */
|
||||
export function getStateTagColor(tag: StateTag): "success" | "accent" | "dim" | undefined {
|
||||
if (tag === "READY") return "success";
|
||||
if (tag === "ACTIVE") return "accent";
|
||||
if (tag === "DONE") return "dim";
|
||||
return undefined; // PENDING — default fg
|
||||
}
|
||||
|
||||
+1
-2
@@ -191,13 +191,12 @@ export class TaskStore {
|
||||
});
|
||||
}
|
||||
|
||||
/** Complete a task. Called only by /lgtm -- requires pending_approval=true. */
|
||||
/** Complete a task. Called only by /lgtm. The human-confirm gate lives in the command layer. */
|
||||
complete(id: string): Task {
|
||||
return this.withLock(() => {
|
||||
const task = this.tasks.get(id);
|
||||
if (!task) throw new Error(`Task #${id} not found`);
|
||||
if (task.status === "completed") throw new Error(`Task #${id} already completed`);
|
||||
if (!task.pending_approval) throw new Error(`Task #${id} not ready. Agent must call lgtm_ask first.`);
|
||||
task.status = "completed";
|
||||
task.updatedAt = Date.now();
|
||||
return task;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { getDisplayStatus, getReviewBadges } from "../review-badges.js";
|
||||
import { getDisplayStatus, getReviewBadges, getStateTag, getStateTagColor } from "../review-badges.js";
|
||||
import type { TaskStore } from "../task-store.js";
|
||||
|
||||
// ---- Types ----
|
||||
@@ -143,6 +143,11 @@ export class TaskWidget {
|
||||
const task = visible[i];
|
||||
const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress";
|
||||
const reviewSuffix = ` ${getReviewBadges(task)}`;
|
||||
const tag = getStateTag(task);
|
||||
// [READY ] [ACTIVE ] [PENDING] [DONE ] — pad so columns line up.
|
||||
const tagColour = getStateTagColor(tag);
|
||||
const tagBox = `[${tag.padEnd(7)}]`;
|
||||
const tagPrefix = (tagColour ? theme.fg(tagColour, tagBox) : tagBox) + " ";
|
||||
|
||||
let icon: string;
|
||||
if (isActive) {
|
||||
@@ -182,14 +187,14 @@ export class TaskWidget {
|
||||
? ` ${theme.fg("dim", `(${elapsed} · ${tokenParts.join(" ")})`)}`
|
||||
: ` ${theme.fg("dim", `(${elapsed})`)}`;
|
||||
}
|
||||
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${theme.fg("accent", form + agentLabel + "…")}${reviewSuffix}${stats}`;
|
||||
text = ` ${icon} ${tagPrefix}${theme.fg("dim", "#" + task.id)} ${theme.fg("accent", form + agentLabel + "…")}${reviewSuffix}${stats}`;
|
||||
} else if (task.status === "completed") {
|
||||
text = ` ${icon} ${theme.fg("dim", theme.strikethrough("#" + task.id + " " + task.subject))}${reviewSuffix}`;
|
||||
text = ` ${icon} ${tagPrefix}${theme.fg("dim", theme.strikethrough("#" + task.id + " " + task.subject))}${reviewSuffix}`;
|
||||
} else {
|
||||
const agentSuffix = task.status === "in_progress" && task.metadata?.agentId
|
||||
? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`)
|
||||
: "";
|
||||
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}${reviewSuffix}`;
|
||||
text = ` ${icon} ${tagPrefix}${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}${reviewSuffix}`;
|
||||
}
|
||||
|
||||
lines.push(truncate(text + suffix));
|
||||
|
||||
@@ -195,9 +195,11 @@ describe("TaskStore (in-memory)", () => {
|
||||
expect(changedFields).toEqual([]);
|
||||
});
|
||||
|
||||
it("complete() requires pending_approval", () => {
|
||||
it("complete() works without pending_approval (human override path)", () => {
|
||||
// The /lgtm command layer is the human gate; complete() itself is permissive.
|
||||
store.create("Test", "Desc", "done");
|
||||
expect(() => store.complete("1")).toThrow("lgtm_ask");
|
||||
const task = store.complete("1");
|
||||
expect(task.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("complete() works when pending_approval=true", () => {
|
||||
|
||||
Reference in New Issue
Block a user