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:
wassname
2026-05-02 08:06:45 +08:00
parent 5b800653a3
commit dbe887f0c2
5 changed files with 108 additions and 44 deletions
+75 -36
View File
@@ -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+)/);
+19
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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));
+4 -2
View File
@@ -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", () => {