From 7f5a39c6b05419e18a67b5b8aacd3ee7c2be8b1b Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 26 Sep 2026 10:28:50 +0200 Subject: [PATCH] =?UTF-8?q?docs(plan):=20Phase=208=20=E2=80=94=20agent=20A?= =?UTF-8?q?PI=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-09-25-phase-8-agent-api.md | 1210 +++++++++++++++++ 1 file changed, 1210 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-25-phase-8-agent-api.md diff --git a/docs/superpowers/plans/2026-09-25-phase-8-agent-api.md b/docs/superpowers/plans/2026-09-25-phase-8-agent-api.md new file mode 100644 index 0000000..c20ebf9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-phase-8-agent-api.md @@ -0,0 +1,1210 @@ +# Phase 8 — Agent API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A bearer-token-authenticated REST API under `/api/agent/` that lets an external AI coding agent list every project/card the owner has, read one card's full detail, and write back exactly three things: a comment, subtasks, or a column move — with agent-authored comments honestly marked as agent-drafted. + +**Architecture:** Every route checks a single static token (`AGENT_API_TOKEN`) against the `Authorization` header, the same pattern already used by `/api/cron/reconcile`'s `CRON_SECRET`. All reads and writes go through the superuser PocketBase connection (`getAdminPb()`), since this surface has no per-caller identity narrower than "the owner" — the token itself is the identity. Writes that need an `author`/`actor` user id resolve the app's one owner account server-side; nothing is ever taken from the request body for identity. + +**Tech Stack:** Next.js 16 Route Handlers · PocketBase 0.30 · vitest + +**Spec:** `docs/superpowers/specs/2026-09-25-phase-8-agent-api-design.md` + +**Prerequisite:** None beyond what already exists (Phase 1's `cards`/`columns`/`comments`/`subtasks` collections, `logActivity`, `relinkProject`, `positionBetween`). + +## Global Constraints + +- Every route lives under `/api/agent/` and starts with the same token check before touching PocketBase or parsing the body. +- Every response uses the existing `jsonOk`/`jsonError` helpers from `src/lib/http/json.ts` — no new response envelope. +- Every write attributes `author`/`actor` to the app's owner user record, resolved server-side (`role = "owner"`), never from the request. +- An agent-posted comment's stored `body` is always prefixed with `🤖 Drafted by your agent:\n\n` before being written — this is the one requirement the whole feature exists to satisfy; no task may skip it. +- `AGENT_API_TOKEN` is read from `process.env` only, documented in `.env.example`, never logged, never returned in any response. + +--- + +## File structure + +``` +src/ +├── lib/ +│ └── agent/ +│ ├── auth.ts # requireAgentToken() +│ ├── auth.test.ts +│ └── owner.ts # getOwnerUser() +└── app/ + └── api/ + └── agent/ + ├── projects/route.ts # GET + ├── projects/[projectId]/cards/route.ts # GET + └── cards/ + └── [cardId]/ + ├── route.ts # GET (full detail) + ├── comments/route.ts # POST + ├── subtasks/route.ts # POST + └── move/route.ts # PATCH +tests/ +└── integration/ + └── agent-api.test.ts +.env.example # + AGENT_API_TOKEN +``` + +--- + +## Task 1: Auth helper and owner-resolution helper + +Pure enough to unit-test without PocketBase; every later route depends on this. + +**Files:** +- Create: `src/lib/agent/auth.ts` +- Create: `src/lib/agent/auth.test.ts` +- Create: `src/lib/agent/owner.ts` +- Modify: `.env.example` + +**Interfaces:** +- Produces: `requireAgentToken(request: Request): Response | null` — returns a `Response` to send immediately when the caller must be refused, or `null` when the request may proceed. Every route in this plan starts with `const denied = requireAgentToken(request); if (denied) return denied;`. +- Produces: `getOwnerUser(): Promise` — the app's one owner account, looked up fresh each call (no caching — an owner account is not expected to change mid-request-lifetime, but resolving it fresh means a future multi-owner change would need to touch only this one function). + +- [ ] **Step 1: Write the failing tests** + +`src/lib/agent/auth.test.ts`: + +```ts +import { describe, it, expect, afterEach } from "vitest"; +import { requireAgentToken } from "@/lib/agent/auth"; + +describe("requireAgentToken", () => { + const originalToken = process.env.AGENT_API_TOKEN; + + afterEach(() => { + if (originalToken === undefined) delete process.env.AGENT_API_TOKEN; + else process.env.AGENT_API_TOKEN = originalToken; + }); + + it("refuses with 500 when the token is not configured", async () => { + delete process.env.AGENT_API_TOKEN; + const request = new Request("http://localhost/api/agent/projects"); + + const result = requireAgentToken(request); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(500); + }); + + it("refuses a request with no Authorization header", () => { + process.env.AGENT_API_TOKEN = "secret123"; + const request = new Request("http://localhost/api/agent/projects"); + + const result = requireAgentToken(request); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(401); + }); + + it("refuses a request with the wrong token", () => { + process.env.AGENT_API_TOKEN = "secret123"; + const request = new Request("http://localhost/api/agent/projects", { + headers: { authorization: "Bearer wrong" }, + }); + + const result = requireAgentToken(request); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(401); + }); + + it("allows a request with the exact right token", () => { + process.env.AGENT_API_TOKEN = "secret123"; + const request = new Request("http://localhost/api/agent/projects", { + headers: { authorization: "Bearer secret123" }, + }); + + const result = requireAgentToken(request); + + expect(result).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/agent/auth"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/agent/auth.ts`: + +```ts +import "server-only"; + +import { jsonError } from "@/lib/http/json"; + +/** + * Checks the Authorization header against AGENT_API_TOKEN. + * + * Same pattern as /api/cron/reconcile's CRON_SECRET check — a second secret + * of the same kind, for a second machine caller. Returns the Response to + * send when the caller must be refused, or null when the request may + * proceed, so every route starts with: + * + * const denied = requireAgentToken(request); + * if (denied) return denied; + */ +export function requireAgentToken(request: Request): Response | null { + const secret = process.env.AGENT_API_TOKEN; + if (!secret) { + console.error("[agent-api] AGENT_API_TOKEN is not set; refusing all requests"); + return jsonError("Not configured", 500); + } + + if (request.headers.get("authorization") !== `Bearer ${secret}`) { + return jsonError("Unauthorized", 401); + } + + return null; +} +``` + +`src/lib/agent/owner.ts`: + +```ts +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import type { UserRecord } from "@/lib/pb/types"; + +/** + * The app's one owner account. + * + * Every agent-initiated write (a comment, an activity entry) is attributed to + * this account — the agent API's whole trust model is "this token IS the + * owner, acting through tooling," so there is never an identity in the + * request to attribute a write to instead. + */ +export async function getOwnerUser(): Promise { + const pb = await getAdminPb(); + return pb.collection("users").getFirstListItem('role = "owner"'); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 4 tests in `auth.test.ts`. + +- [ ] **Step 5: Document the new environment variable** + +Add to `.env.example`, after the `PB_SUPERUSER_PASSWORD` line: + +``` +# A long random secret for the agent API (see docs/superpowers/specs/ +# 2026-09-25-phase-8-agent-api-design.md). Generate with: +# openssl rand -hex 32 +# Anyone holding this can read every project and write comments/subtasks/ +# moves as the owner — treat it like the superuser credentials above. +AGENT_API_TOKEN=replace-me +``` + +- [ ] **Step 6: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/agent/auth.ts src/lib/agent/auth.test.ts src/lib/agent/owner.ts .env.example +git commit -m "feat(agent-api): bearer token check and owner-resolution helper" +``` + +--- + +## Task 2: `GET /api/agent/projects` + +**Files:** +- Create: `src/app/api/agent/projects/route.ts` + +**Interfaces:** +- Consumes: `requireAgentToken` from Task 1. + +- [ ] **Step 1: Write the route** + +`src/app/api/agent/projects/route.ts`: + +```ts +import { getAdminPb } from "@/lib/pb/admin"; +import { requireAgentToken } from "@/lib/agent/auth"; +import { jsonOk } from "@/lib/http/json"; +import type { ClientRecord, ProjectRecord } from "@/lib/pb/types"; + +/** + * GET /api/agent/projects — every project across every client the owner has. + * + * No status filtering: an agent drafting a plan needs to see a paused or + * completed project too, not just active ones — it is planning, not + * browsing the way the home page is. + */ +export async function GET(request: Request): Promise { + const denied = requireAgentToken(request); + if (denied) return denied; + + const pb = await getAdminPb(); + + const [projects, clients] = await Promise.all([ + pb.collection("projects").getFullList({ sort: "name" }), + pb.collection("clients").getFullList(), + ]); + + const clientsById = new Map(clients.map((c) => [c.id, c])); + + const out = projects + .map((project) => { + const client = clientsById.get(project.client); + if (!client) return null; + return { + id: project.id, + name: project.name, + slug: project.slug, + client: { id: client.id, name: client.name, slug: client.slug }, + kind: project.kind, + status: project.status, + }; + }) + .filter((p): p is NonNullable => p !== null); + + return jsonOk(out); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds; the route list includes `/api/agent/projects`. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/api/agent/projects/route.ts +git commit -m "feat(agent-api): GET /api/agent/projects" +``` + +--- + +## Task 3: `GET /api/agent/projects/:projectId/cards` + +**Files:** +- Create: `src/app/api/agent/projects/[projectId]/cards/route.ts` + +**Interfaces:** +- Consumes: `requireAgentToken` from Task 1. + +- [ ] **Step 1: Write the route** + +`src/app/api/agent/projects/[projectId]/cards/route.ts`: + +```ts +import { getAdminPb } from "@/lib/pb/admin"; +import { requireAgentToken } from "@/lib/agent/auth"; +import { jsonError, jsonOk } from "@/lib/http/json"; +import type { CardRecord, ColumnRecord } from "@/lib/pb/types"; + +/** + * GET /api/agent/projects/:projectId/cards — every non-archived card in the + * project, any column, in the same order the board itself renders in. + * + * `?column=inbox` filters to the Inbox column only, matching `is_inbox` — + * never a literal column name, since column names are editable per project. + */ +export async function GET( + request: Request, + context: { params: Promise<{ projectId: string }> } +): Promise { + const denied = requireAgentToken(request); + if (denied) return denied; + + const { projectId } = await context.params; + const pb = await getAdminPb(); + + const project = await pb.collection("projects").getOne(projectId).catch(() => null); + if (!project) return jsonError("Project not found", 404); + + const columns = await pb.collection("columns").getFullList({ + filter: `project = "${projectId}"`, + sort: "position", + }); + const columnsById = new Map(columns.map((c) => [c.id, c])); + + const url = new URL(request.url); + const onlyInbox = url.searchParams.get("column") === "inbox"; + + let cards = await pb.collection("cards").getFullList({ + filter: `project = "${projectId}" && archived = false`, + }); + + if (onlyInbox) { + cards = cards.filter((card) => columnsById.get(card.column)?.is_inbox === true); + } + + cards.sort((a, b) => { + const colA = columnsById.get(a.column)?.position ?? 0; + const colB = columnsById.get(b.column)?.position ?? 0; + return colA - colB || a.position - b.position; + }); + + const out = cards + .map((card) => { + const column = columnsById.get(card.column); + if (!column) return null; + return { + id: card.id, + number: card.number, + title: card.title, + description: card.description, + priority: card.priority, + target_date: card.target_date, + size_band: card.size_band, + column: { + id: column.id, + name: column.name, + is_inbox: column.is_inbox, + is_done: column.is_done, + }, + }; + }) + .filter((c): c is NonNullable => c !== null); + + return jsonOk(out); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add "src/app/api/agent/projects/[projectId]/cards/route.ts" +git commit -m "feat(agent-api): GET /api/agent/projects/:projectId/cards" +``` + +--- + +## Task 4: `GET /api/agent/cards/:cardId` + +**Files:** +- Create: `src/app/api/agent/cards/[cardId]/route.ts` + +**Interfaces:** +- Consumes: `requireAgentToken` from Task 1. + +- [ ] **Step 1: Write the route** + +`src/app/api/agent/cards/[cardId]/route.ts`: + +```ts +import { getAdminPb } from "@/lib/pb/admin"; +import { requireAgentToken } from "@/lib/agent/auth"; +import { jsonError, jsonOk } from "@/lib/http/json"; +import type { + CardRecord, + ColumnRecord, + CommentRecord, + CommitRecord, + SubtaskRecord, + UserRecord, +} from "@/lib/pb/types"; + +/** + * GET /api/agent/cards/:cardId — one card's full detail: everything the list + * endpoint returns, plus its existing checklist, existing comments (with + * author names resolved), and its 10 most recent linked commits — enough for + * an agent to know what has already touched this card before drafting a plan + * for it, without pulling full commit history. + */ +export async function GET( + request: Request, + context: { params: Promise<{ cardId: string }> } +): Promise { + const denied = requireAgentToken(request); + if (denied) return denied; + + const { cardId } = await context.params; + const pb = await getAdminPb(); + + const card = await pb.collection("cards").getOne(cardId).catch(() => null); + if (!card || card.archived) return jsonError("Card not found", 404); + + const column = await pb + .collection("columns") + .getOne(card.column) + .catch(() => null); + + const subtasks = await pb.collection("subtasks").getFullList({ + filter: `card = "${cardId}"`, + sort: "position", + }); + + const comments = await pb.collection("comments").getFullList({ + filter: `card = "${cardId}"`, + sort: "created", + }); + + const userIds = new Set(comments.map((c) => c.author).filter(Boolean)); + const names: Record = {}; + for (const id of userIds) { + const user = await pb.collection("users").getOne(id).catch(() => null); + names[id] = user?.name || user?.email || "Someone"; + } + + const recentCommits = await pb.collection("commits").getList(1, 10, { + filter: `card = "${cardId}" && is_merge = false`, + sort: "-authored_at", + }); + + return jsonOk({ + id: card.id, + number: card.number, + title: card.title, + description: card.description, + priority: card.priority, + target_date: card.target_date, + size_band: card.size_band, + column: column + ? { + id: column.id, + name: column.name, + is_inbox: column.is_inbox, + is_done: column.is_done, + } + : null, + subtasks: subtasks.map((s) => ({ id: s.id, title: s.title, done: s.done })), + comments: comments.map((c) => ({ + id: c.id, + author: names[c.author] ?? "Someone", + body: c.body, + created: c.created, + })), + recentCommits: recentCommits.items.map((commit) => ({ + summary: commit.summary || commit.subject, + authored_at: commit.authored_at, + })), + }); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add "src/app/api/agent/cards/[cardId]/route.ts" +git commit -m "feat(agent-api): GET /api/agent/cards/:cardId" +``` + +--- + +## Task 5: `POST /api/agent/cards/:cardId/comments` + +**Files:** +- Create: `src/app/api/agent/cards/[cardId]/comments/route.ts` + +**Interfaces:** +- Consumes: `requireAgentToken` from Task 1, `getOwnerUser` from Task 1. + +- [ ] **Step 1: Write the route** + +`src/app/api/agent/cards/[cardId]/comments/route.ts`: + +```ts +import { getAdminPb } from "@/lib/pb/admin"; +import { requireAgentToken } from "@/lib/agent/auth"; +import { getOwnerUser } from "@/lib/agent/owner"; +import { jsonError, jsonOk } from "@/lib/http/json"; +import type { CardRecord } from "@/lib/pb/types"; + +/** Matches the comments collection's own `body` field limit. */ +const MAX_BODY_LENGTH = 20000; + +/** + * POST /api/agent/cards/:cardId/comments — post a drafted plan as a comment. + * + * The stored body is always prefixed before being written, so no caller can + * accidentally or deliberately post something indistinguishable from the + * owner's own words. This prefix is the one thing this whole feature exists + * to guarantee — do not make it conditional or configurable. + */ +export async function POST( + request: Request, + context: { params: Promise<{ cardId: string }> } +): Promise { + const denied = requireAgentToken(request); + if (denied) return denied; + + const { cardId } = await context.params; + + let json: unknown; + try { + json = await request.json(); + } catch { + return jsonError("Invalid request body", 400); + } + + const body = (json as { body?: unknown } | null)?.body; + if (typeof body !== "string" || !body.trim()) { + return jsonError("A non-empty body is required", 400); + } + + const pb = await getAdminPb(); + const card = await pb.collection("cards").getOne(cardId).catch(() => null); + if (!card || card.archived) return jsonError("Card not found", 404); + + const owner = await getOwnerUser(); + + const prefix = "🤖 Drafted by your agent:\n\n"; + const trimmed = body.trim().slice(0, MAX_BODY_LENGTH - prefix.length); + + const comment = await pb.collection("comments").create({ + card: cardId, + author: owner.id, + body: `${prefix}${trimmed}`, + }); + + return jsonOk({ id: comment.id }, 201); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add "src/app/api/agent/cards/[cardId]/comments/route.ts" +git commit -m "feat(agent-api): POST /api/agent/cards/:cardId/comments" +``` + +--- + +## Task 6: `POST /api/agent/cards/:cardId/subtasks` + +**Files:** +- Create: `src/app/api/agent/cards/[cardId]/subtasks/route.ts` + +**Interfaces:** +- Consumes: `requireAgentToken` from Task 1, `positionBetween` from `src/lib/ordering/fractional.ts` (already exists: `positionBetween(before: number | null, after: number | null): number`). + +- [ ] **Step 1: Write the route** + +`src/app/api/agent/cards/[cardId]/subtasks/route.ts`: + +```ts +import { getAdminPb } from "@/lib/pb/admin"; +import { requireAgentToken } from "@/lib/agent/auth"; +import { positionBetween } from "@/lib/ordering/fractional"; +import { jsonError, jsonOk } from "@/lib/http/json"; +import type { CardRecord, SubtaskRecord } from "@/lib/pb/types"; + +/** + * POST /api/agent/cards/:cardId/subtasks — turn a plan into a checklist. + * + * Appends after the card's existing subtasks, in the order given, using the + * same fractional-position spacing every other subtask-creation path in this + * app uses (see addSubtask in src/app/actions/subtasks.ts). + */ +export async function POST( + request: Request, + context: { params: Promise<{ cardId: string }> } +): Promise { + const denied = requireAgentToken(request); + if (denied) return denied; + + const { cardId } = await context.params; + + let json: unknown; + try { + json = await request.json(); + } catch { + return jsonError("Invalid request body", 400); + } + + const titles = (json as { titles?: unknown } | null)?.titles; + if (!Array.isArray(titles)) { + return jsonError("titles must be an array of strings", 400); + } + + const cleaned = titles + .filter((t): t is string => typeof t === "string") + .map((t) => t.trim().slice(0, 200)) + .filter((t) => t.length > 0); + + if (cleaned.length === 0) { + return jsonError("At least one non-empty title is required", 400); + } + + const pb = await getAdminPb(); + const card = await pb.collection("cards").getOne(cardId).catch(() => null); + if (!card || card.archived) return jsonError("Card not found", 404); + + const last = await pb + .collection("subtasks") + .getFirstListItem(`card = "${cardId}"`, { sort: "-position" }) + .catch(() => null); + + let position: number | null = last ? last.position : null; + const created: string[] = []; + + for (const title of cleaned) { + position = positionBetween(position, null); + const subtask = await pb.collection("subtasks").create({ + card: cardId, + title, + done: false, + position, + }); + created.push(subtask.id); + } + + return jsonOk({ created }, 201); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add "src/app/api/agent/cards/[cardId]/subtasks/route.ts" +git commit -m "feat(agent-api): POST /api/agent/cards/:cardId/subtasks" +``` + +--- + +## Task 7: `PATCH /api/agent/cards/:cardId/move` + +Mirrors the accept-transition bookkeeping already done by +`src/app/api/owner/cards/[id]/move/route.ts`: moving a card out of the Inbox +into a working column for the first time must mark it accepted, set a start +date, log the acceptance, and re-run commit linking — skipping this would +silently break the commit-linking bounds and the activity timeline for every +card an agent moves. + +**Files:** +- Create: `src/app/api/agent/cards/[cardId]/move/route.ts` + +**Interfaces:** +- Consumes: `requireAgentToken`, `getOwnerUser` from Task 1; `positionBetween` from `src/lib/ordering/fractional.ts`; `logActivity` from `src/app/actions/activity.ts` (existing: `logActivity(entry: { project: string; card?: string; actor: string; action: ActivityAction; field?: string; oldValue?: string; newValue?: string }): Promise`); `relinkProject` from `src/lib/linking/engine.ts` (existing: `relinkProject(projectId: string): Promise`). + +- [ ] **Step 1: Write the route** + +`src/app/api/agent/cards/[cardId]/move/route.ts`: + +```ts +import { getAdminPb } from "@/lib/pb/admin"; +import { requireAgentToken } from "@/lib/agent/auth"; +import { getOwnerUser } from "@/lib/agent/owner"; +import { logActivity } from "@/app/actions/activity"; +import { relinkProject } from "@/lib/linking/engine"; +import { positionBetween } from "@/lib/ordering/fractional"; +import { jsonError, jsonOk } from "@/lib/http/json"; +import type { CardRecord, ColumnRecord } from "@/lib/pb/types"; + +/** + * PATCH /api/agent/cards/:cardId/move — move a card by destination column + * NAME (not id — the agent has no reason to know internal PocketBase ids), + * resolved case-insensitively against the card's own project. + * + * Always appends to the end of the destination column — there is no concept + * of "drop position" for a machine caller, so it lands last, exactly like a + * freshly created card does. + */ +export async function PATCH( + request: Request, + context: { params: Promise<{ cardId: string }> } +): Promise { + const denied = requireAgentToken(request); + if (denied) return denied; + + const { cardId } = await context.params; + + let json: unknown; + try { + json = await request.json(); + } catch { + return jsonError("Invalid request body", 400); + } + + const columnName = (json as { column?: unknown } | null)?.column; + if (typeof columnName !== "string" || !columnName.trim()) { + return jsonError("A destination column name is required", 400); + } + + const pb = await getAdminPb(); + const card = await pb.collection("cards").getOne(cardId).catch(() => null); + if (!card || card.archived) return jsonError("Card not found", 404); + + const columns = await pb.collection("columns").getFullList({ + filter: `project = "${card.project}"`, + sort: "position", + }); + + const destination = columns.find( + (c) => c.name.toLowerCase() === columnName.trim().toLowerCase() + ); + if (!destination) { + return jsonError( + `Unknown column "${columnName}". This project's columns are: ${columns + .map((c) => c.name) + .join(", ")}`, + 400 + ); + } + + const lastInDestination = await pb + .collection("cards") + .getFirstListItem(`column = "${destination.id}" && archived = false`, { + sort: "-position", + }) + .catch(() => null); + const position = positionBetween(lastInDestination ? lastInDestination.position : null, null); + + // Same accept-transition rule as the owner's own drag-and-drop move: a + // card leaving the Inbox for a real working column, for the first time, + // is being accepted — not just relocated. + const accepting = !card.accepted && card.column !== destination.id && !destination.is_inbox; + + const updated = await pb.collection("cards").update(cardId, { + column: destination.id, + position, + ...(accepting + ? { accepted: true, start_date: card.start_date || new Date().toISOString().slice(0, 10) } + : {}), + }); + + if (accepting) { + const owner = await getOwnerUser(); + await logActivity({ + project: card.project, + card: card.id, + actor: owner.id, + action: "card_accepted", + newValue: destination.name, + }).catch((error) => { + console.error("[agent-api] accepted card but activity failed", error); + }); + await relinkProject(card.project).catch((error) => { + console.error("[agent-api] accepted card but relinking failed", error); + }); + } + + return jsonOk({ + id: updated.id, + column: destination.name, + accepted: updated.accepted, + }); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add "src/app/api/agent/cards/[cardId]/move/route.ts" +git commit -m "feat(agent-api): PATCH /api/agent/cards/:cardId/move" +``` + +--- + +## Task 8: Integration tests + +**Files:** +- Create: `tests/integration/agent-api.test.ts` + +- [ ] **Step 1: Write the tests** + +`tests/integration/agent-api.test.ts`: + +```ts +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import PocketBase from "pocketbase"; + +import { APP_URL, DEMO, adminPb, requireServices } from "./helpers"; + +const TOKEN = process.env.AGENT_API_TOKEN; + +let admin: PocketBase; +let projectId: string; +let inboxColumnId: string; +let backlogColumnId: string; + +function authed(path: string, init: RequestInit = {}) { + return fetch(`${APP_URL}${path}`, { + ...init, + headers: { ...(init.headers ?? {}), authorization: `Bearer ${TOKEN}` }, + }); +} + +beforeAll(async () => { + await requireServices(); + if (!TOKEN) { + throw new Error( + "AGENT_API_TOKEN must be set in the environment running this test (see .env.local)." + ); + } + + admin = await adminPb(); + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; + + const inbox = await admin + .collection("columns") + .getFirstListItem(`project = "${projectId}" && is_inbox = true`); + inboxColumnId = inbox.id; + + const backlog = await admin + .collection("columns") + .getFirstListItem(`project = "${projectId}" && is_inbox = false && is_done = false`); + backlogColumnId = backlog.id; +}); + +describe("authentication", () => { + it("refuses a request with no token", async () => { + const response = await fetch(`${APP_URL}/api/agent/projects`); + expect(response.status).toBe(401); + }); + + it("refuses a request with the wrong token", async () => { + const response = await fetch(`${APP_URL}/api/agent/projects`, { + headers: { authorization: "Bearer not-the-real-token" }, + }); + expect(response.status).toBe(401); + }); + + it("accepts a request with the right token", async () => { + const response = await authed("/api/agent/projects"); + expect(response.status).toBe(200); + }); +}); + +describe("GET /api/agent/projects", () => { + it("includes the demo project with its client", async () => { + const response = await authed("/api/agent/projects"); + const projects = await response.json(); + const found = projects.find((p: { id: string }) => p.id === projectId); + expect(found).toBeTruthy(); + expect(found.client.slug).toBe(DEMO.clientSlug); + }); +}); + +describe("GET /api/agent/projects/:projectId/cards", () => { + let cardId: string; + + afterAll(async () => { + if (cardId) await admin.collection("cards").delete(cardId).catch(() => {}); + }); + + it("lists a newly created Inbox card, and the ?column=inbox filter excludes non-Inbox cards", async () => { + const stamp = Date.now(); + const card = await admin.collection("cards").create({ + project: projectId, + column: inboxColumnId, + title: `Agent API probe ${stamp}`, + description: "", + position: 999999, + priority: "medium", + created_by_role: "owner", + accepted: false, + archived: false, + }); + cardId = card.id; + + const all = await authed(`/api/agent/projects/${projectId}/cards`).then((r) => r.json()); + expect(all.some((c: { id: string }) => c.id === cardId)).toBe(true); + + const inboxOnly = await authed(`/api/agent/projects/${projectId}/cards?column=inbox`).then( + (r) => r.json() + ); + expect(inboxOnly.every((c: { column: { is_inbox: boolean } }) => c.column.is_inbox)).toBe( + true + ); + expect(inboxOnly.some((c: { id: string }) => c.id === cardId)).toBe(true); + }); + + it("404s for an unknown project", async () => { + const response = await authed("/api/agent/projects/does-not-exist/cards"); + expect(response.status).toBe(404); + }); +}); + +describe("card detail, comments, subtasks, and move", () => { + let cardId: string; + + beforeAll(async () => { + const card = await admin.collection("cards").create({ + project: projectId, + column: inboxColumnId, + title: `Agent API detail probe ${Date.now()}`, + description: "A card for the agent API's own integration tests.", + position: 999998, + priority: "medium", + created_by_role: "owner", + accepted: false, + archived: false, + }); + cardId = card.id; + }); + + afterAll(async () => { + await admin.collection("cards").delete(cardId).catch(() => {}); + }); + + it("GET returns the card with empty subtasks/comments", async () => { + const response = await authed(`/api/agent/cards/${cardId}`); + const detail = await response.json(); + expect(detail.id).toBe(cardId); + expect(detail.subtasks).toEqual([]); + expect(detail.comments).toEqual([]); + }); + + it("POST comments prefixes the body and attributes it to the owner", async () => { + const response = await authed(`/api/agent/cards/${cardId}/comments`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ body: "Here is the plan." }), + }); + expect(response.status).toBe(201); + + const stored = await admin.collection("comments").getFullList({ + filter: `card = "${cardId}"`, + }); + expect(stored).toHaveLength(1); + expect(stored[0].body.startsWith("🤖 Drafted by your agent:")).toBe(true); + expect(stored[0].body).toContain("Here is the plan."); + + const ownerUser = await admin.collection("users").getFirstListItem('role = "owner"'); + expect(stored[0].author).toBe(ownerUser.id); + }); + + it("rejects an empty comment body", async () => { + const response = await authed(`/api/agent/cards/${cardId}/comments`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ body: " " }), + }); + expect(response.status).toBe(400); + }); + + it("POST subtasks creates them in order after any existing ones", async () => { + const response = await authed(`/api/agent/cards/${cardId}/subtasks`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ titles: ["Step one", "Step two", "Step three"] }), + }); + expect(response.status).toBe(201); + + const stored = await admin.collection("subtasks").getFullList({ + filter: `card = "${cardId}"`, + sort: "position", + }); + expect(stored.map((s) => s.title)).toEqual(["Step one", "Step two", "Step three"]); + }); + + it("rejects a subtasks request with no usable titles", async () => { + const response = await authed(`/api/agent/cards/${cardId}/subtasks`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ titles: [" ", ""] }), + }); + expect(response.status).toBe(400); + }); + + it("PATCH move rejects an unknown column name and lists the real ones", async () => { + const response = await authed(`/api/agent/cards/${cardId}/move`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ column: "Not A Real Column" }), + }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("Not A Real Column"); + }); + + it("PATCH move to a working column accepts the card and logs it", async () => { + const backlog = await admin.collection("columns").getOne(backlogColumnId); + + const response = await authed(`/api/agent/cards/${cardId}/move`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ column: backlog.name }), + }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.accepted).toBe(true); + + const updated = await admin.collection("cards").getOne(cardId); + expect(updated.column).toBe(backlogColumnId); + expect(updated.accepted).toBe(true); + expect(updated.start_date).not.toBe(""); + + const activity = await admin.collection("activity").getFullList({ + filter: `card = "${cardId}" && action = "card_accepted"`, + }); + expect(activity.length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run the integration suite** + +With `npm run dev` running and `AGENT_API_TOKEN` set in the environment +(source `.env.local` first — add a real value there if it is not set yet, +using the same `openssl rand -hex 32` command from Task 1): + +```bash +set -a && source .env.local && set +a && npm run test:integration +``` + +Expected: PASS — all prior integration tests plus every test in +`agent-api.test.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/agent-api.test.ts +git commit -m "test(agent-api): auth, all five routes, and the accept-on-move transition" +``` + +--- + +## Task 9: Full verification + +**This task is work, not a summary. Run every command and act on what it prints.** + +- [ ] **Step 1: Run every test** + +```bash +set -a && source .env.local && set +a && npm run test:all +``` + +Expected: PASS. Unit tests include the 4 new cases in `auth.test.ts`. +Integration tests include every case in `agent-api.test.ts`. + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds; the route list includes all five new +`/api/agent/*` routes. + +- [ ] **Step 3: Exercise the golden path against the running app** + +With `npm run dev` running and `AGENT_API_TOKEN` set: + +```bash +curl -s -H "Authorization: Bearer $AGENT_API_TOKEN" http://localhost:3000/api/agent/projects | head -c 500 +``` + +Expected: a JSON array of projects, each with a nested `client` object. + +Pick one project id from that response and one Inbox card id from +`GET /api/agent/projects//cards?column=inbox`, then: + +```bash +curl -s -X POST -H "Authorization: Bearer $AGENT_API_TOKEN" -H "content-type: application/json" \ + -d '{"body":"This looks like a small config change — plan: read the settings page, add the field, wire it to the form."}' \ + http://localhost:3000/api/agent/cards//comments +``` + +Expected: `201` with `{ "id": "..." }`. Open that card in the browser and +confirm the comment appears, prefixed with "🤖 Drafted by your agent:". + +- [ ] **Step 4: Confirm a wrong or missing token is refused everywhere** + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/agent/projects +curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer wrong" http://localhost:3000/api/agent/projects +``` + +Expected: both print `401`. + +- [ ] **Step 5: Commit any fixes** + +```bash +git add -A +git commit -m "fix: issues found during Phase 8 verification" +``` + +--- + +## Definition of done + +1. `npm run test:all` passes, including every new unit and integration test. +2. `npm run build` succeeds; all five `/api/agent/*` routes appear in the + route list. +3. Every route refuses a request with no token or the wrong token before + touching PocketBase, and refuses with 500 if `AGENT_API_TOKEN` is unset. +4. An agent-posted comment is always prefixed and always attributed to the + owner account, never to a caller-supplied identity. +5. Moving a card out of the Inbox into a working column for the first time + marks it accepted, sets a start date, logs a `card_accepted` activity + row, and re-runs commit linking — identical behavior to the owner's own + drag-and-drop move. +6. An unknown destination column name is rejected with the project's real + column names listed in the error, so a caller can self-correct. + +## Deliberately not in this plan + +- **An MCP server.** A thin wrapper over these same routes, explicitly + deferred until a primary agent tool is settled on. +- **Card creation, deletion, or field edits** beyond the three write + actions. An agent that thinks a card's priority or title should change + says so in a comment; it does not edit the card directly. +- **Per-agent tokens or scoped permissions.** One token, full owner- + equivalent access, matching this app's single-owner architecture. +- **Rate limiting.** Not present on any other route in this app either; + worth adding later if the token is ever shared beyond the owner's own + tooling.