kanban-dev-webapp/docs/superpowers/plans/2026-09-16-phase-4b-intake-submission-and-display.md
Dave 0c4bbd951f docs(plan): Phase 4a and 4b for the client intake wizard
4a builds the stateless interview. Every constraint that matters is
enforced in code rather than requested in the prompt: the seven-question
cap, the answer length, and what fields a brief may contain. parseBrief is
a whitelist, not a parser — a brief asserting priority or accepted loses
those at the boundary and they never reach a caller that could act on them.

4b adds a separate intake route rather than extending Plan 1a's client
card route. That route's tests were written against a two-field body;
widening the shape it accepts would leave them passing while proving less.
Both routes set column, priority, accepted and created_by_role themselves,
and both are separately proven to.

The property both plans are written to: the worst a hostile brief can
achieve is a strangely-worded card in the Inbox.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:46:54 +02:00

31 KiB

Phase 4b — Intake Submission and Display 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: Turn a confirmed brief into an Inbox card with sub-tasks, keep the transcript, and show the brief on the card for both the developer and the client — falling back to the plain request box whenever the assistant is unavailable.

Architecture: A new route handler alongside Plan 1a's POST /api/client/cards, not a modification of it. The existing route is a tested security boundary; extending it to carry a brief would put new, untrusted structure through code whose tests were written for a two-field body. The new route sets column, priority, accepted and created_by_role itself, exactly as the old one does, regardless of what arrives.

Tech Stack: Next.js 16 route handlers · PocketBase 0.30 · Tailwind 4 · vitest

Spec: docs/superpowers/specs/2026-09-16-phase-4-client-intake-wizard-design.md §7, §8, §9, §10

Prerequisite: Plan 4a complete — an interview produces a brief on screen.


Why a new route rather than an extended one

POST /api/client/cards from Plan 1a is covered by integration tests that assert a client manager cannot set column, priority, accepted or created_by_role, and cannot reach another client's project. Those tests were written against a body of { project, title, description }.

Adding an optional brief — nested objects, arrays of sub-tasks, a transcript — changes the shape that boundary accepts. The old tests would still pass while saying less than they used to.

So the intake gets POST /api/client/intake, with its own whitelist and its own tests, and Plan 1a's route is left exactly as it is. Both set the same server-controlled fields, and both are separately proven to.

What the server always decides

Whatever the request body contains:

Field Always
column The project's is_inbox column
priority medium
accepted false
created_by_role "client"
created_by The session's user
project Verified to belong to the caller's client

This is the concrete form of the spec's security property: the worst a hostile brief achieves is a strangely-worded card in the Inbox.


File structure

src/
├── app/
│   ├── api/client/intake/route.ts
│   └── p/[clientSlug]/[projectSlug]/
│       └── c/[cardId]/page.tsx          # MODIFY — render the brief
├── components/
│   ├── intake/
│   │   ├── brief-preview.tsx            # confirm and submit
│   │   └── request-feature.tsx          # wizard, or the plain box
│   └── card/
│       └── card-brief.tsx               # the brief on the card
└── lib/
    └── data/card.ts                     # MODIFY — brief and transcript

Task 1: Schema

Files:

  • Modify: scripts/setup-remote.ts

  • Modify: src/lib/pb/types.ts

  • Step 1: Add the fields

In scripts/setup-remote.ts, beside the other ensure* helpers:

async function ensureCardIntakeFields(pb: PocketBase): Promise<void> {
  const cards = await pb.collections.getOne("cards");
  const have = new Set(cards.fields.map((f: { name: string }) => f.name));

  const additions = [
    { name: "brief", type: "json", required: false, maxSize: 200_000 },
    // Kept deliberately. When a request turns out to mean something other than
    // what was built, the record of what was actually asked is what settles it.
    { name: "transcript", type: "json", required: false, maxSize: 200_000 },
    {
      name: "size_band",
      type: "select",
      required: false,
      maxSelect: 1,
      values: ["small", "medium", "large"],
    },
  ];

  const fields = [...(cards.fields as unknown[])];
  let changed = false;

  for (const field of additions) {
    if (!have.has(field.name)) {
      fields.push({ ...field, presentable: false, hidden: false, system: false });
      changed = true;
    }
  }

  if (changed) {
    await pb.collections.update(cards.id, { fields });
    console.log("  + cards fields: brief, transcript, size_band");
  } else {
    console.log("  = card intake fields already present");
  }
}

Call it after ensureCardNarrativeFields(pb).

  • Step 2: Extend the record type

In src/lib/pb/types.ts, add to CardRecord:

  /** The intake brief, when this card came from the wizard. */
  brief: unknown;
  /** The interview question-and-answer pairs. */
  transcript: unknown;
  size_band: "small" | "medium" | "large" | "";
  • Step 3: Apply and verify
set -a && source .env.local && set +a && npm run setup-remote && npm run build

Expected: the script reports the three fields; the build succeeds.

  • Step 4: Commit
git add scripts/setup-remote.ts src/lib/pb/types.ts
git commit -m "feat(schema): card brief, transcript and size band"

Task 2: The intake route

Files:

  • Create: src/app/api/client/intake/route.ts

  • Step 1: Write the route

src/app/api/client/intake/route.ts:

import { getAdminPb } from "@/lib/pb/admin";
import { requireSession } from "@/lib/auth/session";
import {
  AccessError,
  assertClientOwnsProject,
  canWriteClientFields,
} from "@/lib/auth/access";
import { positionBetween } from "@/lib/ordering/fractional";
import { parseBrief } from "@/lib/intake/brief";
import { sanitiseTurns } from "@/lib/intake/turn";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";

/**
 * POST /api/client/intake — turn a confirmed brief into an Inbox card.
 *
 * Deliberately separate from POST /api/client/cards rather than an extension of
 * it: that route's tests were written against a two-field body, and widening
 * the shape it accepts would leave them passing while proving less. See the
 * header of the Phase 4b plan.
 *
 * The brief arrives from the browser, so it is re-parsed here through the same
 * whitelist the interview used. Trusting the client to send back the brief the
 * server generated would let them send a different one — and the whole point of
 * parseBrief is that fields like priority and accepted never survive it.
 *
 * Column, priority, accepted and created_by_role are set here regardless of
 * what the body contains. That is the security property: the worst a hostile
 * brief can achieve is a strangely-worded card in the Inbox.
 */
export async function POST(request: Request): Promise<Response> {
  try {
    const session = await requireSession();

    let body: unknown;
    try {
      body = await request.json();
    } catch {
      return jsonError("Invalid request body", 400);
    }

    const record = (body ?? {}) as Record<string, unknown>;
    const projectId = record.project;

    if (typeof projectId !== "string" || !projectId) {
      return jsonError("A project id is required", 400);
    }

    const pb = await getAdminPb();

    let project: ProjectRecord;
    try {
      project = await pb.collection("projects").getOne<ProjectRecord>(projectId);
    } catch {
      return jsonError("Project not found", 404);
    }

    assertClientOwnsProject(session, project);

    if (!canWriteClientFields(session, project.client)) {
      return jsonError("Project not found", 404);
    }

    // Re-parsed, not trusted. The browser could send anything here.
    const turns = sanitiseTurns(record.transcript as never);
    const brief = parseBrief(
      typeof record.brief === "string" ? record.brief : JSON.stringify(record.brief ?? {}),
      turns
    );

    const band = record.sizeBand;
    const sizeBand =
      band === "small" || band === "medium" || band === "large" ? band : "";

    const inbox = await pb
      .collection("columns")
      .getFirstListItem<ColumnRecord>(`project = "${projectId}" && is_inbox = true`)
      .catch(() => null);

    if (!inbox) {
      console.error(`[intake] project ${projectId} has no inbox column`);
      return jsonError("This project cannot accept new cards", 409);
    }

    const last = await pb
      .collection("cards")
      .getFirstListItem<CardRecord>(`column = "${inbox.id}"`, { sort: "-position" })
      .catch(() => null);

    const numbered = await pb
      .collection("cards")
      .getFullList<CardRecord>({ filter: `project = "${projectId}"`, fields: "number" });
    const nextNumber =
      Math.max(0, ...numbered.map((c) => Number(c.number) || 0)) + 1;

    const card = await pb.collection("cards").create<CardRecord>({
      project: projectId,
      // Every field below is chosen here, not taken from the request.
      column: inbox.id,
      title: brief.title,
      description: brief.summary,
      position: positionBetween(last ? last.position : null, null),
      priority: "medium",
      created_by: session.userId,
      created_by_role: "client",
      accepted: false,
      archived: false,
      number: nextNumber,
      brief,
      transcript: turns,
      size_band: sizeBand,
    });

    // Sub-tasks come from the brief's decomposition — the thing the developer
    // asked for in the first place: "a written out plan with sub-categories".
    let position = 1000;
    for (const title of brief.subtasks) {
      await pb.collection("subtasks").create({
        card: card.id,
        title,
        done: false,
        position,
      });
      position += 1000;
    }

    await pb.collection("activity").create({
      project: projectId,
      card: card.id,
      actor: session.userId,
      action: "card_created",
      field: "",
      old_value: "",
      new_value: card.title,
    });

    return jsonOk(
      { card: { id: card.id, number: card.number, title: card.title } },
      201
    );
  } catch (error) {
    if (error instanceof AccessError) {
      return jsonError(error.message, error.status);
    }
    console.error("[intake] unexpected error", error);
    return jsonError("Something went wrong", 500);
  }
}
  • Step 2: Verify the build
npm run build

Expected: build succeeds; the route list includes /api/client/intake.

  • Step 3: Commit
git add src/app/api/client/intake
git commit -m "feat(api): intake route creating an Inbox card from a brief"

Task 3: The brief preview

Files:

  • Create: src/components/intake/brief-preview.tsx

  • Step 1: Write the component

src/components/intake/brief-preview.tsx:

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { Send } from "lucide-react";

import { Pill } from "@/components/ui/pill";
import type { Brief } from "@/lib/intake/brief";
import type { SizeBand } from "@/lib/intake/size";
import type { Turn } from "@/lib/intake/turn";

const BAND_LABEL: Record<SizeBand, string> = {
  small: "Looks small",
  medium: "Looks medium",
  large: "Looks large",
};

/**
 * The brief, shown for confirmation before anything is created.
 *
 * The size band is presented as a first impression and carries no time. The
 * spec forbids rendering it next to a date or duration — a band beside a date
 * reads as an estimate however it is labelled.
 */
export function BriefPreview({
  brief,
  turns,
  projectId,
  clientSlug,
  projectSlug,
}: {
  brief: Brief & { sizeBand: SizeBand };
  turns: Turn[];
  projectId: string;
  clientSlug: string;
  projectSlug: string;
}) {
  const router = useRouter();
  const [sending, setSending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function submit() {
    setSending(true);
    setError(null);

    const response = await fetch("/api/client/intake", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        project: projectId,
        brief,
        transcript: turns,
        sizeBand: brief.sizeBand,
      }),
    });

    setSending(false);

    if (!response.ok) {
      setError("Could not send that. Please try again.");
      return;
    }

    router.push(`/p/${clientSlug}/${projectSlug}`);
    router.refresh();
  }

  return (
    <section className="space-y-4 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
      <div className="flex items-start gap-3">
        <div className="min-w-0 flex-1">
          <h2 className="text-base font-semibold text-text-primary">{brief.title}</h2>
          <p className="mt-1 text-sm text-text-secondary">{brief.summary}</p>
        </div>
        <Pill tone="neutral">{BAND_LABEL[brief.sizeBand]}</Pill>
      </div>

      {brief.degraded && (
        <p className="text-xs text-text-muted">
          Written from your answers directly.
        </p>
      )}

      {brief.subtasks.length > 0 && (
        <div className="space-y-1.5">
          <h3 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
            Steps
          </h3>
          <ul className="space-y-1">
            {brief.subtasks.map((task, index) => (
              <li key={index} className="flex gap-2 text-sm text-text-secondary">
                <span aria-hidden className="text-text-muted">
                  {index + 1}.
                </span>
                {task}
              </li>
            ))}
          </ul>
        </div>
      )}

      {brief.openQuestions.length > 0 && (
        <div className="space-y-1.5">
          <h3 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
            Still to confirm
          </h3>
          <ul className="space-y-1">
            {brief.openQuestions.map((question, index) => (
              <li key={index} className="text-sm text-text-muted">
                {question}
              </li>
            ))}
          </ul>
        </div>
      )}

      <div className="flex items-center gap-3 border-t border-border-subtle pt-3">
        <button
          type="button"
          disabled={sending}
          onClick={submit}
          className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
        >
          <Send size={14} strokeWidth={2} />
          {sending ? "Sending…" : "Send this to Dave"}
        </button>
        {error && (
          <span role="alert" className="text-xs text-accent">
            {error}
          </span>
        )}
      </div>

      <p className="text-[11px] text-text-muted">
        This goes to the developer to review. He will confirm what happens next.
      </p>
    </section>
  );
}

The closing line matters: it is the only thing on screen that says what happens after submitting, and it deliberately promises nothing about timing.

  • Step 2: Commit
git add src/components/intake/brief-preview.tsx
git commit -m "feat(intake): brief confirmation before submission"

Task 4: Wiring the wizard into the board

Files:

  • Create: src/components/intake/request-feature.tsx

  • Modify: src/components/board/board.tsx

  • Step 1: Write the switcher

src/components/intake/request-feature.tsx:

"use client";

import { useState } from "react";

import { IntakeWizard } from "@/components/intake/intake-wizard";
import { AddCard } from "@/components/board/add-card";

/**
 * The client's way in: the wizard when the assistant is available, the plain
 * request box from Plan 1c when it is not.
 *
 * The fallback is not a nicety. A client must always be able to file a request —
 * an AI outage cannot be allowed to remove a capability they already had.
 */
export function RequestFeature({
  projectId,
  columnId,
  clientSlug,
  projectSlug,
  assistantAvailable,
}: {
  projectId: string;
  columnId: string;
  clientSlug: string;
  projectSlug: string;
  assistantAvailable: boolean;
}) {
  // Flips if the interview reports the provider unavailable mid-flow.
  const [fallback, setFallback] = useState(!assistantAvailable);

  if (fallback) {
    return (
      <AddCard
        role="client_manager"
        projectId={projectId}
        columnId={columnId}
        clientSlug={clientSlug}
        projectSlug={projectSlug}
      />
    );
  }

  return (
    <IntakeWizard
      projectId={projectId}
      clientSlug={clientSlug}
      projectSlug={projectSlug}
      onFallback={() => setFallback(true)}
    />
  );
}
  • Step 2: Render it in the Inbox column

In src/components/board/board.tsx, replace the client-manager branch of the AddCard render added in Plan 1c Task 12:

            {role === "owner" && (
              <AddCard
                role={role}
                projectId={projectId}
                columnId={column.id}
                clientSlug={clientSlug}
                projectSlug={projectSlug}
              />
            )}

            {role === "client_manager" && column.is_inbox && (
              <RequestFeature
                projectId={projectId}
                columnId={column.id}
                clientSlug={clientSlug}
                projectSlug={projectSlug}
                assistantAvailable={assistantAvailable}
              />
            )}

Add assistantAvailable: boolean to Board's props, and pass it from the board page:

import { aiConfigured } from "@/lib/ai/settings";
        assistantAvailable={await aiConfigured()}

aiConfigured returns only whether a key exists — never the key.

  • Step 3: Verify the build
npm run build

Expected: build succeeds.

  • Step 4: Commit
git add src/components/intake/request-feature.tsx src/components/board src/app/p
git commit -m "feat(intake): wizard on the board with a plain-box fallback"

Task 5: The brief on the card

Files:

  • Create: src/components/card/card-brief.tsx

  • Modify: src/lib/data/card.ts

  • Modify: src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx

  • Step 1: Write the component

src/components/card/card-brief.tsx:

"use client";

import { useState } from "react";
import { MessageSquare } from "lucide-react";

import { Pill } from "@/components/ui/pill";

interface StoredBrief {
  title?: string;
  summary?: string;
  subtasks?: string[];
  openQuestions?: string[];
}

interface StoredTurn {
  question?: string;
  answer?: string;
}

/**
 * What the client asked for, as they asked for it.
 *
 * Shown to both roles. The developer needs it to build the right thing; the
 * client needs to see their request recorded faithfully. The transcript is
 * behind a toggle — it is the evidence, not the summary.
 *
 * Everything is rendered as text. This is client-authored content that passed
 * through a model, which is the least trustworthy string in the product.
 */
export function CardBrief({
  brief,
  transcript,
  sizeBand,
}: {
  brief: unknown;
  transcript: unknown;
  sizeBand: string;
}) {
  const [showTranscript, setShowTranscript] = useState(false);

  const parsed = (brief ?? {}) as StoredBrief;
  const turns = Array.isArray(transcript) ? (transcript as StoredTurn[]) : [];

  const openQuestions = Array.isArray(parsed.openQuestions) ? parsed.openQuestions : [];

  if (!parsed.summary && turns.length === 0) return null;

  return (
    <section className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
      <div className="flex items-center gap-3">
        <h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
          What was asked for
        </h2>
        {sizeBand && <Pill tone="neutral">{sizeBand}</Pill>}
        {turns.length > 0 && (
          <button
            type="button"
            onClick={() => setShowTranscript((value) => !value)}
            aria-pressed={showTranscript}
            className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-text-muted hover:text-text-secondary"
          >
            <MessageSquare size={12} strokeWidth={2} />
            {showTranscript ? "Hide the conversation" : "Show the conversation"}
          </button>
        )}
      </div>

      {parsed.summary && (
        <p className="whitespace-pre-wrap text-sm text-text-secondary">
          {parsed.summary}
        </p>
      )}

      {openQuestions.length > 0 && (
        <div className="space-y-1">
          <h3 className="text-[11px] font-medium uppercase tracking-wide text-text-muted">
            Still to confirm
          </h3>
          <ul className="space-y-0.5">
            {openQuestions.map((question, index) => (
              <li key={index} className="text-sm text-text-muted">
                {question}
              </li>
            ))}
          </ul>
        </div>
      )}

      {showTranscript && turns.length > 0 && (
        <ol className="space-y-2.5 border-t border-border-subtle pt-3">
          {turns.map((turn, index) => (
            <li key={index} className="space-y-0.5">
              <p className="text-xs text-text-muted">{turn.question}</p>
              <p className="whitespace-pre-wrap text-sm text-text-secondary">
                {turn.answer}
              </p>
            </li>
          ))}
        </ol>
      )}
    </section>
  );
}
  • Step 2: Render it on the card

src/lib/data/card.ts already returns the full CardRecord, so brief, transcript and size_band come through with no loader change.

In the card page, between CardHeader and CardNarrative:

          <CardBrief
            brief={detail.card.brief}
            transcript={detail.card.transcript}
            sizeBand={detail.card.size_band ?? ""}
          />

Ordering is deliberate: what was asked for, then what has been done about it.

  • Step 3: Verify the build
npm run build

Expected: build succeeds.

  • Step 4: Commit
git add src/components/card/card-brief.tsx src/app/p
git commit -m "feat(card): show the intake brief and transcript"

Task 6: Integration tests

Files:

  • Create: tests/integration/intake-submit.test.ts

  • Step 1: Write the tests

tests/integration/intake-submit.test.ts:

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import PocketBase from "pocketbase";

import { APP_URL, DEMO, adminPb, requireServices, signIn } from "./helpers";

let admin: PocketBase;
let cookie: string;
let projectId: string;
let inboxId: string;
let doneId: string;
const createdCards: string[] = [];

beforeAll(async () => {
  await requireServices();
  admin = await adminPb();
  cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword);

  const project = await admin
    .collection("projects")
    .getFirstListItem(`slug = "${DEMO.projectSlug}"`);
  projectId = project.id;

  inboxId = (
    await admin
      .collection("columns")
      .getFirstListItem(`project = "${projectId}" && is_inbox = true`)
  ).id;
  doneId = (
    await admin
      .collection("columns")
      .getFirstListItem(`project = "${projectId}" && is_done = true`)
  ).id;
});

afterAll(async () => {
  for (const id of createdCards) {
    await admin.collection("cards").delete(id).catch(() => {});
  }
});

const brief = {
  title: "Add a POS at the counter",
  summary: "Till staff need to take card payments.",
  subtasks: ["Choose a card reader", "Wire it to the till"],
  openQuestions: ["Which reader is in use today?"],
  area: "",
  degraded: false,
};

describe("a brief becomes an Inbox card with sub-tasks", () => {
  it("creates the card, the subtasks and the activity row", async () => {
    const response = await fetch(`${APP_URL}/api/client/intake`, {
      method: "POST",
      headers: { "content-type": "application/json", cookie },
      body: JSON.stringify({
        project: projectId,
        brief,
        transcript: [{ question: "What would you like?", answer: "A POS." }],
        sizeBand: "medium",
      }),
    });

    expect(response.status).toBe(201);
    const { card } = await response.json();
    createdCards.push(card.id);

    const created = await admin.collection("cards").getOne(card.id);
    expect(created.column).toBe(inboxId);
    expect(created.accepted).toBe(false);
    expect(created.created_by_role).toBe("client");
    expect(created.title).toBe(brief.title);
    expect(created.size_band).toBe("medium");
    expect(created.number).toBeGreaterThan(0);

    const subtasks = await admin
      .collection("subtasks")
      .getFullList({ filter: `card = "${card.id}"`, sort: "position" });
    expect(subtasks.map((s) => s.title)).toEqual(brief.subtasks);

    const activity = await admin
      .collection("activity")
      .getFullList({ filter: `card = "${card.id}"` });
    expect(activity.length).toBeGreaterThan(0);
  });
});

describe("the server decides the fields that matter", () => {
  it("ignores an escalating brief entirely", async () => {
    // The whole security property in one test: whatever the body claims, the
    // card lands in the Inbox at medium priority, unaccepted.
    const response = await fetch(`${APP_URL}/api/client/intake`, {
      method: "POST",
      headers: { "content-type": "application/json", cookie },
      body: JSON.stringify({
        project: projectId,
        brief: { ...brief, priority: "critical", accepted: true, column: doneId },
        transcript: [],
        sizeBand: "large",
        // Top-level attempts too, not only inside the brief.
        priority: "critical",
        accepted: true,
        column: doneId,
        created_by_role: "owner",
      }),
    });

    expect(response.status).toBe(201);
    const { card } = await response.json();
    createdCards.push(card.id);

    const created = await admin.collection("cards").getOne(card.id);
    expect(created.column).toBe(inboxId);
    expect(created.priority).toBe("medium");
    expect(created.accepted).toBe(false);
    expect(created.created_by_role).toBe("client");
  });

  it("rejects an unknown size band rather than storing it", async () => {
    const response = await fetch(`${APP_URL}/api/client/intake`, {
      method: "POST",
      headers: { "content-type": "application/json", cookie },
      body: JSON.stringify({
        project: projectId,
        brief,
        transcript: [],
        sizeBand: "enormous",
      }),
    });

    const { card } = await response.json();
    createdCards.push(card.id);

    const created = await admin.collection("cards").getOne(card.id);
    expect(created.size_band).toBe("");
  });
});

describe("the intake route is scoped like every other client route", () => {
  it("refuses another client's project", async () => {
    const stamp = Date.now();
    const other = await admin
      .collection("clients")
      .create({ name: "Intake scope probe", slug: `isp-${stamp}`, active: true });
    const otherProject = await admin.collection("projects").create({
      client: other.id,
      name: "Probe",
      slug: `isp-${stamp}`,
      status: "active",
    });

    const response = await fetch(`${APP_URL}/api/client/intake`, {
      method: "POST",
      headers: { "content-type": "application/json", cookie },
      body: JSON.stringify({ project: otherProject.id, brief, transcript: [] }),
    });

    expect(response.status).toBe(404);

    await admin.collection("projects").delete(otherProject.id);
    await admin.collection("clients").delete(other.id);
  });

  it("refuses an unauthenticated request", async () => {
    const response = await fetch(`${APP_URL}/api/client/intake`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ project: projectId, brief, transcript: [] }),
    });
    expect(response.status).toBe(401);
  });
});
  • Step 2: Run the integration suite

With npm run dev running:

set -a && source .env.local && set +a && npm run test:integration

Expected: PASS — 55 tests (50 after Plan 4a, plus 5 here).

  • Step 3: Commit
git add tests/integration/intake-submit.test.ts
git commit -m "test(intake): server-decided fields and project scoping"

Task 7: Full verification

This task is work, not a summary. Run every command and act on what it prints.

  • Step 1: Run every test
set -a && source .env.local && set +a && npm run test:all

Expected: PASS. Unit tests total 295 — unchanged from Plan 4a; this plan adds no pure modules. Integration tests total 55.

  • Step 2: Verify the build and the colour check
npm run build
grep -rnE "bg-(neutral|gray|slate|zinc|stone)-|text-(neutral|gray|slate|zinc|stone)-|#[0-9a-fA-F]{6}" --include="*.tsx" src/components src/app

Expected: build succeeds; the grep prints nothing.

  • Step 3: Walk the whole thing end to end

As the client manager: open the board, run the interview, read the brief, send it.

Expected: you land back on the board with a new Inbox card carrying the "Requested" pill.

As the owner: open that card.

Expected: the brief above the fold, the sub-tasks as a checklist, "Show the conversation" revealing the transcript, and "Accept request" working exactly as it did before this phase existed.

  • Step 4: Confirm nothing escalated

Check the card in the PocketBase admin UI.

Expected: column is the Inbox, priority is medium, accepted is false, created_by_role is client — regardless of anything the interview contained.

  • Step 5: Confirm the fallback

Clear the API key and reload the board as the client manager.

Expected: the plain "Request a feature" box appears and still creates a card. A client must never lose the ability to file a request.

  • Step 6: Check both themes

Open the wizard, the brief preview and a card with a brief, in light and dark.

  • Step 7: Commit any fixes
git add -A
git commit -m "fix: issues found during Phase 4b verification"

Definition of done

  1. npm run test:all passes: 295 unit, 55 integration.
  2. npm run build succeeds; the colour check prints nothing.
  3. A completed interview creates one Inbox card with sub-tasks, the brief and the transcript attached.
  4. column, priority, accepted and created_by_role are server-decided, proven against a body that tries to set all four.
  5. A client manager cannot submit against another client's project.
  6. The brief and transcript render for both roles, as text.
  7. With no provider configured, the plain request box appears and works.
  8. Accepting the card uses Plan 1c's Inbox flow, unchanged.

Deliberately not in this plan

  • Editing a brief after submission. It is the record of what was asked; the developer adjusts the card instead. Rewriting it destroys the thing it is kept for.
  • Notifying the developer. The Inbox badge already exists. Email belongs with the n8n work parked since Phase 1.
  • Regenerating a brief from the transcript. The interview is a moment in time, not a source to re-derive from.
  • The roadmap view — Phase 5, the last of the plan.