From 423b34fc551411692bdd62296785bdea9eac741e Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:14:15 +0800 Subject: [PATCH] feat: add robot review lane --- README.md | 30 ++++++++++-- src/index.ts | 97 ++++++++++++++++++++++++++++++++------ src/review-badges.ts | 15 ++++++ src/ui/task-widget.ts | 10 ++-- test/review-badges.test.ts | 53 +++++++++++++++++++++ test/task-widget.test.ts | 14 ++++++ 6 files changed, 197 insertions(+), 22 deletions(-) create mode 100644 src/review-badges.ts create mode 100644 test/review-badges.test.ts diff --git a/README.md b/README.md index 9ccfe6c..88d2151 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A [pi](https://pi.dev) extension that adds structured human sign-off to task tra The core idea: agents cannot mark tasks complete themselves. They must call `lgtm_ask` with auditable evidence and explicit failure-mode analysis, then a human signs off via `/lgtm `. +Tasks can also carry a separate fresh-perspective robot review from a subagent or other model family. That review is observational only and never completes the task. + ## Install ```bash @@ -19,6 +21,7 @@ pi -e ./src/index.ts ``` ![example](media/screenshot.png) +![alt text](img/README-1776381151332-image.png) ## What is different from pi-tasks @@ -37,10 +40,14 @@ Stripped: `TaskExecute`, `TaskOutput`, `TaskStop`, `process-tracker.ts`, subagen ● 3 tasks (1 done, 1 in progress, 1 open) ✔ #1 Design schema ✳ #2 Implementing cache layer… (2m 49s · ↑ 4.1k ↓ 1.2k) - ◻ #3 Load test 👀 + ◻ #3 Load test 🛠 🤖 👀 ``` -`👀` means the agent called `lgtm_ask` and the task is waiting for human sign-off. +Badges: + +- `🛠` tool evidence attached via `lgtm_ask` +- `🤖` robot review attached via `robot_review_ask` +- `👀` pending human sign-off via `/lgtm` ## Tools @@ -82,6 +89,22 @@ After calling this, the task shows `👀` and is only completable via `/lgtm ` @@ -122,7 +145,8 @@ PI_TASKS_DEBUG=1 # trace to stderr ``` src/ -├── index.ts # 5 tools + /tasks + /lgtm commands + widget + event handlers +├── index.ts # 6 tools + /tasks + /lgtm commands + widget + event handlers +├── review-badges.ts # Review badge helpers for tool/robot/human lanes ├── types.ts # Task, TaskStatus types ├── task-store.ts # File-backed store with CRUD, locking, complete() method ├── auto-clear.ts # Turn-based auto-clearing of completed tasks diff --git a/src/index.ts b/src/index.ts index bc852fa..72477f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,32 +6,28 @@ * 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 + * lgtm_ask — Present evidence + failure modes for human sign-off + * robot_review_ask — Attach observational review from a fresh-perspective agent * * Commands: * /tasks — Interactive task management menu * /lgtm — 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 { getReviewBadges, REVIEW_BADGES } from "./review-badges.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 TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "lgtm_ask", "robot_review_ask"]); const REMINDER_INTERVAL = 4; const AUTO_CLEAR_DELAY = 4; @@ -191,7 +187,7 @@ Tasks are completed only via /lgtm after calling lgtm_ask with evidence.`, pi.registerTool({ name: "TaskList", label: "TaskList", - description: `List all LGTM sign-off tasks. Tasks with 👀 are pending human sign-off via /lgtm.`, + description: `List all LGTM sign-off tasks. Review badges: ${REVIEW_BADGES.tool}=tool evidence, ${REVIEW_BADGES.robot}=robot review, ${REVIEW_BADGES.human}=pending human sign-off via /lgtm.`, parameters: Type.Object({}), execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { @@ -207,7 +203,8 @@ Tasks are completed only via /lgtm after calling lgtm_ask with evidence.`, const lines = sorted.map(task => { let line = `#${task.id} [${task.status}] ${task.subject}`; - if (task.pending_approval && task.status !== "completed") line += " 👀"; + const reviewBadges = getReviewBadges(task); + if (reviewBadges.length > 0) line += ` ${reviewBadges.join(" ")}`; if (task.blockedBy.length > 0) { const openBlockers = task.blockedBy.filter(bid => { const blocker = store.get(bid); @@ -239,9 +236,10 @@ Tasks are completed only via /lgtm after calling lgtm_ask with evidence.`, if (!task) return Promise.resolve(textResult("Task not found")); const desc = task.description.replace(/\\n/g, "\n"); + const reviewBadges = getReviewBadges(task); const lines: string[] = [ `Task #${task.id}: ${task.subject}`, - `Status: ${task.status}${task.pending_approval && task.status !== "completed" ? " 👀 (pending sign-off)" : ""}`, + `Status: ${task.status}${reviewBadges.length ? ` ${reviewBadges.join(" ")}` : ""}${task.pending_approval && task.status !== "completed" ? " (pending human sign-off)" : ""}`, `Done criterion: ${task.done_criterion}`, ]; lines.push(`Description: ${desc}`); @@ -397,6 +395,54 @@ After this, task enters pending sign-off state — only completable via /lgtm `- ${o}`).join("\n")}\n\n` + + `### Blind spots\n${params.blind_spots}\n\n` + + `${REVIEW_BADGES.robot} Robot review stored. Human sign-off still requires \`/lgtm ${task.id}\`.`; + + return Promise.resolve(textResult(result)); + }, + }); + // ────────────────────────────────────────────────── // /tasks command // ────────────────────────────────────────────────── @@ -442,12 +488,14 @@ After this, task enters pending sign-off state — only completable via /lgtm { 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}`); + const choices = tasks.map(t => { + const badges = getReviewBadges(t); + return `${statusIcon(t)} #${t.id} [${t.status}] ${t.subject}${badges.length ? ` ${badges.join(" ")}` : ""}`; + }); choices.push("← Back"); const selected = await ui.select("Tasks", choices); @@ -470,7 +518,7 @@ After this, task enters pending sign-off state — only completable via /lgtm 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}`, diff --git a/src/review-badges.ts b/src/review-badges.ts new file mode 100644 index 0000000..d49ff99 --- /dev/null +++ b/src/review-badges.ts @@ -0,0 +1,15 @@ +import type { Task } from "./types.js"; + +export const REVIEW_BADGES = { + tool: "🛠", + robot: "🤖", + human: "👀", +} as const; + +export function getReviewBadges(task: Task): string[] { + const badges: string[] = []; + if (task.metadata?.lgtm_evidence) badges.push(REVIEW_BADGES.tool); + if (task.metadata?.robot_review_observations?.length) badges.push(REVIEW_BADGES.robot); + if (task.pending_approval && task.status !== "completed") badges.push(REVIEW_BADGES.human); + return badges; +} diff --git a/src/ui/task-widget.ts b/src/ui/task-widget.ts index 27cdc19..f6727f2 100644 --- a/src/ui/task-widget.ts +++ b/src/ui/task-widget.ts @@ -9,6 +9,7 @@ */ import { truncateToWidth } from "@mariozechner/pi-tui"; +import { getReviewBadges } from "../review-badges.js"; import type { TaskStore } from "../task-store.js"; // ---- Types ---- @@ -141,6 +142,8 @@ export class TaskWidget { for (let i = 0; i < visible.length; i++) { const task = visible[i]; const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress"; + const reviewBadges = getReviewBadges(task); + const reviewSuffix = reviewBadges.length > 0 ? ` ${reviewBadges.join(" ")}` : ""; let icon: string; if (isActive) { @@ -180,15 +183,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 + "…")}${stats}`; + text = ` ${icon} ${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))}`; + text = ` ${icon} ${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)})`) : ""; - const approvalSuffix = (task as any).pending_approval ? " 👀" : ""; - text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}${approvalSuffix}`; + text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}${reviewSuffix}`; } lines.push(truncate(text + suffix)); diff --git a/test/review-badges.test.ts b/test/review-badges.test.ts new file mode 100644 index 0000000..62e03e0 --- /dev/null +++ b/test/review-badges.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { getReviewBadges, REVIEW_BADGES } from "../src/review-badges.js"; +import type { Task } from "../src/types.js"; + +function makeTask(overrides: Partial = {}): Task { + return { + id: "1", + subject: "Test", + description: "Desc", + done_criterion: "done", + pending_approval: false, + status: "pending", + progress_label: undefined, + metadata: {}, + blocks: [], + blockedBy: [], + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +describe("getReviewBadges", () => { + it("returns no badges when no review artifacts exist", () => { + expect(getReviewBadges(makeTask())).toEqual([]); + }); + + it("returns tool, robot, and human badges independently", () => { + const task = makeTask({ + pending_approval: true, + metadata: { + lgtm_evidence: "npm test", + robot_review_observations: ["Observed one unchecked edge case"], + }, + }); + + expect(getReviewBadges(task)).toEqual([ + REVIEW_BADGES.tool, + REVIEW_BADGES.robot, + REVIEW_BADGES.human, + ]); + }); + + it("hides the human badge once the task is completed", () => { + const task = makeTask({ + pending_approval: true, + status: "completed", + metadata: { lgtm_evidence: "ok" }, + }); + + expect(getReviewBadges(task)).toEqual([REVIEW_BADGES.tool]); + }); +}); diff --git a/test/task-widget.test.ts b/test/task-widget.test.ts index 60454ea..8479b5d 100644 --- a/test/task-widget.test.ts +++ b/test/task-widget.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { REVIEW_BADGES } from "../src/review-badges.js"; import { TaskStore } from "../src/task-store.js"; import { TaskWidget, type Theme, type UICtx } from "../src/ui/task-widget.js"; @@ -100,6 +101,19 @@ describe("TaskWidget", () => { expect(lines[1]).toContain("~~#1 Done task~~"); }); + it("renders robot review badges on completed tasks", () => { + store.create("Done task", "Desc", "done"); + store.update("1", { + metadata: { robot_review_observations: ["Observed output drift on seed 2"] }, + pending_approval: true, + }); + store.complete("1"); + widget.update(); + + const lines = renderWidget(ui.state); + expect(lines[1]).toContain(REVIEW_BADGES.robot); + }); + it("renders active tasks with spinner icon", () => { store.create("Running thing", "Desc", "done criterion", "Processing data"); store.update("1", { status: "in_progress" });