SLOPSHOPPER

Timefold PRD

Define the requirements of an optimization model with the Timefold PRD Chat connector, follow the interview's checklist in a pane in Claude Code, and build the…

newpaneguardcommandtoast
v0.3.0Apache-2.0updated 2026-10-16TimefoldAI/skills/plugins/prd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prd
│ ┃ Requirements coverage ✕ › fix the failing auth test and add an audit log call │ ┃ No interview yet. Ask Claude to start a │ ┃ Timefold requirements interview. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /prd-panel │ ⎿ prd: Requirements pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Requirements coverage
No interview yet. Ask Claude to start a Timefold requirements interview.
README

Timefold PRD

A plugin that helps you define the requirements of one optimization model, such as shift scheduling, delivery routing or technician dispatch. Claude interviews you in the words of your own business and writes a requirements document (PRD) for Timefold.

The plugin has two parts:

  • The Timefold PRD Chat connector (.mcp.json): the MCP server that stores the interview and the PRD at Timefold. You sign in with your Timefold account the first time Claude uses it.
  • A requirements pane for Claude Code (hooks/): a mod that shows the interview's checklist beside the conversation, as the PRD Chat website does. The pane shows the requirements coverage as a percentage and the interview's reference, such as #PRD-3F9A2C. Each topic shows its status (open, in progress or covered), and the topic Claude asks about now is marked. Unfold a topic to see what Claude captured. On a topic that is not covered, press Talk about this to make Claude ask about that topic next. Download report asks Claude to write the PRD and saves it in the session folder, with the same file name as the website gives it, such as plan-van-routes-PRD-3F9A2C.md. Submit for build shows the same confirmation in the pane as the PRD Chat website: "We send your requirements document, as Markdown and PDF, to our solutions team", with your sign-in email as the contact email. The confirmation does not wait for the question Claude asks now. When you select Submit, the pane sends the PRD to Timefold. If Claude has not saved the PRD since your last answer, Claude writes it first, and the pane then sends it. To use another contact email, ask Claude to submit for build with that email. In Claude chat and Cowork, ask Claude to submit for build: Claude asks you the same question first.

If your Timefold account can build models, the connector also has the model build tools, and the pane shows two more items:

  • Build model asks Claude to start a model build from the PRD with start_model_build. Claude then follows the guide that the connector gives for each phase.
  • Model build, below the topics, shows the phases of the build. Each phase shows its status (not started, in progress or done), and the phase that the build is in now is marked. The pane gets the phases from the connector's tools: each phase ends with one complete_* tool, and that tool's description gives the next phase. Thus, the pane shows a new or changed phase without an update of the plugin. The pane follows the phase from the results of the build tools that Claude calls.

The pane shows in the Claude Code terminal and in the Code tab of the Claude desktop app. In Claude on the web and in the desktop chat, the connector works without the pane.

Install

In Claude Code:

claude plugin marketplace add TimefoldAI/skills
claude plugin install prd@timefold

Then ask Claude to "help me define the requirements for my delivery routing". The pane opens when the interview starts, if the terminal is at least 144 columns wide. Run /prd-panel to open it at any width.

Permissions

The pane reads the interview's progress with one extra call to the connector, the read-only tool get_interview_progress. The mod makes this call in the background after each interview step, not Claude, so no conversation stands behind it. In auto mode, the permission classifier then gives no verdict and refuses the call, and in the other modes you would get a permission prompt after each step. When you confirm Submit for build in the pane, the mod also calls submit_for_build itself, for the same reason.

So the mod approves these two calls itself, through a tool.check hook (see Extend permissions with hooks):

  • It approves only a tool whose name ends in __get_interview_progress, or in __submit_for_build. The pane calls submit_for_build only after you select Submit in its confirmation, and that selection is your consent.
  • It approves only the calls the mod itself makes (next.origin.plugin is this plugin). When Claude calls the same tool, the usual permission decision applies.
  • It approves nothing else. Every other tool call goes to your rules, your hooks and your permission mode as before.

Both tools work only on your own interview: the connector refuses an interview that belongs to another user. Deny rules from your organization's managed settings still hold over the mod.

To see what the mod hooks and calls before you install it, run claude plugin validate plugins/prd in a clone of this repository.

Develop

claude --plugin-dir plugins/prd   # load this folder for one session
claude plugin test plugins/prd    # run the mod's tests
Source 2 files
hooks/register.tsx 721 lines
1import { atom, read, update } from "claude-code";
2import type {
3    EngineInterface,
4    McpToolResult,
5    Register,
6    ToolInfo,
7} from "claude-code";
8
9import type {
10    BuildPhase,
11    InterviewProgress,
12    InterviewSection,
13    ModelBuild,
14    SubmitConfirmation,
15} from "../types";
16
17const PANE = { id: "prd-chat", title: "Requirements coverage" };
18const INTERVIEW_TOOL =
19    /^mcp__(.+)__(start_requirements_interview|record_interview_turn)$/;
20const INTERVIEW_ID = /interviewId: (\S+)/;
21const SAVE_PRD_TOOL = /^mcp__(.+)__save_prd$/;
22const START_BUILD_TOOL = "start_model_build";
23const BUILD_TOOL =
24    /^mcp__(.+)__(start_model_build|get_model_build|amend_model_constraints|complete_\w+)$/;
25// why: each phase of a build ends with one complete_* tool, so these tools are the phases
26const PHASE_TOOL = /^complete_(\w+?)(?:_phase)?$/;
27const PHASE_ENDED = /\bends a build's (\w+) phase/i;
28const NEXT_GUIDE = /\breturns the (\w+) guide/i;
29const BUILD_PHASE = /\b(?:build is (?:now )?in|phase:) (\w+)/gi;
30const BUILD_DONE = "DONE";
31const ACRONYMS: Record<string, string> = { ui: "UI", dto: "DTO", dtos: "DTOs" };
32const BUILD_PROMPT =
33    "Please build the Timefold model from this PRD now: call start_model_build with the full PRD and follow the guide it returns.";
34const REFERENCE_LENGTH = 6;
35const MAX_SLUG_LENGTH = 60;
36const BAR_WIDTH = 20;
37const UPPER_THRESHOLD = 85;
38const MIDDLE_THRESHOLD = 50;
39
40let answerOpenQuestion: ((text: string) => void) | undefined;
41let isDownloadRequested = false;
42let pendingSubmitEmail: string | undefined;
43
44const progress = atom(
45    { plugin: "prd", key: "progress" } as const,
46    null,
47);
48const expandedIds = atom(
49    { plugin: "prd", key: "expandedIds" } as const,
50    [],
51);
52const interviewServer = atom({ plugin: "prd", key: "server" } as const, null);
53const confirmation = atom(
54    { plugin: "prd", key: "confirmation" } as const,
55    null,
56);
57const buildPhases = atom(
58    { plugin: "prd", key: "buildPhases" } as const,
59    null,
60);
61const modelBuild = atom({ plugin: "prd", key: "build" } as const, null);
62
63type Status = "open" | "inProgress" | "covered";
64
65const STATUS = {
66    open: { glyph: "○", label: "Open", color: "gray" },
67    inProgress: { glyph: "◐", label: "In progress", color: "yellow" },
68    covered: { glyph: "●", label: "Covered", color: "green" },
69} as const;
70
71type PhaseStatus = "waiting" | "active" | "done";
72
73const PHASE_STATUS = {
74    waiting: { glyph: "○", label: "Not started", color: "gray" },
75    active: { glyph: "◐", label: "In progress", color: "yellow" },
76    done: { glyph: "●", label: "Done", color: "green" },
77} as const;
78
79function statusOf(section: InterviewSection, fullCoverage: number): Status {
80    if (section.coverage >= fullCoverage) return "covered";
81
82    return section.coverage > 0 ? "inProgress" : "open";
83}
84
85// why: the same square-rooted percentage as PRD Chat's coverage bar, so the first answers move it fast
86function coveragePercent(interview: InterviewProgress): number {
87    const covered = interview.sections.reduce(
88        (sum, section) =>
89            sum + Math.min(section.coverage, interview.fullCoverage),
90        0,
91    );
92    const whole = interview.sections.length * interview.fullCoverage;
93
94    return whole === 0 ? 0 : Math.round(Math.sqrt(covered / whole) * 100);
95}
96
97function coverageColor(percent: number) {
98    if (percent >= UPPER_THRESHOLD) return "green";
99
100    return percent >= MIDDLE_THRESHOLD ? "yellow" : "red";
101}
102
103function coverageBar(percent: number) {
104    const filled = Math.round((percent / 100) * BAR_WIDTH);
105
106    return "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
107}
108
109function referenceOf(interviewId: string) {
110    const hex = interviewId.replaceAll("-", "").slice(0, REFERENCE_LENGTH);
111
112    return `#PRD-${hex.toUpperCase()}`;
113}
114
115function titleSlug(title: string) {
116    const [first = "", ...rest] = title
117        .toLowerCase()
118        .normalize("NFKD")
119        .replace(/\p{Mark}/gu, "")
120        .split(/[^a-z0-9]+/)
121        .filter(Boolean);
122    let slug = first.slice(0, MAX_SLUG_LENGTH);
123
124    for (const word of rest) {
125        const next = `${slug}-${word}`;
126
127        if (next.length > MAX_SLUG_LENGTH) break;
128        slug = next;
129    }
130
131    return slug;
132}
133
134// why: PRD Chat names its download the same way: the title as a slug, then the reference
135function prdFileName(interviewId: string, problemSummary: string | null) {
136    const reference = referenceOf(interviewId).slice(1);
137    const slug = problemSummary ? titleSlug(problemSummary) : "";
138
139    return slug ? `${slug}-${reference}.md` : `${reference}.md`;
140}
141
142function phaseLabel(name: string) {
143    const words = name
144        .split("_")
145        .map((word) => ACRONYMS[word] ?? word);
146    const [first = "", ...rest] = words;
147
148    return [first.charAt(0).toUpperCase() + first.slice(1), ...rest].join(" ");
149}
150
151// why: the connector changes its phases over time, so read them from its complete_* tools
152function buildPhasesOf(tools: ToolInfo[], server: string): BuildPhase[] | null {
153    const prefix = `mcp__${server}__`;
154    const own = tools.filter((tool) => tool.name.startsWith(prefix));
155
156    if (!own.some((tool) => tool.name === prefix + START_BUILD_TOOL)) return null;
157    const ends = own.flatMap((tool) => {
158        const name = tool.name.slice(prefix.length);
159        const phaseName = PHASE_TOOL.exec(name)?.[1];
160
161        if (phaseName === undefined) return [];
162        const id = (
163            PHASE_ENDED.exec(tool.description)?.[1] ?? phaseName
164        ).toUpperCase();
165        const next = NEXT_GUIDE.exec(tool.description)?.[1]?.toUpperCase();
166
167        return [{ phase: { id, tool: name, label: phaseLabel(phaseName) }, next }];
168    });
169    const followers = new Set(ends.map((end) => end.next));
170    const ordered: BuildPhase[] = [];
171    // why: each description names the next phase's guide, so walk that chain from the phase no other one leads to
172    let current = ends.find((end) => !followers.has(end.phase.id));
173
174    while (current && !ordered.includes(current.phase)) {
175        ordered.push(current.phase);
176        const next = current.next;
177
178        current = ends.find((end) => end.phase.id === next);
179    }
180
181    return [
182        ...ordered,
183        ...ends.map((end) => end.phase).filter((phase) => !ordered.includes(phase)),
184    ];
185}
186
187function phaseIdIn(text: string, phases: BuildPhase[]) {
188    return [...text.matchAll(BUILD_PHASE)]
189        .map((match) => (match[1] ?? "").toUpperCase())
190        .find(
191            (id) =>
192                id === BUILD_DONE || phases.some((phase) => phase.id === id),
193        );
194}
195
196// why: a phase the result does not name is still known: start begins the first one, and complete_* moves on to the next
197function phaseAfter(tool: string, phases: BuildPhase[]) {
198    if (tool === START_BUILD_TOOL) return phases[0]?.id;
199    const index = phases.findIndex((phase) => phase.tool === tool);
200
201    if (index < 0) return undefined;
202
203    return phases[index + 1]?.id ?? BUILD_DONE;
204}
205
206function phaseStatusOf(
207    index: number,
208    build: ModelBuild | null,
209    phases: BuildPhase[],
210): PhaseStatus {
211    if (build === null) return "waiting";
212    if (build.phaseId === BUILD_DONE) return "done";
213    const activeIndex = phases.findIndex((phase) => phase.id === build.phaseId);
214
215    if (activeIndex < 0 || index > activeIndex) return "waiting";
216
217    return index === activeIndex ? "active" : "done";
218}
219
220function isInterviewProgress(value: unknown): value is InterviewProgress {
221    if (typeof value !== "object" || value === null) return false;
222    const candidate = value as Partial<InterviewProgress>;
223
224    return (
225        typeof candidate.interviewId === "string" &&
226        typeof candidate.fullCoverage === "number" &&
227        Array.isArray(candidate.sections)
228    );
229}
230
231function parsedJson(text: string): unknown {
232    try {
233        return JSON.parse(text);
234    } catch {
235        return undefined;
236    }
237}
238
239// why: Claude Code's $.mcp.call hands the structured result over as a JSON text block, not as structuredContent
240function progressOf(result: McpToolResult): unknown {
241    if (result.structuredContent !== undefined) return result.structuredContent;
242    // why: the server also sends a plain-text summary block, so try each text block
243    return result.content
244        .map((block) => (block.type === "text" ? parsedJson(block.text ?? "") : undefined))
245        .find(isInterviewProgress);
246}
247
248function interviewIdOf(
249    input: Record<string, unknown>,
250    resultText: string | undefined,
251) {
252    if (typeof input.interviewId === "string") return input.interviewId;
253
254    return INTERVIEW_ID.exec(resultText ?? "")?.[1];
255}
256
257function markdownOf(input: Record<string, unknown>) {
258    return typeof input.markdown === "string" ? input.markdown : undefined;
259}
260
261function sendAsUser($: EngineInterface, text: string) {
262    if (answerOpenQuestion) {
263        answerOpenQuestion(text);
264
265        return;
266    }
267    void $.prompt
268        .submit({ text, asUser: true })
269        .then((entered) => {
270            if (entered.drop !== undefined)
271                $.ui.toast(`PRD Chat: the message was not sent: ${entered.drop}`);
272        })
273        .catch((error: unknown) =>
274            $.ui.toast(`PRD Chat: the message was not sent: ${String(error)}`),
275        );
276}
277
278async function downloadReport(
279    $: EngineInterface,
280    interviewId: string,
281    markdown: string,
282) {
283    const interview = await read($, progress);
284    const problemSummary =
285        interview?.interviewId === interviewId ? interview.problemSummary : null;
286    const fileName = prdFileName(interviewId, problemSummary);
287
288    await $.fs
289        .write(fileName, markdown)
290        .then(() => $.ui.toast(`PRD Chat: the report is saved as ${fileName}.`))
291        .catch((error: unknown) =>
292            $.ui.toast(`PRD Chat: the report was not saved: ${String(error)}`),
293        );
294}
295
296function resultText(result: McpToolResult) {
297    return result.content
298        .map((block) => (block.type === "text" ? (block.text ?? "") : ""))
299        .join(" ")
300        .trim();
301}
302
303async function submitForBuild(
304    $: EngineInterface,
305    interviewId: string,
306    contactEmail: string,
307) {
308    const server = await read($, interviewServer);
309
310    if (server === null) {
311        $.ui.toast("PRD Chat: the interview's connector is not known yet.");
312
313        return;
314    }
315    $.ui.toast("PRD Chat: sending your requirements to Timefold.");
316    const result = await $.mcp
317        .call(server, "submit_for_build", { interviewId, contactEmail })
318        .catch((error: unknown) => ({
319            content: [{ type: "text" as const, text: String(error) }],
320            isError: true,
321        }));
322
323    $.ui.toast(
324        result.isError
325            ? `PRD Chat: not submitted: ${resultText(result)}`
326            : `PRD Chat: submitted. Our solutions team will contact you at ${contactEmail}.`,
327    );
328}
329
330// why: the confirmation is drawn in the pane, not with $.ui.ask, which waits behind the interview's open question
331async function openConfirmation(
332    $: EngineInterface,
333    interview: InterviewProgress,
334) {
335    if (interview.contactEmail === undefined) {
336        $.ui.toast("PRD Chat: this connector cannot submit for build yet.");
337
338        return;
339    }
340    const contactEmail = interview.contactEmail;
341
342    await update($, confirmation, () => ({
343        interviewId: interview.interviewId,
344        contactEmail,
345    }));
346}
347
348// why: Submit in PRD Chat's confirmation is the user's consent to the handoff
349async function confirmSubmit($: EngineInterface, confirmed: SubmitConfirmation) {
350    const interview = await read($, progress);
351
352    await update($, confirmation, () => null);
353    if (interview?.interviewId !== confirmed.interviewId) return;
354    if (interview.hasCurrentPrd) {
355        await submitForBuild($, interview.interviewId, confirmed.contactEmail);
356
357        return;
358    }
359    pendingSubmitEmail = confirmed.contactEmail;
360    $.ui.toast("PRD Chat: Claude writes the requirements document first.");
361    sendAsUser($, "Please write the PRD now.");
362}
363
364async function refreshBuildPhases($: EngineInterface, server: string) {
365    const tools = await $.tool.list().catch(() => null);
366
367    if (tools === null) return null;
368    const phases = buildPhasesOf(tools, server);
369
370    await update($, buildPhases, () => phases);
371
372    return phases;
373}
374
375async function followBuild(
376    $: EngineInterface,
377    server: string,
378    tool: string,
379    text: string,
380) {
381    const phases = (await refreshBuildPhases($, server)) ?? [];
382    const phaseId = phaseIdIn(text, phases) ?? phaseAfter(tool, phases);
383
384    if (phaseId === undefined) return;
385    await update($, modelBuild, () => ({ phaseId }));
386    await $.ui.open(PANE);
387}
388
389function requestBuild($: EngineInterface) {
390    $.ui.toast("PRD Chat: Claude starts the model build.");
391    sendAsUser($, BUILD_PROMPT);
392}
393
394async function refreshProgress(
395    $: EngineInterface,
396    server: string,
397    interviewId: string,
398) {
399    const result = await $.mcp
400        .call(server, "get_interview_progress", { interviewId })
401        .catch((error: unknown) => {
402            $.ui.toast(
403                `PRD Chat: the requirements pane was not updated: ${String(error)}`,
404            );
405
406            return null;
407        });
408
409    if (result === null) return;
410    const loaded = result.isError ? undefined : progressOf(result);
411
412    if (!isInterviewProgress(loaded)) {
413        $.ui.toast("PRD Chat: the interview progress could not be loaded.");
414
415        return;
416    }
417
418    await update($, progress, () => loaded);
419    await update($, interviewServer, () => server);
420    await refreshBuildPhases($, server);
421    await $.ui.open(PANE);
422}
423
424export const register: Register = (on) => {
425    on(
426        "tool.check",
427        { tool: /^mcp__.+__get_interview_progress$/ },
428        ($, e, next) =>
429            next.origin.plugin === $.plugin.name
430                ? {
431                      decision: "allow",
432                      reason: "The PRD Chat pane reads the interview's progress.",
433                  }
434                : next(e),
435    );
436    on(
437        "tool.check",
438        { tool: /^mcp__.+__submit_for_build$/ },
439        ($, e, next) =>
440            next.origin.plugin === $.plugin.name
441                ? {
442                      decision: "allow",
443                      reason: "The user confirmed Submit for build and the contact email in the PRD Chat pane.",
444                  }
445                : next(e),
446    );
447    on("session.start", async ($, e, next) => {
448        await $.command.register({
449            name: "prd-panel",
450            description:
451                "Show the requirements of the current PRD Chat interview",
452        });
453
454        return next(e);
455    });
456
457    on("command.run", { command: "prd-panel" }, async ($) => {
458        await $.ui.open(PANE);
459
460        return { text: "Requirements pane opened." };
461    });
462
463    on("tool.call", { tool: "AskUserQuestion" }, async ($, e, next) => {
464        const redirected = new Promise<string>((resolve) => {
465            answerOpenQuestion = resolve;
466        });
467        const asked = next(e);
468
469        try {
470            const outcome = await Promise.race([
471                asked.then((ran) => ({ ran })),
472                redirected.then((text) => ({ text })),
473            ]);
474
475            if ("ran" in outcome) return outcome.ran;
476            const answers = Object.fromEntries(
477                e.questions.map((question) => [
478                    question.question,
479                    outcome.text,
480                ]),
481            );
482
483            return { result: { questions: e.questions, answers } };
484        } finally {
485            answerOpenQuestion = undefined;
486        }
487    });
488
489    on("tool.call", { tool: INTERVIEW_TOOL }, async ($, e, next) => {
490        const ran = await next(e);
491        const server = INTERVIEW_TOOL.exec(e.tool)?.[1];
492        const interviewId = interviewIdOf(e, ran.text);
493
494        if (ran.deny === undefined && !ran.isError && server && interviewId)
495            await refreshProgress($, server, interviewId);
496
497        return ran;
498    });
499
500    on("tool.call", { tool: SAVE_PRD_TOOL }, async ($, e, next) => {
501        const ran = await next(e);
502        const isSaved = ran.deny === undefined && !ran.isError;
503        const markdown = markdownOf(e);
504        const interviewId = interviewIdOf(e, undefined);
505        const server = SAVE_PRD_TOOL.exec(e.tool)?.[1];
506
507        if (isSaved && server && interviewId)
508            await refreshProgress($, server, interviewId);
509        if (isSaved && interviewId && pendingSubmitEmail !== undefined) {
510            const contactEmail = pendingSubmitEmail;
511
512            pendingSubmitEmail = undefined;
513            await submitForBuild($, interviewId, contactEmail);
514        }
515        if (
516            isDownloadRequested &&
517            isSaved &&
518            markdown !== undefined &&
519            interviewId !== undefined
520        ) {
521            isDownloadRequested = false;
522            await downloadReport($, interviewId, markdown);
523        }
524
525        return ran;
526    });
527
528    on("tool.call", { tool: BUILD_TOOL }, async ($, e, next) => {
529        const ran = await next(e);
530        const match = BUILD_TOOL.exec(e.tool);
531        const server = match?.[1];
532        const tool = match?.[2];
533
534        if (ran.deny === undefined && !ran.isError && server && tool)
535            await followBuild($, server, tool, ran.text ?? "");
536
537        return ran;
538    });
539
540    on("ui.render", { component: "Pane", requestId: PANE.id }, async ($, e) => {
541        const { Box, Text, Button } = $.ui.resolve(e);
542        const interview = await read($, progress);
543        const expanded = await read($, expandedIds);
544        const confirming = await read($, confirmation);
545        const phases = await read($, buildPhases);
546        const build = await read($, modelBuild);
547        const buildSection = phases !== null && phases.length > 0 && (
548            <Box key="build-phases" flexDirection="column">
549                <Text bold>Model build</Text>
550                {phases.map((phase, index) => {
551                    const statusKey = phaseStatusOf(index, build, phases);
552                    const status = PHASE_STATUS[statusKey];
553
554                    return (
555                        <Box key={`phase-${phase.id}`} gap={1}>
556                            <Text color={status.color}>{status.glyph}</Text>
557                            <Text>{phase.label}</Text>
558                            <Text color={status.color} dimColor>
559                                {status.label}
560                            </Text>
561                            {statusKey === "active" && (
562                                <Text color="cyan" bold>
563                                    ◀ now
564                                </Text>
565                            )}
566                        </Box>
567                    );
568                })}
569            </Box>
570        );
571
572        if (interview === null)
573            return build === null ? (
574                <Text dimColor>
575                    No interview yet. Ask Claude to start a Timefold
576                    requirements interview.
577                </Text>
578            ) : (
579                <Box flexDirection="column">{buildSection}</Box>
580            );
581        const percent = coveragePercent(interview);
582        const toggle = (id: string) =>
583            update($, expandedIds, (ids) =>
584                ids.includes(id)
585                    ? ids.filter((each) => each !== id)
586                    : [...ids, id],
587            );
588        const talkAbout = (section: InterviewSection) =>
589            sendAsUser($, `Let's talk about ${section.label}: ${section.question}`);
590        const requestDownload = () => {
591            isDownloadRequested = true;
592            $.ui.toast("PRD Chat: preparing the report.");
593            sendAsUser($, "Please write the PRD now.");
594        };
595
596        return (
597            <Box flexDirection="column" gap={1}>
598                <Box flexDirection="column">
599                    {interview.problemSummary && (
600                        <Text bold>{interview.problemSummary}</Text>
601                    )}
602                    <Text dimColor>{referenceOf(interview.interviewId)}</Text>
603                    <Box gap={1}>
604                        <Text color={coverageColor(percent)}>
605                            {coverageBar(percent)}
606                        </Text>
607                        <Text color={coverageColor(percent)} bold>
608                            {`${percent}%`}
609                        </Text>
610                    </Box>
611                </Box>
612                <Box flexDirection="column">
613                    {interview.sections.map((section) => {
614                        const statusKey = statusOf(
615                            section,
616                            interview.fullCoverage,
617                        );
618                        const status = STATUS[statusKey];
619                        const isExpanded = expanded.includes(section.id);
620                        const isCurrent =
621                            section.id === interview.currentSectionId;
622
623                        return (
624                            <Box key={section.id} flexDirection="column">
625                                <Box gap={1}>
626                                    <Text color={status.color}>
627                                        {status.glyph}
628                                    </Text>
629                                    <Button
630                                        key={`toggle-${section.id}`}
631                                        plain
632                                        label={`${isExpanded ? "▾" : "▸"} ${section.label}`}
633                                        onPress={() => toggle(section.id)}
634                                    />
635                                    <Text color={status.color} dimColor>
636                                        {status.label}
637                                    </Text>
638                                    {isCurrent && (
639                                        <Text color="cyan" bold>
640                                            ◀ now
641                                        </Text>
642                                    )}
643                                </Box>
644                                {isExpanded && (
645                                    <Box flexDirection="column" paddingLeft={4}>
646                                        <Text dimColor>{section.question}</Text>
647                                        <Text>
648                                            {section.captured ||
649                                                "Nothing captured yet."}
650                                        </Text>
651                                        {statusKey !== "covered" && (
652                                            <Button
653                                                key={`talk-${section.id}`}
654                                                label="Talk about this"
655                                                onPress={() => talkAbout(section)}
656                                            />
657                                        )}
658                                    </Box>
659                                )}
660                            </Box>
661                        );
662                    })}
663                </Box>
664                {buildSection}
665                {confirming?.interviewId === interview.interviewId ? (
666                    <Box
667                        key="submit-confirmation"
668                        flexDirection="column"
669                        borderStyle="round"
670                        paddingX={1}
671                    >
672                        <Text bold>Submit for build?</Text>
673                        <Text>
674                            We send your requirements document, as Markdown and
675                            PDF, to our solutions team.
676                        </Text>
677                        {/* why: no Change email: a text field in the pane gets no keys while Claude's question dialog is open */}
678                        <Text>{`Contact email: ${confirming.contactEmail}`}</Text>
679                        <Box gap={1}>
680                            <Button
681                                key="confirm-submit"
682                                variant="primary"
683                                label="Submit"
684                                onPress={() => confirmSubmit($, confirming)}
685                            />
686                            <Button
687                                key="cancel-submit"
688                                label="Cancel"
689                                onPress={() =>
690                                    update($, confirmation, () => null)
691                                }
692                            />
693                        </Box>
694                    </Box>
695                ) : (
696                    <Box gap={1}>
697                        <Button
698                            key="download-report"
699                            label="Download report"
700                            onPress={requestDownload}
701                        />
702                        <Button
703                            key="submit-build"
704                            variant="primary"
705                            label="Submit for build"
706                            onPress={() => openConfirmation($, interview)}
707                        />
708                        {phases !== null && (
709                            <Button
710                                key="build-model"
711                                label="Build model"
712                                onPress={() => requestBuild($)}
713                            />
714                        )}
715                    </Box>
716                )}
717            </Box>
718        );
719    });
720};
721
types/index.d.ts 46 lines
1export type InterviewSection = {
2    id: string;
3    label: string;
4    question: string;
5    coverage: number;
6    captured: string;
7};
8
9export type InterviewProgress = {
10    interviewId: string;
11    problemSummary: string | null;
12    currentSectionId: string | null;
13    fullCoverage: number;
14    sections: InterviewSection[];
15    contactEmail?: string;
16    hasCurrentPrd?: boolean;
17};
18
19export type SubmitConfirmation = {
20    interviewId: string;
21    contactEmail: string;
22};
23
24export type BuildPhase = {
25    id: string;
26    tool: string;
27    label: string;
28};
29
30export type ModelBuild = {
31    phaseId: string;
32};
33
34declare module "claude-code" {
35    interface PluginState {
36        "prd": {
37            progress: InterviewProgress | null;
38            expandedIds: string[];
39            server: string | null;
40            confirmation: SubmitConfirmation | null;
41            buildPhases: BuildPhase[] | null;
42            build: ModelBuild | null;
43        };
44    }
45}
46