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>
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
npm run test:allpasses: 295 unit, 55 integration.npm run buildsucceeds; the colour check prints nothing.- A completed interview creates one Inbox card with sub-tasks, the brief and the transcript attached.
column,priority,acceptedandcreated_by_roleare server-decided, proven against a body that tries to set all four.- A client manager cannot submit against another client's project.
- The brief and transcript render for both roles, as text.
- With no provider configured, the plain request box appears and works.
- 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.