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…

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:
.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.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:
start_model_build. Claude then follows the guide that the connector gives for each phase.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.
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.
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):
__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.next.origin.plugin is this plugin). When Claude calls the same tool, the usual permission decision applies.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.
claude --plugin-dir plugins/prd # load this folder for one session
claude plugin test plugins/prd # run the mod's testshooks/register.tsx 721 lines1import { 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};
721types/index.d.ts 46 lines1export 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