diff --git a/README.md b/README.md index e9b9711..06bb024 100644 --- a/README.md +++ b/README.md @@ -1,328 +1,141 @@ -# @tintinweb/pi-tasks +# @wassname/pi-lgtm -A [pi](https://pi.dev) extension that brings **Claude Code-style task tracking and coordination** to pi. Track multi-step work with structured tasks, dependency management, and a persistent visual widget. +A [pi](https://pi.dev) extension that adds structured human sign-off to task tracking. Fork of [@tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks) with a minimal LGTM layer. -> **Status:** Early release. - -pi-tasks screenshot - -https://github.com/user-attachments/assets/1d0ee87a-e0a5-4bfa-a9b9-2f9144cb905b - - - -## Features - -- **7 LLM-callable tools** — `TaskCreate`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop`, `TaskExecute` — matching Claude Code's exact tool specs and descriptions -- **Persistent widget** — live task list above the editor with `✔`/`◼`/`◻` status icons, task numbers (`#1`, `#2`, …), strikethrough for completed tasks, star spinner (`✳✽`) for active tasks with elapsed time and token counts -- **System-reminder injection** — periodic `` nudges appended to tool results when task tools haven't been used recently (matches Claude Code's behavior exactly) -- **Prompt guidelines** — workflow contract encoded in tool descriptions, nudging the LLM at the point of tool use -- **Dependency management** — bidirectional `blocks`/`blockedBy` relationships with warnings for cycles, self-deps, and dangling references -- **Shared task lists** — multiple pi sessions can share a file-backed task list for agent team coordination -- **File locking** — concurrent access is safe when multiple sessions share a task list -- **Background process tracking** — track spawned processes with output buffering, blocking wait, and graceful stop -- **Subagent integration** — tasks with `agentType` can be executed as subagents via `TaskExecute` (requires [@tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents)). Auto-cascade mode flows through the task DAG automatically when enabled. +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 `. ## Install ```bash -pi install npm:@tintinweb/pi-tasks +pi install npm:@wassname/pi-lgtm ``` -Or load directly for development: +Or for development: ```bash pi -e ./src/index.ts ``` +## What is different from pi-tasks + +| pi-tasks | pi-lgtm | +|---|---| +| Agent calls `TaskUpdate { status: "completed" }` | Blocked -- throws error | +| No evidence required | `lgtm_ask` requires evidence, 2 failure modes, evidence vs failures | +| Tasks complete immediately | Agent sets `pending_approval`, human runs `/lgtm ` | +| No done criterion | `done_criterion` required on create: falsifiable observation | + +Stripped: `TaskExecute`, `TaskOutput`, `TaskStop`, `process-tracker.ts`, subagent RPC, settings menu. + ## Widget -The extension renders a persistent widget above the editor: - ``` -● 4 tasks (1 done, 1 in progress, 2 open) - ✔ #1 Design the flux capacitor - ✳ #2 Acquiring plutonium… (2m 49s · ↑ 4.1k ↓ 1.2k) - ◻ #3 Install flux capacitor in DeLorean › blocked by #1 - ◻ #4 Test time travel at 88 mph › blocked by #2, #3 +● 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 👀 ``` -| Icon | Meaning | -|------|---------| -| `✔` | Completed (strikethrough + dim) | -| `◼` | In-progress (not actively executing) | -| `◻` | Pending | -| `✳`/`✽` | Animated star spinner — actively executing task (shows `activeForm` text, elapsed time, token counts) | +`👀` means the agent called `lgtm_ask` and the task is waiting for human sign-off. ## Tools ### `TaskCreate` -Create a structured task. Used proactively for complex multi-step work. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `subject` | string | yes | Brief imperative title | -| `description` | string | yes | Detailed context and acceptance criteria | -| `activeForm` | string | no | Present continuous form for spinner (e.g., "Running tests") | -| `agentType` | string | no | Agent type for subagent execution (e.g., `"general-purpose"`, `"Explore"`) | -| `metadata` | object | no | Arbitrary key-value pairs | - ``` -→ Task #1 created successfully: Fix authentication bug +subject, description, done_criterion (required), activeForm (optional) ``` +`done_criterion` must be a falsifiable observation: what you expect to see AND what you would see if it is wrong. Example: `"All 92 tests pass. If wrong: type errors in build or failures in task-store.test.ts."` + ### `TaskList` -List all tasks with status, owner, and blocked-by info. - -``` -#1 [pending] Fix authentication bug -#2 [in_progress] Write unit tests (agent-1) -#3 [pending] Update docs [blocked by #1, #2] -``` - -Sort order: pending first, then in-progress, then completed (each group by ID). +Lists all tasks. `👀` indicates pending sign-off. ### `TaskGet` -Get full details for a specific task. - -``` -Task #2: Write unit tests -Status: in_progress -Owner: agent-1 -Description: Add tests for the auth module -Blocked by: #1 -Blocks: #3 -``` - -Shows owner (if set) and open (non-completed) dependency edges. Non-empty metadata is displayed as JSON. +Full task details including `done_criterion` and approval state. ### `TaskUpdate` -Update task fields, status, metadata, and dependencies. +Update status (`pending | in_progress | deleted`), subject, description, done_criterion, dependencies. Cannot set `completed` -- use `/lgtm`. -| Parameter | Type | Description | -|-----------|------|-------------| -| `taskId` | string | Task ID (required) | -| `status` | `pending` / `in_progress` / `completed` / `deleted` | New status | -| `subject` | string | New title | -| `description` | string | New description | -| `activeForm` | string | Spinner text | -| `owner` | string | Agent name | -| `metadata` | object | Shallow merge (null values delete keys) | -| `addBlocks` | string[] | Task IDs this task blocks | -| `addBlockedBy` | string[] | Task IDs that block this task | +### `lgtm_ask` + +The epistemic gate. Required fields: + +| Field | Description | +|---|---| +| `taskId` | Task to submit | +| `evidence` | Exact command run + output, commit hash, config/seeds, file paths. "I ran X and got Y" not "I wrote X". | +| `failure_mode_1` | Most likely way this is wrong despite evidence | +| `failure_mode_2` | Second most likely failure mode | +| `evidence_vs_failures` | How would evidence look different if FM1 or FM2 were true? | +| `evidence_files` | Optional file paths to inspect (validated: must exist) | +| `remaining_uncertainty` | What is NOT tested, deferred edge cases, known limitations | + +After calling this, the task shows `👀` and is only completable via `/lgtm `. Evidence is stored on the task so the human can review it hours later without scrolling back. + +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. + +## Commands + +### `/lgtm ` + +Human-only sign-off. Shows stored evidence, failure modes, and remaining uncertainty for review, then asks for confirmation. Without ``, shows a list of pending-approval tasks. + +### `/tasks` + +Interactive menu: view tasks, create task, clear completed/all. + +## Task lifecycle ``` -→ Updated task #1 status -→ Updated task #2 owner, status -→ Updated task #3 blocks -→ Updated task #3 blocks (warning: cycle: #3 and #1 block each other) -→ Updated task #1 deleted +pending -> in_progress -> (lgtm_ask) -> pending_approval 👀 -> (/lgtm) -> completed + -> deleted ``` -Setting `status: "deleted"` permanently removes the task. +## Storage -Dependencies are bidirectional: `addBlocks: ["3"]` on task 1 also adds `blockedBy: ["1"]` to task 3. - -### `TaskOutput` - -Retrieve output from a background task process. - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `task_id` | string | — | Task ID or agent ID (required) | -| `block` | boolean | `true` | Wait for completion | -| `timeout` | number | `30000` | Max wait time in ms (max 600000) | - -Both task IDs and agent IDs (including partial prefixes) are accepted — agent IDs are resolved via the internal `agentTaskMap`. - -### `TaskStop` - -Stop a running background task process. Sends SIGTERM, waits 5 seconds, then SIGKILL. For subagent tasks, sends a stop RPC. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `task_id` | string | Task ID or agent ID to stop | - -### `TaskExecute` - -Execute one or more tasks as background subagents. Requires [@tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents). - -| Parameter | Type | Description | -|-----------|------|-------------| -| `task_ids` | string[] | Task IDs to execute (required) | -| `additional_context` | string | Extra context appended to each agent's prompt | -| `model` | string | Model override (e.g., `"sonnet"`, `"haiku"`) | -| `max_turns` | number | Max turns per agent | - -Tasks must be `pending`, have `agentType` set, and all `blockedBy` dependencies `completed`. Each task spawns as an independent background subagent. - -With **auto-cascade** enabled (via `/tasks` → Settings), completed tasks automatically trigger execution of their unblocked dependents — flowing through the DAG like a build system. - -## Task Lifecycle - -``` -pending → in_progress → completed - → deleted (permanently removed) -``` - -Tasks are created as `pending`. Mark `in_progress` before starting work, `completed` when done. `deleted` removes entirely — IDs never reset. - -## Dependency Management - -- **Bidirectional edges:** `addBlocks`/`addBlockedBy` maintain both sides automatically -- **Dependency warnings:** cycles, self-dependencies, and references to non-existent tasks are stored but produce warnings in the tool response -- **Display-time filtering:** `TaskList` only shows non-completed blockers in `[blocked by ...]` -- **Raw data preserved:** `TaskGet` shows ALL edges, including completed blockers -- **Cleanup on deletion:** removing a task cleans up all edges pointing to it - -## Task Storage - -Task storage is controlled by the `taskScope` setting (`/tasks` → Settings → Task storage): +Controlled by `taskScope` in `.pi/tasks-config.json`: | Mode | File | Behaviour | -|------|------|-----------| -| `memory` | *(none)* | In-memory only — tasks lost when session ends | -| `session` **(default)** | `/.pi/tasks/tasks-.json` | Per-session file — isolated between sessions, survives resume | -| `project` | `/.pi/tasks/tasks.json` | Shared across all sessions in the project | +|---|---|---| +| `memory` | none | In-memory, lost on session end | +| `session` (default) | `.pi/tasks/tasks-.json` | Per-session, survives resume | +| `project` | `.pi/tasks/tasks.json` | Shared across all sessions | -On new session start, if all persisted tasks are completed they are auto-cleared for a clean slate. On session resume, all tasks (including completed) are shown so the user can review progress. Empty session files are automatically deleted when all tasks are cleared. +Override via env: -### Auto-clear completed tasks - -The `autoClearCompleted` setting controls automatic cleanup of completed tasks: - -| Mode | Behaviour | -|------|-----------| -| `never` | Completed tasks stay visible until manually cleared via `/tasks` → Clear completed | -| `on_list_complete` **(default)** | Cleared after all tasks are done and a few idle turns pass | -| `on_task_complete` | Each completed task cleared individually after a few turns | - -Both auto-clear modes use a turn-based delay for non-jarring UX — tasks linger briefly so you see the completion before they disappear. - -Settings (`taskScope`, `autoCascade`, `autoClearCompleted`) are saved to `/.pi/tasks-config.json`. - -### Override via environment variables - -| Variable | Value | Behaviour | -|----------|-------|-----------| -| `PI_TASKS` | `off` | In-memory only (CI/automation) | -| `PI_TASKS` | `sprint-1` | Named shared list at `~/.pi/tasks/sprint-1.json` | -| `PI_TASKS` | `/abs/path/tasks.json` | Explicit absolute file path | -| `PI_TASKS` | `./tasks.json` | Relative path resolved from cwd | -| *(unset)* | | Uses `taskScope` setting (default: `session`) | -| `PI_TASKS_DEBUG` | `1` | Trace RPC communication (request/reply/timeout) and spawn errors to stderr | - -Named and explicit paths use a file-locked store with stale-lock detection — safe for multiple pi sessions coordinating on the same task list. - -**CI example** (`.envrc`): ```bash -export PI_TASKS=off +PI_TASKS=off # in-memory (CI) +PI_TASKS=sprint-1 # named shared list at ~/.pi/tasks/sprint-1.json +PI_TASKS=/abs/path # explicit path +PI_TASKS_DEBUG=1 # trace to stderr ``` -**Shared team list** (`.envrc`): -```bash -export PI_TASKS=my-project -``` - -## `/tasks` Command - -Interactive menu: - -``` -Tasks -├─ View all tasks (4) -├─ Create task -├─ Clear completed (1) -├─ Clear all (4) -└─ Settings -``` - -- **View all tasks** — select a task to see details and take actions (start, complete, delete) -- **Create task** — input prompts for subject and description -- **Clear completed** — remove all completed tasks -- **Clear all** — remove all tasks regardless of status -- **Settings** — configure task storage, auto-cascade, and auto-clear completed tasks (saved to `tasks-config.json`) - -## Cross-extension Communication with [`@tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) - -[`pi-tasks`](https://github.com/tintinweb/pi-tasks) communicates with [`@tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) via pi's eventbus using a scoped request/reply RPC protocol. No shared global state — just events. - -### Presence Detection - -Load order doesn't matter. Two handshake paths ensure detection regardless of which extension loads first: - -1. **Ping on init** — [`pi-tasks`](https://github.com/tintinweb/pi-tasks) emits `subagents:rpc:ping` with a unique `requestId` and listens for `subagents:rpc:ping:reply:{requestId}`. If [`pi-subagents`](https://github.com/tintinweb/pi-subagents) is already loaded, it replies immediately. -2. **Ready broadcast** — [`pi-subagents`](https://github.com/tintinweb/pi-subagents) emits `subagents:ready` when it initializes. If [`pi-tasks`](https://github.com/tintinweb/pi-tasks) loaded first, it picks this up. - -``` -┌─────────────┐ ┌──────────────────┐ -│ pi-tasks │ │ pi-subagents │ -└──────┬──────┘ └────────┬─────────┘ - │ │ - │──── subagents:rpc:ping ───────────▶│ - │◀─── subagents:rpc:ping:reply ──────│ - │ │ - │◀─── subagents:ready ───────────────│ (broadcast on init) - │ │ -``` - -### Spawning Subagents - -When `TaskExecute` runs, it sends a spawn RPC with a scoped reply channel: - -``` -pi-tasks pi-subagents - │ │ - │── subagents:rpc:spawn ─────────────────▶│ { requestId, type, prompt, options } - │◀─ subagents:rpc:spawn:reply:{reqId} ───│ { id } (or { error }) - │ │ -``` - -The returned `id` is stored in an in-memory `agentTaskMap` (agentId → taskId) for O(1) completion lookup. A 30-second timeout rejects the Promise if no reply arrives. - -### Lifecycle Events - -[`pi-subagents`](https://github.com/tintinweb/pi-subagents) emits lifecycle events that [`pi-tasks`](https://github.com/tintinweb/pi-tasks) listens to: - -| Event | Payload | Action | -|-------|---------|--------| -| `subagents:completed` | `{ id, result? }` | Mark task `completed`, trigger auto-cascade if enabled | -| `subagents:failed` | `{ id, error?, status }` | Revert task to `pending`, store error in metadata | - -### Standalone Mode - -If [`pi-subagents`](https://github.com/tintinweb/pi-subagents) is not installed, everything works except `TaskExecute`, which returns a friendly error message. All core task tools (create, list, get, update, dependencies, widget, system-reminder injection) function independently. - ## Architecture ``` src/ -├── index.ts # Extension entry: 7 tools + /tasks command + widget + subagent integration -├── types.ts # Task, TaskStatus, BackgroundProcess types -├── task-store.ts # File-backed store with CRUD, dependencies, locking -├── auto-clear.ts # Turn-based auto-clearing of completed tasks (AutoClearManager) -├── tasks-config.ts # Config persistence (taskScope, autoCascade, autoClearCompleted) → .pi/tasks-config.json -├── process-tracker.ts # Background process output buffering and stop +├── index.ts # 5 tools + /tasks + /lgtm commands + widget + event handlers +├── 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 +├── tasks-config.ts # Config persistence -> .pi/tasks-config.json └── ui/ - ├── task-widget.ts # Persistent widget with status icons and spinner - └── settings-menu.ts # /tasks → Settings panel (SettingsList TUI component) + └── task-widget.ts # Widget with status icons, spinner, 👀 indicator ``` -## Future Work - -- **Background Bash auto-task creation** — Claude Code auto-creates tasks when `Bash` runs with `run_in_background: true`. Pi's bash tool currently lacks a `run_in_background` parameter (only `command` + `timeout`), so there's nothing to hook into. Once pi adds background execution support to its bash tool, we can use the `tool_call` event to detect it and auto-create tasks via `TaskStore`/`ProcessTracker`. - ## Development ```bash npm install -npm run typecheck # TypeScript validation -npm test # Run unit tests (145 tests) +npm run typecheck +npm test # 92 tests +npm run build ``` ## License -MIT — [tintinweb](https://github.com/tintinweb) +MIT -- based on [tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks) (MIT) diff --git a/package.json b/package.json index a3e9a45..ec0e748 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,24 @@ { - "name": "@tintinweb/pi-tasks", + "name": "@wassname/pi-lgtm", "version": "0.4.2", - "description": "A pi extension that brings Claude Code-style task tracking and coordination to pi.", - "author": "tintinweb", + "description": "A pi extension providing goal tracking with structural sign-off and LGTM workflow.", + "author": "wassname", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/tintinweb/pi-tasks.git" + "url": "https://github.com/wassname/pi-lgtm.git" }, - "homepage": "https://github.com/tintinweb/pi-tasks#readme", + "homepage": "https://github.com/wassname/pi-lgtm#readme", "bugs": { - "url": "https://github.com/tintinweb/pi-tasks/issues" + "url": "https://github.com/wassname/pi-lgtm/issues" }, "keywords": [ "pi-package", "pi", "pi-extension", - "task", - "tasks", - "todo", - "coordination" + "lgtm", + "sign-off", + "goal-tracking" ], "dependencies": { "@mariozechner/pi-coding-agent": "^0.62.0", @@ -45,7 +44,7 @@ "extensions": [ "./src/index.ts" ], - "video": "https://github.com/tintinweb/pi-tasks/raw/master/media/demo.mp4", - "image": "https://github.com/tintinweb/pi-tasks/raw/master/media/screenshot.png" + "video": "", + "image": "" } } diff --git a/src/index.ts b/src/index.ts index 12327c4..a99b229 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,63 +1,49 @@ /** - * @tintinweb/pi-tasks — A pi extension providing Claude Code-style task tracking and coordination. + * pi-lgtm — Task tracking with structured human sign-off for pi coding agent. * * Tools: - * TaskCreate — Create a structured task + * TaskCreate — Create a task with done_criterion * TaskList — List all tasks with status * TaskGet — Get full task details - * TaskUpdate — Update task fields, status, dependencies - * TaskOutput — Get output from a background task process - * TaskStop — Stop a running background task process - * TaskExecute — Execute tasks as subagents (requires @tintinweb/pi-subagents) + * TaskUpdate — Update task fields (completion requires /lgtm) + * lgtm_ask — Present evidence + failure modes for sign-off * * Commands: * /tasks — Interactive task management menu + * /lgtm — Human signs off on a task (only way to complete) */ -import { randomUUID } from "node:crypto"; +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 { ProcessTracker } from "./process-tracker.js"; import { TaskStore } from "./task-store.js"; import { loadTasksConfig } from "./tasks-config.js"; -import { openSettingsMenu } from "./ui/settings-menu.js"; import { TaskWidget, type UICtx } from "./ui/task-widget.js"; -// ---- Debug ---- - const DEBUG = !!process.env.PI_TASKS_DEBUG; function debug(...args: unknown[]) { - if (DEBUG) console.error("[pi-tasks]", ...args); + if (DEBUG) console.error("[pi-lgtm]", ...args); } -// ---- Helpers ---- - function textResult(msg: string) { return { content: [{ type: "text" as const, text: msg }], details: undefined as any }; } -/** Task tool names — used to detect task tool usage for reminder suppression. */ -const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskOutput", "TaskStop", "TaskExecute"]); - -/** How many turns without task tool usage before injecting a reminder. */ +const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "lgtm_ask"]); const REMINDER_INTERVAL = 4; - -/** How many turns completed tasks linger before auto-clearing. */ const AUTO_CLEAR_DELAY = 4; const SYSTEM_REMINDER = ` -The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done). Also consider cleaning up the task list if it has become stale. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable. Make sure that you NEVER mention this reminder to the user +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. `; export default function (pi: ExtensionAPI) { - // Initialize store and config const cfg = loadTasksConfig(); const piTasks = process.env.PI_TASKS; const taskScope = cfg.taskScope ?? "session"; - /** Resolve the task store path from env/config (without session ID). */ function resolveStorePath(sessionId?: string): string | undefined { if (piTasks === "off") return undefined; if (piTasks?.startsWith("/")) return piTasks; @@ -67,179 +53,14 @@ export default function (pi: ExtensionAPI) { if (taskScope === "session" && sessionId) { return join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`); } - if (taskScope === "session") return undefined; // no session ID yet, start in-memory + if (taskScope === "session") return undefined; return join(process.cwd(), ".pi", "tasks", "tasks.json"); } - // For project scope (or env override), create store immediately. - // For session scope, start with in-memory and upgrade once we have the session ID. let store = new TaskStore(resolveStorePath()); - const tracker = new ProcessTracker(); const widget = new TaskWidget(store); - - // ── Subagent integration state ── - /** Latest ExtensionContext — refreshed on every tool execution so cascade always has a valid one. */ - let latestCtx: ExtensionContext | undefined; - /** Cascade config — set by TaskExecute, consumed by completion listener. */ - let cascadeConfig: { additionalContext?: string; model?: string; maxTurns?: number } | undefined; - /** Maps agent IDs to task IDs for O(1) completion lookup. */ - const agentTaskMap = new Map(); - - // ── Subagent RPC helpers ── - - /** RPC reply envelope — matches pi-mono's RpcResponse shape. */ - type RpcReply = - | { success: true; data?: T } - | { success: false; error: string }; - - /** Call a subagents RPC method: emit request, wait for scoped reply, unwrap envelope. */ - function rpcCall(channel: string, params: Record, timeoutMs: number): Promise { - const requestId = randomUUID(); - debug(`rpc:send ${channel}`, { requestId }); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - unsub(); - debug(`rpc:timeout ${channel}`, { requestId }); - reject(new Error(`${channel} timeout`)); - }, timeoutMs); - const unsub = pi.events.on(`${channel}:reply:${requestId}`, (raw: unknown) => { - unsub(); clearTimeout(timer); - debug(`rpc:reply ${channel}`, { requestId, raw }); - const reply = raw as RpcReply; - if (reply.success) resolve(reply.data as T); - else reject(new Error(reply.error)); - }); - pi.events.emit(channel, { requestId, ...params }); - debug(`rpc:emitted ${channel}`, { requestId }); - }); - } - - /** Spawn a subagent via pi.events RPC (requires @tintinweb/pi-subagents extension). */ - function spawnSubagent(type: string, prompt: string, options?: any): Promise { - debug("spawn:call", { type, options: { ...options, prompt: undefined } }); - return rpcCall<{ id: string }>("subagents:rpc:spawn", { type, prompt, options }, 30_000) - .then(d => { debug("spawn:ok", d); return d.id; }); - } - - /** Stop a subagent via pi.events RPC (requires @tintinweb/pi-subagents extension). */ - function stopSubagent(agentId: string): Promise { - return rpcCall("subagents:rpc:stop", { agentId }, 10_000).catch(() => {}); - } - - // ── Subagent extension presence & version detection ── - const PROTOCOL_VERSION = 2; - let subagentsAvailable = false; - let pendingWarning: string | undefined; - - /** Ping subagents and check protocol version. Works with any handler version. */ - function checkSubagentsVersion() { - const requestId = randomUUID(); - const timer = setTimeout(() => { unsub(); }, 5_000); - const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, (raw: unknown) => { - unsub(); clearTimeout(timer); - const remoteVersion = (raw as any)?.data?.version as number | undefined; - if (remoteVersion === undefined) { - pendingWarning = - "@tintinweb/pi-subagents is outdated — please update for task execution support."; - } else if (remoteVersion > PROTOCOL_VERSION) { - pendingWarning = - `@tintinweb/pi-tasks is outdated (protocol v${PROTOCOL_VERSION}, ` + - `pi-subagents has v${remoteVersion}) — please update for task execution support.`; - } else if (remoteVersion < PROTOCOL_VERSION) { - pendingWarning = - `@tintinweb/pi-subagents is outdated (protocol v${remoteVersion}, ` + - `pi-tasks has v${PROTOCOL_VERSION}) — please update for task execution support.`; - } else { - subagentsAvailable = true; - } - }); - pi.events.emit("subagents:rpc:ping", { requestId }); - } - - checkSubagentsVersion(); - pi.events.on("subagents:ready", () => checkSubagentsVersion()); - - /** Build a prompt for a task being executed by a subagent. */ - function buildTaskPrompt(task: { id: string; subject: string; description: string }, additionalContext?: string): string { - let prompt = `You are executing task #${task.id}: "${task.subject}"\n\n${task.description}`; - if (additionalContext) prompt += `\n\n${additionalContext}`; - prompt += `\n\nComplete this task fully. Do not attempt to manage tasks yourself.`; - return prompt; - } - const autoClear = new AutoClearManager(() => store, () => cfg.autoClearCompleted ?? "on_list_complete", AUTO_CLEAR_DELAY); - // ── Subagent completion listener ── - // Listens for subagent lifecycle events to update task status and optionally cascade. - - // Success → mark task completed, cascade if enabled - pi.events.on("subagents:completed", async (data) => { - const { id, result } = data as { id: string; result?: string }; - const taskId = agentTaskMap.get(id); - if (!taskId) return; - agentTaskMap.delete(id); - const task = store.get(taskId); - if (!task) return; - - store.update(task.id, { status: "completed", metadata: { ...task.metadata, result } }); - widget.setActiveTask(task.id, false); - - // Auto-cascade: find unblocked dependents with agentType - if ((cfg.autoCascade ?? false) && cascadeConfig && latestCtx) { - const unblocked = store.list().filter(t => - t.status === "pending" && - t.metadata?.agentType && - t.blockedBy.includes(task.id) && - t.blockedBy.every(depId => store.get(depId)?.status === "completed") - ); - for (const next of unblocked) { - store.update(next.id, { status: "in_progress" }); - const prompt = buildTaskPrompt(next, cascadeConfig.additionalContext); - try { - const agentId = await spawnSubagent(next.metadata.agentType, prompt, { - description: next.subject, - isBackground: true, - maxTurns: cascadeConfig.maxTurns, - }); - agentTaskMap.set(agentId, next.id); - store.update(next.id, { owner: agentId, metadata: { ...next.metadata, agentId } }); - widget.setActiveTask(next.id); - } catch (err: any) { - store.update(next.id, { status: "pending", metadata: { ...next.metadata, lastError: err.message } }); - } - } - } - autoClear.trackCompletion(task.id, currentTurn); - widget.update(); - }); - - // Failure → store error, revert to pending, don't cascade (branch stops) - // Intentional stop (status === "stopped") → mark completed, preserve partial result - pi.events.on("subagents:failed", (data) => { - const { id, error, result, status } = data as { id: string; error?: string; result?: string; status: string }; - const taskId = agentTaskMap.get(id); - if (!taskId) return; - agentTaskMap.delete(id); - const task = store.get(taskId); - if (!task) return; - - if (status === "stopped") { - // Intentional stop — mark completed, preserve partial result - store.update(task.id, { status: "completed", metadata: { ...task.metadata, result: result || task.metadata?.result } }); - autoClear.trackCompletion(task.id, currentTurn); - } else { - // Actual error — revert to pending - store.update(task.id, { status: "pending", metadata: { ...task.metadata, lastError: error || status } }); - autoClear.resetBatchCountdown(); - } - widget.setActiveTask(task.id, false); - widget.update(); - }); - - // ── Session-scoped store upgrade ── - // For session scope, the store starts in-memory (no session ID at init time). - // Upgrade to file-backed on first context arrival (turn_start, before_agent_start, - // or tool_execution_start — whichever fires first). let storeUpgraded = false; let persistedTasksShown = false; function upgradeStoreIfNeeded(ctx: ExtensionContext) { @@ -253,10 +74,6 @@ export default function (pi: ExtensionAPI) { storeUpgraded = true; } - /** Restore widget on session start/resume if there's unfinished work. - * On new sessions, auto-clear if all tasks are completed (clean slate). - * On resume, always show tasks (user may want to review). - * Only runs once — the first caller wins. */ function showPersistedTasks(isResume = false) { if (persistedTasksShown) return; persistedTasksShown = true; @@ -271,21 +88,17 @@ export default function (pi: ExtensionAPI) { } } - // ── Turn tracking for system-reminder injection ── let currentTurn = 0; let lastTaskToolUseTurn = 0; let reminderInjectedThisCycle = false; pi.on("turn_start", async (_event, ctx) => { currentTurn++; - latestCtx = ctx; widget.setUICtx(ctx.ui as UICtx); upgradeStoreIfNeeded(ctx); if (autoClear.onTurnStart(currentTurn)) widget.update(); }); - // ── Token usage tracking ── - // Feed per-turn token counts from assistant messages into the widget. pi.on("turn_end", async (event) => { const msg = event.message as any; if (msg?.role === "assistant" && msg.usage) { @@ -293,80 +106,41 @@ export default function (pi: ExtensionAPI) { } }); - // ── System-reminder injection via tool_result event ── - // Appends a nudge to non-task tool results when tasks exist - // but task tools haven't been used recently (mimics Claude Code's behavior). pi.on("tool_result", async (event) => { - // Task tool usage resets the reminder timer if (TASK_TOOL_NAMES.has(event.toolName)) { lastTaskToolUseTurn = currentTurn; reminderInjectedThisCycle = false; return {}; } - - // Cheap checks first — avoid store.list() disk I/O when possible if (currentTurn - lastTaskToolUseTurn < REMINDER_INTERVAL) return {}; if (reminderInjectedThisCycle) return {}; - const tasks = store.list(); if (tasks.length === 0) return {}; - - // Append system-reminder to tool result content. - // Reset the baseline so the next reminder fires REMINDER_INTERVAL turns later. reminderInjectedThisCycle = true; lastTaskToolUseTurn = currentTurn; - return { - content: [...event.content, { type: "text" as const, text: SYSTEM_REMINDER }], - }; + return { content: [...event.content, { type: "text" as const, text: SYSTEM_REMINDER }] }; }); - // Grab UI context early — before_agent_start fires before any tool calls, - // so persisted tasks show up immediately on session start. pi.on("before_agent_start", async (_event, ctx) => { - latestCtx = ctx; widget.setUICtx(ctx.ui as UICtx); upgradeStoreIfNeeded(ctx); showPersistedTasks(); - if (pendingWarning) { - ctx.ui.notify(pendingWarning, "warning"); - pendingWarning = undefined; - } }); - // session_switch fires on /new (reason: "new") and /resume (reason: "resume"). - // On /new: reset all session-scoped state so the store switches to the new session file. - // On resume: reload persisted tasks from the existing session file. pi.on("session_switch" as any, async (event: any, ctx: ExtensionContext) => { - latestCtx = ctx; widget.setUICtx(ctx.ui as UICtx); - const isResume = event?.reason === "resume"; - - // Reset session-scoped state for both /new and /resume storeUpgraded = false; persistedTasksShown = false; currentTurn = 0; lastTaskToolUseTurn = 0; reminderInjectedThisCycle = false; autoClear.reset(); - - // Memory mode has no file-backed store to switch — clear explicitly on /new - if (!isResume && taskScope === "memory") { - store.clearAll(); - } - + if (!isResume && taskScope === "memory") store.clearAll(); upgradeStoreIfNeeded(ctx); showPersistedTasks(isResume); }); - // Keep latestCtx fresh on every tool execution as well. - pi.on("tool_execution_start", async (_event, ctx) => { - latestCtx = ctx; - widget.setUICtx(ctx.ui as UICtx); - upgradeStoreIfNeeded(ctx); - widget.update(); - }); - // ────────────────────────────────────────────────── // Tool 1: TaskCreate // ────────────────────────────────────────────────── @@ -374,67 +148,39 @@ export default function (pi: ExtensionAPI) { pi.registerTool({ name: "TaskCreate", label: "TaskCreate", - description: `Use this tool to create a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. -It also helps the user understand the progress of the task and overall progress of their requests. + description: `Create a task with a clear done_criterion. -## When to Use This Tool +## When to Use -Use this tool proactively in these scenarios: - -- Complex multi-step tasks - When a task requires 3 or more distinct steps or actions -- Non-trivial and complex tasks - Tasks that require careful planning or multiple operations -- Plan mode - When using plan mode, create a task list to track the work -- User explicitly requests todo list - When the user directly asks you to use the todo list -- User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) -- After receiving new instructions - Immediately capture user requirements as tasks -- When you start working on a task - Mark it as in_progress BEFORE beginning work -- After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation - -## When NOT to Use This Tool - -Skip using this tool when: -- There is only a single, straightforward task -- The task is trivial and tracking it provides no organizational benefit -- The task can be completed in less than 3 trivial steps -- The task is purely conversational or informational - -NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. +- Complex multi-step tasks (3+ steps) +- When user provides a list of things to do ## Task Fields -- **subject**: A brief, actionable title in imperative form (e.g., "Fix authentication bug in login flow") -- **description**: Detailed description of what needs to be done, including context and acceptance criteria -- **activeForm** (optional): Present continuous form shown in the spinner when the task is in_progress (e.g., "Fixing authentication bug"). If omitted, the spinner shows the subject instead. +- **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 -All tasks are created with status \`pending\`. - -## Tips - -- Create tasks with clear, specific subjects that describe the outcome -- Include enough detail in the description for another agent to understand and complete the task -- After creating tasks, use TaskUpdate to set up dependencies (blocks/blockedBy) if needed -- Check TaskList first to avoid creating duplicate tasks -- Include \`agentType\` (e.g., "general-purpose", "Explore") to mark tasks for subagent execution via TaskExecute`, +Tasks are completed only via /lgtm after calling lgtm_ask with evidence.`, promptGuidelines: [ - "When working on complex multi-step tasks, use TaskCreate to track progress and TaskUpdate to update status.", - "Mark tasks as in_progress before starting work and completed when done.", - "Use TaskList to check for available work after completing a task.", + "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: "A brief title for the task" }), - description: Type.String({ description: "A detailed description of what needs to be done" }), - activeForm: Type.Optional(Type.String({ description: "Present continuous form shown in spinner when in_progress (e.g., 'Running tests')" })), - agentType: Type.Optional(Type.String({ description: "Agent type for subagent execution (e.g., 'general-purpose', 'Explore'). Tasks with agentType can be started via TaskExecute." })), - metadata: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Arbitrary metadata to attach to the task" })), + 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 meta = params.metadata ?? {}; - if (params.agentType) meta.agentType = params.agentType; - const task = store.create(params.subject, params.description, params.activeForm, Object.keys(meta).length > 0 ? meta : undefined); + 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 successfully: ${task.subject}`)); + return Promise.resolve(textResult(`Task #${task.id} created: ${task.subject}\nDone criterion: ${task.done_criterion}`)); }, }); @@ -445,33 +191,13 @@ All tasks are created with status \`pending\`. pi.registerTool({ name: "TaskList", label: "TaskList", - description: `Use this tool to list all tasks in the task list. - -## When to Use This Tool - -- To see what tasks are available to work on (status: 'pending', no owner, not blocked) -- To check overall progress on the project -- To find tasks that are blocked and need dependencies resolved -- After completing a task, to check for newly unblocked work or claim the next available task -- **Prefer working on tasks in ID order** (lowest ID first) when multiple tasks are available, as earlier tasks often set up context for later ones - -## Output - -Returns a summary of each task: -- **id**: Task identifier (use with TaskGet, TaskUpdate) -- **subject**: Brief description of the task -- **status**: 'pending', 'in_progress', or 'completed' -- **owner**: Agent ID if assigned, empty if available -- **blockedBy**: List of open task IDs that must be resolved first (tasks with blockedBy cannot be claimed until dependencies resolve) - -Use TaskGet with a specific task ID to view full details including description and comments.`, + 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")); - // Sort: pending first (by ID), then in_progress (by ID), then completed (by ID) const statusOrder: Record = { pending: 0, in_progress: 1, completed: 2 }; const sorted = [...tasks].sort((a, b) => { const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0); @@ -481,22 +207,15 @@ Use TaskGet with a specific task ID to view full details including description a const lines = sorted.map(task => { let line = `#${task.id} [${task.status}] ${task.subject}`; - - if (task.owner) { - line += ` (${task.owner})`; - } - - // Only show non-completed blockers + 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(", ")}]`; - } + if (openBlockers.length > 0) line += ` [blocked by ${openBlockers.map(id => "#" + id).join(", ")}]`; } - return line; }); @@ -511,65 +230,33 @@ Use TaskGet with a specific task ID to view full details including description a pi.registerTool({ name: "TaskGet", label: "TaskGet", - description: `Use this tool to retrieve a task by its ID from the task list. - -## When to Use This Tool - -- When you need the full description and context before starting work on a task -- To understand task dependencies (what it blocks, what blocks it) -- After being assigned a task, to get complete requirements - -## Output - -Returns full task details: -- **subject**: Task title -- **description**: Detailed requirements and context -- **status**: 'pending', 'in_progress', or 'completed' -- **blocks**: Tasks waiting on this one to complete -- **blockedBy**: Tasks that must complete before this one can start - -## Tips - -- After fetching a task, verify its blockedBy list is empty before beginning work. -- Use TaskList to see all tasks in summary form.`, + description: `Get full task details including done_criterion and approval state.`, parameters: Type.Object({ - taskId: Type.String({ description: "The ID of the task to retrieve" }), + 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`)); + if (!task) return Promise.resolve(textResult("Task not found")); - // Unescape literal \n sequences the LLM may have double-escaped in JSON const desc = task.description.replace(/\\n/g, "\n"); - const lines: string[] = [ `Task #${task.id}: ${task.subject}`, - `Status: ${task.status}`, + `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}`); - } + 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 (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(", ")}`); - } - - // Show metadata if non-empty + 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)}`); - } + if (metaKeys.length > 0) lines.push(`Metadata: ${JSON.stringify(task.metadata)}`); return Promise.resolve(textResult(lines.join("\n"))); }, @@ -582,364 +269,138 @@ Returns full task details: pi.registerTool({ name: "TaskUpdate", label: "TaskUpdate", - description: `Use this tool to update a task in the task list. + description: `Update task fields or status. -## When to Use This Tool +Status: pending -> in_progress -> (call lgtm_ask) -> /lgtm -> completed -**Before starting work on a task:** -- Mark it in_progress BEFORE beginning — do not start work without updating status first -- After resolving, call TaskList to find your next task - -**Mark tasks as resolved:** -- When you have completed the work described in a task -- When a task is no longer needed or has been superseded -- IMPORTANT: Always mark your assigned tasks as resolved when you finish them -- After resolving, call TaskList to find your next task - -- ONLY mark a task as completed when you have FULLY accomplished it -- If you encounter errors, blockers, or cannot finish, keep the task as in_progress -- When blocked, create a new task describing what needs to be resolved -- Never mark a task as completed if: - - Tests are failing - - Implementation is partial - - You encountered unresolved errors - - You couldn't find necessary files or dependencies - -**Delete tasks:** -- When a task is no longer relevant or was created in error -- Setting status to \`deleted\` permanently removes the task - -**Update task details:** -- When requirements change or become clearer -- When establishing dependencies between tasks - -## Fields You Can Update - -- **status**: The task status (see Status Workflow below) -- **subject**: Change the task title (imperative form, e.g., "Run tests") -- **description**: Change the task description -- **activeForm**: Present continuous form shown in spinner when in_progress (e.g., "Running tests") -- **owner**: Change the task owner (agent name) -- **metadata**: Merge metadata keys into the task (set a key to null to delete it) -- **addBlocks**: Mark tasks that cannot start until this one completes -- **addBlockedBy**: Mark tasks that must complete before this one can start - -## Status Workflow - -Status progresses: \`pending\` → \`in_progress\` → \`completed\` - -Use \`deleted\` to permanently remove a task. - -## Staleness - -Make sure to read a task's latest state using \`TaskGet\` before updating it. - -## Examples - -Mark task as in progress when starting work: -\`\`\`json -{"taskId": "1", "status": "in_progress"} -\`\`\` - -Mark task as completed after finishing work: -\`\`\`json -{"taskId": "1", "status": "completed"} -\`\`\` - -Delete a task: -\`\`\`json -{"taskId": "1", "status": "deleted"} -\`\`\` - -Claim a task by setting owner: -\`\`\`json -{"taskId": "1", "owner": "my-name"} -\`\`\` - -Set up task dependencies: -\`\`\`json -{"taskId": "2", "addBlockedBy": ["1"]} -\`\`\``, +Cannot set status=completed here. Use lgtm_ask then /lgtm .`, parameters: Type.Object({ - taskId: Type.String({ description: "The ID of the task to update" }), - status: Type.Optional(Type.Unsafe<"pending" | "in_progress" | "completed" | "deleted">({ + taskId: Type.String({ description: "Task ID to update" }), + status: Type.Optional(Type.Unsafe<"pending" | "in_progress" | "deleted">({ anyOf: [ - { type: "string", enum: ["pending", "in_progress", "completed"] }, + { type: "string", enum: ["pending", "in_progress"] }, { type: "string", const: "deleted" }, ], - description: "New status for the task", + description: "New status. Cannot set completed — use /lgtm after lgtm_ask.", })), - subject: Type.Optional(Type.String({ description: "New subject for the task" })), - description: Type.Optional(Type.String({ description: "New description for the task" })), - activeForm: Type.Optional(Type.String({ description: "Present continuous form shown in spinner when in_progress" })), - owner: Type.Optional(Type.String({ description: "New owner for the task" })), - metadata: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Metadata keys to merge into the task. Set a key to null to delete it." })), - addBlocks: Type.Optional(Type.Array(Type.String(), { description: "Task IDs that this task blocks" })), - addBlockedBy: Type.Optional(Type.Array(Type.String(), { description: "Task IDs that block this task" })), + 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; - const { task, changedFields, warnings } = store.update(taskId, fields); + 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`)); } - // Update widget active task tracking if (fields.status === "in_progress") { widget.setActiveTask(taskId); autoClear.resetBatchCountdown(); } else if (fields.status === "pending") { autoClear.resetBatchCountdown(); - } else if (fields.status === "completed" || fields.status === "deleted") { + } else if (fields.status === "deleted") { widget.setActiveTask(taskId, false); - if (fields.status === "completed") autoClear.trackCompletion(taskId, currentTurn); } widget.update(); - let msg = `Updated task #${taskId} ${changedFields.join(", ")}`; - if (warnings.length > 0) { - msg += ` (warning: ${warnings.join("; ")})`; - } + let msg = `Updated task #${taskId}: ${changedFields.join(", ")}`; + if (warnings.length > 0) msg += ` (warning: ${warnings.join("; ")})`; return Promise.resolve(textResult(msg)); }, }); // ────────────────────────────────────────────────── - // Tool 5: TaskOutput + // Tool 5: lgtm_ask // ────────────────────────────────────────────────── pi.registerTool({ - name: "TaskOutput", - label: "TaskOutput", - description: `- Retrieves output from a running or completed task (background shell, agent, or remote session) -- Takes a task_id parameter identifying the task -- Returns the task output along with status information -- Use block=true (default) to wait for task completion -- Use block=false for non-blocking check of current status -- Task IDs can be found using the /tasks command -- Works with all task types: background shells, async agents, and remote sessions`, + 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 . + +## 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**: Second most likely failure mode +- **evidence_vs_failures**: How would evidence look different if failure modes were true? +- **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({ - task_id: Type.String({ description: "The task ID to get output from" }), - block: Type.Boolean({ description: "Whether to wait for completion", default: true }), - timeout: Type.Number({ description: "Max wait time in ms", default: 30000, minimum: 0, maximum: 600000 }), + 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: "Second most likely failure mode" }), + evidence_vs_failures: Type.String({ description: "How would evidence differ if failure modes were true?" }), + 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." })), }), - async execute(_toolCallId, params, signal, _onUpdate, _ctx) { - const { task_id, block, timeout } = params; + 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`)); - const processOutput = tracker.getOutput(task_id); - if (!processOutput) { - // No shell process — check if this is a subagent task - // Support both task IDs and agent IDs (resolve agent ID → task ID) - let resolvedId = task_id; - if (!store.get(resolvedId)) { - // Check if this is an agent ID mapped to a task - for (const [agentId, taskId] of agentTaskMap) { - if (agentId === task_id || agentId.startsWith(task_id)) { resolvedId = taskId; break; } - } - } - const task = store.get(resolvedId); - if (!task) throw new Error(`No task found with ID ${task_id}`); - - if (task.metadata?.agentId) { - // Subagent task — wait for completion if blocking - if (block && task.status === "in_progress") { - await new Promise((resolve) => { - const timer = setTimeout(() => { unsubOk(); unsubFail(); resolve(); }, timeout ?? 30000); - const cleanup = () => { clearTimeout(timer); resolve(); }; - const unsubOk = pi.events.on("subagents:completed", (d: unknown) => { - if ((d as any).id === task.metadata?.agentId) { unsubOk(); unsubFail(); cleanup(); } - }); - const unsubFail = pi.events.on("subagents:failed", (d: unknown) => { - if ((d as any).id === task.metadata?.agentId) { unsubOk(); unsubFail(); cleanup(); } - }); - // Re-check in case status changed between the outer check and listener registration - const current = store.get(task_id); - if (current && current.status !== "in_progress") { unsubOk(); unsubFail(); cleanup(); } - signal?.addEventListener("abort", () => { unsubOk(); unsubFail(); cleanup(); }, { once: true }); - }); - } - const updated = store.get(task_id) ?? task; - return textResult(`Task #${task_id} [${updated.status}] — subagent ${task.metadata.agentId}`); - } - throw new Error(`No background process for task ${task_id}`); - } - - if (block && processOutput.status === "running") { - const result = await tracker.waitForCompletion(task_id, timeout ?? 30000, signal ?? undefined); - if (result) { - return textResult( - `Task #${task_id} (${result.status})${result.exitCode !== undefined ? ` exit code: ${result.exitCode}` : ""}\n\n${result.output}`, - ); + if (params.evidence_files?.length) { + for (const f of params.evidence_files) { + if (!existsSync(f)) return Promise.resolve(textResult(`Evidence file not found: ${f}`)); } } - return textResult( - `Task #${task_id} (${processOutput.status})${processOutput.exitCode !== undefined ? ` exit code: ${processOutput.exitCode}` : ""}\n\n${processOutput.output}`, - ); - }, - }); - - // ────────────────────────────────────────────────── - // Tool 6: TaskStop - // ────────────────────────────────────────────────── - - pi.registerTool({ - name: "TaskStop", - label: "TaskStop", - description: ` -- Stops a running background task by its ID -- Takes a task_id parameter identifying the task to stop -- Returns a success or failure status -- Use this tool when you need to terminate a long-running task`, - parameters: Type.Object({ - task_id: Type.Optional(Type.String({ description: "The ID of the background task to stop" })), - shell_id: Type.Optional(Type.String({ description: "Deprecated: use task_id instead" })), - }), - - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - const taskId = params.task_id ?? params.shell_id; - if (!taskId) throw new Error("task_id is required"); - - const stopped = await tracker.stop(taskId); - if (!stopped) { - // No shell process — check if this is a subagent task - // Support both task IDs and agent IDs - let resolvedId = taskId; - if (!store.get(resolvedId)) { - for (const [agentId, tId] of agentTaskMap) { - if (agentId === taskId || agentId.startsWith(taskId)) { resolvedId = tId; break; } - } - } - const task = store.get(resolvedId); - if (task?.metadata?.agentId && task.status === "in_progress") { - store.update(taskId, { status: "completed" }); - autoClear.trackCompletion(taskId, currentTurn); - await stopSubagent(task.metadata.agentId); - widget.setActiveTask(taskId, false); - widget.update(); - return textResult(`Task #${taskId} stopped successfully`); - } - throw new Error(`No running background process for task ${taskId}`); - } - - store.update(taskId, { status: "completed" }); - autoClear.trackCompletion(taskId, currentTurn); - widget.setActiveTask(taskId, false); - widget.update(); - return textResult(`Task #${taskId} stopped successfully`); - }, - }); - - // ────────────────────────────────────────────────── - // Tool 7: TaskExecute - // ────────────────────────────────────────────────── - - pi.registerTool({ - name: "TaskExecute", - label: "TaskExecute", - description: `Execute one or more tasks as subagents. - -## When to Use This Tool - -- To start execution of tasks that have \`agentType\` set (created via TaskCreate with agentType parameter) -- Tasks must be \`pending\` with all blockedBy dependencies \`completed\` -- Each task runs as an independent background subagent - -## Parameters - -- **task_ids**: Array of task IDs to execute -- **additional_context**: Extra context appended to each agent's prompt -- **model**: Model override for agents (e.g., "sonnet", "haiku") -- **max_turns**: Maximum turns per agent`, - promptGuidelines: [ - "Never use the Agent tool for tasks launched via TaskExecute — agents are already running.", - ], - parameters: Type.Object({ - task_ids: Type.Array(Type.String(), { description: "Task IDs to execute as subagents" }), - additional_context: Type.Optional(Type.String({ description: "Extra context for agent prompts" })), - model: Type.Optional(Type.String({ description: "Model override for agents" })), - max_turns: Type.Optional(Type.Number({ description: "Max turns per agent", minimum: 1 })), - }), - - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - if (!subagentsAvailable) { - return textResult( - "Subagent execution is currently unavailable. " + - "Ensure the @tintinweb/pi-subagents extension is loaded and try again." - ); - } - - const results: string[] = []; - const launched: string[] = []; - - for (const taskId of params.task_ids) { - const task = store.get(taskId); - if (!task) { - results.push(`#${taskId}: not found`); - continue; - } - if (task.status !== "pending") { - results.push(`#${taskId}: not pending (status: ${task.status})`); - continue; - } - if (!task.metadata?.agentType) { - results.push(`#${taskId}: no agentType set — create with agentType parameter or update metadata`); - continue; - } - - // Check all blockers are completed - const openBlockers = task.blockedBy.filter(bid => { - const blocker = store.get(bid); - return !blocker || blocker.status !== "completed"; - }); - if (openBlockers.length > 0) { - results.push(`#${taskId}: blocked by ${openBlockers.map(id => "#" + id).join(", ")}`); - continue; - } - - // Mark in_progress and spawn agent via RPC - store.update(taskId, { status: "in_progress" }); - const prompt = buildTaskPrompt(task, params.additional_context); - try { - const agentId = await spawnSubagent(task.metadata.agentType, prompt, { - description: task.subject, - isBackground: true, - maxTurns: params.max_turns, - }); - agentTaskMap.set(agentId, taskId); - store.update(taskId, { owner: agentId, metadata: { ...task.metadata, agentId } }); - widget.setActiveTask(taskId); - launched.push(`#${taskId} → agent ${agentId}`); - } catch (err: any) { - debug(`spawn:error task=#${taskId}`, err); - store.update(taskId, { status: "pending" }); - results.push(`#${taskId}: spawn failed — ${err.message}`); - } - } - - // Save cascade config for the completion listener - cascadeConfig = { - additionalContext: params.additional_context, - model: params.model, - maxTurns: params.max_turns, - }; - + 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_evidence_vs_failures: params.evidence_vs_failures, + lgtm_evidence_files: params.evidence_files ?? [], + lgtm_remaining_uncertainty: params.remaining_uncertainty ?? "", + lgtm_submitted_at: new Date().toISOString(), + }, + }); widget.update(); - const lines: string[] = []; - if (launched.length > 0) { - lines.push( - `Launched ${launched.length} agent(s):\n${launched.join("\n")}\n` + - `Use TaskOutput to check progress. Do not spawn additional agents for these tasks.` - ); - } - if (results.length > 0) lines.push(`Skipped:\n${results.join("\n")}`); - if (lines.length === 0) lines.push("No tasks to execute."); + 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}` + : ""; - return textResult(lines.join("\n\n")); + 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` + + `### Evidence vs failure modes\n${params.evidence_vs_failures}` + + 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)); }, }); @@ -957,24 +418,16 @@ Set up task dependencies: const taskCount = tasks.length; const completedCount = tasks.filter(t => t.status === "completed").length; - const choices: string[] = [ - `View all tasks (${taskCount})`, - "Create task", - ]; + 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})`); - choices.push("Settings"); 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 === "Settings") { - await settingsMenu(); - } else if (choice.startsWith("Clear completed")) { + 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(); @@ -994,23 +447,19 @@ Set up task dependencies: return mainMenu(); } - const statusIcon = (status: string) => { - switch (status) { - case "completed": return "✔"; - case "in_progress": return "◼"; - default: return "◻"; - } + 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.status)} #${t.id} [${t.status}] ${t.subject}` - ); + 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(); - // Extract task ID from selection const match = selected.match(/#(\d+)/); if (match) await viewTaskDetail(match[1]); else return viewTasks(); @@ -1021,17 +470,25 @@ Set up task dependencies: if (!task) return viewTasks(); const actions: string[] = []; - - if (task.status === "pending") { - actions.push("▸ Start (in_progress)"); - } - if (task.status === "in_progress") { - actions.push("✓ Complete"); + 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 title = `#${task.id} [${task.status}] ${task.subject}\n${task.description}`; + 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: ${em.lgtm_failure_mode_1}`); + parts.push(`FM2: ${em.lgtm_failure_mode_2}`); + 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)") { @@ -1039,12 +496,6 @@ Set up task dependencies: widget.setActiveTask(taskId); widget.update(); return viewTasks(); - } else if (action === "✓ Complete") { - store.update(taskId, { status: "completed" }); - autoClear.trackCompletion(taskId, currentTurn); - widget.setActiveTask(taskId, false); - widget.update(); - return viewTasks(); } else if (action === "✗ Delete") { store.update(taskId, { status: "deleted" }); widget.setActiveTask(taskId, false); @@ -1054,16 +505,15 @@ Set up task dependencies: return viewTasks(); }; - const settingsMenu = (): Promise => - openSettingsMenu(ui, cfg, mainMenu, AUTO_CLEAR_DELAY); - const createTask = async (): Promise => { 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); + store.create(subject, description, done_criterion); widget.update(); return mainMenu(); }; @@ -1071,4 +521,71 @@ Set up task dependencies: await mainMenu(); }, }); + + // ────────────────────────────────────────────────── + // /lgtm command — human sign-off only + // ────────────────────────────────────────────────── + + async function signOff(taskId: string, ctx: ExtensionCommandContext): Promise { + 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: ${m.lgtm_failure_mode_1}`); + evidenceParts.push(`FM2: ${m.lgtm_failure_mode_2}`); + evidenceParts.push(`Evidence vs failures: ${m.lgtm_evidence_vs_failures}`); + 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 ", + 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); + }, + }); } diff --git a/src/process-tracker.ts b/src/process-tracker.ts deleted file mode 100644 index 31db32e..0000000 --- a/src/process-tracker.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * process-tracker.ts — Background process management for tasks. - * - * Tracks spawned child processes, buffers their output, and supports - * blocking wait and graceful stop (SIGTERM → 5s → SIGKILL). - */ - -import type { ChildProcess } from "node:child_process"; -import type { BackgroundProcess } from "./types.js"; - -export interface ProcessOutput { - output: string; - status: BackgroundProcess["status"]; - exitCode?: number; - startedAt: number; - completedAt?: number; - command?: string; -} - -export class ProcessTracker { - private processes = new Map(); - - /** Register a spawned process for a task. */ - track(taskId: string, proc: ChildProcess, command?: string): void { - const bp: BackgroundProcess = { - taskId, - pid: proc.pid!, - command, - output: [], - status: "running", - startedAt: Date.now(), - proc, - abortController: new AbortController(), - waiters: [], - }; - - // Buffer stdout - proc.stdout?.on("data", (data: Buffer) => { - bp.output.push(data.toString()); - }); - - // Buffer stderr - proc.stderr?.on("data", (data: Buffer) => { - bp.output.push(data.toString()); - }); - - // Handle process exit - proc.on("close", (code, _signal) => { - if (bp.status === "running") { - bp.status = code === 0 ? "completed" : "error"; - } - bp.exitCode = code ?? undefined; - bp.completedAt = Date.now(); - // Notify all waiters - for (const resolve of bp.waiters) resolve(); - bp.waiters = []; - }); - - proc.on("error", (err) => { - if (bp.status === "running") { - bp.status = "error"; - bp.output.push(`Process error: ${err.message}`); - bp.completedAt = Date.now(); - for (const resolve of bp.waiters) resolve(); - bp.waiters = []; - } - }); - - this.processes.set(taskId, bp); - } - - /** Get current output and status for a task's process. */ - getOutput(taskId: string): ProcessOutput | undefined { - const bp = this.processes.get(taskId); - if (!bp) return undefined; - return { - output: bp.output.join(""), - status: bp.status, - exitCode: bp.exitCode, - startedAt: bp.startedAt, - completedAt: bp.completedAt, - command: bp.command, - }; - } - - /** Wait for a task's process to complete, with timeout. */ - waitForCompletion(taskId: string, timeout: number, signal?: AbortSignal): Promise { - const bp = this.processes.get(taskId); - if (!bp) return Promise.resolve(undefined); - if (bp.status !== "running") return Promise.resolve(this.getOutput(taskId)); - - return new Promise((resolve) => { - let settled = false; - const timer = setTimeout(finish, timeout); - - function finish() { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve(self.getOutput(taskId)); - } - - const self = this; - bp.waiters.push(finish); - signal?.addEventListener("abort", finish, { once: true }); - }); - } - - /** Stop a task's background process. SIGTERM → 5s → SIGKILL. */ - async stop(taskId: string): Promise { - const bp = this.processes.get(taskId); - if (!bp || bp.status !== "running") return false; - - bp.status = "stopped"; - bp.proc.kill("SIGTERM"); - - // Wait up to 5s for graceful exit - await new Promise((resolve) => { - const timer = setTimeout(() => { - try { bp.proc.kill("SIGKILL"); } catch { /* already dead */ } - resolve(); - }, 5000); - - bp.proc.on("close", () => { - clearTimeout(timer); - resolve(); - }); - }); - - bp.completedAt = Date.now(); - for (const resolve of bp.waiters) resolve(); - bp.waiters = []; - return true; - } - - /** Get the process record for a task. */ - getProcess(taskId: string): BackgroundProcess | undefined { - return this.processes.get(taskId); - } -} diff --git a/src/task-store.ts b/src/task-store.ts index c2f3263..7102432 100644 --- a/src/task-store.ts +++ b/src/task-store.ts @@ -14,24 +14,17 @@ const TASKS_DIR = join(homedir(), ".pi", "tasks"); const LOCK_RETRY_MS = 50; const LOCK_MAX_RETRIES = 100; // 5s max -/** Simple file-based locking. */ function acquireLock(lockPath: string): void { for (let i = 0; i < LOCK_MAX_RETRIES; i++) { try { - // O_EXCL: fail if file exists writeFileSync(lockPath, `${process.pid}`, { flag: "wx" }); return; } catch (e: any) { if (e.code === "EEXIST") { - // Check for stale lock (process no longer running) try { const pid = parseInt(readFileSync(lockPath, "utf-8"), 10); - if (pid && !isProcessRunning(pid)) { - unlinkSync(lockPath); - continue; - } - } catch { /* ignore read errors */ } - // Wait and retry + if (pid && !isProcessRunning(pid)) { unlinkSync(lockPath); continue; } + } catch { /* ignore */ } const start = Date.now(); while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ } continue; @@ -53,8 +46,6 @@ function isProcessRunning(pid: number): boolean { export class TaskStore { private filePath: string | undefined; private lockPath: string | undefined; - - // In-memory state (always kept in sync) private nextId = 1; private tasks = new Map(); @@ -68,61 +59,42 @@ export class TaskStore { this.load(); } - /** Read store from disk (file-backed mode only). */ private load(): void { - if (!this.filePath) return; - if (!existsSync(this.filePath)) return; + if (!this.filePath || !existsSync(this.filePath)) return; try { const data: TaskStoreData = JSON.parse(readFileSync(this.filePath, "utf-8")); this.nextId = data.nextId; this.tasks.clear(); - for (const t of data.tasks) { - this.tasks.set(t.id, t); - } + for (const t of data.tasks) this.tasks.set(t.id, t); } catch { /* corrupt file — start fresh */ } } - /** Write store to disk atomically (file-backed mode only). */ private save(): void { if (!this.filePath) return; - const data: TaskStoreData = { - nextId: this.nextId, - tasks: Array.from(this.tasks.values()), - }; const tmpPath = this.filePath + ".tmp"; - writeFileSync(tmpPath, JSON.stringify(data, null, 2)); + writeFileSync(tmpPath, JSON.stringify({ nextId: this.nextId, tasks: Array.from(this.tasks.values()) }, null, 2)); renameSync(tmpPath, this.filePath); } - /** Execute a mutation with file locking (if file-backed). */ private withLock(fn: () => T): T { if (!this.lockPath) return fn(); acquireLock(this.lockPath); - try { - this.load(); // Re-read latest state - const result = fn(); - this.save(); - return result; - } finally { - releaseLock(this.lockPath); - } + try { this.load(); const result = fn(); this.save(); return result; } + finally { releaseLock(this.lockPath); } } - create(subject: string, description: string, activeForm?: string, metadata?: Record): Task { + create(subject: string, description: string, done_criterion: string, activeForm?: string, metadata?: Record): Task { return this.withLock(() => { const now = Date.now(); const task: Task = { id: String(this.nextId++), - subject, - description, + subject, description, done_criterion, + pending_approval: false, status: "pending", - activeForm, - owner: undefined, + activeForm, owner: undefined, metadata: metadata ?? {}, - blocks: [], - blockedBy: [], - createdAt: now, - updatedAt: now, + blocks: [], blockedBy: [], + createdAt: now, updatedAt: now, }; this.tasks.set(task.id, task); return task; @@ -134,16 +106,17 @@ export class TaskStore { return this.tasks.get(id); } - /** List all tasks sorted by ID ascending. */ list(): Task[] { if (this.filePath) this.load(); return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id)); } update(id: string, fields: { - status?: TaskStatus | "deleted"; + status?: Exclude | "deleted"; subject?: string; description?: string; + done_criterion?: string; + pending_approval?: boolean; activeForm?: string; owner?: string; metadata?: Record; @@ -157,10 +130,12 @@ export class TaskStore { const changedFields: string[] = []; const warnings: string[] = []; - // Handle deletion + if ((fields.status as string) === "completed") { + throw new Error(`Use /lgtm ${id} to complete tasks. Call lgtm_ask first to submit evidence.`); + } + if (fields.status === "deleted") { this.tasks.delete(id); - // Clean up dependency edges pointing to this task for (const t of this.tasks.values()) { t.blocks = t.blocks.filter(bid => bid !== id); t.blockedBy = t.blockedBy.filter(bid => bid !== id); @@ -168,80 +143,42 @@ export class TaskStore { return { task: undefined, changedFields: ["deleted"], warnings: [] }; } - if (fields.status !== undefined) { - task.status = fields.status; - changedFields.push("status"); - } - if (fields.subject !== undefined) { - task.subject = fields.subject; - changedFields.push("subject"); - } - if (fields.description !== undefined) { - task.description = fields.description; - changedFields.push("description"); - } - if (fields.activeForm !== undefined) { - task.activeForm = fields.activeForm; - changedFields.push("activeForm"); - } - if (fields.owner !== undefined) { - task.owner = fields.owner; - changedFields.push("owner"); - } + if (fields.status !== undefined) { task.status = fields.status as TaskStatus; changedFields.push("status"); } + if (fields.subject !== undefined) { task.subject = fields.subject; changedFields.push("subject"); } + if (fields.description !== undefined) { task.description = fields.description; changedFields.push("description"); } + if (fields.done_criterion !== undefined) { task.done_criterion = fields.done_criterion; changedFields.push("done_criterion"); } + if (fields.pending_approval !== undefined) { task.pending_approval = fields.pending_approval; changedFields.push("pending_approval"); } + if (fields.activeForm !== undefined) { task.activeForm = fields.activeForm; changedFields.push("activeForm"); } + if (fields.owner !== undefined) { task.owner = fields.owner; changedFields.push("owner"); } - // Metadata: shallow merge, null deletes keys if (fields.metadata !== undefined) { for (const [key, value] of Object.entries(fields.metadata)) { - if (value === null) { - delete task.metadata[key]; - } else { - task.metadata[key] = value; - } + if (value === null) delete task.metadata[key]; + else task.metadata[key] = value; } changedFields.push("metadata"); } - // Bidirectional dependency edges - if (fields.addBlocks && fields.addBlocks.length > 0) { + if (fields.addBlocks?.length) { for (const targetId of fields.addBlocks) { - if (!task.blocks.includes(targetId)) { - task.blocks.push(targetId); - } + if (!task.blocks.includes(targetId)) task.blocks.push(targetId); const target = this.tasks.get(targetId); - if (target && !target.blockedBy.includes(id)) { - target.blockedBy.push(id); - target.updatedAt = Date.now(); - } - // Warnings for problematic edges - if (targetId === id) { - warnings.push(`#${id} blocks itself`); - } else if (!target) { - warnings.push(`#${targetId} does not exist`); - } else if (target.blocks.includes(id)) { - warnings.push(`cycle: #${id} and #${targetId} block each other`); - } + if (target && !target.blockedBy.includes(id)) { target.blockedBy.push(id); target.updatedAt = Date.now(); } + if (targetId === id) warnings.push(`#${id} blocks itself`); + else if (!target) warnings.push(`#${targetId} does not exist`); + else if (target.blocks.includes(id)) warnings.push(`cycle: #${id} and #${targetId} block each other`); } changedFields.push("blocks"); } - if (fields.addBlockedBy && fields.addBlockedBy.length > 0) { + if (fields.addBlockedBy?.length) { for (const targetId of fields.addBlockedBy) { - if (!task.blockedBy.includes(targetId)) { - task.blockedBy.push(targetId); - } + if (!task.blockedBy.includes(targetId)) task.blockedBy.push(targetId); const target = this.tasks.get(targetId); - if (target && !target.blocks.includes(id)) { - target.blocks.push(id); - target.updatedAt = Date.now(); - } - // Warnings for problematic edges - if (targetId === id) { - warnings.push(`#${id} blocks itself`); - } else if (!target) { - warnings.push(`#${targetId} does not exist`); - } else if (task.blocks.includes(targetId)) { - warnings.push(`cycle: #${id} and #${targetId} block each other`); - } + if (target && !target.blocks.includes(id)) { target.blocks.push(id); target.updatedAt = Date.now(); } + if (targetId === id) warnings.push(`#${id} blocks itself`); + else if (!target) warnings.push(`#${targetId} does not exist`); + else if (task.blocks.includes(targetId)) warnings.push(`cycle: #${id} and #${targetId} block each other`); } changedFields.push("blockedBy"); } @@ -251,12 +188,23 @@ export class TaskStore { }); } - /** Delete a task by ID. Returns true if deleted. */ + /** Complete a task. Called only by /lgtm -- requires pending_approval=true. */ + 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; + }); + } + delete(id: string): boolean { return this.withLock(() => { if (!this.tasks.has(id)) return false; this.tasks.delete(id); - // Clean up dependency edges for (const t of this.tasks.values()) { t.blocks = t.blocks.filter(bid => bid !== id); t.blockedBy = t.blockedBy.filter(bid => bid !== id); @@ -265,7 +213,6 @@ export class TaskStore { }); } - /** Remove all tasks. */ clearAll(): number { return this.withLock(() => { const count = this.tasks.size; @@ -274,24 +221,18 @@ export class TaskStore { }); } - /** Delete the backing file (if file-backed and empty). */ deleteFileIfEmpty(): boolean { if (!this.filePath || this.tasks.size > 0) return false; try { unlinkSync(this.filePath); } catch { /* ignore */ } return true; } - /** Remove all completed tasks. */ clearCompleted(): number { return this.withLock(() => { let count = 0; for (const [id, task] of this.tasks) { - if (task.status === "completed") { - this.tasks.delete(id); - count++; - } + if (task.status === "completed") { this.tasks.delete(id); count++; } } - // Clean up dependency edges for deleted tasks if (count > 0) { const validIds = new Set(this.tasks.keys()); for (const t of this.tasks.values()) { diff --git a/src/types.ts b/src/types.ts index 6ba2cf3..492d969 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,8 @@ export interface Task { id: string; subject: string; description: string; + done_criterion: string; // required: what "done" looks like + pending_approval: boolean; // set by lgtm_ask, required before /lgtm status: TaskStatus; activeForm?: string; owner?: string; @@ -23,18 +25,3 @@ export interface TaskStoreData { nextId: number; tasks: Task[]; } - -/** Background process associated with a task. */ -export interface BackgroundProcess { - taskId: string; - pid: number; - command?: string; - output: string[]; - status: "running" | "completed" | "error" | "stopped"; - exitCode?: number; - startedAt: number; - completedAt?: number; - proc: import("node:child_process").ChildProcess; - abortController: AbortController; - waiters: Array<() => void>; -} diff --git a/src/ui/settings-menu.ts b/src/ui/settings-menu.ts deleted file mode 100644 index bbef2bc..0000000 --- a/src/ui/settings-menu.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * settings-menu.ts — Polished settings panel for /tasks → Settings. - * - * Uses ui.custom() + SettingsList for native TUI rendering with keyboard - * navigation, live toggle, and per-row descriptions — matching pi-coding-agent's - * own settings panel style. - */ - -import { getSettingsListTheme } from "@mariozechner/pi-coding-agent"; -import { Container, type SettingItem, SettingsList, Spacer, Text } from "@mariozechner/pi-tui"; -import { saveTasksConfig, type TasksConfig } from "../tasks-config.js"; - -// ── Types ─────────────────────────────────────────────────────────────────── - -export type SettingsUI = { - custom( - factory: (tui: any, theme: any, keybindings: any, done: (result: T) => void) => any, - options?: { overlay?: boolean; overlayOptions?: any }, - ): Promise; -}; - -// ── Settings panel ────────────────────────────────────────────────────────── - -export async function openSettingsMenu( - ui: SettingsUI, - cfg: TasksConfig, - onBack: () => Promise, - clearDelayTurns: number, -): Promise { - await ui.custom((_tui, theme, _kb, done) => { - const items: SettingItem[] = [ - { - id: "taskScope", - label: "Task storage", - description: - "memory: tasks live only in memory, lost when session ends. " + - "session: persisted per session (tasks-.json), survives resume. " + - "project: shared across all sessions (tasks.json). " + - "Takes effect on next session start.", - currentValue: cfg.taskScope ?? "session", - values: ["memory", "session", "project"], - }, - { - id: "autoCascade", - label: "Auto-execute with agents", - description: - "When ON: pending agent tasks start automatically once their dependencies complete. " + - "When OFF: use TaskExecute to launch them manually.", - currentValue: (cfg.autoCascade ?? false) ? "on" : "off", - values: ["on", "off"], - }, - { - id: "autoClearCompleted", - label: "Auto-clear completed tasks", - description: - "never: completed tasks stay visible until manually cleared. " + - "on_list_complete: cleared automatically after all tasks are done. " + - "on_task_complete: each task cleared shortly after it completes. " + - `Clearing lags ~${clearDelayTurns} turns.`, - currentValue: cfg.autoClearCompleted ?? "on_list_complete", - values: ["never", "on_list_complete", "on_task_complete"], - }, - ]; - - const list = new SettingsList( - items, - /* maxVisible */ 10, - getSettingsListTheme(), - /* onChange */ (id, newValue) => { - if (id === "autoCascade") { - cfg.autoCascade = newValue === "on"; - saveTasksConfig(cfg); - } - if (id === "taskScope") { - cfg.taskScope = newValue as "memory" | "session" | "project"; - saveTasksConfig(cfg); - } - if (id === "autoClearCompleted") { - cfg.autoClearCompleted = newValue as TasksConfig["autoClearCompleted"]; - saveTasksConfig(cfg); - } - }, - /* onCancel */ () => done(undefined), - ); - - // Container doesn't forward handleInput to children — subclass to fix. - class SettingsPanel extends Container { - handleInput(data: string) { list.handleInput(data); } - } - - const root = new SettingsPanel(); - root.addChild(new Text(theme.bold(theme.fg("accent", "⚙ Task Settings")), 0, 0)); - root.addChild(new Spacer(1)); - root.addChild(list); - - return root; - }); - - return onBack(); -} diff --git a/src/ui/task-widget.ts b/src/ui/task-widget.ts index d14628d..58582bd 100644 --- a/src/ui/task-widget.ts +++ b/src/ui/task-widget.ts @@ -187,7 +187,8 @@ export class TaskWidget { 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}`; + const approvalSuffix = (task as any).pending_approval ? " 👀" : ""; + text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}${approvalSuffix}`; } lines.push(truncate(text + suffix)); diff --git a/test/auto-clear.test.ts b/test/auto-clear.test.ts index e956806..3716b80 100644 --- a/test/auto-clear.test.ts +++ b/test/auto-clear.test.ts @@ -13,8 +13,9 @@ describe("auto-clear: on_task_complete mode", () => { }); it("does not clear completed task before REMINDER_INTERVAL turns", () => { - store.create("Task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); // Turns 2, 3, 4 — not enough @@ -26,8 +27,9 @@ describe("auto-clear: on_task_complete mode", () => { }); it("clears completed task after REMINDER_INTERVAL turns", () => { - store.create("Task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); // Turn 5 = turn 1 + 4 (REMINDER_INTERVAL) @@ -37,13 +39,15 @@ describe("auto-clear: on_task_complete mode", () => { }); it("clears each task independently based on its own completion turn", () => { - store.create("Task A", "Desc"); - store.create("Task B", "Desc"); + store.create("Task A", "Desc", "done"); + store.create("Task B", "Desc", "done"); - store.update("1", { status: "completed" }); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); - store.update("2", { status: "completed" }); + store.update("2", { pending_approval: true }); + store.complete("2"); manager.trackCompletion("2", 3); // Turn 5: Task A expires (1+4), Task B still lingers (3+4=7) @@ -57,11 +61,12 @@ describe("auto-clear: on_task_complete mode", () => { }); it("does not clear pending or in_progress tasks", () => { - store.create("Pending", "Desc"); - store.create("In Progress", "Desc"); - store.create("Completed", "Desc"); + store.create("Pending", "Desc", "done"); + store.create("In Progress", "Desc", "done"); + store.create("Completed", "Desc", "done"); store.update("2", { status: "in_progress" }); - store.update("3", { status: "completed" }); + store.update("3", { pending_approval: true }); + store.complete("3"); manager.trackCompletion("3", 1); manager.onTurnStart(5); @@ -71,10 +76,11 @@ describe("auto-clear: on_task_complete mode", () => { }); it("cleans up dependency edges when auto-clearing", () => { - store.create("Blocker", "Desc"); - store.create("Blocked", "Desc"); + store.create("Blocker", "Desc", "done"); + store.create("Blocked", "Desc", "done"); store.update("1", { addBlocks: ["2"] }); - store.update("1", { status: "completed" }); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); manager.onTurnStart(5); @@ -83,8 +89,9 @@ describe("auto-clear: on_task_complete mode", () => { }); it("returns true when tasks are cleared", () => { - store.create("Task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); expect(manager.onTurnStart(4)).toBe(false); @@ -102,9 +109,10 @@ describe("auto-clear: on_list_complete mode", () => { }); it("does not clear when some tasks are still pending", () => { - store.create("Done", "Desc"); - store.create("Pending", "Desc"); - store.update("1", { status: "completed" }); + store.create("Done", "Desc", "done"); + store.create("Pending", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); for (let turn = 2; turn <= 10; turn++) { @@ -115,10 +123,12 @@ describe("auto-clear: on_list_complete mode", () => { }); it("does not clear immediately when all tasks complete", () => { - store.create("A", "Desc"); - store.create("B", "Desc"); - store.update("1", { status: "completed" }); - store.update("2", { status: "completed" }); + store.create("A", "Desc", "done"); + store.create("B", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); + store.update("2", { pending_approval: true }); + store.complete("2"); manager.trackCompletion("2", 1); // Turns 2-4: not enough @@ -129,10 +139,12 @@ describe("auto-clear: on_list_complete mode", () => { }); it("clears all completed tasks after REMINDER_INTERVAL turns when all are completed", () => { - store.create("A", "Desc"); - store.create("B", "Desc"); - store.update("1", { status: "completed" }); - store.update("2", { status: "completed" }); + store.create("A", "Desc", "done"); + store.create("B", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); + store.update("2", { pending_approval: true }); + store.complete("2"); manager.trackCompletion("2", 1); manager.onTurnStart(5); @@ -140,14 +152,15 @@ describe("auto-clear: on_list_complete mode", () => { }); it("resets countdown when a new task is created before REMINDER_INTERVAL", () => { - store.create("A", "Desc"); - store.update("1", { status: "completed" }); + store.create("A", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); // Turn 3: new task created — reset countdown manager.onTurnStart(3); manager.resetBatchCountdown(); - store.create("B", "Desc"); + store.create("B", "Desc", "done"); // Turn 5 would have cleared, but countdown was reset at turn 3 manager.onTurnStart(5); @@ -155,10 +168,12 @@ describe("auto-clear: on_list_complete mode", () => { }); it("resets countdown when a task goes back to in_progress", () => { - store.create("A", "Desc"); - store.create("B", "Desc"); - store.update("1", { status: "completed" }); - store.update("2", { status: "completed" }); + store.create("A", "Desc", "done"); + store.create("B", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); + store.update("2", { pending_approval: true }); + store.complete("2"); manager.trackCompletion("2", 1); // Turn 3: task 2 goes back to in_progress @@ -172,8 +187,9 @@ describe("auto-clear: on_list_complete mode", () => { }); it("returns true when tasks are cleared", () => { - store.create("Task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); expect(manager.onTurnStart(4)).toBe(false); @@ -191,10 +207,12 @@ describe("auto-clear: never mode", () => { }); it("never clears completed tasks regardless of turns", () => { - store.create("A", "Desc"); - store.create("B", "Desc"); - store.update("1", { status: "completed" }); - store.update("2", { status: "completed" }); + store.create("A", "Desc", "done"); + store.create("B", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); + store.update("2", { pending_approval: true }); + store.complete("2"); manager.trackCompletion("1", 1); manager.trackCompletion("2", 1); @@ -205,8 +223,9 @@ describe("auto-clear: never mode", () => { }); it("trackCompletion is a no-op", () => { - store.create("Task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); manager.onTurnStart(100); @@ -220,8 +239,9 @@ describe("auto-clear: dynamic mode switching", () => { let mode: AutoClearMode = "never"; const manager = new AutoClearManager(() => store, () => mode); - store.create("Task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); // Track in never mode — no-op manager.trackCompletion("1", 1); @@ -241,13 +261,14 @@ describe("auto-clear: store getter (session switch)", () => { let store = new TaskStore(); const manager = new AutoClearManager(() => store, () => "on_task_complete"); - store.create("Old task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Old task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); // Simulate session switch — swap store store = new TaskStore(); - store.create("New task", "Desc"); + store.create("New task", "Desc", "done"); manager.reset(); // Old task tracking was reset, new store has no completed tasks @@ -262,8 +283,9 @@ describe("auto-clear: store getter (session switch)", () => { // Swap to new store with a completed task store = new TaskStore(); - store.create("Task in new store", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task in new store", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); manager.onTurnStart(5); @@ -276,8 +298,9 @@ describe("auto-clear: reset (new session)", () => { const store = new TaskStore(); const manager = new AutoClearManager(() => store, () => "on_task_complete"); - store.create("Task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); // Simulate /new — reset before the delay expires @@ -292,8 +315,9 @@ describe("auto-clear: reset (new session)", () => { const store = new TaskStore(); const manager = new AutoClearManager(() => store, () => "on_list_complete"); - store.create("Task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); // Simulate /new — reset before the delay expires @@ -308,8 +332,9 @@ describe("auto-clear: reset (new session)", () => { const store = new TaskStore(); const manager = new AutoClearManager(() => store, () => "on_task_complete"); - store.create("Task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); manager.trackCompletion("1", 1); manager.reset(); diff --git a/test/process-tracker.test.ts b/test/process-tracker.test.ts deleted file mode 100644 index 6594799..0000000 --- a/test/process-tracker.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { spawn } from "node:child_process"; -import { beforeEach, describe, expect, it } from "vitest"; -import { ProcessTracker } from "../src/process-tracker.js"; - -describe("ProcessTracker", () => { - let tracker: ProcessTracker; - - beforeEach(() => { - tracker = new ProcessTracker(); - }); - - it("returns undefined for untracked task", () => { - expect(tracker.getOutput("999")).toBeUndefined(); - expect(tracker.getProcess("999")).toBeUndefined(); - }); - - it("tracks a process and captures stdout", async () => { - const proc = spawn("echo", ["hello world"]); - tracker.track("1", proc, "echo hello world"); - - await new Promise((r) => proc.on("close", r)); - // Small delay for event processing - await new Promise((r) => setTimeout(r, 50)); - - const out = tracker.getOutput("1"); - expect(out).toBeDefined(); - expect(out!.output).toContain("hello world"); - expect(out!.status).toBe("completed"); - expect(out!.exitCode).toBe(0); - expect(out!.command).toBe("echo hello world"); - expect(out!.startedAt).toBeGreaterThan(0); - expect(out!.completedAt).toBeGreaterThan(0); - }); - - it("tracks a process and captures stderr", async () => { - const proc = spawn("sh", ["-c", "echo errdata >&2"]); - tracker.track("1", proc); - - await new Promise((r) => proc.on("close", r)); - await new Promise((r) => setTimeout(r, 50)); - - const out = tracker.getOutput("1"); - expect(out!.output).toContain("errdata"); - }); - - it("reports error status for non-zero exit", async () => { - const proc = spawn("sh", ["-c", "exit 42"]); - tracker.track("1", proc); - - await new Promise((r) => proc.on("close", r)); - await new Promise((r) => setTimeout(r, 50)); - - const out = tracker.getOutput("1"); - expect(out!.status).toBe("error"); - expect(out!.exitCode).toBe(42); - }); - - it("waitForCompletion returns immediately for already-completed process", async () => { - const proc = spawn("echo", ["done"]); - tracker.track("1", proc); - - await new Promise((r) => proc.on("close", r)); - await new Promise((r) => setTimeout(r, 50)); - - const out = await tracker.waitForCompletion("1", 1000); - expect(out).toBeDefined(); - expect(out!.status).toBe("completed"); - }); - - it("waitForCompletion returns undefined for untracked task", async () => { - const out = await tracker.waitForCompletion("999", 1000); - expect(out).toBeUndefined(); - }); - - it("waitForCompletion waits for process to finish", async () => { - const proc = spawn("sh", ["-c", "sleep 0.1 && echo waited"]); - tracker.track("1", proc); - - const out = await tracker.waitForCompletion("1", 5000); - expect(out).toBeDefined(); - expect(out!.output).toContain("waited"); - expect(out!.status).toBe("completed"); - }); - - it("waitForCompletion times out if process takes too long", async () => { - const proc = spawn("sleep", ["10"]); - tracker.track("1", proc); - - const out = await tracker.waitForCompletion("1", 200); - expect(out).toBeDefined(); - expect(out!.status).toBe("running"); - - // Cleanup - proc.kill("SIGKILL"); - }); - - it("stop sends SIGTERM and marks process stopped", async () => { - const proc = spawn("sleep", ["10"]); - tracker.track("1", proc); - - // Small delay to let process start - await new Promise((r) => setTimeout(r, 50)); - - const stopped = await tracker.stop("1"); - expect(stopped).toBe(true); - - const out = tracker.getOutput("1"); - expect(out!.status).toBe("stopped"); - expect(out!.completedAt).toBeGreaterThan(0); - }); - - it("stop returns false for untracked task", async () => { - expect(await tracker.stop("999")).toBe(false); - }); - - it("stop returns false for already-completed process", async () => { - const proc = spawn("echo", ["quick"]); - tracker.track("1", proc); - - await new Promise((r) => proc.on("close", r)); - await new Promise((r) => setTimeout(r, 50)); - - expect(await tracker.stop("1")).toBe(false); - }); - - it("getProcess returns the background process record", () => { - const proc = spawn("echo", ["test"]); - tracker.track("1", proc, "echo test"); - - const bp = tracker.getProcess("1"); - expect(bp).toBeDefined(); - expect(bp!.taskId).toBe("1"); - expect(bp!.command).toBe("echo test"); - expect(bp!.status).toBe("running"); - expect(bp!.pid).toBeGreaterThan(0); - - proc.kill("SIGKILL"); - }); - - it("handles process error event", async () => { - const proc = spawn("nonexistent-binary-that-does-not-exist-xyz"); - tracker.track("1", proc); - - await new Promise((r) => proc.on("error", () => r())); - await new Promise((r) => setTimeout(r, 50)); - - const out = tracker.getOutput("1"); - expect(out!.status).toBe("error"); - expect(out!.output).toContain("Process error:"); - }); - - it("waitForCompletion respects abort signal", async () => { - const proc = spawn("sleep", ["10"]); - tracker.track("1", proc); - - const ac = new AbortController(); - setTimeout(() => ac.abort(), 100); - - const out = await tracker.waitForCompletion("1", 60000, ac.signal); - expect(out).toBeDefined(); - expect(out!.status).toBe("running"); - - proc.kill("SIGKILL"); - }); - - it("notifies waiters when process completes", async () => { - const proc = spawn("sh", ["-c", "sleep 0.1"]); - tracker.track("1", proc); - - const [r1, r2] = await Promise.all([ - tracker.waitForCompletion("1", 5000), - tracker.waitForCompletion("1", 5000), - ]); - - expect(r1!.status).toBe("completed"); - expect(r2!.status).toBe("completed"); - }); -}); diff --git a/test/subagent-integration.test.ts b/test/subagent-integration.test.ts deleted file mode 100644 index dd3c36f..0000000 --- a/test/subagent-integration.test.ts +++ /dev/null @@ -1,893 +0,0 @@ -/** - * Tests for task-subagent integration: TaskExecute tool, completion listener, - * auto-cascade, and widget agent ID display. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import initExtension from "../src/index.js"; -import { TaskStore } from "../src/task-store.js"; -import { TaskWidget, type Theme, type UICtx } from "../src/ui/task-widget.js"; - -// Force in-memory task store for all integration tests — prevents file-backed -// store from loading stale tasks across test instances. -beforeEach(() => { process.env.PI_TASKS = "off"; }); -afterEach(() => { delete process.env.PI_TASKS; }); - -// ---- Mock pi ---- - -type MockEventBus = { - on: (channel: string, handler: (data: unknown) => void) => () => void; - emit: (channel: string, data: unknown) => void; -}; - -/** Minimal mock of ExtensionAPI with events, tool capture, and event hooks. */ -function mockPi() { - const tools = new Map(); - const commands = new Map(); - const eventHandlers = new Map void)[]>(); - const lifecycleHandlers = new Map any)[]>(); - - const pi = { - registerTool(def: any) { tools.set(def.name, def); }, - registerCommand(name: string, def: any) { commands.set(name, def); }, - on(event: string, handler: any) { - if (!lifecycleHandlers.has(event)) lifecycleHandlers.set(event, []); - lifecycleHandlers.get(event)!.push(handler); - }, - events: { - emit(channel: string, data: unknown) { - for (const h of eventHandlers.get(channel) ?? []) h(data); - }, - on(channel: string, handler: (data: unknown) => void) { - if (!eventHandlers.has(channel)) eventHandlers.set(channel, []); - eventHandlers.get(channel)!.push(handler); - return () => { - const arr = eventHandlers.get(channel); - if (arr) eventHandlers.set(channel, arr.filter(h => h !== handler)); - }; - }, - }, - sendUserMessage: vi.fn(), - }; - - return { - pi, - tools, - commands, - /** Execute a registered tool by name. */ - async executeTool(name: string, params: any, ctx?: any) { - const tool = tools.get(name); - if (!tool) throw new Error(`Tool ${name} not registered`); - return tool.execute("call-1", params, undefined, undefined, ctx ?? mockCtx()); - }, - /** Fire lifecycle event handlers (turn_start, tool_result, etc.) */ - async fireLifecycle(event: string, ...args: any[]) { - for (const h of lifecycleHandlers.get(event) ?? []) { - await h(...args); - } - }, - /** Emit an event on pi.events (simulates subagent extension). */ - emitEvent(channel: string, data: unknown) { - pi.events.emit(channel, data); - }, - }; -} - -/** Minimal mock ExtensionContext. */ -function mockCtx() { - return { - model: { id: "test-model", name: "Test" }, - modelRegistry: {}, - ui: { - setWidget: vi.fn(), - setStatus: vi.fn(), - notify: vi.fn(), - }, - }; -} - -// ---- Mock subagents extension (RPC responders) ---- - -/** Simulates the @tintinweb/pi-subagents extension: responds to ping + spawn RPCs and emits ready. */ -function installSubagentsMock(pi: { events: MockEventBus }, opts?: { spawnError?: string }) { - let idCounter = 0; - const spawned: Array<{ id: string; type: string; prompt: string; options: any }> = []; - const stopped: string[] = []; - - // Respond to ping — reply on scoped channel - const unsubPing = pi.events.on("subagents:rpc:ping", (data: unknown) => { - const { requestId } = data as { requestId: string }; - pi.events.emit(`subagents:rpc:ping:reply:${requestId}`, { success: true, data: { version: 2 } }); - }); - - // Respond to spawn — reply on scoped channel - const unsubSpawn = pi.events.on("subagents:rpc:spawn", (data: unknown) => { - const { requestId, type, prompt, options } = data as { - requestId: string; type: string; prompt: string; options?: any; - }; - if (opts?.spawnError) { - pi.events.emit(`subagents:rpc:spawn:reply:${requestId}`, { success: false, error: opts.spawnError }); - return; - } - const id = `agent-${++idCounter}`; - spawned.push({ id, type, prompt, options }); - pi.events.emit(`subagents:rpc:spawn:reply:${requestId}`, { success: true, data: { id } }); - }); - - // Respond to stop — reply on scoped channel - const unsubStop = pi.events.on("subagents:rpc:stop", (data: unknown) => { - const { requestId, agentId } = data as { requestId: string; agentId: string }; - const known = spawned.some(s => s.id === agentId); - if (known) { - stopped.push(agentId); - pi.events.emit(`subagents:rpc:stop:reply:${requestId}`, { success: true }); - } else { - pi.events.emit(`subagents:rpc:stop:reply:${requestId}`, { success: false, error: "Agent not found" }); - } - }); - - // Broadcast readiness - pi.events.emit("subagents:ready", {}); - - return { - spawned, - stopped, - unsub() { unsubPing(); unsubSpawn(); unsubStop(); }, - }; -} - -// ---- Tests ---- - -describe("TaskExecute", () => { - let mock: ReturnType; - let rpc: ReturnType; - - beforeEach(() => { - mock = mockPi(); - // Install mock BEFORE init so ping reply is received during extension init - rpc = installSubagentsMock(mock.pi); - initExtension(mock.pi as any); - }); - - afterEach(() => { - rpc.unsub(); - }); - - it("is registered as a tool", () => { - expect(mock.tools.has("TaskExecute")).toBe(true); - }); - - it("returns error when subagent extension is not loaded", async () => { - // Re-init without mock to simulate missing extension - const freshMock = mockPi(); - initExtension(freshMock.pi as any); - - await freshMock.executeTool("TaskCreate", { - subject: "Test task", - description: "Do something", - agentType: "general-purpose", - }); - - const result = await freshMock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(result.content[0].text).toContain("Subagent execution is currently unavailable"); - }); - - it("rejects non-existent tasks", async () => { - const result = await mock.executeTool("TaskExecute", { task_ids: ["999"] }); - expect(result.content[0].text).toContain("#999: not found"); - }); - - it("rejects tasks without agentType", async () => { - await mock.executeTool("TaskCreate", { - subject: "No agent type", - description: "Plain task", - }); - - const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(result.content[0].text).toContain("#1: no agentType set"); - }); - - it("rejects non-pending tasks", async () => { - await mock.executeTool("TaskCreate", { - subject: "Already started", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskUpdate", { taskId: "1", status: "in_progress" }); - - const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(result.content[0].text).toContain("#1: not pending"); - }); - - it("rejects tasks with unresolved blockers", async () => { - await mock.executeTool("TaskCreate", { - subject: "Blocker", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskCreate", { - subject: "Blocked", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] }); - - const result = await mock.executeTool("TaskExecute", { task_ids: ["2"] }); - expect(result.content[0].text).toContain("#2: blocked by #1"); - }); - - it("spawns agent for valid task and updates metadata", async () => { - await mock.executeTool("TaskCreate", { - subject: "Run tests", - description: "Run the test suite", - agentType: "general-purpose", - }); - - const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(result.content[0].text).toContain("Launched 1 agent"); - expect(result.content[0].text).toContain("#1 → agent agent-1"); - - // Verify the RPC responder was called - expect(rpc.spawned).toHaveLength(1); - expect(rpc.spawned[0].type).toBe("general-purpose"); - expect(rpc.spawned[0].prompt).toContain("Run the test suite"); - expect(rpc.spawned[0].options.isBackground).toBe(true); - }); - - it("passes additional_context and max_turns to spawned agents", async () => { - await mock.executeTool("TaskCreate", { - subject: "Explore codebase", - description: "Find all API endpoints", - agentType: "Explore", - }); - - await mock.executeTool("TaskExecute", { - task_ids: ["1"], - additional_context: "Focus on REST endpoints only", - max_turns: 10, - }); - - expect(rpc.spawned[0].prompt).toContain("Focus on REST endpoints only"); - expect(rpc.spawned[0].options.maxTurns).toBe(10); - }); - - it("allows executing tasks whose blockers are all completed", async () => { - await mock.executeTool("TaskCreate", { - subject: "Blocker", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskCreate", { - subject: "Dependent", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] }); - await mock.executeTool("TaskUpdate", { taskId: "1", status: "completed" }); - - const result = await mock.executeTool("TaskExecute", { task_ids: ["2"] }); - expect(result.content[0].text).toContain("Launched 1 agent"); - }); - - it("handles mixed valid and invalid tasks in one call", async () => { - await mock.executeTool("TaskCreate", { - subject: "Valid", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskCreate", { - subject: "No agent type", - description: "Desc", - }); - - const result = await mock.executeTool("TaskExecute", { task_ids: ["1", "2", "999"] }); - const text = result.content[0].text; - expect(text).toContain("Launched 1 agent"); - expect(text).toContain("#2: no agentType set"); - expect(text).toContain("#999: not found"); - }); -}); - -describe("TaskExecute via ready broadcast", () => { - it("detects subagents when ready fires after tasks init", async () => { - // Init tasks WITHOUT the mock — subagents not available yet - const mock = mockPi(); - initExtension(mock.pi as any); - - // Now install the mock (simulates subagents loading later) and broadcast ready - const rpc = installSubagentsMock(mock.pi); - - // Create a task and execute — should work because ready was received - await mock.executeTool("TaskCreate", { - subject: "Late-loaded test", - description: "Desc", - agentType: "general-purpose", - }); - - const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(result.content[0].text).toContain("Launched 1 agent"); - expect(rpc.spawned).toHaveLength(1); - - rpc.unsub(); - }); -}); - -describe("Completion listener", () => { - let mock: ReturnType; - let rpc: ReturnType; - - beforeEach(() => { - mock = mockPi(); - rpc = installSubagentsMock(mock.pi); - initExtension(mock.pi as any); - }); - - afterEach(() => { - rpc.unsub(); - }); - - it("marks task completed on subagents:completed event", async () => { - await mock.executeTool("TaskCreate", { - subject: "Agent task", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - - // Simulate agent completion - mock.emitEvent("subagents:completed", { id: "agent-1" }); - - const result = await mock.executeTool("TaskGet", { taskId: "1" }); - expect(result.content[0].text).toContain("Status: completed"); - }); - - it("reverts task to pending on subagents:failed event", async () => { - await mock.executeTool("TaskCreate", { - subject: "Failing task", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - - // Simulate agent failure - mock.emitEvent("subagents:failed", { id: "agent-1", error: "Out of turns", status: "error" }); - - const result = await mock.executeTool("TaskGet", { taskId: "1" }); - expect(result.content[0].text).toContain("Status: pending"); - }); - - it("ignores events for unknown agent IDs", async () => { - await mock.executeTool("TaskCreate", { - subject: "Unrelated", - description: "Desc", - }); - - // Should not throw or modify anything - mock.emitEvent("subagents:completed", { id: "unknown-agent" }); - mock.emitEvent("subagents:failed", { id: "unknown-agent", error: "boom", status: "error" }); - - const result = await mock.executeTool("TaskGet", { taskId: "1" }); - expect(result.content[0].text).toContain("Status: pending"); - }); -}); - -describe("Auto-cascade", () => { - let mock: ReturnType; - let rpc: ReturnType; - - beforeEach(() => { - mock = mockPi(); - rpc = installSubagentsMock(mock.pi); - initExtension(mock.pi as any); - }); - - afterEach(() => { - rpc.unsub(); - }); - - it("does NOT cascade when auto-cascade is off (default)", async () => { - // Create A → B chain - await mock.executeTool("TaskCreate", { - subject: "Task A", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskCreate", { - subject: "Task B", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] }); - - // Execute A - await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(rpc.spawned).toHaveLength(1); - - // Complete A - mock.emitEvent("subagents:completed", { id: "agent-1" }); - - // B should NOT have been auto-started - expect(rpc.spawned).toHaveLength(1); - - // B should still be pending - const result = await mock.executeTool("TaskGet", { taskId: "2" }); - expect(result.content[0].text).toContain("Status: pending"); - }); - - it("does NOT cascade on failure (branch stops)", async () => { - await mock.executeTool("TaskCreate", { - subject: "Task A", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskCreate", { - subject: "Task B", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] }); - - await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - mock.emitEvent("subagents:failed", { id: "agent-1", error: "crashed", status: "error" }); - - // B should not start - expect(rpc.spawned).toHaveLength(1); - const result = await mock.executeTool("TaskGet", { taskId: "2" }); - expect(result.content[0].text).toContain("Status: pending"); - }); - - it("tasks without agentType are not cascaded even if unblocked", async () => { - await mock.executeTool("TaskCreate", { - subject: "Agent task", - description: "Desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskCreate", { - subject: "Manual task", - description: "Desc", - // No agentType — manual - }); - await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] }); - - await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - mock.emitEvent("subagents:completed", { id: "agent-1" }); - - // Manual task should stay pending - expect(rpc.spawned).toHaveLength(1); - }); -}); - - -describe("Standalone operation (no subagents extension)", () => { - let mock: ReturnType; - - beforeEach(() => { - // Init WITHOUT installSubagentsMock — no subagents extension present - mock = mockPi(); - initExtension(mock.pi as any); - }); - - it("all core task tools are registered", () => { - for (const name of ["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskExecute"]) { - expect(mock.tools.has(name)).toBe(true); - } - }); - - it("TaskCreate works without subagents", async () => { - const result = await mock.executeTool("TaskCreate", { - subject: "Write tests", - description: "Add unit tests for the parser", - }); - expect(result.content[0].text).toContain("Write tests"); - }); - - it("TaskList works without subagents", async () => { - await mock.executeTool("TaskCreate", { subject: "A", description: "desc" }); - await mock.executeTool("TaskCreate", { subject: "B", description: "desc" }); - const result = await mock.executeTool("TaskList", {}); - expect(result.content[0].text).toContain("#1"); - expect(result.content[0].text).toContain("#2"); - }); - - it("TaskGet works without subagents", async () => { - await mock.executeTool("TaskCreate", { subject: "Read me", description: "details here" }); - const result = await mock.executeTool("TaskGet", { taskId: "1" }); - expect(result.content[0].text).toContain("Read me"); - expect(result.content[0].text).toContain("details here"); - }); - - it("TaskUpdate works without subagents", async () => { - await mock.executeTool("TaskCreate", { subject: "Update me", description: "desc" }); - await mock.executeTool("TaskUpdate", { taskId: "1", status: "in_progress" }); - const result = await mock.executeTool("TaskGet", { taskId: "1" }); - expect(result.content[0].text).toContain("in_progress"); - }); - - it("TaskExecute gracefully refuses without subagents", async () => { - await mock.executeTool("TaskCreate", { - subject: "Agent task", - description: "desc", - agentType: "general-purpose", - }); - const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(result.content[0].text).toContain("Subagent execution is currently unavailable"); - }); - - it("subagents lifecycle events are silently ignored without mapped agents", () => { - // These should not throw even though no subagents extension is loaded - mock.emitEvent("subagents:completed", { id: "ghost-agent", result: "done" }); - mock.emitEvent("subagents:failed", { id: "ghost-agent", error: "boom", status: "error" }); - // No crash = pass - }); - - it("task dependencies work without subagents", async () => { - await mock.executeTool("TaskCreate", { subject: "First", description: "desc" }); - await mock.executeTool("TaskCreate", { subject: "Second", description: "desc" }); - await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] }); - - const result = await mock.executeTool("TaskGet", { taskId: "2" }); - expect(result.content[0].text).toContain("Blocked by"); - expect(result.content[0].text).toContain("#1"); - }); -}); - -describe("RPC protocol correctness", () => { - it("ping uses scoped reply channel (not shared channel)", () => { - const mock = mockPi(); - const emitted: Array<{ channel: string; data: unknown }> = []; - const origEmit = mock.pi.events.emit.bind(mock.pi.events); - mock.pi.events.emit = (channel: string, data: unknown) => { - emitted.push({ channel, data }); - origEmit(channel, data); - }; - - initExtension(mock.pi as any); - - // Find the ping emit - const pingEmit = emitted.find(e => e.channel === "subagents:rpc:ping"); - expect(pingEmit).toBeDefined(); - const pingData = pingEmit!.data as { requestId: string }; - expect(pingData.requestId).toBeDefined(); - expect(typeof pingData.requestId).toBe("string"); - }); - - it("spawn reply cleans up listener and timer on success", async () => { - const mock = mockPi(); - const rpc = installSubagentsMock(mock.pi); - initExtension(mock.pi as any); - - await mock.executeTool("TaskCreate", { - subject: "Test", - description: "desc", - agentType: "general-purpose", - }); - - await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(rpc.spawned).toHaveLength(1); - - // Second spawn should get a fresh requestId (not conflict with first) - await mock.executeTool("TaskCreate", { - subject: "Test 2", - description: "desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskExecute", { task_ids: ["2"] }); - expect(rpc.spawned).toHaveLength(2); - expect(rpc.spawned[0].id).not.toBe(rpc.spawned[1].id); - - rpc.unsub(); - }); - - it("spawn RPC rejects on timeout when no responder exists", async () => { - const mock = mockPi(); - // Install ping handler (for version check) but no spawn handler - installVersionedMock(mock.pi, 2); - initExtension(mock.pi as any); - - await mock.executeTool("TaskCreate", { - subject: "Timeout test", - description: "desc", - agentType: "general-purpose", - }); - - // spawnSubagent has a 30s timeout — we'll advance timers - vi.useFakeTimers(); - const execPromise = mock.executeTool("TaskExecute", { task_ids: ["1"] }); - await vi.advanceTimersByTimeAsync(31000); - - const result = await execPromise; - expect(result.content[0].text).toContain("timeout"); - - vi.useRealTimers(); - }); - - it("ready broadcast sets subagentsAvailable even after init", async () => { - const mock = mockPi(); - initExtension(mock.pi as any); - - // Initially no subagents - await mock.executeTool("TaskCreate", { - subject: "Test", - description: "desc", - agentType: "general-purpose", - }); - let result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(result.content[0].text).toContain("Subagent execution is currently unavailable"); - - // Reset task status - await mock.executeTool("TaskUpdate", { taskId: "1", status: "pending" }); - - // Late subagents extension broadcasts ready - const rpc = installSubagentsMock(mock.pi); - - result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(result.content[0].text).toContain("Launched 1 agent"); - - rpc.unsub(); - }); - - it("spawn RPC rejects with error message from server", async () => { - const mock = mockPi(); - installSubagentsMock(mock.pi, { spawnError: "No active session" }); - initExtension(mock.pi as any); - - await mock.executeTool("TaskCreate", { - subject: "Err test", - description: "desc", - agentType: "general-purpose", - }); - - const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(result.content[0].text).toContain("No active session"); - }); - - it("stop RPC resolves on success", async () => { - const mock = mockPi(); - const rpc = installSubagentsMock(mock.pi); - initExtension(mock.pi as any); - - // Spawn a task so we have an agent to stop - await mock.executeTool("TaskCreate", { - subject: "Stoppable", - description: "desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - expect(rpc.spawned).toHaveLength(1); - - const result = await mock.executeTool("TaskStop", { task_id: "1" }); - expect(result.content[0].text).toContain("stopped successfully"); - expect(rpc.stopped).toContain("agent-1"); - - rpc.unsub(); - }); - - it("stop RPC returns false on error (agent not found) without throwing", async () => { - const mock = mockPi(); - const rpc = installSubagentsMock(mock.pi); - initExtension(mock.pi as any); - - // Create and execute a task, then simulate agent already gone - await mock.executeTool("TaskCreate", { - subject: "Ghost", - description: "desc", - agentType: "general-purpose", - }); - await mock.executeTool("TaskExecute", { task_ids: ["1"] }); - - // Clear spawned list so the mock's stop handler won't find the agent - rpc.spawned.length = 0; - - // TaskStop should still succeed (stopSubagent catches the error) - const result = await mock.executeTool("TaskStop", { task_id: "1" }); - expect(result.content[0].text).toContain("stopped successfully"); - - rpc.unsub(); - }); - - it("stop RPC returns false on timeout without throwing", async () => { - const mock = mockPi(); - initExtension(mock.pi as any); - - // Mark subagents as available via ready broadcast, but no stop handler installed - mock.pi.events.emit("subagents:ready", {}); - - await mock.executeTool("TaskCreate", { - subject: "Timeout stop", - description: "desc", - agentType: "general-purpose", - }); - // Manually set task as in_progress with an agentId (no spawn handler) - await mock.executeTool("TaskUpdate", { - taskId: "1", - status: "in_progress", - metadata: { agentType: "general-purpose", agentId: "ghost-agent" }, - }); - - vi.useFakeTimers(); - const stopPromise = mock.executeTool("TaskStop", { task_id: "1" }); - await vi.advanceTimersByTimeAsync(11000); - - // Should resolve (not throw) — stopSubagent catches timeout - const result = await stopPromise; - expect(result.content[0].text).toContain("stopped successfully"); - - vi.useRealTimers(); - }); -}); - -/** Install a ping-only mock with a specific protocol version (or no version for v1). */ -function installVersionedMock(pi: { events: MockEventBus }, version?: number) { - const unsubPing = pi.events.on("subagents:rpc:ping", (data: unknown) => { - const { requestId } = data as { requestId: string }; - if (version !== undefined) { - pi.events.emit(`subagents:rpc:ping:reply:${requestId}`, { success: true, data: { version } }); - } else { - // v1 handler — no envelope, no version - pi.events.emit(`subagents:rpc:ping:reply:${requestId}`, {}); - } - }); - pi.events.emit("subagents:ready", {}); - return { unsub() { unsubPing(); } }; -} - -describe("Protocol version mismatch", () => { - it("matching version — no warning", async () => { - const mock = mockPi(); - installVersionedMock(mock.pi, 2); - initExtension(mock.pi as any); - - // No warning on before_agent_start - const ctx = mockCtx(); - await mock.fireLifecycle("before_agent_start", {}, ctx); - expect(ctx.ui.notify).not.toHaveBeenCalled(); - }); - - it("old handler (no version) — warns about pi-subagents", async () => { - const mock = mockPi(); - installVersionedMock(mock.pi); // no version = v1 - initExtension(mock.pi as any); - - const ctx = mockCtx(); - await mock.fireLifecycle("before_agent_start", {}, ctx); - expect(ctx.ui.notify).toHaveBeenCalledWith( - expect.stringContaining("pi-subagents is outdated"), - "warning", - ); - }); - - it("handler ahead (v3) — warns about pi-tasks", async () => { - const mock = mockPi(); - installVersionedMock(mock.pi, 3); - initExtension(mock.pi as any); - - const ctx = mockCtx(); - await mock.fireLifecycle("before_agent_start", {}, ctx); - expect(ctx.ui.notify).toHaveBeenCalledWith( - expect.stringContaining("pi-tasks is outdated"), - "warning", - ); - }); - - it("handler behind (v1) — warns about pi-subagents", async () => { - const mock = mockPi(); - installVersionedMock(mock.pi, 1); - initExtension(mock.pi as any); - - const ctx = mockCtx(); - await mock.fireLifecycle("before_agent_start", {}, ctx); - expect(ctx.ui.notify).toHaveBeenCalledWith( - expect.stringContaining("pi-subagents is outdated"), - "warning", - ); - }); - - it("warning shown only once", async () => { - const mock = mockPi(); - installVersionedMock(mock.pi); // v1 — triggers warning - initExtension(mock.pi as any); - - const ctx1 = mockCtx(); - await mock.fireLifecycle("before_agent_start", {}, ctx1); - expect(ctx1.ui.notify).toHaveBeenCalledOnce(); - - const ctx2 = mockCtx(); - await mock.fireLifecycle("before_agent_start", {}, ctx2); - expect(ctx2.ui.notify).not.toHaveBeenCalled(); - }); -}); - -describe("Widget agent ID display", () => { - let store: TaskStore; - let widget: TaskWidget; - let ui: ReturnType; - - function mockUICtx() { - const state = { - widgets: new Map(), - statuses: new Map(), - }; - const ctx: UICtx = { - setWidget(key, content, options) { state.widgets.set(key, { content, options }); }, - setStatus(key, text) { state.statuses.set(key, text); }, - }; - return { ctx, state }; - } - - function mockTheme(): Theme { - return { - fg: (_color: string, text: string) => text, - bold: (text: string) => text, - strikethrough: (text: string) => `~~${text}~~`, - }; - } - - function renderWidget(state: ReturnType["state"]): string[] { - const entry = state.widgets.get("tasks"); - if (!entry?.content) return []; - const theme = mockTheme(); - const tui = { terminal: { columns: 200 } }; - return entry.content(tui, theme).render(); - } - - beforeEach(() => { - vi.useFakeTimers(); - store = new TaskStore(); - widget = new TaskWidget(store); - ui = mockUICtx(); - widget.setUICtx(ui.ctx); - }); - - afterEach(() => { - widget.dispose(); - vi.useRealTimers(); - }); - - it("shows agent ID for active agent-backed tasks", () => { - store.create("Agent task", "Desc", "Running tests", { agentType: "general-purpose", agentId: "abc1234567890" }); - store.update("1", { status: "in_progress" }); - widget.setActiveTask("1", true); - - const lines = renderWidget(ui.state); - expect(lines[1]).toContain("agent abc12"); - expect(lines[1]).toContain("Running tests"); - }); - - it("shows agent ID for non-active in_progress agent-backed tasks", () => { - store.create("Agent task", "Desc", undefined, { agentType: "general-purpose", agentId: "xyz9876543210" }); - store.update("1", { status: "in_progress" }); - // NOT calling setActiveTask — simulates external agent management - widget.update(); - - const lines = renderWidget(ui.state); - expect(lines[1]).toContain("agent xyz98"); - expect(lines[1]).toContain("Agent task"); - }); - - it("does not show agent ID for tasks without agentId", () => { - store.create("Manual task", "Desc"); - store.update("1", { status: "in_progress" }); - widget.update(); - - const lines = renderWidget(ui.state); - expect(lines[1]).not.toContain("agent"); - expect(lines[1]).toContain("Manual task"); - }); - - it("does not show agent ID for pending tasks", () => { - store.create("Pending agent task", "Desc", undefined, { agentType: "general-purpose", agentId: "abc12345" }); - widget.update(); - - const lines = renderWidget(ui.state); - expect(lines[1]).not.toContain("agent abc"); - }); - - it("does not show agent ID for completed tasks", () => { - store.create("Done", "Desc", undefined, { agentType: "general-purpose", agentId: "abc12345" }); - store.update("1", { status: "completed" }); - widget.update(); - - const lines = renderWidget(ui.state); - expect(lines[1]).not.toContain("agent abc"); - }); -}); diff --git a/test/task-store.test.ts b/test/task-store.test.ts index ecb7b9a..b1e4741 100644 --- a/test/task-store.test.ts +++ b/test/task-store.test.ts @@ -4,6 +4,13 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { TaskStore } from "../src/task-store.js"; +// Helper: create a task and set pending_approval so complete() works +function createAndApprove(store: TaskStore, subject: string) { + const task = store.create(subject, "Desc", "done criterion"); + store.update(task.id, { pending_approval: true }); + return task; +} + describe("TaskStore (in-memory)", () => { let store: TaskStore; @@ -12,25 +19,27 @@ describe("TaskStore (in-memory)", () => { }); it("creates tasks with auto-incrementing IDs", () => { - const t1 = store.create("First task", "Description 1"); - const t2 = store.create("Second task", "Description 2"); + const t1 = store.create("First task", "Description 1", "criterion 1"); + const t2 = store.create("Second task", "Description 2", "criterion 2"); expect(t1.id).toBe("1"); expect(t2.id).toBe("2"); expect(t1.status).toBe("pending"); expect(t1.subject).toBe("First task"); expect(t1.description).toBe("Description 1"); + expect(t1.done_criterion).toBe("criterion 1"); + expect(t1.pending_approval).toBe(false); }); it("creates tasks with optional fields", () => { - const t = store.create("Task", "Desc", "Running task", { key: "value" }); + const t = store.create("Task", "Desc", "done criterion", "Running task", { key: "value" }); expect(t.activeForm).toBe("Running task"); expect(t.metadata).toEqual({ key: "value" }); }); it("gets a task by ID", () => { - store.create("Test", "Desc"); + store.create("Test", "Desc", "done"); const task = store.get("1"); expect(task).toBeDefined(); @@ -42,16 +51,16 @@ describe("TaskStore (in-memory)", () => { }); it("lists all tasks sorted by ID", () => { - store.create("Task 3", "Desc"); - store.create("Task 1", "Desc"); - store.create("Task 2", "Desc"); + store.create("Task 3", "Desc", "done"); + store.create("Task 1", "Desc", "done"); + store.create("Task 2", "Desc", "done"); const tasks = store.list(); expect(tasks.map(t => t.id)).toEqual(["1", "2", "3"]); }); it("updates task status", () => { - store.create("Test", "Desc"); + store.create("Test", "Desc", "done"); const { task, changedFields } = store.update("1", { status: "in_progress" }); expect(task!.status).toBe("in_progress"); @@ -59,7 +68,7 @@ describe("TaskStore (in-memory)", () => { }); it("updates multiple fields at once", () => { - store.create("Test", "Desc"); + store.create("Test", "Desc", "done"); const { changedFields } = store.update("1", { subject: "Updated subject", description: "Updated desc", @@ -76,7 +85,7 @@ describe("TaskStore (in-memory)", () => { }); it("deletes a task with status: deleted", () => { - store.create("Test", "Desc"); + store.create("Test", "Desc", "done"); const { changedFields } = store.update("1", { status: "deleted" }); expect(changedFields).toEqual(["deleted"]); @@ -85,16 +94,16 @@ describe("TaskStore (in-memory)", () => { }); it("preserves ID counter after deletion", () => { - store.create("Task 1", "Desc"); - store.create("Task 2", "Desc"); + store.create("Task 1", "Desc", "done"); + store.create("Task 2", "Desc", "done"); store.update("1", { status: "deleted" }); - const t3 = store.create("Task 3", "Desc"); + const t3 = store.create("Task 3", "Desc", "done"); expect(t3.id).toBe("3"); // Not "1" — counter continues }); it("merges metadata with null key deletion", () => { - store.create("Test", "Desc", undefined, { a: 1, b: 2, c: 3 }); + store.create("Test", "Desc", "done", undefined, { a: 1, b: 2, c: 3 }); store.update("1", { metadata: { b: null, d: 4 } }); const task = store.get("1")!; @@ -102,8 +111,8 @@ describe("TaskStore (in-memory)", () => { }); it("sets up bidirectional blocks via addBlocks", () => { - store.create("Blocker", "Desc"); - store.create("Blocked", "Desc"); + store.create("Blocker", "Desc", "done"); + store.create("Blocked", "Desc", "done"); store.update("1", { addBlocks: ["2"] }); @@ -114,8 +123,8 @@ describe("TaskStore (in-memory)", () => { }); it("sets up bidirectional blocks via addBlockedBy", () => { - store.create("Blocker", "Desc"); - store.create("Blocked", "Desc"); + store.create("Blocker", "Desc", "done"); + store.create("Blocked", "Desc", "done"); store.update("2", { addBlockedBy: ["1"] }); @@ -126,8 +135,8 @@ describe("TaskStore (in-memory)", () => { }); it("does not duplicate dependency edges", () => { - store.create("A", "Desc"); - store.create("B", "Desc"); + store.create("A", "Desc", "done"); + store.create("B", "Desc", "done"); store.update("1", { addBlocks: ["2"] }); store.update("1", { addBlocks: ["2"] }); // duplicate @@ -137,8 +146,8 @@ describe("TaskStore (in-memory)", () => { }); it("cleans up dependency edges on deletion", () => { - store.create("A", "Desc"); - store.create("B", "Desc"); + store.create("A", "Desc", "done"); + store.create("B", "Desc", "done"); store.update("1", { addBlocks: ["2"] }); store.update("1", { status: "deleted" }); @@ -148,9 +157,9 @@ describe("TaskStore (in-memory)", () => { }); it("clears completed tasks", () => { - store.create("Completed", "Desc"); - store.create("Pending", "Desc"); - store.update("1", { status: "completed" }); + createAndApprove(store, "Completed"); + store.create("Pending", "Desc", "done"); + store.complete("1"); const count = store.clearCompleted(); @@ -159,21 +168,41 @@ describe("TaskStore (in-memory)", () => { expect(store.list()[0].id).toBe("2"); }); + it("throws on update status=completed (must use /lgtm)", () => { + store.create("Test", "Desc", "done"); + expect(() => store.update("1", { status: "completed" as any })).toThrow("Use /lgtm"); + }); + it("returns not found for update on non-existent task", () => { - const { task, changedFields } = store.update("999", { status: "completed" }); + const { task, changedFields } = store.update("999", { status: "in_progress" }); expect(task).toBeUndefined(); expect(changedFields).toEqual([]); }); + it("complete() requires pending_approval", () => { + store.create("Test", "Desc", "done"); + expect(() => store.complete("1")).toThrow("lgtm_ask"); + }); + + it("complete() works when pending_approval=true", () => { + createAndApprove(store, "Test"); + const task = store.complete("1"); + expect(task.status).toBe("completed"); + }); + + it("complete() throws on non-existent task", () => { + expect(() => store.complete("999")).toThrow("not found"); + }); + it("delete method works", () => { - store.create("Test", "Desc"); + store.create("Test", "Desc", "done"); expect(store.delete("1")).toBe(true); expect(store.delete("1")).toBe(false); // already deleted expect(store.list()).toHaveLength(0); }); it("creates tasks with metadata via TaskCreate", () => { - const t = store.create("With meta", "Desc", undefined, { pr: "123", reviewer: "alice" }); + const t = store.create("With meta", "Desc", "done", undefined, { pr: "123", reviewer: "alice" }); expect(t.metadata).toEqual({ pr: "123", reviewer: "alice" }); const retrieved = store.get("1")!; @@ -181,8 +210,8 @@ describe("TaskStore (in-memory)", () => { }); it("allows circular dependencies with warning", () => { - store.create("A", "Desc"); - store.create("B", "Desc"); + store.create("A", "Desc", "done"); + store.create("B", "Desc", "done"); store.update("1", { addBlocks: ["2"] }); const { warnings } = store.update("2", { addBlocks: ["1"] }); @@ -192,57 +221,67 @@ describe("TaskStore (in-memory)", () => { }); it("allows self-dependency with warning", () => { - store.create("Self", "Desc"); + store.create("Self", "Desc", "done"); const { warnings } = store.update("1", { addBlocks: ["1"] }); expect(store.get("1")!.blocks).toContain("1"); expect(warnings).toContain("#1 blocks itself"); }); it("stores dangling edge IDs with warning", () => { - store.create("Real", "Desc"); + store.create("Real", "Desc", "done"); const { warnings } = store.update("1", { addBlocks: ["9999"] }); expect(store.get("1")!.blocks).toContain("9999"); expect(warnings).toContain("#9999 does not exist"); }); it("returns no warnings for valid dependencies", () => { - store.create("A", "Desc"); - store.create("B", "Desc"); + store.create("A", "Desc", "done"); + store.create("B", "Desc", "done"); const { warnings } = store.update("1", { addBlocks: ["2"] }); expect(warnings).toEqual([]); }); it("accepts whitespace-only subjects (matches Claude Code)", () => { - const t = store.create(" ", "Desc"); + const t = store.create(" ", "Desc", "done"); expect(t.subject).toBe(" "); }); it("updates activeForm field", () => { - store.create("Test", "Desc"); + store.create("Test", "Desc", "done"); const { changedFields } = store.update("1", { activeForm: "Running tests" }); expect(changedFields).toContain("activeForm"); expect(store.get("1")!.activeForm).toBe("Running tests"); }); it("updates description field", () => { - store.create("Test", "Original desc"); + store.create("Test", "Original desc", "done"); const { changedFields } = store.update("1", { description: "Updated desc" }); expect(changedFields).toContain("description"); expect(store.get("1")!.description).toBe("Updated desc"); }); + it("updates done_criterion field", () => { + store.create("Test", "Desc", "original criterion"); + const { changedFields } = store.update("1", { done_criterion: "updated criterion" }); + expect(changedFields).toContain("done_criterion"); + expect(store.get("1")!.done_criterion).toBe("updated criterion"); + }); + it("returns empty changedFields when updating non-existent task", () => { - const { task, changedFields, warnings } = store.update("999", { status: "completed" }); + const { task, changedFields, warnings } = store.update("999", { status: "in_progress" }); expect(task).toBeUndefined(); expect(changedFields).toEqual([]); expect(warnings).toEqual([]); }); it("clearCompleted cleans up dependency edges", () => { - store.create("Blocker", "Desc"); - store.create("Blocked", "Desc"); + store.create("Blocker", "Desc", "done"); + store.create("Blocked", "Desc", "done"); store.update("1", { addBlocks: ["2"] }); - store.update("1", { status: "completed" }); + createAndApprove(store, "dummy"); // need task 1 to have pending_approval + // Actually set pending_approval on task 1 + store.update("1", { pending_approval: true }); + store.complete("1"); store.clearCompleted(); @@ -251,9 +290,9 @@ describe("TaskStore (in-memory)", () => { }); it("handles multiple addBlocks in one call", () => { - store.create("Blocker", "Desc"); - store.create("B1", "Desc"); - store.create("B2", "Desc"); + store.create("Blocker", "Desc", "done"); + store.create("B1", "Desc", "done"); + store.create("B2", "Desc", "done"); store.update("1", { addBlocks: ["2", "3"] }); @@ -263,44 +302,42 @@ describe("TaskStore (in-memory)", () => { }); it("addBlockedBy warns on self-dependency", () => { - store.create("Self", "Desc"); + store.create("Self", "Desc", "done"); const { warnings } = store.update("1", { addBlockedBy: ["1"] }); expect(store.get("1")!.blockedBy).toContain("1"); expect(warnings).toContain("#1 blocks itself"); }); it("addBlockedBy warns on dangling ref", () => { - store.create("Real", "Desc"); + store.create("Real", "Desc", "done"); const { warnings } = store.update("1", { addBlockedBy: ["9999"] }); expect(store.get("1")!.blockedBy).toContain("9999"); expect(warnings).toContain("#9999 does not exist"); }); it("addBlockedBy warns on cycle", () => { - store.create("A", "Desc"); - store.create("B", "Desc"); + store.create("A", "Desc", "done"); + store.create("B", "Desc", "done"); store.update("1", { addBlocks: ["2"] }); const { warnings } = store.update("1", { addBlockedBy: ["2"] }); expect(warnings).toContain("cycle: #1 and #2 block each other"); }); it("clearCompleted returns 0 when no completed tasks", () => { - store.create("Pending", "Desc"); + store.create("Pending", "Desc", "done"); expect(store.clearCompleted()).toBe(0); }); it("list sorts pending → in_progress → completed with all three present", () => { - store.create("Pending task", "Desc"); - store.create("Completed task", "Desc"); - store.create("In-progress task", "Desc"); - store.create("Another pending", "Desc"); + store.create("Pending task", "Desc", "done"); + createAndApprove(store, "Completed task"); + store.create("In-progress task", "Desc", "done"); + store.create("Another pending", "Desc", "done"); - store.update("2", { status: "completed" }); + store.complete("2"); store.update("3", { status: "in_progress" }); const tasks = store.list(); - // Store returns by ID; TaskList tool sorts by status group - // Here we verify the raw list order (by ID), then test status-grouped sort const statusOrder: Record = { pending: 0, in_progress: 1, completed: 2 }; const sorted = [...tasks].sort((a, b) => { const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0); @@ -319,7 +356,6 @@ describe("TaskStore (file-backed)", () => { const filePath = join(tasksDir, `${testListId}.json`); afterEach(() => { - // Clean up test file try { rmSync(filePath); } catch { /* */ } try { rmSync(filePath + ".lock"); } catch { /* */ } try { rmSync(filePath + ".tmp"); } catch { /* */ } @@ -327,9 +363,8 @@ describe("TaskStore (file-backed)", () => { it("persists tasks to disk", () => { const store1 = new TaskStore(testListId); - store1.create("Persistent task", "Should survive reload"); + store1.create("Persistent task", "Should survive reload", "done"); - // Create a new store instance pointing to same file const store2 = new TaskStore(testListId); const tasks = store2.list(); @@ -339,7 +374,7 @@ describe("TaskStore (file-backed)", () => { it("persists in_progress updates to disk", () => { const store1 = new TaskStore(testListId); - store1.create("Task", "Desc"); + store1.create("Task", "Desc", "done"); store1.update("1", { status: "in_progress" }); const store2 = new TaskStore(testListId); @@ -348,9 +383,10 @@ describe("TaskStore (file-backed)", () => { it("persists completed tasks to disk", () => { const store1 = new TaskStore(testListId); - store1.create("Done task", "Desc"); - store1.create("Pending task", "Desc"); - store1.update("1", { status: "completed" }); + store1.create("Done task", "Desc", "done"); + store1.create("Pending task", "Desc", "done"); + store1.update("1", { pending_approval: true }); + store1.complete("1"); const store2 = new TaskStore(testListId); expect(store2.get("1")).toBeDefined(); @@ -361,11 +397,12 @@ describe("TaskStore (file-backed)", () => { it("restores all tasks across instances", () => { const store1 = new TaskStore(testListId); - store1.create("Pending", "Desc"); - store1.create("In progress", "Desc"); - store1.create("Done", "Desc"); + store1.create("Pending", "Desc", "done"); + store1.create("In progress", "Desc", "done"); + store1.create("Done", "Desc", "done"); store1.update("2", { status: "in_progress" }); - store1.update("3", { status: "completed" }); + store1.update("3", { pending_approval: true }); + store1.complete("3"); const store2 = new TaskStore(testListId); const tasks = store2.list(); @@ -377,11 +414,11 @@ describe("TaskStore (file-backed)", () => { it("persists ID counter across instances", () => { const store1 = new TaskStore(testListId); - store1.create("Task 1", "Desc"); - store1.create("Task 2", "Desc"); + store1.create("Task 1", "Desc", "done"); + store1.create("Task 2", "Desc", "done"); const store2 = new TaskStore(testListId); - const t3 = store2.create("Task 3", "Desc"); + const t3 = store2.create("Task 3", "Desc", "done"); expect(t3.id).toBe("3"); }); }); @@ -397,7 +434,7 @@ describe("TaskStore (absolute path)", () => { it("accepts absolute path and persists tasks", () => { const store1 = new TaskStore(absFilePath); - store1.create("Abs path task", "Desc"); + store1.create("Abs path task", "Desc", "done"); const store2 = new TaskStore(absFilePath); expect(store2.list()).toHaveLength(1); @@ -406,9 +443,10 @@ describe("TaskStore (absolute path)", () => { it("persists completed tasks when using absolute path", () => { const store1 = new TaskStore(absFilePath); - store1.create("Pending", "Desc"); - store1.create("Completed", "Desc"); - store1.update("2", { status: "completed" }); + store1.create("Pending", "Desc", "done"); + store1.create("Completed", "Desc", "done"); + store1.update("2", { pending_approval: true }); + store1.complete("2"); const raw = JSON.parse(readFileSync(absFilePath, "utf-8")); expect(raw.tasks).toHaveLength(2); diff --git a/test/task-widget.test.ts b/test/task-widget.test.ts index 1317e55..60454ea 100644 --- a/test/task-widget.test.ts +++ b/test/task-widget.test.ts @@ -68,7 +68,7 @@ describe("TaskWidget", () => { }); it("renders pending tasks with ◻ icon", () => { - store.create("Do something", "Desc"); + store.create("Do something", "Desc", "done"); widget.update(); const lines = renderWidget(ui.state); @@ -80,7 +80,7 @@ describe("TaskWidget", () => { }); it("renders in-progress tasks with ◼ icon", () => { - store.create("Working on it", "Desc"); + store.create("Working on it", "Desc", "done"); store.update("1", { status: "in_progress" }); widget.update(); @@ -90,8 +90,9 @@ describe("TaskWidget", () => { }); it("renders completed tasks with ✔ icon and strikethrough", () => { - store.create("Done task", "Desc"); - store.update("1", { status: "completed" }); + store.create("Done task", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); widget.update(); const lines = renderWidget(ui.state); @@ -100,7 +101,7 @@ describe("TaskWidget", () => { }); it("renders active tasks with spinner icon", () => { - store.create("Running thing", "Desc", "Processing data"); + store.create("Running thing", "Desc", "done criterion", "Processing data"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -112,8 +113,8 @@ describe("TaskWidget", () => { }); it("shows blocked-by info for pending tasks", () => { - store.create("Blocker", "Desc"); - store.create("Blocked", "Desc"); + store.create("Blocker", "Desc", "done"); + store.create("Blocked", "Desc", "done"); store.update("2", { addBlockedBy: ["1"] }); widget.update(); @@ -123,10 +124,11 @@ describe("TaskWidget", () => { }); it("hides completed blockers in blocked-by suffix", () => { - store.create("Blocker", "Desc"); - store.create("Blocked", "Desc"); + store.create("Blocker", "Desc", "done"); + store.create("Blocked", "Desc", "done"); store.update("2", { addBlockedBy: ["1"] }); - store.update("1", { status: "completed" }); + store.update("1", { pending_approval: true }); + store.complete("1"); widget.update(); const lines = renderWidget(ui.state); @@ -135,10 +137,11 @@ describe("TaskWidget", () => { }); it("shows status summary in header", () => { - store.create("Task A", "Desc"); - store.create("Task B", "Desc"); - store.create("Task C", "Desc"); - store.update("1", { status: "completed" }); + store.create("Task A", "Desc", "done"); + store.create("Task B", "Desc", "done"); + store.create("Task C", "Desc", "done"); + store.update("1", { pending_approval: true }); + store.complete("1"); store.update("2", { status: "in_progress" }); widget.update(); @@ -150,7 +153,7 @@ describe("TaskWidget", () => { }); it("clears widget when all tasks are deleted", () => { - store.create("Task", "Desc"); + store.create("Task", "Desc", "done"); widget.update(); expect(ui.state.widgets.get("tasks")?.content).toBeDefined(); @@ -172,7 +175,7 @@ describe("TaskWidget", () => { }); it("tracks token usage for active tasks", () => { - store.create("Active task", "Desc", "Running"); + store.create("Active task", "Desc", "done criterion", "Running"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -186,7 +189,7 @@ describe("TaskWidget", () => { }); it("deactivates a task with setActiveTask(id, false)", () => { - store.create("Task", "Desc", "Doing work"); + store.create("Task", "Desc", "done criterion", "Doing work"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -202,12 +205,13 @@ describe("TaskWidget", () => { }); it("prunes stale active IDs on update", () => { - store.create("Task", "Desc"); + store.create("Task", "Desc", "done"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); // Complete the task externally - store.update("1", { status: "completed" }); + store.update("1", { pending_approval: true }); + store.complete("1"); widget.update(); // Should render as completed, not active @@ -217,8 +221,8 @@ describe("TaskWidget", () => { }); it("supports multiple active tasks simultaneously", () => { - store.create("Task A", "Desc", "Processing A"); - store.create("Task B", "Desc", "Processing B"); + store.create("Task A", "Desc", "done criterion", "Processing A"); + store.create("Task B", "Desc", "done criterion", "Processing B"); store.update("1", { status: "in_progress" }); store.update("2", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -230,8 +234,8 @@ describe("TaskWidget", () => { }); it("distributes token usage across all active tasks", () => { - store.create("Task A", "Desc", "A"); - store.create("Task B", "Desc", "B"); + store.create("Task A", "Desc", "done criterion", "A"); + store.create("Task B", "Desc", "done criterion", "B"); store.update("1", { status: "in_progress" }); store.update("2", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -246,7 +250,7 @@ describe("TaskWidget", () => { }); it("dispose clears widget and timer", () => { - store.create("Task", "Desc"); + store.create("Task", "Desc", "done"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -255,7 +259,7 @@ describe("TaskWidget", () => { }); it("uses subject as fallback when no activeForm", () => { - store.create("My Subject", "Desc"); + store.create("My Subject", "Desc", "done"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -264,7 +268,7 @@ describe("TaskWidget", () => { }); it("shows elapsed time but no token arrows when tokens are zero", () => { - store.create("No tokens", "Desc", "Working"); + store.create("No tokens", "Desc", "done criterion", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -280,7 +284,7 @@ describe("TaskWidget", () => { }); it("cleans up metrics when stale active IDs are pruned", () => { - store.create("Task", "Desc", "Running"); + store.create("Task", "Desc", "done criterion", "Running"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); widget.addTokenUsage(100, 50); @@ -290,7 +294,7 @@ describe("TaskWidget", () => { widget.update(); // Reactivate with same ID (new task) — should get fresh metrics - store.create("Task 2", "Desc", "Running"); // ID 2 + store.create("Task 2", "Desc", "done criterion", "Running"); // ID 2 store.update("2", { status: "in_progress" }); widget.setActiveTask("2", true); @@ -300,7 +304,7 @@ describe("TaskWidget", () => { }); it("indents task lines under header", () => { - store.create("Indented task", "Desc"); + store.create("Indented task", "Desc", "done"); widget.update(); const lines = renderWidget(ui.state); @@ -309,7 +313,7 @@ describe("TaskWidget", () => { }); it("widget is placed aboveEditor", () => { - store.create("Task", "Desc"); + store.create("Task", "Desc", "done"); widget.update(); const entry = ui.state.widgets.get("tasks"); @@ -336,7 +340,7 @@ describe("formatDuration (via widget rendering)", () => { }); it("shows seconds for short durations", () => { - store.create("Quick", "Desc", "Working"); + store.create("Quick", "Desc", "done criterion", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -348,7 +352,7 @@ describe("formatDuration (via widget rendering)", () => { }); it("shows hours for long durations", () => { - store.create("Long", "Desc", "Working"); + store.create("Long", "Desc", "done criterion", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -360,7 +364,7 @@ describe("formatDuration (via widget rendering)", () => { }); it("shows exact hours without minutes", () => { - store.create("Exact", "Desc", "Working"); + store.create("Exact", "Desc", "done criterion", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -372,7 +376,7 @@ describe("formatDuration (via widget rendering)", () => { }); it("shows minutes and seconds", () => { - store.create("Medium", "Desc", "Working"); + store.create("Medium", "Desc", "done criterion", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -384,7 +388,7 @@ describe("formatDuration (via widget rendering)", () => { }); it("formats small token counts without k suffix", () => { - store.create("Small", "Desc", "Working"); + store.create("Small", "Desc", "done criterion", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); @@ -397,7 +401,7 @@ describe("formatDuration (via widget rendering)", () => { }); it("formats token counts with k suffix and removes .0", () => { - store.create("Large", "Desc", "Working"); + store.create("Large", "Desc", "done criterion", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true);