Browse Source

refactor: interview feature to plain-text format

Alvin Real 3 months ago
parent
commit
c761621f4a
7 changed files with 325 additions and 297 deletions
  1. 40 0
      PR_BODY.md
  2. 80 61
      src/interview/document.ts
  3. 34 35
      src/interview/interview.test.ts
  4. 112 84
      src/interview/parser.ts
  5. 45 23
      src/interview/prompts.ts
  6. 13 69
      src/interview/service.ts
  7. 1 25
      src/interview/types.ts

+ 40 - 0
PR_BODY.md

@@ -0,0 +1,40 @@
+## Summary
+Refactor the interview feature from XML-wrapped JSON to a plain-text format for LLM communication. This reduces token usage and simplifies the parsing logic.
+
+## Changes
+
+### New Plain-Text Format
+```
+Q1: What platform are you targeting?
+- Web *
+- Mobile
+- Desktop
+
+Q2: What is the timeline?
+- 1 week
+- 1 month *
+- 3 months
+```
+
+### Key Changes
+- **parser.ts**: New `parsePlainTextQuestions()` function with regex patterns
+- **prompts.ts**: All prompts updated to use plain-text format instructions
+- **types.ts**: Removed `summary` and `title` fields from `InterviewAssistantState`
+- **document.ts**: Simplified markdown format (just Q&A list, no spec sections)
+- **service.ts**: Removed title-based file renaming, added `formatInstructions` import
+- **interview.test.ts**: Updated all tests for new format
+
+### Benefits
+- Lower token usage (no JSON quotes, braces, indentation)
+- Faster LLM generation
+- Simpler parsing logic with clear error messages
+- No backward compatibility needed (breaking change)
+
+## Testing
+All 50 tests pass:
+```
+bun test src/interview/interview.test.ts
+```
+
+## Migration
+This is a breaking change. Existing interviews using the old `<interview_state>` JSON format will need to be restarted with the new format.

+ 80 - 61
src/interview/document.ts

@@ -102,40 +102,35 @@ export function slugify(value: string): string {
 
 // ─── Markdown Document Operations ────────────────────────────────────
 
-function extractHistorySection(document: string): string {
-  const marker = '## Q&A history\n\n';
-  const index = document.indexOf(marker);
-  return index >= 0 ? document.slice(index + marker.length).trim() : '';
-}
+function extractAnswersFromDocument(document: string): Array<{ question: string; answer: string }> {
+  const pairs: Array<{ question: string; answer: string }> = [];
+  const lines = document.split('\n');
+  let currentQuestion: string | null = null;
 
-export function extractSummarySection(document: string): string {
-  const marker = '## Current spec\n\n';
-  const historyMarker = '\n\n## Q&A history';
-  const start = document.indexOf(marker);
-  if (start < 0) {
-    return '';
+  for (const line of lines) {
+    const trimmed = line.trim();
+    if (trimmed.startsWith('Q: ')) {
+      currentQuestion = trimmed.slice(3);
+    } else if (trimmed.startsWith('A: ') && currentQuestion) {
+      pairs.push({
+        question: currentQuestion,
+        answer: trimmed.slice(3),
+      });
+      currentQuestion = null;
+    }
   }
-  const summaryStart = start + marker.length;
-  const summaryEnd = document.indexOf(historyMarker, summaryStart);
-  return document
-    .slice(summaryStart, summaryEnd >= 0 ? summaryEnd : undefined)
-    .trim();
-}
 
-export function extractTitle(document: string): string {
-  const match = document.match(/^#\s+(.+)$/m);
-  return match?.[1]?.trim() ?? '';
+  return pairs;
 }
 
+
+
 export function buildInterviewDocument(
   idea: string,
-  summary: string,
-  history: string,
+  questions: InterviewQuestion[],
+  answers: Array<{ questionId: string; answer: string }>,
   meta?: { sessionID?: string; baseMessageCount?: number },
 ): string {
-  const normalizedSummary = summary.trim() || 'Waiting for interview answers.';
-  const normalizedHistory = history.trim() || 'No answers yet.';
-
   const frontmatter = meta?.sessionID
     ? [
         '---',
@@ -147,18 +142,23 @@ export function buildInterviewDocument(
       ].join('\n')
     : '';
 
+  const qaLines: string[] = [];
+  for (const answer of answers) {
+    const question = questions.find((q) => q.id === answer.questionId);
+    if (question) {
+      qaLines.push(`Q: ${question.question}`);
+      qaLines.push(`A: ${answer.answer.trim()}`);
+      qaLines.push('');
+    }
+  }
+
   return [
     frontmatter,
     `# ${idea}`,
     '',
-    '## Current spec',
-    '',
-    normalizedSummary,
-    '',
-    '## Q&A history',
-    '',
-    normalizedHistory,
+    '## Q&A',
     '',
+    ...qaLines,
   ].join('\n');
 }
 
@@ -187,7 +187,7 @@ export async function ensureInterviewFile(
   } catch {
     await fs.writeFile(
       record.markdownPath,
-      buildInterviewDocument(record.idea, '', '', {
+      buildInterviewDocument(record.idea, [], [], {
         sessionID: record.sessionID,
         baseMessageCount: record.baseMessageCount,
       }),
@@ -210,16 +210,11 @@ export async function readInterviewDocument(
 
 export async function rewriteInterviewDocument(
   record: InterviewRecord,
-  summary: string,
+  _questions: InterviewQuestion[],
 ): Promise<string> {
-  const existing = await readInterviewDocument(record);
-  const history = extractHistorySection(existing);
-  const next = buildInterviewDocument(record.idea, summary, history, {
-    sessionID: record.sessionID,
-    baseMessageCount: record.baseMessageCount,
-  });
-  await fs.writeFile(record.markdownPath, next, 'utf8');
-  return next;
+  // For now, just return the existing document as-is
+  // The document is updated via appendInterviewAnswers when answers are submitted
+  return readInterviewDocument(record);
 }
 
 export async function appendInterviewAnswers(
@@ -228,29 +223,53 @@ export async function appendInterviewAnswers(
   answers: InterviewAnswer[],
 ): Promise<void> {
   const existing = await readInterviewDocument(record);
-  const summary = extractSummarySection(existing);
-  const history = extractHistorySection(existing);
+  const existingQaPairs = extractAnswersFromDocument(existing);
+
   const questionMap = new Map(
     questions.map((question) => [question.id, question]),
   );
-  const appended = answers
+
+  // Build new Q&A pairs from submitted answers
+  const newQaPairs = answers
     .map((answer) => {
       const question = questionMap.get(answer.questionId);
-      return question
-        ? `Q: ${question.question}\nA: ${answer.answer.trim()}`
-        : null;
+      if (!question) return null;
+      return {
+        question: question.question,
+        answer: answer.answer.trim(),
+      };
     })
-    .filter((value): value is string => value !== null)
-    .join('\n\n');
-  const nextHistory = [history === 'No answers yet.' ? '' : history, appended]
-    .filter(Boolean)
-    .join('\n\n');
-  await fs.writeFile(
-    record.markdownPath,
-    buildInterviewDocument(record.idea, summary, nextHistory, {
-      sessionID: record.sessionID,
-      baseMessageCount: record.baseMessageCount,
-    }),
-    'utf8',
-  );
+    .filter((value): value is { question: string; answer: string } => value !== null);
+
+  const allQaPairs = [...existingQaPairs, ...newQaPairs];
+
+  // Rebuild the document with all Q&A pairs
+  const frontmatter = record.sessionID
+    ? [
+        '---',
+        `sessionID: ${record.sessionID}`,
+        `baseMessageCount: ${record.baseMessageCount ?? 0}`,
+        `updatedAt: ${new Date().toISOString()}`,
+        '---',
+        '',
+      ].join('\n')
+    : '';
+
+  const qaLines: string[] = [];
+  for (const pair of allQaPairs) {
+    qaLines.push(`Q: ${pair.question}`);
+    qaLines.push(`A: ${pair.answer}`);
+    qaLines.push('');
+  }
+
+  const document = [
+    frontmatter,
+    `# ${record.idea}`,
+    '',
+    '## Q&A',
+    '',
+    ...qaLines,
+  ].join('\n');
+
+  await fs.writeFile(record.markdownPath, document, 'utf8');
 }

+ 34 - 35
src/interview/interview.test.ts

@@ -142,7 +142,8 @@ describe("interview service", () => {
       expect(output.parts.length).toBe(1);
       expect(output.parts[0].type).toBe("text");
       expect(output.parts[0].text).toContain("My App Idea");
-      expect(output.parts[0].text).toContain("<interview_state>");
+      expect(output.parts[0].text).toContain("Q1:");
+      expect(output.parts[0].text).toContain("- ");
 
       // Should send UI notification prompt to session
       expect(ctx.client.session.prompt).toHaveBeenCalled();
@@ -189,8 +190,7 @@ describe("interview service", () => {
         "utf8"
       );
       expect(content).toContain("# Test Idea");
-      expect(content).toContain("## Current spec");
-      expect(content).toContain("## Q&A history");
+      expect(content).toContain("## Q&A");
 
       // Cleanup
       await fs.rm(tempDir, { recursive: true, force: true });
@@ -238,7 +238,7 @@ describe("interview service", () => {
         parts: [
           {
             type: "text",
-            text: 'Here are some questions.\n<interview_state>\n{\n  "summary": "Building a test app",\n  "questions": [\n    {\n      "id": "q-1",\n      "question": "What platform?",\n      "options": ["Web", "Mobile"],\n      "suggested": "Web"\n    }\n  ]\n}\n</interview_state>',
+            text: 'Here are some questions.\n\nQ1: What platform?\n- Web *\n- Mobile',
           },
         ],
       });
@@ -255,14 +255,11 @@ describe("interview service", () => {
         "utf8"
       );
 
-      // Verify Q/A was appended to history section
-      expect(content).toContain("## Q&A history");
+      // Verify Q/A was appended
+      expect(content).toContain("## Q&A");
       expect(content).toContain("Q: What platform?");
       expect(content).toContain("A: Web");
 
-      // Verify the Current spec section exists (even if empty after submission)
-      expect(content).toContain("## Current spec");
-
       // Cleanup
       await fs.rm(tempDir, { recursive: true, force: true });
     });
@@ -281,7 +278,7 @@ describe("interview service", () => {
           parts: [
             {
               type: "text",
-              text: 'First question.\n<interview_state>\n{\n  "summary": "Building an app",\n  "questions": [\n    {\n      "id": "q-1",\n      "question": "What is the name?",\n      "options": ["App1", "App2"],\n      "suggested": "App1"\n    }\n  ]\n}\n</interview_state>',
+              text: 'First question.\n\nQ1: What is the name?\n- App1 *\n- App2',
             },
           ],
         },
@@ -292,7 +289,7 @@ describe("interview service", () => {
           parts: [
             {
               type: "text",
-              text: 'Second question.\n<interview_state>\n{\n  "summary": "Building App1",\n  "questions": [\n    {\n      "id": "q-2",\n      "question": "What color?",\n      "options": ["Red", "Blue"],\n      "suggested": "Blue"\n    }\n  ]\n}\n</interview_state>',
+              text: 'Second question.\n\nQ1: What color?\n- Red\n- Blue *',
             },
           ],
         },
@@ -330,14 +327,14 @@ describe("interview service", () => {
         parts: [
           {
             type: "text",
-            text: 'Acknowledged.\n<interview_state>\n{\n  "summary": "Building App1",\n  "questions": [\n    {\n      "id": "q-2",\n      "question": "What color?",\n      "options": ["Red", "Blue"],\n      "suggested": "Blue"\n    }\n  ]\n}\n</interview_state>',
+            text: 'Acknowledged.\n\nQ1: What color?\n- Red\n- Blue *',
           },
         ],
       });
 
-      // Submit second answer (q-2 is the active question now)
+      // Submit second answer (q-1 is the active question now - plain text format)
       await service.submitAnswers(requiredInterviewId, [
-        { questionId: "q-2", answer: "Blue" },
+        { questionId: "q-1", answer: "Blue" },
       ]);
 
       // Read file after submission
@@ -392,7 +389,7 @@ describe("interview service", () => {
         parts: [
           {
             type: "text",
-            text: 'Here are some questions.\n<interview_state>\n{\n  "summary": "Building a test app",\n  "questions": [\n    {\n      "id": "q-1",\n      "question": "What platform?",\n      "options": ["Web", "Mobile"],\n      "suggested": "Web"\n    }\n  ]\n}\n</interview_state>',
+            text: 'Here are some questions.\n\nQ1: What platform?\n- Web *\n- Mobile',
           },
         ],
       });
@@ -408,8 +405,9 @@ describe("interview service", () => {
         "utf8"
       );
 
-      expect(content).not.toContain("## Q&A history\n\nNo answers yet.\n\nQ:");
-      expect(content).toContain("## Q&A history\n\nQ: What platform?\nA: Web");
+      expect(content).toContain("## Q&A");
+      expect(content).toContain("Q: What platform?");
+      expect(content).toContain("A: Web");
 
       await fs.rm(tempDir, { recursive: true, force: true });
     });
@@ -461,7 +459,7 @@ describe("interview service", () => {
         parts: [
           {
             type: "text",
-            text: 'Here are some questions.\n<interview_state>\n{\n  "summary": "Building a test app",\n  "questions": [\n    {\n      "id": "q-1",\n      "question": "What platform?",\n      "options": ["Web", "Mobile"],\n      "suggested": "Web"\n    }\n  ]\n}\n</interview_state>',
+            text: 'Here are some questions.\n\nQ1: What platform?\n- Web *\n- Mobile',
           },
         ],
       });
@@ -530,7 +528,7 @@ describe("interview service", () => {
         parts: [
           {
             type: "text",
-            text: 'Here are some questions.\n<interview_state>\n{\n  "summary": "Building a test app",\n  "questions": [\n    {\n      "id": "q-1",\n      "question": "What platform?",\n      "options": ["Web", "Mobile"],\n      "suggested": "Web"\n    }\n  ]\n}\n</interview_state>',
+            text: 'Here are some questions.\n\nQ1: What platform?\n- Web *\n- Mobile',
           },
         ],
       });
@@ -1093,7 +1091,6 @@ describe("interview service", () => {
       const outputText = extractOutputText(output);
       expect(outputText).toContain("at most 5 questions");
       expect(outputText).toContain("Return 0 to 5 questions");
-      expect(outputText).toContain("Do not ask more than 5 questions");
 
       // Cleanup
       await fs.rm(tempDir, { recursive: true, force: true });
@@ -1182,7 +1179,7 @@ describe("interview service", () => {
         parts: [
           {
             type: "text",
-            text: 'Questions.\n<interview_state>\n{\n  "summary": "Test",\n  "questions": [\n    {"id": "q-1", "question": "Q1?", "options": ["A", "B"]},\n    {"id": "q-2", "question": "Q2?", "options": ["A", "B"]},\n    {"id": "q-3", "question": "Q3?", "options": ["A", "B"]},\n    {"id": "q-4", "question": "Q4?", "options": ["A", "B"]}\n  ]\n}\n</interview_state>',
+            text: 'Questions.\n\nQ1: Q1?\n- A\n- B\n\nQ2: Q2?\n- A\n- B\n\nQ3: Q3?\n- A\n- B\n\nQ4: Q4?\n- A\n- B',
           },
         ],
       });
@@ -1240,7 +1237,7 @@ describe("interview service", () => {
         parts: [
           {
             type: "text",
-            text: 'Question.\n<interview_state>\n{\n  "summary": "Test",\n  "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
+            text: 'Question.\n\nQ1: What?\n- A\n- B',
           },
         ],
       });
@@ -1298,7 +1295,7 @@ describe("interview service", () => {
         parts: [
           {
             type: "text",
-            text: 'Question.\n<interview_state>\n{\n  "summary": "Test",\n  "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
+            text: 'Question.\n\nQ1: What?\n- A\n- B',
           },
         ],
       });
@@ -1332,7 +1329,7 @@ describe("interview service", () => {
   });
 
   describe("agent-provided title", () => {
-    test("renames file when assistant provides title in interview_state", async () => {
+    test("file uses original idea slug - no title renaming in plain-text format", async () => {
       const tempDir = await fs.mkdtemp("/tmp/interview-test-");
 
       // Start with empty messages
@@ -1360,7 +1357,7 @@ describe("interview service", () => {
         output
       );
 
-      // Initial file should use slugified user input
+      // File should use slugified user input
       const interviewDir = path.join(tempDir, "interview");
       let files = await fs.readdir(interviewDir);
       expect(files.length).toBe(1);
@@ -1371,25 +1368,25 @@ describe("interview service", () => {
       );
       const requiredInterviewId = requireInterviewId(interviewId);
 
-      // Now add agent response with a concise title
+      // Now add agent response with questions (plain-text format)
       messagesData.push({
         info: { role: "assistant" },
         parts: [
           {
             type: "text",
-            text: 'Here are some questions.\n<interview_state>\n{\n  "summary": "Building a task management app",\n  "title": "task-manager",\n  "questions": [{"id": "q-1", "question": "What platform?", "options": ["Web", "Mobile"]}]\n}\n</interview_state>',
+            text: 'Here are some questions.\n\nQ1: What platform?\n- Web\n- Mobile',
           },
         ],
       });
 
-      // Sync interview (this triggers the rename)
+      // Sync interview
       const state = await service.getInterviewState(requiredInterviewId);
 
-      // File should be renamed to use assistant-provided title
+      // File should keep original name (no title renaming in plain-text format)
       files = await fs.readdir(interviewDir);
       expect(files.length).toBe(1);
-      expect(files[0]).toBe("task-manager.md");
-      expect(state.markdownPath).toContain("task-manager.md");
+      expect(files[0]).toBe("my-great-app-idea-with-long-description.md");
+      expect(state.markdownPath).toContain("my-great-app-idea-with-long-description.md");
 
       // Cleanup
       await fs.rm(tempDir, { recursive: true, force: true });
@@ -1625,7 +1622,7 @@ describe("interview service", () => {
       await fs.rm(tempDir, { recursive: true, force: true });
     });
 
-    test("kickoff prompt includes title field guidance", async () => {
+    test("kickoff prompt uses plain-text format without title field", async () => {
       const tempDir = await fs.mkdtemp("/tmp/interview-test-");
       const ctx = createMockContext({ directory: tempDir });
 
@@ -1646,10 +1643,12 @@ describe("interview service", () => {
         output
       );
 
-      // Kickoff prompt should mention title field
+      // Kickoff prompt should use plain-text format (no JSON/title)
       const outputText = extractOutputText(output);
-      expect(outputText).toContain('"title":');
-      expect(outputText).toContain("concise-kebab-case-title-for-filename");
+      expect(outputText).toContain("Q1:");
+      expect(outputText).toContain("- ");
+      expect(outputText).not.toContain('"title":');
+      expect(outputText).not.toContain("<interview_state>");
 
       // Cleanup
       await fs.rm(tempDir, { recursive: true, force: true });

+ 112 - 84
src/interview/parser.ts

@@ -3,47 +3,30 @@ import type {
   InterviewMessage,
   InterviewQuestion,
 } from './types';
-import { RawInterviewStateSchema, RawQuestionSchema } from './types';
-
-const INTERVIEW_BLOCK_REGEX =
-  /<interview_state>\s*([\s\S]*?)\s*<\/interview_state>/i;
-
-function normalizeQuestion(
-  value: unknown,
-  index: number,
-): InterviewQuestion | null {
-  // Validate raw question object with Zod
-  const result = RawQuestionSchema.safeParse(value);
-  if (!result.success) {
-    return null;
-  }
-  const question =
-    typeof result.data.question === 'string' ? result.data.question.trim() : '';
-  if (!question) {
-    return null;
-  }
 
-  const options = Array.isArray(result.data.options)
-    ? result.data.options
-        .filter((option): option is string => typeof option === 'string')
-        .map((option) => option.trim())
-        .filter(Boolean)
-        .slice(0, 4)
-    : [];
+const QUESTION_REGEX = /^Q(\d+):\s*(.+)$/;
+const OPTION_REGEX = /^-\s+(.+?)(\s+\*)?$/;
 
-  return {
-    id:
-      typeof result.data.id === 'string' && result.data.id.trim().length > 0
-        ? result.data.id.trim()
-        : `q-${index + 1}`,
-    question,
-    options,
-    suggested:
-      typeof result.data.suggested === 'string' &&
-      result.data.suggested.trim().length > 0
-        ? result.data.suggested.trim()
-        : undefined,
-  };
+function finalizeQuestion(
+  state: InterviewAssistantState,
+  current: {
+    number: number;
+    question: string;
+    options: string[];
+    suggested?: string;
+  } | null,
+  maxQuestions: number,
+): void {
+  if (!current || state.questions.length >= maxQuestions) {
+    return;
+  }
+
+  state.questions.push({
+    id: `q-${current.number}`,
+    question: current.question,
+    options: current.options.slice(0, 4),
+    suggested: current.suggested,
+  });
 }
 
 export function flattenMessage(message: InterviewMessage): string {
@@ -54,69 +37,113 @@ export function flattenMessage(message: InterviewMessage): string {
 }
 
 export function buildFallbackState(
-  messages: InterviewMessage[],
+  _messages: InterviewMessage[],
 ): InterviewAssistantState {
-  const answerCount = messages.filter(
-    (message) => message.info?.role === 'user',
-  ).length;
-
   return {
-    summary:
-      answerCount > 0
-        ? 'Interview in progress.'
-        : 'Waiting for the first interview response.',
     questions: [],
   };
 }
 
-export function parseAssistantState(
+export function parsePlainTextQuestions(
   text: string,
   maxQuestions = 2,
 ): {
   state: InterviewAssistantState | null;
   error?: string;
 } {
-  const match = text.match(INTERVIEW_BLOCK_REGEX);
-  if (!match) {
+  const lines = text
+    .split(/\r?\n/)
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0);
+
+  const firstQuestionIndex = lines.findIndex((line) => QUESTION_REGEX.test(line));
+  if (firstQuestionIndex < 0) {
     return { state: null };
   }
 
-  try {
-    const raw = JSON.parse(match[1]);
-    // Validate raw LLM output with Zod before processing
-    const parsed = RawInterviewStateSchema.parse(raw) as Record<
-      string,
-      unknown
-    >;
-    const summary =
-      typeof parsed.summary === 'string' ? parsed.summary.trim() : '';
-    const title =
-      typeof parsed.title === 'string' && parsed.title.trim().length > 0
-        ? parsed.title.trim()
-        : undefined;
-    const questions = Array.isArray(parsed.questions)
-      ? parsed.questions
-          .map((value, index) => normalizeQuestion(value, index))
-          .filter((value): value is InterviewQuestion => value !== null)
-          .slice(0, maxQuestions)
-      : [];
+  const state: InterviewAssistantState = { questions: [] };
+  let current:
+    | {
+        number: number;
+        question: string;
+        options: string[];
+        suggested?: string;
+      }
+    | null = null;
+  let lastQuestionNumber = 0;
 
-    return {
-      state: {
-        summary,
-        title,
-        questions,
-      },
-    };
-  } catch (error) {
+  for (let index = firstQuestionIndex; index < lines.length; index += 1) {
+    const line = lines[index];
+    const questionMatch = line.match(QUESTION_REGEX);
+    if (questionMatch) {
+      finalizeQuestion(state, current, maxQuestions);
+
+      const number = Number.parseInt(questionMatch[1], 10);
+      const question = questionMatch[2]?.trim() ?? '';
+      if (!question) {
+        return { state: null, error: `Question Q${number} is missing text.` };
+      }
+      if (number <= lastQuestionNumber) {
+        return {
+          state: null,
+          error: `Question numbers must increase sequentially. Found Q${number} after Q${lastQuestionNumber}.`,
+        };
+      }
+
+      current = {
+        number,
+        question,
+        options: [],
+      };
+      lastQuestionNumber = number;
+      continue;
+    }
+
+    if (!current) {
+      continue;
+    }
+
+    const optionMatch = line.match(OPTION_REGEX);
+    if (!optionMatch) {
+      return {
+        state: null,
+        error: `Expected an option line starting with "- " after Q${current.number}, got: ${line}`,
+      };
+    }
+
+    const option = optionMatch[1]?.trim() ?? '';
+    if (!option) {
+      return {
+        state: null,
+        error: `Question Q${current.number} has an empty option.`,
+      };
+    }
+
+    current.options.push(option);
+    if (optionMatch[2]) {
+      current.suggested = option;
+    }
+  }
+
+  finalizeQuestion(state, current, maxQuestions);
+
+  if (current && current.options.length === 0) {
     return {
       state: null,
-      error:
-        error instanceof Error
-          ? error.message
-          : 'Failed to parse interview state',
+      error: `Question Q${current.number} must include at least one option.`,
     };
   }
+
+  for (const question of state.questions) {
+    if (question.options.length === 0) {
+      return {
+        state: null,
+        error: `Question ${question.id} must include at least one option.`,
+      };
+    }
+  }
+
+  return { state };
 }
 
 export function findLatestAssistantState(
@@ -134,7 +161,7 @@ export function findLatestAssistantState(
       continue;
     }
 
-    const parsed = parseAssistantState(flattenMessage(message), maxQuestions);
+    const parsed = parsePlainTextQuestions(flattenMessage(message), maxQuestions);
     if (parsed.state) {
       return {
         state: parsed.state,
@@ -143,7 +170,8 @@ export function findLatestAssistantState(
     }
 
     if (!latestAssistantError) {
-      latestAssistantError = parsed.error ?? 'Missing <interview_state> block';
+      latestAssistantError =
+        parsed.error ?? 'Missing plain-text interview questions.';
     }
   }
 

+ 45 - 23
src/interview/prompts.ts

@@ -18,6 +18,33 @@ function formatQuestionContext(questions: InterviewQuestion[]): string {
     .join('\n\n');
 }
 
+export function formatInstructions(maxQuestions: number): string {
+  return [
+    'After any short human-friendly preface, you MUST include questions in this exact plain-text format:',
+    '',
+    'Q1: What is your first question?',
+    '- First option',
+    '- Second option *',
+    '- Third option',
+    '',
+    'Q2: What is your second question?',
+    '- Option A *',
+    '- Option B',
+    '- Option C',
+    '- Option D',
+    '',
+    'Format rules:',
+    '- Use "Q{n}: " prefix for each question (Q1:, Q2:, etc.)',
+    '- Use "- " prefix for each option',
+    '- Add " *" suffix to mark the suggested/recommended option',
+    '- Include 1 to 4 options per question',
+    '- Separate questions with an empty line',
+    '- No JSON, no XML, no code blocks',
+    `- Return 0 to ${maxQuestions} questions`,
+    '- If there are no more useful questions, return zero questions',
+  ].join('\n');
+}
+
 export function buildKickoffPrompt(idea: string, maxQuestions: number): string {
   return [
     'You are running an interview q&a session for the user inside their repository.',
@@ -25,26 +52,8 @@ export function buildKickoffPrompt(idea: string, maxQuestions: number): string {
     `Clarify the idea through short rounds of at most ${maxQuestions} questions at a time.`,
     'When useful, each question may include 2 to 4 answer options and one suggested option.',
     'Be practical. Focus on the highest-ambiguity and highest-risk decisions first.',
-    'After any short human-friendly preface, you MUST include a machine-readable block in this exact format:',
-    '<interview_state>',
-    '{',
-    '  "summary": "one short paragraph about the current understanding",',
-    '  "title": "concise-kebab-case-title-for-filename",',
-    '  "questions": [',
-    '    {',
-    '      "id": "short-kebab-id-2",',
-    '      "question": "question text",',
-    '      "options": ["option 1", "option 2", "option 3"],',
-    '      "suggested": "best suggested option"',
-    '    }',
-    '  ]',
-    '}',
-    '</interview_state>',
-    'Rules:',
-    `- Return 0 to ${maxQuestions} questions.`,
-    '- If there are no more useful questions, return zero questions.',
-    `- Do not ask more than ${maxQuestions} questions in one round.`,
-    '- Provide a concise "title" field (kebab-case, 3-6 words) suitable for a filename.',
+    '',
+    formatInstructions(maxQuestions),
   ].join('\n');
 }
 
@@ -54,14 +63,15 @@ export function buildResumePrompt(
 ): string {
   return [
     'Resume the interview from this existing markdown document.',
-    'Use the current spec and Q&A history as ground truth so far.',
+    'Use the current Q&A history as ground truth so far.',
     'Do not restart from scratch.',
     '',
     document,
     '',
     `Ask the next highest-value clarifying questions, up to ${maxQuestions} at a time.`,
     'If there are no more useful questions, return zero questions.',
-    'Return the same <interview_state> JSON block format as before.',
+    '',
+    formatInstructions(maxQuestions),
   ].join('\n');
 }
 
@@ -85,6 +95,18 @@ export function buildAnswerPrompt(
     answerText,
     'Now update your understanding and ask the next highest-value clarifying questions.',
     `Return 0 to ${maxQuestions} questions. If there are no more useful questions, return zero questions.`,
-    'Return the same <interview_state> JSON block format as before.',
+    '',
+    formatInstructions(maxQuestions),
   ].join('\n\n');
 }
+
+export function buildRetryPrompt(error: string, maxQuestions: number): string {
+  return [
+    'Your previous response could not be parsed correctly.',
+    `Error: ${error}`,
+    '',
+    'Please fix the format and try again.',
+    '',
+    formatInstructions(maxQuestions),
+  ].join('\n');
+}

+ 13 - 69
src/interview/service.ts

@@ -15,8 +15,6 @@ import {
   createInterviewFilePath,
   DEFAULT_OUTPUT_FOLDER,
   ensureInterviewFile,
-  extractSummarySection,
-  extractTitle,
   normalizeOutputFolder,
   readInterviewDocument,
   relativeInterviewPath,
@@ -29,6 +27,8 @@ import {
   buildAnswerPrompt,
   buildKickoffPrompt,
   buildResumePrompt,
+  buildRetryPrompt,
+  formatInstructions,
 } from './prompts';
 import type {
   InterviewAnswer,
@@ -202,49 +202,7 @@ export function createInterviewService(
     browserOpener(url);
   }
 
-  async function maybeRenameWithTitle(
-    interview: InterviewRecord,
-    assistantTitle: string | undefined,
-  ): Promise<void> {
-    if (!assistantTitle) {
-      return;
-    }
-    const newSlug = slugify(assistantTitle);
-    if (!newSlug) {
-      return;
-    }
 
-    const currentFileName = path.basename(interview.markdownPath, '.md');
-    // If already matches (or user-provided idea matches), skip
-    if (currentFileName === newSlug) {
-      return;
-    }
-
-    const dir = path.dirname(interview.markdownPath);
-    const newPath = path.join(dir, `${newSlug}.md`);
-
-    // Don't overwrite existing files
-    try {
-      await fs.access(newPath);
-      // File exists, don't rename
-      return;
-    } catch {
-      // File doesn't exist, safe to rename
-    }
-
-    try {
-      await fs.rename(interview.markdownPath, newPath);
-      interview.markdownPath = newPath;
-      log('[interview] renamed file with assistant title:', {
-        from: currentFileName,
-        to: newSlug,
-      });
-    } catch (error) {
-      log('[interview] failed to rename file:', {
-        error: error instanceof Error ? error.message : String(error),
-      });
-    }
-  }
 
   async function loadMessages(sessionID: string): Promise<InterviewMessage[]> {
     const result = await ctx.client.session.messages({
@@ -318,13 +276,11 @@ export function createInterviewService(
       }
     }
 
-    const document = await fs.readFile(markdownPath, 'utf8');
     const messages = await loadMessages(sessionID);
-    const title = extractTitle(document);
     const record: InterviewRecord = {
       id: `${Date.now()}-${++idCounter}-${slugify(path.basename(markdownPath, '.md')) || 'interview'}`,
       sessionID,
-      idea: title || path.basename(markdownPath, '.md'),
+      idea: path.basename(markdownPath, '.md'),
       markdownPath,
       createdAt: nowIso(),
       status: 'active',
@@ -349,17 +305,10 @@ export function createInterviewService(
       .slice(interview.baseMessageCount)
       .filter(isUserVisibleMessage);
     const parsed = findLatestAssistantState(interviewMessages, maxQuestions);
-    const existingDocument = await readInterviewDocument(interview);
     const fallbackState = buildFallbackState(interviewMessages);
-    const state = parsed.state ?? {
-      ...fallbackState,
-      summary: extractSummarySection(existingDocument) || fallbackState.summary,
-    };
-
-    // Rename file if assistant provided a title (and file hasn't been renamed yet)
-    await maybeRenameWithTitle(interview, state.title);
+    const state = parsed.state ?? fallbackState;
 
-    const document = await rewriteInterviewDocument(interview, state.summary);
+    const document = await rewriteInterviewDocument(interview, state.questions);
 
     const interviewState: InterviewState = {
       interview,
@@ -382,7 +331,6 @@ export function createInterviewService(
                   : 'awaiting-agent',
       lastParseError: parsed.latestAssistantError,
       isBusy: sessionBusy.get(interview.sessionID) === true,
-      summary: state.summary,
       questions: state.questions,
       document,
     };
@@ -694,16 +642,13 @@ export function createInterviewService(
         continue;
       }
 
-      const title = extractTitle(content) || entry.replace(/\.md$/, '');
-      const summary = extractSummarySection(content) || '';
       const baseName = entry.replace(/\.md$/, '');
 
       items.push({
         fileName: entry,
         resumeCommand: `/interview ${baseName}`,
-        title,
-        summary:
-          summary.length > 120 ? `${summary.slice(0, 120)}\u2026` : summary,
+        title: baseName,
+        summary: '',
       });
     }
 
@@ -743,17 +688,16 @@ export function createInterviewService(
           `Current spec summary: ${state.summary}`,
           ``,
           `Ask up to ${maxQuestions} new clarifying questions about aspects that are still unclear or underspecified.`,
-          `Include the structured <interview_state> block with new questions.`,
+          ``,
+          formatInstructions(maxQuestions),
         ].join('\n');
       } else {
         prompt = [
-          `The user confirmed the interview spec is complete.`,
-          ``,
-          `Current spec summary: ${state.summary}`,
+          `The user confirmed the interview is complete.`,
           ``,
-          `Produce a final, polished version of the full spec document.`,
-          `Do NOT include any <interview_state> block — just output the final spec as clean markdown.`,
-          `The spec should be comprehensive, well-structured, and ready for implementation.`,
+          `Produce a final, polished summary of the interview as clean markdown.`,
+          `Do NOT include any question blocks — just output the final summary.`,
+          `The summary should be comprehensive, well-structured, and ready for implementation.`,
         ].join('\n');
       }
 

+ 1 - 25
src/interview/types.ts

@@ -1,5 +1,3 @@
-import { z } from 'zod';
-
 export interface InterviewQuestion {
   id: string;
   question: string;
@@ -13,28 +11,9 @@ export interface InterviewAnswer {
 }
 
 export interface InterviewAssistantState {
-  summary: string;
-  title?: string;
   questions: InterviewQuestion[];
 }
 
-// ─── Zod Schemas (for validating untrusted LLM output) ─────────────
-
-/** Raw question object from LLM output — loose, everything optional. */
-export const RawQuestionSchema = z.object({
-  id: z.string().optional(),
-  question: z.string().optional(),
-  options: z.array(z.unknown()).optional(),
-  suggested: z.unknown().optional(),
-});
-
-/** Raw interview_state block from LLM output. */
-export const RawInterviewStateSchema = z.object({
-  summary: z.unknown().optional(),
-  title: z.unknown().optional(),
-  questions: z.array(z.unknown()).optional(),
-});
-
 // ─── Interfaces ─────────────────────────────────────────────────────
 
 export interface InterviewRecord {
@@ -71,7 +50,7 @@ export interface InterviewFileItem {
   fileName: string;
   resumeCommand: string;
   title: string;
-  summary: string;
+  summary?: string;
   sessionID?: string;
   directory?: string;
 }
@@ -89,7 +68,6 @@ export interface InterviewState {
     | 'session-disconnected';
   lastParseError?: string;
   isBusy: boolean;
-  summary: string;
   questions: InterviewQuestion[];
   document: string;
 }
@@ -106,8 +84,6 @@ export interface InterviewStateEntry {
     | 'completed'
     | 'error'
     | 'session-disconnected';
-  summary: string;
-  title: string;
   questions: Array<{
     id: string;
     question: string;