feat: add robot review lane

This commit is contained in:
wassname
2026-04-17 08:14:15 +08:00
parent 2773971e32
commit 423b34fc55
6 changed files with 197 additions and 22 deletions
+27 -3
View File
@@ -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 <id>`.
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 <id
The tool result includes a non-blocking self-check prompt asking whether the evidence directly addresses the `done_criterion` and whether a skeptical reviewer would find it convincing.
### `robot_review_ask`
Attach a fresh-perspective robot review to a task.
Required fields:
| Field | Description |
|---|---|
| `taskId` | Task to annotate |
| `reviewer` | Model/provider/family/class used for the review |
| `scope` | What the reviewer inspected |
| `observations` | Concrete observations only. No advice, verdicts, or editorial |
| `blind_spots` | What the reviewer did not inspect or could not verify |
Use this from a separate subagent or other model when possible. The review is additive: it shows up as `🤖`, is visible in task detail and `/lgtm`, and does not complete the task.
## Commands
### `/lgtm <id>`
@@ -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
+82 -15
View File
@@ -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 <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 { 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 <i
},
});
pi.registerTool({
name: "robot_review_ask",
label: "robot_review_ask",
description: `Attach fresh-perspective robot review observations to a task.
Use this from a separate subagent or model when possible, ideally from a different model family/class than the implementation agent.
Observations only: report what you saw, not advice, verdicts, prioritization, or editorial.
This does not complete the task. Human /lgtm remains the only completion path.`,
parameters: Type.Object({
taskId: Type.String({ description: "Task ID to attach robot review to" }),
reviewer: Type.String({ description: "Reviewer identity, model family, or class" }),
scope: Type.String({ description: "What the reviewer examined" }),
observations: Type.Array(Type.String(), {
minItems: 1,
description: "Observations only. Concrete things noticed in the artifacts. No recommendations, interpretation, or editorial.",
}),
blind_spots: Type.String({ description: "What the reviewer did not inspect or could not verify" }),
}),
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`));
store.update(params.taskId, {
metadata: {
robot_review_reviewer: params.reviewer,
robot_review_scope: params.scope,
robot_review_observations: params.observations,
robot_review_blind_spots: params.blind_spots,
robot_review_submitted_at: new Date().toISOString(),
},
});
widget.update();
const result =
`## Robot review attached to task #${task.id}: ${task.subject}\n` +
`Reviewer: ${params.reviewer}\n` +
`Scope: ${params.scope}\n\n` +
`### Observations\n${params.observations.map(o => `- ${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 <i
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}`);
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 <i
actions.push("✗ Delete");
actions.push("← Back");
const pendingNote = task.pending_approval && task.status !== "completed" ? "\n👀 Pending /lgtm sign-off" : "";
const pendingNote = task.pending_approval && task.status !== "completed" ? `\n${REVIEW_BADGES.human} Pending /lgtm sign-off` : "";
const em = task.metadata;
let evidenceNote = "";
if (em.lgtm_evidence) {
@@ -482,7 +530,16 @@ After this, task enters pending sign-off state — only completable via /lgtm <i
if (em.lgtm_verification_hints?.length) parts.push(`Hints: ${em.lgtm_verification_hints.join(", ")}`);
evidenceNote = parts.join("\n");
}
const title = `#${task.id} [${task.status}] ${task.subject}\nDone: ${task.done_criterion}${pendingNote}\n${task.description}${evidenceNote}`;
let robotNote = "";
if (em.robot_review_observations?.length) {
const parts = [`\n\nRobot review (${em.robot_review_submitted_at ?? "?"})`];
if (em.robot_review_reviewer) parts.push(`Reviewer: ${em.robot_review_reviewer}`);
if (em.robot_review_scope) parts.push(`Scope: ${em.robot_review_scope}`);
parts.push(`Observations:\n- ${em.robot_review_observations.join("\n- ")}`);
if (em.robot_review_blind_spots) parts.push(`Blind spots: ${em.robot_review_blind_spots}`);
robotNote = parts.join("\n");
}
const title = `#${task.id} [${task.status}] ${task.subject}\nDone: ${task.done_criterion}${pendingNote}\n${task.description}${evidenceNote}${robotNote}`;
const action = await ui.select(title, actions);
if (action === "▸ Start (in_progress)") {
@@ -541,6 +598,16 @@ After this, task enters pending sign-off state — only completable via /lgtm <i
if (m.lgtm_verification_hints?.length) evidenceParts.push(`Hints: ${m.lgtm_verification_hints.join(", ")}`);
evidenceParts.push(`Submitted: ${m.lgtm_submitted_at}`);
}
if (m.robot_review_observations?.length) {
const robotParts = [
`Robot review:\nReviewer: ${m.robot_review_reviewer ?? "?"}`,
`Scope: ${m.robot_review_scope ?? "?"}`,
`Observations:\n- ${m.robot_review_observations.join("\n- ")}`,
];
if (m.robot_review_blind_spots) robotParts.push(`Blind spots: ${m.robot_review_blind_spots}`);
if (m.robot_review_submitted_at) robotParts.push(`Submitted: ${m.robot_review_submitted_at}`);
evidenceParts.push(robotParts.join("\n"));
}
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}`,
+15
View File
@@ -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;
}
+6 -4
View File
@@ -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));
+53
View File
@@ -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> = {}): 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]);
});
});
+14
View File
@@ -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" });