Browse Source

feat: implement interactive 11-section spec with block comment review and sub-agent instructions in interview

bobbyunknown 1 month ago
parent
commit
a7307b472b

+ 8 - 3
docs/interview.md

@@ -65,9 +65,10 @@ Example:
 interview/kanban-design-tool.md
 ```
 
-The file contains two sections:
+The file contains three sections:
 
-- `Current spec` — rewritten as the interview becomes clearer
+- `Frontmatter` — session meta data for recovery
+- `Current spec` — 11-section structured specification doc (Introduction, Purpose, Requirements, Data Contracts, Acceptance Criteria, etc.)
 - `Q&A history` — append-only question/answer record
 
 Example:
@@ -77,7 +78,11 @@ Example:
 
 ## Current spec
 
-A collaborative kanban tool for design teams with shared boards, comments, and web-first workflows.
+# Introduction
+...
+
+## 1. Purpose & Scope
+...
 
 ## Q&A history
 

+ 90 - 0
src/interview/dashboard.ts

@@ -181,6 +181,9 @@ export function createDashboardServer(config: DashboardConfig): {
   consumeNudgeAction: (
     interviewId: string,
   ) => 'more-questions' | 'confirm-complete' | null;
+  consumeBlockComment: (
+    interviewId: string,
+  ) => { section: string; comment: string } | null;
   authToken: string;
   discoverSessionDirectories: () => Promise<void>;
   addManualFolder: (dir: string) => void;
@@ -406,6 +409,7 @@ export function createDashboardServer(config: DashboardConfig): {
             : Date.now(),
           filePath: path.join(interviewDir, entry),
           nudgeAction: null,
+          pendingBlockComment: null,
         });
 
         // Also register the session directory
@@ -668,6 +672,7 @@ export function createDashboardServer(config: DashboardConfig): {
         lastUpdatedAt: Date.now(),
         filePath: '',
         nudgeAction: null,
+        pendingBlockComment: null,
       });
       dedupRecovered(interviewId, stateCache);
       fileCache = null;
@@ -727,6 +732,7 @@ export function createDashboardServer(config: DashboardConfig): {
           lastUpdatedAt: Date.now(),
           filePath: state.filePath ?? '',
           nudgeAction: null,
+          pendingBlockComment: state.pendingBlockComment ?? null,
         });
       }
 
@@ -868,6 +874,81 @@ export function createDashboardServer(config: DashboardConfig): {
       return;
     }
 
+    // ── API: submit block comment (browser → dashboard) ────────────
+    if (
+      request.method === 'POST' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/block-comment')
+    ) {
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/block-comment', '');
+      if (!isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+
+      let body: unknown;
+      try {
+        body = await readJsonBody(request);
+      } catch {
+        sendJson(response, 400, { error: 'Invalid JSON' });
+        return;
+      }
+
+      const { section, comment } = body as {
+        section?: string;
+        comment?: string;
+      };
+      if (typeof section !== 'string' || typeof comment !== 'string') {
+        sendJson(response, 400, {
+          error: 'section and comment must be strings',
+        });
+        return;
+      }
+
+      entry.pendingBlockComment = { section, comment };
+      entry.mode = 'awaiting-agent';
+      entry.lastUpdatedAt = Date.now();
+      sendJson(response, 200, { status: 'ok' });
+      return;
+    }
+
+    // ── API: get pending block comment (session polls, auth required) ─
+    if (
+      request.method === 'GET' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/block-comment')
+    ) {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/block-comment', '');
+      if (!isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+      const val = entry.pendingBlockComment;
+      if (val) {
+        entry.pendingBlockComment = null;
+      }
+      sendJson(response, 200, val || {});
+      return;
+    }
+
     // ── API: get pending answers (session polls, auth required) ────
     if (
       request.method === 'GET' &&
@@ -1084,6 +1165,8 @@ export function createDashboardServer(config: DashboardConfig): {
         if (existing.pendingAnswers)
           entry.pendingAnswers ??= existing.pendingAnswers;
         if (existing.nudgeAction) entry.nudgeAction ??= existing.nudgeAction;
+        if (existing.pendingBlockComment)
+          entry.pendingBlockComment ??= existing.pendingBlockComment;
       }
       stateCache.set(entry.interviewId, entry);
       dedupRecovered(entry.interviewId, stateCache);
@@ -1112,6 +1195,13 @@ export function createDashboardServer(config: DashboardConfig): {
       entry.nudgeAction = null;
       return action;
     },
+    consumeBlockComment: (id: string) => {
+      const entry = stateCache.get(id);
+      if (!entry?.pendingBlockComment) return null;
+      const comment = entry.pendingBlockComment;
+      entry.pendingBlockComment = null;
+      return comment;
+    },
     authToken,
     discoverSessionDirectories,
     addManualFolder: (dir: string) => {

+ 53 - 0
src/interview/document.ts

@@ -5,6 +5,7 @@ import type {
   InterviewAnswer,
   InterviewQuestion,
   InterviewRecord,
+  SpecBlock,
 } from './types';
 
 // ─── Path Utilities ──────────────────────────────────────────────────
@@ -254,3 +255,55 @@ export async function appendInterviewAnswers(
     'utf8',
   );
 }
+
+export function parseSpecBlocks(markdown: string): SpecBlock[] {
+  const blocks: SpecBlock[] = [];
+  const lines = markdown.split('\n');
+
+  let currentBlockId: string | null = null;
+  let currentBlockTitle: string | null = null;
+  let currentBlockLines: string[] = [];
+
+  const flush = () => {
+    if (currentBlockId) {
+      blocks.push({
+        id: currentBlockId,
+        title: currentBlockTitle || currentBlockId,
+        content: currentBlockLines.join('\n').trim(),
+      });
+    }
+  };
+
+  for (const line of lines) {
+    const headerMatch = line.match(/^##\s+(\d+)\.\s+(.+)$/);
+    if (headerMatch) {
+      flush();
+      const num = headerMatch[1];
+      const name = headerMatch[2].trim();
+      currentBlockId = `section-${num}`;
+      currentBlockTitle = `${num}. ${name}`;
+      currentBlockLines = [];
+    } else if (line.startsWith('# ') && !line.startsWith('## ')) {
+      // Intro section before ## 1.
+      if (currentBlockId === null) {
+        currentBlockId = 'section-0';
+        currentBlockTitle = 'Introduction';
+        currentBlockLines = [];
+      }
+    } else if (line.startsWith('## ') && !headerMatch) {
+      // Any other H2
+      flush();
+      const name = line.replace(/^##\s+/, '').trim();
+      currentBlockId = `section-${slugify(name)}`;
+      currentBlockTitle = name;
+      currentBlockLines = [];
+    }
+
+    if (currentBlockId !== null) {
+      currentBlockLines.push(line);
+    }
+  }
+
+  flush();
+  return blocks;
+}

+ 11 - 0
src/interview/manager.test.ts

@@ -639,6 +639,17 @@ describe('interview manager - integration with real dashboard', () => {
       );
       expect(state1Response.status).toBe(200);
 
+      // Trigger active event poll to register session2/interviewId2 explicitly in dashboard
+      await manager2.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            sessionID: 'session-2',
+            status: { type: 'idle' },
+          },
+        },
+      });
+
       const state2Response = await fetch(
         `http://127.0.0.1:${freePort}/api/interviews/${interviewId2}/state?token=${auth?.token}`,
       );

+ 152 - 127
src/interview/manager.ts

@@ -59,6 +59,8 @@ export function createInterviewManager(
       listInterviews: () => service.listInterviews(),
       submitAnswers: async (interviewId, answers) =>
         service.submitAnswers(interviewId, answers),
+      submitBlockComment: async (interviewId, section, comment) =>
+        service.submitBlockComment(interviewId, section, comment),
       handleNudgeAction: async (interviewId, action) =>
         service.handleNudgeAction(interviewId, action),
       outputFolder: resolvedOutputPath,
@@ -102,6 +104,7 @@ export function createInterviewManager(
         if (!interviewId) continue;
         pollPendingAnswers(sessionID).catch(() => {});
         pollNudgeAction(sessionID).catch(() => {});
+        pollBlockComment(sessionID).catch(() => {});
       }
     }, FALLBACK_POLL_INTERVAL);
     fallbackTimer?.unref();
@@ -142,6 +145,7 @@ export function createInterviewManager(
             lastUpdatedAt: Date.now(),
             filePath: interview.markdownPath,
             nudgeAction: null,
+            pendingBlockComment: null,
           });
           // Register session directory for file scanning
           dashboard?.registerSession({
@@ -177,167 +181,167 @@ export function createInterviewManager(
           const retry = await probeDashboard(dashboardPort);
           if (!retry.alive) {
             log(
-              '[interview] dashboard mode: no dashboard found, falling back to per-session server',
-              {
-                port: dashboardPort,
-              },
+              '[interview] dashboard probe failed twice, falling back to local server',
             );
-            // Fall back to per-session mode — start our own server
-            const perSessionServer = createInterviewServer({
-              getState: async (interviewId) =>
-                service.getInterviewState(interviewId),
-              listInterviewFiles: async () => service.listInterviewFiles(),
-              listInterviews: () => service.listInterviews(),
-              submitAnswers: async (interviewId, answers) =>
-                service.submitAnswers(interviewId, answers),
-              handleNudgeAction: async (interviewId, action) =>
-                service.handleNudgeAction(interviewId, action),
-              outputFolder: path.join(ctx.directory, outputFolder),
-              port: 0, // random port
-            });
-            service.setBaseUrlResolver(() => perSessionServer.ensureStarted());
-            isDashboard = false;
-            initDone = true;
-            return;
+            throw new Error('Dashboard not reachable');
           }
         }
 
+        const creds = await readDashboardAuthFile(dashboardPort);
+        if (!creds) {
+          throw new Error('Dashboard credentials file missing');
+        }
+
         dashboardBaseUrl = `http://127.0.0.1:${dashboardPort}`;
-        const auth = await readDashboardAuthFile(dashboardPort);
-        authToken = auth?.token ?? '';
+        authToken = creds.token;
 
         service.setBaseUrlResolver(() => Promise.resolve(dashboardBaseUrl));
 
-        // State push: HTTP to dashboard
+        // State push: across HTTP to the dashboard process
         service.setStatePushCallback((id, state) => {
-          pushStateViaHttp(dashboardBaseUrl, authToken, id, state).catch(
-            (err) => {
-              log('[interview] failed to push state to dashboard', {
-                error: err instanceof Error ? err.message : String(err),
-              });
-            },
-          );
+          if (dashboardBaseUrl && authToken) {
+            pushStateViaHttp(dashboardBaseUrl, authToken, id, state).catch(
+              (err) => {
+                log('[interview] failed to push state to dashboard:', {
+                  error: err instanceof Error ? err.message : String(err),
+                });
+              },
+            );
+          }
         });
 
         // Interview created: POST to dashboard so it appears immediately
         service.setOnInterviewCreated((interview) => {
-          registerInterviewViaHttp(
-            dashboardBaseUrl,
-            authToken,
-            interview,
-          ).catch((err) => {
-            log('[interview] failed to register interview with dashboard', {
-              error: err instanceof Error ? err.message : String(err),
+          if (dashboardBaseUrl && authToken) {
+            registerInterviewViaHttp(
+              dashboardBaseUrl,
+              authToken,
+              interview,
+            ).catch((err) => {
+              log('[interview] failed to register interview with dashboard:', {
+                error: err instanceof Error ? err.message : String(err),
+              });
             });
-          });
+          }
         });
 
-        log('[interview] dashboard mode: we are a session', {
-          port: dashboardPort,
+        log('[interview] dashboard mode: registered as session client', {
+          dashboardUrl: dashboardBaseUrl,
         });
-
-        // Start fallback poll timer now that we know we're in session mode
-        startFallbackTimer();
       }
     } catch (err) {
-      log('[interview] dashboard mode init failed', {
-        error: err instanceof Error ? err.message : String(err),
+      log(
+        '[interview] dashboard election failed or unreachable. Falling back to per-session server.',
+        { error: err instanceof Error ? err.message : String(err) },
+      );
+      // Fallback: spawn a per-session server exactly like non-dashboard mode
+      isDashboard = false;
+      const resolvedOutputPath = path.join(ctx.directory, outputFolder);
+      const server = createInterviewServer({
+        getState: async (interviewId) => service.getInterviewState(interviewId),
+        listInterviewFiles: async () => service.listInterviewFiles(),
+        listInterviews: () => service.listInterviews(),
+        submitAnswers: async (interviewId, answers) =>
+          service.submitAnswers(interviewId, answers),
+        submitBlockComment: async (interviewId, section, comment) =>
+          service.submitBlockComment(interviewId, section, comment),
+        handleNudgeAction: async (interviewId, action) =>
+          service.handleNudgeAction(interviewId, action),
+        outputFolder: resolvedOutputPath,
+        port: 0,
       });
+      service.setBaseUrlResolver(() => server.ensureStarted());
+      service.setStatePushCallback(() => {}); // no-op on fallback
     } finally {
       initDone = true;
     }
   })();
 
-  async function ensureInit(): Promise<void> {
-    if (!initDone) await initPromise;
+  async function ensureInitialized() {
+    if (!initDone) {
+      await initPromise;
+    }
   }
 
-  // ── Lazy session registration ──────────────────────────────────
-  // Register with dashboard on first hook call that includes a
-  // session ID. Dashboard needs our directory for file scanning.
-  async function registerSessionIfNeeded(sessionID: string): Promise<void> {
-    if (registeredSessions.has(sessionID)) return;
-    registeredSessions.add(sessionID);
-    if (isDashboard) return;
+  // ── Client Poll Implementations (polls dashboard server) ──────────
+  async function pollPendingAnswers(sessionID: string) {
+    const interviewId = service.getActiveInterviewId(sessionID);
+    if (!interviewId) return;
 
     try {
-      await fetch(`${dashboardBaseUrl}/api/register?token=${authToken}`, {
-        method: 'POST',
-        headers: { 'content-type': 'application/json' },
-        body: JSON.stringify({
-          sessionID,
-          directory: ctx.directory,
-          pid: process.pid,
-        }),
-        signal: AbortSignal.timeout(3000),
-      });
+      const res = await fetch(
+        `${dashboardBaseUrl}/api/interviews/${interviewId}/pending?token=${authToken}`,
+        { signal: AbortSignal.timeout(3000) },
+      );
+      const body = (await res.json()) as {
+        answers?: Array<{ questionId: string; answer: string }> | null;
+      };
+      if (res.ok && body.answers && body.answers.length > 0) {
+        log('[interview] delivering pending answers (HTTP poll)', {
+          interviewId,
+          count: body.answers.length,
+        });
+        await service.submitAnswers(interviewId, body.answers);
+      }
     } catch (err) {
-      log('[interview] failed to register session with dashboard', {
+      log('[interview] failed polling pending answers:', {
         error: err instanceof Error ? err.message : String(err),
       });
     }
   }
 
-  // ── Answer polling ─────────────────────────────────────────────
-  // When LLM finishes responding (session goes idle), check if the
-  // user submitted answers via the dashboard UI while we were busy.
-  async function pollPendingAnswers(sessionID: string): Promise<void> {
+  async function pollNudgeAction(sessionID: string) {
     const interviewId = service.getActiveInterviewId(sessionID);
     if (!interviewId) return;
 
     try {
-      const response = await fetch(
-        `${dashboardBaseUrl}/api/interviews/${interviewId}/pending?token=${authToken}`,
+      const res = await fetch(
+        `${dashboardBaseUrl}/api/interviews/${interviewId}/nudge?token=${authToken}`,
         { signal: AbortSignal.timeout(3000) },
       );
-      if (!response.ok) return;
-
-      const data = (await response.json()) as {
-        answers: Array<{ questionId: string; answer: string }> | null;
+      const body = (await res.json()) as {
+        action?: 'more-questions' | 'confirm-complete' | null;
       };
-      if (!data.answers || data.answers.length === 0) return;
-
-      log('[interview] delivering pending answers from dashboard', {
-        interviewId,
-        count: data.answers.length,
-      });
-
-      // submitAnswers reads answers, injects prompt locally, and
-      // the resulting state push updates the dashboard cache
-      await service.submitAnswers(interviewId, data.answers);
+      if (res.ok && body.action) {
+        log('[interview] delivering nudge action (HTTP poll)', {
+          interviewId,
+          action: body.action,
+        });
+        await service.handleNudgeAction(interviewId, body.action);
+      }
     } catch (err) {
-      log('[interview] failed to poll pending answers', {
+      log('[interview] failed polling nudge action:', {
         error: err instanceof Error ? err.message : String(err),
       });
     }
   }
 
-  // ── Nudge polling ──────────────────────────────────────────────
-  // Check if the user nudged the agent from the dashboard UI.
-  async function pollNudgeAction(sessionID: string): Promise<void> {
+  async function pollBlockComment(sessionID: string) {
     const interviewId = service.getActiveInterviewId(sessionID);
     if (!interviewId) return;
 
     try {
-      const response = await fetch(
-        `${dashboardBaseUrl}/api/interviews/${interviewId}/nudge?token=${authToken}`,
+      const res = await fetch(
+        `${dashboardBaseUrl}/api/interviews/${interviewId}/block-comment?token=${authToken}`,
         { signal: AbortSignal.timeout(3000) },
       );
-      if (!response.ok) return;
-
-      const data = (await response.json()) as {
-        action: 'more-questions' | 'confirm-complete' | null;
+      const body = (await res.json()) as {
+        section?: string;
+        comment?: string;
       };
-      if (!data.action) return;
-
-      log('[interview] delivering nudge action from dashboard', {
-        interviewId,
-        action: data.action,
-      });
-
-      await service.handleNudgeAction(interviewId, data.action);
+      if (res.ok && body.section && body.comment) {
+        log('[interview] delivering block comment (HTTP poll)', {
+          interviewId,
+          section: body.section,
+        });
+        await service.submitBlockComment(
+          interviewId,
+          body.section,
+          body.comment,
+        );
+      }
     } catch (err) {
-      log('[interview] failed to poll nudge action', {
+      log('[interview] failed polling block comment:', {
         error: err instanceof Error ? err.message : String(err),
       });
     }
@@ -345,38 +349,45 @@ export function createInterviewManager(
 
   return {
     registerCommand: (c) => service.registerCommand(c),
-
     handleCommandExecuteBefore: async (input, output) => {
-      await ensureInit();
-      await service.handleCommandExecuteBefore(input, output);
-      if (input.sessionID) {
-        await registerSessionIfNeeded(input.sessionID);
+      await ensureInitialized();
+
+      // Register session in our registry so dashboard/fallback timers track it.
+      // Send a session-connect HTTP ping to the dashboard if we're a client.
+      const sessionID = input.sessionID;
+      registeredSessions.add(sessionID);
+
+      if (!isDashboard && dashboardBaseUrl) {
+        fetch(`${dashboardBaseUrl}/api/sessions?token=${authToken}`, {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({
+            sessionID,
+            directory: ctx.directory,
+            pid: process.pid,
+          }),
+          signal: AbortSignal.timeout(3000),
+        }).catch(() => {});
+        startFallbackTimer();
       }
-    },
 
+      await service.handleCommandExecuteBefore(input, output);
+    },
     handleEvent: async (input) => {
-      await ensureInit();
-      await service.handleEvent(input);
-
+      await ensureInitialized();
       const { event } = input;
       const properties = event.properties ?? {};
-      const sessionID = properties.sessionID as string | undefined;
+      const sessionID = (properties.sessionID as string | undefined) ?? null;
 
-      // Register session on first sighting
-      if (sessionID) {
-        await registerSessionIfNeeded(sessionID);
-      }
+      await service.handleEvent(input);
 
-      // When LLM finishes responding, push updated state + poll for pending answers
-      if (event.type === 'session.status' && sessionID) {
+      // Event hook: Session is idle. Check for any pending user submissions
+      // queued on the dashboard and deliver them to OpenCode.
+      if (event.type === 'session.status') {
         const status = properties.status as { type?: string } | undefined;
-        if (status?.type === 'idle') {
+        if (sessionID && status?.type === 'idle') {
           const interviewId = service.getActiveInterviewId(sessionID);
-
-          // Process pending nudges/answers BEFORE refreshing state.
-          // handleNudgeAction sets sessionBusy=true, so the state refresh
-          // below correctly pushes 'awaiting-agent' instead of 'completed'.
-          if (!isDashboard) {
+          if (!isDashboard && dashboardBaseUrl) {
             // Session mode: HTTP poll the dashboard
             await pollPendingAnswers(sessionID);
             await pollNudgeAction(sessionID);
@@ -444,6 +455,7 @@ function stateToEntry(
     lastUpdatedAt: Date.now(),
     filePath: state.interview.markdownPath,
     nudgeAction: null,
+    pendingBlockComment: null,
   };
 }
 
@@ -477,7 +489,20 @@ async function registerInterviewViaHttp(
       interviewId: interview.id,
       sessionID: interview.sessionID,
       idea: interview.idea,
+      mode: 'awaiting-agent',
+      summary: 'Interview created.',
+      title: interview.idea,
+      questions: [],
+      pendingAnswers: null,
+      lastUpdatedAt: Date.now(),
+      filePath: interview.markdownPath,
+      nudgeAction: null,
+      pendingBlockComment: null,
     }),
     signal: AbortSignal.timeout(3000),
+  }).catch((err) => {
+    log('[interview] failed to register interview with dashboard via HTTP:', {
+      error: err instanceof Error ? err.message : String(err),
+    });
   });
 }

+ 58 - 5
src/interview/prompts.ts

@@ -18,17 +18,68 @@ function formatQuestionContext(questions: InterviewQuestion[]): string {
     .join('\n\n');
 }
 
+const SPECIFICATION_TEMPLATE_GUIDELINE = `
+Your target document MUST be structured strictly using the following 11-section template (exclude the frontmatter itself from the summary JSON field, as the tool handles frontmatter automatically):
+
+# Introduction
+[Short intro to the spec and goals]
+
+## 1. Purpose & Scope
+[Intended audience, boundaries, and assumptions]
+
+## 2. Definitions
+[Acronyms, terms defined]
+
+## 3. Requirements, Constraints & Guidelines
+[Explicitly list requirements using:
+- **REQ-001**: Description
+- **SEC-001**: Security constraints
+- **CON-001**: System constraints/technologies
+- **GUD-001**: Guidelines]
+
+## 4. Interfaces & Data Contracts
+[APIs, JSON schemas, protocol buffers, or class/data structures]
+
+## 5. Acceptance Criteria
+[Define testable criteria in Given-When-Then format:
+- **AC-001**: Given [context], When [action], Then [expected outcome]]
+
+## 6. Test Automation Strategy
+[Mocking approach, test framework, unit/integration details]
+
+## 7. Rationale & Context
+[Trade-offs and architectural decisions]
+
+## 8. Dependencies & External Integrations
+[Conceptual integrations or external dependencies:
+- **EXT-001**: Dependency details]
+
+## 9. Examples & Edge Cases
+[Concrete code examples, settings, or JSON structures]
+
+## 10. Validation Criteria
+[Testing or validation check logic]
+
+## 11. Related Specifications / Further Reading
+[Internal doc references]
+
+ANTI-ASSUMPTION & SUB-AGENT DELEGATION RULE:
+Do not invent file structures, API signatures, package lists, or library behaviors. If you need to trace local code, verify file paths, or check configurations, you MUST call the sub-agent '@explorer' or search files directly. If you need to search documentation or web info for external APIs/libraries, you MUST call the sub-agent '@librarian' or search the web. Do not guess. Pause, run discovery, and integrate facts into the spec.
+`;
+
 export function buildKickoffPrompt(idea: string, maxQuestions: number): string {
   return [
     'You are running an interview q&a session for the user inside their repository.',
     `Initial idea: ${idea}`,
+    `Goal: Iteratively generate and populate a highly structured Specification document.`,
+    SPECIFICATION_TEMPLATE_GUIDELINE,
     `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",',
+    '  "summary": "Full specification markdown (strictly matching the 11 section titles above)",',
     '  "title": "concise-kebab-case-title-for-filename",',
     '  "questions": [',
     '    {',
@@ -42,7 +93,7 @@ export function buildKickoffPrompt(idea: string, maxQuestions: number): string {
     '</interview_state>',
     'Rules:',
     `- Return 0 to ${maxQuestions} questions.`,
-    '- If there are no more useful questions, return zero questions.',
+    '- If there are no more useful questions or the specification is complete, 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.',
   ].join('\n');
@@ -55,12 +106,13 @@ export function buildResumePrompt(
   return [
     'Resume the interview from this existing markdown document.',
     'Use the current spec and Q&A history as ground truth so far.',
+    SPECIFICATION_TEMPLATE_GUIDELINE,
     '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.',
+    'If there are no more useful questions or the spec is complete, return zero questions.',
     'Return the same <interview_state> JSON block format as before.',
   ].join('\n');
 }
@@ -79,12 +131,13 @@ export function buildAnswerPrompt(
 
   return [
     'Continue the same interview.',
+    SPECIFICATION_TEMPLATE_GUIDELINE,
     'These were the active questions:',
     formatQuestionContext(questions),
     'The user answered:',
     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.`,
+    'Now update the specification summary document and ask the next highest-value clarifying questions.',
+    `Return 0 to ${maxQuestions} questions. If there are no more useful questions or the spec is complete, return zero questions.`,
     'Return the same <interview_state> JSON block format as before.',
   ].join('\n\n');
 }

+ 43 - 0
src/interview/server.ts

@@ -78,6 +78,11 @@ export function createInterviewServer(deps: {
     interviewId: string,
     answers: InterviewAnswer[],
   ) => Promise<void>;
+  submitBlockComment: (
+    interviewId: string,
+    section: string,
+    comment: string,
+  ) => Promise<void>;
   handleNudgeAction: (
     interviewId: string,
     action: 'more-questions' | 'confirm-complete',
@@ -195,6 +200,44 @@ export function createInterviewServer(deps: {
       return;
     }
 
+    const blockCommentMatch = pathname.match(
+      /^\/api\/interviews\/([^/]+)\/block-comment$/,
+    );
+    if (request.method === 'POST' && blockCommentMatch) {
+      try {
+        const body = (await readJsonBody(request)) as {
+          section?: string;
+          comment?: string;
+        };
+        if (
+          typeof body.section !== 'string' ||
+          typeof body.comment !== 'string'
+        ) {
+          sendJson(response, 400, {
+            error: 'section and comment must be strings',
+          });
+          return;
+        }
+        await deps.submitBlockComment(
+          decodeURIComponent(blockCommentMatch[1]),
+          body.section,
+          body.comment,
+        );
+        sendJson(response, 200, {
+          ok: true,
+          message: 'Block feedback forwarded.',
+        });
+      } catch (error) {
+        const message =
+          error instanceof Error
+            ? error.message
+            : 'Failed to submit block comment.';
+        const status = getSubmissionStatus(error);
+        sendJson(response, status, { ok: false, message });
+      }
+      return;
+    }
+
     // Nudge: ask more questions or confirm complete
     const nudgeMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/nudge$/);
     if (request.method === 'POST' && nudgeMatch) {

+ 62 - 0
src/interview/service.ts

@@ -18,6 +18,7 @@ import {
   extractSummarySection,
   extractTitle,
   normalizeOutputFolder,
+  parseSpecBlocks,
   readInterviewDocument,
   relativeInterviewPath,
   resolveExistingInterviewPath,
@@ -138,6 +139,11 @@ export function createInterviewService(
     interviewId: string,
     answers: InterviewAnswer[],
   ) => Promise<void>;
+  submitBlockComment: (
+    interviewId: string,
+    section: string,
+    comment: string,
+  ) => Promise<void>;
   handleNudgeAction: (
     interviewId: string,
     action: 'more-questions' | 'confirm-complete',
@@ -360,6 +366,7 @@ export function createInterviewService(
     await maybeRenameWithTitle(interview, state.title);
 
     const document = await rewriteInterviewDocument(interview, state.summary);
+    const blocks = parseSpecBlocks(document);
 
     const interviewState: InterviewState = {
       interview,
@@ -385,6 +392,7 @@ export function createInterviewService(
       summary: state.summary,
       questions: state.questions,
       document,
+      blocks,
     };
 
     // Push state to dashboard if callback is set (dashboard mode)
@@ -710,6 +718,59 @@ export function createInterviewService(
     return sorted;
   }
 
+  async function submitBlockComment(
+    interviewId: string,
+    sectionTitle: string,
+    comment: string,
+  ): Promise<void> {
+    const interview = getInterviewById(interviewId);
+    if (!interview) {
+      throw new Error('Interview not found');
+    }
+    if (interview.status === 'abandoned') {
+      throw new Error('Interview session is no longer active.');
+    }
+    if (sessionBusy.get(interview.sessionID) === true) {
+      throw new Error(
+        'Interview session is busy. Wait for the current response.',
+      );
+    }
+
+    sessionBusy.set(interview.sessionID, true);
+    let promptSent = false;
+
+    try {
+      const state = await getInterviewState(interviewId);
+      if (state.mode === 'error') {
+        throw new Error('Interview is waiting for a valid agent update.');
+      }
+
+      const prompt = [
+        `The user submitted specific feedback/comments for the section "${sectionTitle}".`,
+        `Feedback: ${comment}`,
+        ``,
+        `Update the specification summary (focusing heavily on making changes to the "${sectionTitle}" section) to address this feedback.`,
+        `If this feedback implies other parts of the spec should change, update them too.`,
+        `Include the updated 11-section specification and ask the next highest-value clarifying questions as questions (up to ${maxQuestions} questions) if needed.`,
+        `Return the same <interview_state> JSON block format as before.`,
+      ].join('\n');
+
+      const model = sessionModel.get(interview.sessionID);
+      await ctx.client.session.promptAsync({
+        path: { id: interview.sessionID },
+        body: {
+          parts: [createInternalAgentTextPart(prompt)],
+          ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
+        },
+      });
+      promptSent = true;
+    } finally {
+      if (!promptSent) {
+        sessionBusy.set(interview.sessionID, false);
+      }
+    }
+  }
+
   async function handleNudgeAction(
     interviewId: string,
     action: 'more-questions' | 'confirm-complete',
@@ -783,6 +844,7 @@ export function createInterviewService(
     listInterviewFiles,
     listInterviews,
     submitAnswers,
+    submitBlockComment,
     handleNudgeAction,
   };
 }

+ 11 - 0
src/interview/types.ts

@@ -76,6 +76,12 @@ export interface InterviewFileItem {
   directory?: string;
 }
 
+export interface SpecBlock {
+  id: string;
+  title: string;
+  content: string;
+}
+
 export interface InterviewState {
   interview: InterviewRecord;
   url: string;
@@ -92,6 +98,7 @@ export interface InterviewState {
   summary: string;
   questions: InterviewQuestion[];
   document: string;
+  blocks: SpecBlock[];
 }
 
 /** Wire format for dashboard state cache entries. */
@@ -121,4 +128,8 @@ export interface InterviewStateEntry {
   lastUpdatedAt: number;
   filePath: string;
   nudgeAction: 'more-questions' | 'confirm-complete' | null;
+  pendingBlockComment: {
+    section: string;
+    comment: string;
+  } | null;
 }

+ 141 - 8
src/interview/ui.ts

@@ -515,6 +515,65 @@ export function renderInterviewPage(
     <style>
       ${sharedStyles()}
       .brand-mark { width: 144px; height: 144px; }
+      .spec-block-card {
+        background: rgba(255,255,255,0.01);
+        border: 1px solid rgba(255,255,255,0.05);
+        border-radius: 8px;
+        padding: 24px;
+        margin-bottom: 20px;
+        text-align: left;
+        transition: all 0.2s ease;
+      }
+      .spec-block-card:hover {
+        border-color: rgba(255,255,255,0.15);
+        background: rgba(255,255,255,0.02);
+      }
+      .spec-block-card h3 {
+        margin-top: 0;
+        font-size: 18px;
+        color: #ffffff;
+        border-bottom: 1px solid rgba(255,255,255,0.08);
+        padding-bottom: 8px;
+        margin-bottom: 14px;
+      }
+      .spec-block-content {
+        font-size: 15px;
+        line-height: 1.6;
+        color: rgba(255,255,255,0.8);
+      }
+      .spec-block-comment-box {
+        margin-top: 16px;
+        padding-top: 16px;
+        border-top: 1px dashed rgba(255,255,255,0.08);
+        display: flex;
+        flex-direction: column;
+        gap: 10px;
+      }
+      .spec-block-comment-input {
+        min-height: 60px !important;
+        font-size: 14px !important;
+        padding: 10px !important;
+      }
+      .comment-submit-btn {
+        align-self: flex-end;
+        background: transparent;
+        border: 1px solid rgba(255,255,255,0.2);
+        color: rgba(255,255,255,0.8);
+        padding: 8px 16px;
+        border-radius: 6px;
+        font-size: 13px;
+        cursor: pointer;
+        transition: all 0.2s ease;
+      }
+      .comment-submit-btn:hover:not(:disabled) {
+        border-color: #34d399;
+        color: #34d399;
+        background: rgba(52,211,153,0.05);
+      }
+      .comment-submit-btn:disabled {
+        opacity: 0.3;
+        cursor: not-allowed;
+      }
       h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 12px; line-height: 1.2; }
       h2 { font-size: 18px; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(255,255,255,0.4); margin-bottom: 24px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; }
       h3 { font-size: 18px; font-weight: 500; margin-bottom: 16px; line-height: 1.4; }
@@ -1292,21 +1351,94 @@ export function renderInterviewPage(
         return paragraphs.map(p => \`<p>\${p.replace(/\\n/g, '<br>')}</p>\`).join('');
       }
 
+      async function submitBlockComment(sectionTitle, commentText, button) {
+        if (!commentText.trim()) return;
+        button.disabled = true;
+        const submitStatus = document.getElementById('submitStatus');
+        submitStatus.textContent = '';
+        
+        try {
+          const res = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/block-comment', {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ section: sectionTitle, comment: commentText }),
+          });
+          const payload = await res.json();
+          if (res.ok) {
+            submitStatus.textContent = 'Feedback queued for block: ' + sectionTitle;
+            const input = button.previousElementSibling;
+            if (input) input.value = '';
+            refresh().catch(() => {});
+          } else {
+            submitStatus.textContent = payload.error || 'Failed to submit comment.';
+          }
+        } catch (err) {
+          submitStatus.textContent = 'Error submitting block connection feedback.';
+        } finally {
+          button.disabled = false;
+        }
+      }
+
       function renderCompletedView(data) {
         const container = document.getElementById('questions');
-        const { spec, qaPairs } = parseDocument(data.document);
         const frag = document.createDocumentFragment();
 
-        // Spec section
-        if (spec) {
+        const blocks = data.blocks || [];
+        if (blocks.length > 0) {
           const specLabel = document.createElement('div');
           specLabel.className = 'section-label';
-          specLabel.textContent = 'Current Spec';
+          specLabel.textContent = 'Interactive Specifications (11 Sections)';
           frag.appendChild(specLabel);
-          const specBlock = document.createElement('div');
-          specBlock.className = 'spec-block';
-          specBlock.innerHTML = simpleMarkdown(spec);
-          frag.appendChild(specBlock);
+
+          for (const block of blocks) {
+            const card = document.createElement('div');
+            card.className = 'spec-block-card';
+
+            const header = document.createElement('h3');
+            header.textContent = block.title;
+            card.appendChild(header);
+
+            const content = document.createElement('div');
+            content.className = 'spec-block-content';
+            content.innerHTML = simpleMarkdown(block.content);
+            card.appendChild(content);
+
+            // Per-block comment integration for micro annotations
+            const commentBox = document.createElement('div');
+            commentBox.className = 'spec-block-comment-box';
+
+            const textarea = document.createElement('textarea');
+            textarea.className = 'spec-block-comment-input';
+            textarea.placeholder = 'Provide review comment/revision for ' + block.title + '...';
+            commentBox.appendChild(textarea);
+
+            const sendBtn = document.createElement('button');
+            sendBtn.className = 'comment-submit-btn';
+            sendBtn.type = 'button';
+            sendBtn.textContent = 'Revise Section';
+            sendBtn.addEventListener('click', () => {
+              submitBlockComment(block.title, textarea.value, sendBtn);
+            });
+            commentBox.appendChild(sendBtn);
+
+            card.appendChild(commentBox);
+            frag.appendChild(card);
+          }
+        } else {
+          // Fallback legacy parse if blocks array isn't populated
+          const { spec, qaPairs } = parseDocument(data.document);
+
+          // Spec section
+          if (spec) {
+            const specLabel = document.createElement('div');
+            specLabel.className = 'section-label';
+            specLabel.textContent = 'Current Spec';
+            frag.appendChild(specLabel);
+            const specBlock = document.createElement('div');
+            specBlock.className = 'spec-block';
+            specBlock.innerHTML = simpleMarkdown(spec);
+            frag.appendChild(specBlock);
+          }
         }
 
         // Q&A section
@@ -1315,6 +1447,7 @@ export function renderInterviewPage(
         qaLabel.textContent = 'Q&A History';
         frag.appendChild(qaLabel);
 
+        const { qaPairs } = parseDocument(data.document);
         if (!qaPairs.length) {
           const empty = document.createElement('p');
           empty.className = 'qa-empty';