From 85f5d1e358bd940113f6ee9e0a1e9a3d3429f495 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 3 Nov 2026 20:09:13 -0800 Subject: [PATCH 01/11] fix(web): show questions written as JSON in a reply as a questions card --- .../src/pages/workspace/questions-card.tsx | 12 ++++ .../src/pages/workspace/transcript.test.tsx | 62 +++++++++++++++++++ apps/web/src/pages/workspace/transcript.tsx | 12 ++++ apps/web/src/pages/workspace/visible-chat.ts | 22 ++++++- 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/workspace/questions-card.tsx b/apps/web/src/pages/workspace/questions-card.tsx index 467fb598..95ea4e98 100644 --- a/apps/web/src/pages/workspace/questions-card.tsx +++ b/apps/web/src/pages/workspace/questions-card.tsx @@ -21,6 +21,7 @@ import { } from "@corbits-builder/schemas/questions"; import { wording } from "../../config.ts"; import type { CallResult } from "./live-run.ts"; +import { jsonValues } from "./visible-chat.ts"; import "./questions-card.css"; const text = wording.questions; @@ -39,6 +40,17 @@ export function askedQuestions( return askQuestionsIssue(part.input) === undefined ? part.input : undefined; } +/** + * Questions the coordinator wrote into its reply as ask_questions JSON instead of calling the tool. + * The transcript drops JSON from the reply, so without a card the questions would not show at all. + */ +export function writtenQuestions(text: string): AskQuestions | undefined { + return jsonValues(text).find( + (value): value is AskQuestions => + AskQuestions.allows(value) && askQuestionsIssue(value) === undefined, + ); +} + type Answer = { readonly chosen: readonly string[]; readonly other: boolean; diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index d62c89d2..28727fcb 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -372,4 +372,66 @@ describe("conversation transcript", () => { expect(several).not.toContain("--radius"); expect(several).not.toContain(">Start Clear Skies<"); }); + + const writtenAsk = JSON.stringify({ + questions: [ + { + id: "audience", + prompt: "Who is it for?", + kind: "single", + options: [ + { value: "teams", label: "Small teams" }, + { value: "solo", label: "Just me" }, + ], + }, + ], + }); + + test("questions written as JSON in a reply become a card", () => { + for (const json of [writtenAsk, "```json\n" + writtenAsk + "\n```"]) { + const html = renderToStaticMarkup( + Promise.resolve(true)} + />, + ); + expect(html).toContain("First questions:"); + expect(html).toContain("Who is it for?"); + expect(html).toContain("Small teams"); + expect(html).not.toContain('"questions"'); + expect(html).not.toContain("
");
+    }
+  });
+
+  test("written questions add no card beside an ask_questions call", () => {
+    const html = renderToStaticMarkup(
+       Promise.resolve(true)}
+      />,
+    );
+    expect(html.split("Who is it for?")).toHaveLength(2);
+  });
 });
diff --git a/apps/web/src/pages/workspace/transcript.tsx b/apps/web/src/pages/workspace/transcript.tsx
index 2e79437b..f5447801 100644
--- a/apps/web/src/pages/workspace/transcript.tsx
+++ b/apps/web/src/pages/workspace/transcript.tsx
@@ -78,6 +78,7 @@ import {
   askedQuestions,
   QuestionsCard,
   type QuestionsState,
+  writtenQuestions,
 } from "./questions-card.tsx";
 import type { AskQuestions } from "@corbits-builder/schemas/questions";
 
@@ -388,11 +389,22 @@ function threadSections(
       continue;
     }
     open.turns.push({ turn: item.turn, index: item.index });
+    const asked = open.cards.length;
     for (const tool of toolsOf(visibleTurnParts(item.turn))) {
       const ask = askedQuestions(tool, results.get(tool.toolCallId));
       if (ask !== undefined)
         open.cards.push({ kind: "questions", callId: tool.toolCallId, ask });
     }
+    const written =
+      item.turn.role === "assistant" && open.cards.length === asked
+        ? writtenQuestions(item.turn.text)
+        : undefined;
+    if (written !== undefined)
+      open.cards.push({
+        kind: "questions",
+        callId: `written-${String(item.index)}`,
+        ask: written,
+      });
   }
   return sections;
 }
diff --git a/apps/web/src/pages/workspace/visible-chat.ts b/apps/web/src/pages/workspace/visible-chat.ts
index 8f0df425..fc43c9f1 100644
--- a/apps/web/src/pages/workspace/visible-chat.ts
+++ b/apps/web/src/pages/workspace/visible-chat.ts
@@ -138,6 +138,24 @@ function jsonEnd(text: string, start: number): number {
   return -1;
 }
 
+/** The JSON objects and arrays written in the text, outermost only. */
+export function jsonValues(text: string): unknown[] {
+  const values: unknown[] = [];
+  for (let index = 0; index < text.length; index += 1) {
+    const char = text[index] ?? "";
+    if (char !== "{" && char !== "[") continue;
+    const end = jsonEnd(text, index);
+    if (end === -1) continue;
+    try {
+      values.push(JSON.parse(text.slice(index, end)) as unknown);
+      index = end - 1;
+    } catch {
+      // Not a payload. Keep scanning.
+    }
+  }
+  return values;
+}
+
 /** Drop JSON values. A status object is not a sentence. */
 function stripJson(text: string): string {
   let out = "";
@@ -256,7 +274,9 @@ export function visibleChatText(text: string): string {
   const kept: string[] = [];
   for (const block of source.split(/(```[^\n]*\n[\s\S]*?```)/)) {
     if (block.startsWith("```")) {
-      kept.push(block.trim());
+      // A fence that held only JSON is empty once the JSON is dropped.
+      if (block.replace(/```[^\n]*/g, "").trim().length > 0)
+        kept.push(block.trim());
       continue;
     }
     for (const paragraph of block.split(/\n{2,}/)) {

From 66fb9b613e4b3c3295bdc43945e24a40699d8686 Mon Sep 17 00:00:00 2001
From: Sawyer 
Date: Tue, 3 Nov 2026 20:09:48 -0800
Subject: [PATCH 02/11] fix(coordinator): ask opening questions through
 ask_questions cards

---
 apps/hub/src/config/questions.ts    | 2 +-
 workflows/coordinator/src/prompt.ts | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/apps/hub/src/config/questions.ts b/apps/hub/src/config/questions.ts
index 5301ac6c..74e0d720 100644
--- a/apps/hub/src/config/questions.ts
+++ b/apps/hub/src/config/questions.ts
@@ -1,3 +1,3 @@
 // The ask_questions result the coordinator reads; the person's answers reach it as their next message.
 export const QUESTIONS_ASKED =
-  "Asked. The person's answers arrive as their next message.";
+  "Asked. The person sees the questions as cards below your reply and answers them there; their answers arrive as their next message.";
diff --git a/workflows/coordinator/src/prompt.ts b/workflows/coordinator/src/prompt.ts
index 73d4a4b2..d4d07c51 100644
--- a/workflows/coordinator/src/prompt.ts
+++ b/workflows/coordinator/src/prompt.ts
@@ -11,7 +11,7 @@ Interview like a good product partner, matching the depth to what they bring. A
 - Early on, offer to keep it high level or walk through the details, and follow their choice.
 - Open with a broad question: what matters most to them, and anything they already picture. Follow what they raise as far as they want to take it.
 - Then cover each remaining decision that would change what gets built and that only they can make, such as who it is for, who sees what, and how it should feel to use. A topic they have an opinion on is theirs to decide.
-- Ask those together in one message, at most three, most important first, in plain words a non-developer can answer. When options help, offer at most three concrete ones for each and say which you recommend. Their answers are usually enough to write the brief.
+- Ask those together in one ask_questions call, at most three, most important first, in plain words a non-developer can answer, each with at most three concrete options, the one you recommend preselected, and allowOther where their own words may fit better. They answer on the cards shown below your reply, so your reply only introduces them. Their answers are usually enough to write the brief.
 - Decide everything else yourself and record it as an assumption they can correct. When the person hands the decisions to you ("just build it", "you decide", "I don't want to make decisions"), that is their answer to all of them: ask nothing, make the calls, record them as assumptions and move straight on. When they say "go with your call" or skip ahead on one question, take your recommendation for it, record it as an assumption and move on.
 The document list's preferenceGuidance is how the person has said they want to work, including how much to ask; what they say in the conversation takes precedence over it.
 
@@ -19,7 +19,7 @@ The document list's preferenceGuidance is how the person has said they want to w
 You write the brief yourself with write_artifact, in the shape the problem-brief skill describes. Write it once the person has answered the essentials: who it is for and what it must do. It holds only what the person said, confirmed or left to you, in plain product language, naming people by role; a section with nothing real to say is left out. Revise it as answers arrive. Once it covers who it is for, what it must do and what done looks like, move on to the next document; later answers can still change it.
 
 ## Constraints and approaches
-The constraints are the bounds the app is built within: the budget for building and running it, platforms and devices, the data it works with and how private it is, accessibility, the timeline, the outside services it connects to, and what the first version includes. Once the brief is approved, ask the person the bounds that are theirs to decide, together in one message: at most four, most important first, each in plain words with a few options and your recommendation. These are choices such as who can use it and whether they sign in, which devices they use, what it keeps about people and where, what the first version includes and what waits, and what they are willing to spend to run it. Once they answer, use deploy_writer for the constraints with their answers. Purely technical choices, such as the data source, the framework and the hosting, are yours to settle as assumptions they can correct. When the person has handed the decisions to you, in their words or in their preferenceGuidance, their answer is to take your recommendations: record them as assumptions and use deploy_writer for the constraints straight away.
+The constraints are the bounds the app is built within: the budget for building and running it, platforms and devices, the data it works with and how private it is, accessibility, the timeline, the outside services it connects to, and what the first version includes. Once the brief is approved, ask the person the bounds that are theirs to decide, together in one ask_questions call: at most four, most important first, each in plain words with a few options and your recommendation. These are choices such as who can use it and whether they sign in, which devices they use, what it keeps about people and where, what the first version includes and what waits, and what they are willing to spend to run it. Once they answer, use deploy_writer for the constraints with their answers. Purely technical choices, such as the data source, the framework and the hosting, are yours to settle as assumptions they can correct. When the person has handed the decisions to you, in their words or in their preferenceGuidance, their answer is to take your recommendations: record them as assumptions and use deploy_writer for the constraints straight away.
 
 The approaches are two or three different ways to build the app within the constraints, each with its trade-offs and a rough estimate, and one recommended. Use deploy_writer for the approaches once the constraints are approved. When the document arrives, explain the recommended approach in plain words: what it means for their app, what it costs in time and money, and what the main alternative would give them instead; then ask them to choose. When they pick a different one, use deploy_writer for the approaches again with their choice so the document records it.
 

From cf42c80e935c9471b14320822115b86562f89744 Mon Sep 17 00:00:00 2001
From: Sawyer 
Date: Tue, 3 Nov 2026 20:28:23 -0800
Subject: [PATCH 03/11] test(web): settle a written questions card once the
 person answers

---
 .../src/pages/workspace/transcript.test.tsx   | 20 +++++++++++++++++++
 1 file changed, 20 insertions(+)

diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx
index 28727fcb..604f20c2 100644
--- a/apps/web/src/pages/workspace/transcript.test.tsx
+++ b/apps/web/src/pages/workspace/transcript.test.tsx
@@ -409,6 +409,26 @@ describe("conversation transcript", () => {
     }
   });
 
+  test("a written questions card settles once the person answers", () => {
+    const html = renderToStaticMarkup(
+       Promise.resolve(true)}
+      />,
+    );
+    expect(html).toContain(wording.questions.answered(["Small teams"]));
+    expect(html).not.toContain(wording.questions.send);
+  });
+
   test("written questions add no card beside an ask_questions call", () => {
     const html = renderToStaticMarkup(
       
Date: Tue, 3 Nov 2026 22:05:11 -0800
Subject: [PATCH 04/11] test(web): check the settled card, not its wording

---
 apps/web/src/pages/workspace/transcript.test.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx
index 604f20c2..a4c4f028 100644
--- a/apps/web/src/pages/workspace/transcript.test.tsx
+++ b/apps/web/src/pages/workspace/transcript.test.tsx
@@ -425,7 +425,7 @@ describe("conversation transcript", () => {
         onAnswerQuestions={() => Promise.resolve(true)}
       />,
     );
-    expect(html).toContain(wording.questions.answered(["Small teams"]));
+    expect(html).toContain("questions-card-settled");
     expect(html).not.toContain(wording.questions.send);
   });
 

From 1f247b64d9f7d4120fe41a540fceb8a872938f49 Mon Sep 17 00:00:00 2001
From: Sawyer 
Date: Tue, 3 Nov 2026 22:09:33 -0800
Subject: [PATCH 05/11] fix(coordinator): leave how to ask to the ask_questions
 tool

---
 apps/hub/src/config/questions.ts      | 2 +-
 apps/hub/src/projects/deploy-tools.ts | 2 +-
 workflows/coordinator/src/prompt.ts   | 6 +++---
 3 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/apps/hub/src/config/questions.ts b/apps/hub/src/config/questions.ts
index 74e0d720..51cb3742 100644
--- a/apps/hub/src/config/questions.ts
+++ b/apps/hub/src/config/questions.ts
@@ -1,3 +1,3 @@
 // The ask_questions result the coordinator reads; the person's answers reach it as their next message.
 export const QUESTIONS_ASKED =
-  "Asked. The person sees the questions as cards below your reply and answers them there; their answers arrive as their next message.";
+  "Asked. The questions show on a card below your reply; the person's answers arrive as their next message.";
diff --git a/apps/hub/src/projects/deploy-tools.ts b/apps/hub/src/projects/deploy-tools.ts
index 6e9535f9..e98d6886 100644
--- a/apps/hub/src/projects/deploy-tools.ts
+++ b/apps/hub/src/projects/deploy-tools.ts
@@ -261,7 +261,7 @@ export function createDeployTools(options?: {
     {
       name: "ask_questions",
       description:
-        "Put up to five questions to the person, each shown as a card they answer. id is a short slug unique within the call. kind single takes one option and multi any number. preselected holds the option values you recommend, at most one for single. allowOther lets the person answer in their own words. Their answers arrive as their next message.",
+        "Put up to five questions to the person on one card shown below your reply; your reply introduces them without repeating them. id is a short slug unique within the call. kind single takes one option and multi any number. preselected holds the option values you recommend, at most one for single. allowOther lets the person answer in their own words. Their answers arrive as their next message.",
       inputSchema: { ...AskQuestions.toJsonSchema() },
     },
     {
diff --git a/workflows/coordinator/src/prompt.ts b/workflows/coordinator/src/prompt.ts
index d4d07c51..b03317a0 100644
--- a/workflows/coordinator/src/prompt.ts
+++ b/workflows/coordinator/src/prompt.ts
@@ -9,9 +9,9 @@ You build web apps that run in a browser on desktop and phone: a React client wi
 ## Asking the person
 Interview like a good product partner, matching the depth to what they bring. A bare idea such as "build a weather app" needs brainstorming first: who it is for, the moment they reach for it, what would make it worth using. A detailed request needs less exploring and more confirming its limits and what success looks like. Agreeing how you will work together answers none of these.
 - Early on, offer to keep it high level or walk through the details, and follow their choice.
-- Open with a broad question: what matters most to them, and anything they already picture. Follow what they raise as far as they want to take it.
+- Open with a broad question in your reply, not with ask_questions: what matters most to them, and anything they already picture. Follow what they raise as far as they want to take it.
 - Then cover each remaining decision that would change what gets built and that only they can make, such as who it is for, who sees what, and how it should feel to use. A topic they have an opinion on is theirs to decide.
-- Ask those together in one ask_questions call, at most three, most important first, in plain words a non-developer can answer, each with at most three concrete options, the one you recommend preselected, and allowOther where their own words may fit better. They answer on the cards shown below your reply, so your reply only introduces them. Their answers are usually enough to write the brief.
+- Ask those together in one ask_questions call.
 - Decide everything else yourself and record it as an assumption they can correct. When the person hands the decisions to you ("just build it", "you decide", "I don't want to make decisions"), that is their answer to all of them: ask nothing, make the calls, record them as assumptions and move straight on. When they say "go with your call" or skip ahead on one question, take your recommendation for it, record it as an assumption and move on.
 The document list's preferenceGuidance is how the person has said they want to work, including how much to ask; what they say in the conversation takes precedence over it.
 
@@ -19,7 +19,7 @@ The document list's preferenceGuidance is how the person has said they want to w
 You write the brief yourself with write_artifact, in the shape the problem-brief skill describes. Write it once the person has answered the essentials: who it is for and what it must do. It holds only what the person said, confirmed or left to you, in plain product language, naming people by role; a section with nothing real to say is left out. Revise it as answers arrive. Once it covers who it is for, what it must do and what done looks like, move on to the next document; later answers can still change it.
 
 ## Constraints and approaches
-The constraints are the bounds the app is built within: the budget for building and running it, platforms and devices, the data it works with and how private it is, accessibility, the timeline, the outside services it connects to, and what the first version includes. Once the brief is approved, ask the person the bounds that are theirs to decide, together in one ask_questions call: at most four, most important first, each in plain words with a few options and your recommendation. These are choices such as who can use it and whether they sign in, which devices they use, what it keeps about people and where, what the first version includes and what waits, and what they are willing to spend to run it. Once they answer, use deploy_writer for the constraints with their answers. Purely technical choices, such as the data source, the framework and the hosting, are yours to settle as assumptions they can correct. When the person has handed the decisions to you, in their words or in their preferenceGuidance, their answer is to take your recommendations: record them as assumptions and use deploy_writer for the constraints straight away.
+The constraints are the bounds the app is built within: the budget for building and running it, platforms and devices, the data it works with and how private it is, accessibility, the timeline, the outside services it connects to, and what the first version includes. Once the brief is approved, ask the person the bounds that are theirs to decide, together in one ask_questions call. These are choices such as who can use it and whether they sign in, which devices they use, what it keeps about people and where, what the first version includes and what waits, and what they are willing to spend to run it. Once they answer, use deploy_writer for the constraints with their answers. Purely technical choices, such as the data source, the framework and the hosting, are yours to settle as assumptions they can correct. When the person has handed the decisions to you, in their words or in their preferenceGuidance, their answer is to take your recommendations: record them as assumptions and use deploy_writer for the constraints straight away.
 
 The approaches are two or three different ways to build the app within the constraints, each with its trade-offs and a rough estimate, and one recommended. Use deploy_writer for the approaches once the constraints are approved. When the document arrives, explain the recommended approach in plain words: what it means for their app, what it costs in time and money, and what the main alternative would give them instead; then ask them to choose. When they pick a different one, use deploy_writer for the approaches again with their choice so the document records it.
 

From bf7c502ab58010df3be16e78b144c7ffdc56c6f8 Mon Sep 17 00:00:00 2001
From: Sawyer 
Date: Tue, 3 Nov 2026 22:09:35 -0800
Subject: [PATCH 06/11] fix(web): keep JSON scan errors other than a parse
 failure

---
 apps/web/src/pages/workspace/visible-chat.ts | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/apps/web/src/pages/workspace/visible-chat.ts b/apps/web/src/pages/workspace/visible-chat.ts
index fc43c9f1..9181f955 100644
--- a/apps/web/src/pages/workspace/visible-chat.ts
+++ b/apps/web/src/pages/workspace/visible-chat.ts
@@ -142,15 +142,16 @@ function jsonEnd(text: string, start: number): number {
 export function jsonValues(text: string): unknown[] {
   const values: unknown[] = [];
   for (let index = 0; index < text.length; index += 1) {
-    const char = text[index] ?? "";
+    const char = text.charAt(index);
     if (char !== "{" && char !== "[") continue;
     const end = jsonEnd(text, index);
     if (end === -1) continue;
     try {
       values.push(JSON.parse(text.slice(index, end)) as unknown);
       index = end - 1;
-    } catch {
-      // Not a payload. Keep scanning.
+    } catch (error) {
+      // Not a payload: keep scanning. Anything else is a real failure.
+      if (!(error instanceof SyntaxError)) throw error;
     }
   }
   return values;

From ebc44777de9f93eae9c150748bf15bd5819c40e6 Mon Sep 17 00:00:00 2001
From: Sawyer 
Date: Tue, 3 Nov 2026 22:23:33 -0800
Subject: [PATCH 07/11] fix(web): skip written questions when the turn calls
 ask_questions

---
 .../src/pages/workspace/transcript.test.tsx   | 26 +++++++++++++++++++
 apps/web/src/pages/workspace/transcript.tsx   |  9 ++++---
 2 files changed, 32 insertions(+), 3 deletions(-)

diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx
index a4c4f028..064f8a69 100644
--- a/apps/web/src/pages/workspace/transcript.test.tsx
+++ b/apps/web/src/pages/workspace/transcript.test.tsx
@@ -454,4 +454,30 @@ describe("conversation transcript", () => {
     );
     expect(html.split("Who is it for?")).toHaveLength(2);
   });
+
+  test("written questions add no card while an ask_questions call is still running", () => {
+    const html = renderToStaticMarkup(
+       Promise.resolve(true)}
+      />,
+    );
+    expect(html).not.toContain("Who is it for?");
+  });
 });
diff --git a/apps/web/src/pages/workspace/transcript.tsx b/apps/web/src/pages/workspace/transcript.tsx
index f5447801..b4bc57f9 100644
--- a/apps/web/src/pages/workspace/transcript.tsx
+++ b/apps/web/src/pages/workspace/transcript.tsx
@@ -75,6 +75,7 @@ import { workUnits, type WorkUnit } from "./work-unit.ts";
 import { WorkUnitView } from "./work-unit-view.tsx";
 import type { CallResult } from "./live-run.ts";
 import {
+  ASK_QUESTIONS,
   askedQuestions,
   QuestionsCard,
   type QuestionsState,
@@ -389,14 +390,16 @@ function threadSections(
       continue;
     }
     open.turns.push({ turn: item.turn, index: item.index });
-    const asked = open.cards.length;
-    for (const tool of toolsOf(visibleTurnParts(item.turn))) {
+    const tools = toolsOf(visibleTurnParts(item.turn));
+    for (const tool of tools) {
       const ask = askedQuestions(tool, results.get(tool.toolCallId));
       if (ask !== undefined)
         open.cards.push({ kind: "questions", callId: tool.toolCallId, ask });
     }
+    // Only a turn that made no ask_questions call, running or failed included, falls back to its text.
+    const called = tools.some((tool) => tool.name === ASK_QUESTIONS);
     const written =
-      item.turn.role === "assistant" && open.cards.length === asked
+      item.turn.role === "assistant" && !called
         ? writtenQuestions(item.turn.text)
         : undefined;
     if (written !== undefined)

From be91d85060fdc46f6b915d7f6a50745e0ddafe25 Mon Sep 17 00:00:00 2001
From: Sawyer 
Date: Tue, 3 Nov 2026 22:49:19 -0800
Subject: [PATCH 08/11] test(web): check for leaked JSON as the page escapes it

---
 apps/web/src/pages/workspace/transcript.test.tsx | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx
index 064f8a69..d0362dfe 100644
--- a/apps/web/src/pages/workspace/transcript.test.tsx
+++ b/apps/web/src/pages/workspace/transcript.test.tsx
@@ -404,7 +404,8 @@ describe("conversation transcript", () => {
       expect(html).toContain("First questions:");
       expect(html).toContain("Who is it for?");
       expect(html).toContain("Small teams");
-      expect(html).not.toContain('"questions"');
+      // Text content escapes quotes, so leaked JSON would show as "questions".
+      expect(html).not.toContain(""questions"");
       expect(html).not.toContain("
");
     }
   });

From 2f2a42ca023996c0e185cee7f91420d54cdc3ca7 Mon Sep 17 00:00:00 2001
From: Sawyer 
Date: Tue, 3 Nov 2026 23:04:03 -0800
Subject: [PATCH 09/11] fix(web): one written card per stretch, and no false
 failure beside it

---
 .../src/pages/workspace/transcript.test.tsx   | 48 +++++++++++++++++++
 apps/web/src/pages/workspace/transcript.tsx   |  7 ++-
 apps/web/src/pages/workspace/visible-chat.ts  | 10 +++-
 3 files changed, 62 insertions(+), 3 deletions(-)

diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx
index d0362dfe..0c673213 100644
--- a/apps/web/src/pages/workspace/transcript.test.tsx
+++ b/apps/web/src/pages/workspace/transcript.test.tsx
@@ -481,4 +481,52 @@ describe("conversation transcript", () => {
     );
     expect(html).not.toContain("Who is it for?");
   });
+
+  test("questions repeated as JSON after an ask_questions call add no second card", () => {
+    const html = renderToStaticMarkup(
+       Promise.resolve(true)}
+      />,
+    );
+    expect(html.split("Who is it for?")).toHaveLength(2);
+    expect(html).not.toContain(wording.questions.replaced);
+  });
+
+  test("a reply that is only written questions shows no failure", () => {
+    const failing = writtenAsk.replace(
+      "Who is it for?",
+      "What should an error say?",
+    );
+    const html = renderToStaticMarkup(
+       Promise.resolve(true)}
+      />,
+    );
+    expect(html).toContain("What should an error say?");
+    expect(html).not.toContain(wording.message.stepFailed);
+  });
 });
diff --git a/apps/web/src/pages/workspace/transcript.tsx b/apps/web/src/pages/workspace/transcript.tsx
index b4bc57f9..4c022ec9 100644
--- a/apps/web/src/pages/workspace/transcript.tsx
+++ b/apps/web/src/pages/workspace/transcript.tsx
@@ -375,10 +375,13 @@ function threadSections(
 ): Section[] {
   const sections: Section[] = [];
   let open: Extract | null = null;
+  // Whether this stretch made an ask_questions call, running or failed included.
+  let called = false;
   for (const item of items) {
     if (item.kind === "turn" && item.turn.role === "person") {
       sections.push({ kind: "person", turn: item.turn, index: item.index });
       open = null;
+      called = false;
       continue;
     }
     if (open === null) {
@@ -396,8 +399,8 @@ function threadSections(
       if (ask !== undefined)
         open.cards.push({ kind: "questions", callId: tool.toolCallId, ask });
     }
-    // Only a turn that made no ask_questions call, running or failed included, falls back to its text.
-    const called = tools.some((tool) => tool.name === ASK_QUESTIONS);
+    // Only a stretch that made no ask_questions call falls back to questions written in its text.
+    called ||= tools.some((tool) => tool.name === ASK_QUESTIONS);
     const written =
       item.turn.role === "assistant" && !called
         ? writtenQuestions(item.turn.text)
diff --git a/apps/web/src/pages/workspace/visible-chat.ts b/apps/web/src/pages/workspace/visible-chat.ts
index 9181f955..25bedf8c 100644
--- a/apps/web/src/pages/workspace/visible-chat.ts
+++ b/apps/web/src/pages/workspace/visible-chat.ts
@@ -157,6 +157,11 @@ export function jsonValues(text: string): unknown[] {
   return values;
 }
 
+const asksQuestions = (value: unknown) =>
+  typeof value === "object" &&
+  value !== null &&
+  Array.isArray((value as { questions?: unknown }).questions);
+
 /** Drop JSON values. A status object is not a sentence. */
 function stripJson(text: string): string {
   let out = "";
@@ -292,7 +297,10 @@ export function visibleChatText(text: string): string {
     .join("\n\n")
     .replace(/(\S)[ \t]{2,}/g, "$1 ")
     .trim();
-  return prose.length > 0 ? prose : plainFallback(text);
+  if (prose.length > 0) return prose;
+  // Questions written as JSON become a card of their own; words in them are not a failure to report.
+  if (jsonValues(text).some(asksQuestions)) return "";
+  return plainFallback(text);
 }
 
 const roleLabels: Readonly> = wording.activity.role;

From 42bb0e3574bef723a9e689c0eaa1b07654c3805e Mon Sep 17 00:00:00 2001
From: Sawyer 
Date: Tue, 3 Nov 2026 23:18:41 -0800
Subject: [PATCH 10/11] fix(web): drop written cards from a stretch that calls
 ask_questions

---
 .../src/pages/workspace/questions-card.tsx    |  7 +--
 .../src/pages/workspace/transcript.test.tsx   | 48 +++++++++++++------
 apps/web/src/pages/workspace/transcript.tsx   | 19 +++++---
 apps/web/src/pages/workspace/visible-chat.ts  | 13 +++--
 4 files changed, 56 insertions(+), 31 deletions(-)

diff --git a/apps/web/src/pages/workspace/questions-card.tsx b/apps/web/src/pages/workspace/questions-card.tsx
index 95ea4e98..6dffa1e4 100644
--- a/apps/web/src/pages/workspace/questions-card.tsx
+++ b/apps/web/src/pages/workspace/questions-card.tsx
@@ -21,7 +21,7 @@ import {
 } from "@corbits-builder/schemas/questions";
 import { wording } from "../../config.ts";
 import type { CallResult } from "./live-run.ts";
-import { jsonValues } from "./visible-chat.ts";
+import { isAskQuestions, jsonValues } from "./visible-chat.ts";
 import "./questions-card.css";
 
 const text = wording.questions;
@@ -45,10 +45,7 @@ export function askedQuestions(
  * The transcript drops JSON from the reply, so without a card the questions would not show at all.
  */
 export function writtenQuestions(text: string): AskQuestions | undefined {
-  return jsonValues(text).find(
-    (value): value is AskQuestions =>
-      AskQuestions.allows(value) && askQuestionsIssue(value) === undefined,
-  );
+  return jsonValues(text).find(isAskQuestions);
 }
 
 type Answer = {
diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx
index 0c673213..fb604135 100644
--- a/apps/web/src/pages/workspace/transcript.test.tsx
+++ b/apps/web/src/pages/workspace/transcript.test.tsx
@@ -482,31 +482,51 @@ describe("conversation transcript", () => {
     expect(html).not.toContain("Who is it for?");
   });
 
-  test("questions repeated as JSON after an ask_questions call add no second card", () => {
+  test("questions written as JSON in a stretch that calls ask_questions add no second card", () => {
+    const call = {
+      role: "assistant" as const,
+      text: "",
+      at: 1,
+      parts: [
+        {
+          kind: "tool-trace" as const,
+          toolCallId: "call-ask",
+          name: "ask_questions",
+          status: "output-available" as const,
+          input: JSON.parse(writtenAsk) as unknown,
+        },
+      ],
+    };
+    const written = { role: "assistant" as const, text: writtenAsk, at: 2 };
+    for (const turns of [
+      [call, written],
+      [written, call],
+    ]) {
+      const html = renderToStaticMarkup(
+         Promise.resolve(true)}
+        />,
+      );
+      expect(html.split("Who is it for?")).toHaveLength(2);
+      expect(html).not.toContain(wording.questions.replaced);
+    }
+  });
+
+  test("questions JSON the card cannot take is not hidden", () => {
     const html = renderToStaticMarkup(
        Promise.resolve(true)}
       />,
     );
-    expect(html.split("Who is it for?")).toHaveLength(2);
-    expect(html).not.toContain(wording.questions.replaced);
+    expect(html).toContain(wording.message.stepFailed);
   });
 
   test("a reply that is only written questions shows no failure", () => {
diff --git a/apps/web/src/pages/workspace/transcript.tsx b/apps/web/src/pages/workspace/transcript.tsx
index 4c022ec9..4991e19a 100644
--- a/apps/web/src/pages/workspace/transcript.tsx
+++ b/apps/web/src/pages/workspace/transcript.tsx
@@ -375,13 +375,12 @@ function threadSections(
 ): Section[] {
   const sections: Section[] = [];
   let open: Extract | null = null;
-  // Whether this stretch made an ask_questions call, running or failed included.
-  let called = false;
+  // Stretches that made an ask_questions call in any turn, running or failed included.
+  const calling = new Set>();
   for (const item of items) {
     if (item.kind === "turn" && item.turn.role === "person") {
       sections.push({ kind: "person", turn: item.turn, index: item.index });
       open = null;
-      called = false;
       continue;
     }
     if (open === null) {
@@ -399,22 +398,28 @@ function threadSections(
       if (ask !== undefined)
         open.cards.push({ kind: "questions", callId: tool.toolCallId, ask });
     }
-    // Only a stretch that made no ask_questions call falls back to questions written in its text.
-    called ||= tools.some((tool) => tool.name === ASK_QUESTIONS);
+    if (tools.some((tool) => tool.name === ASK_QUESTIONS)) calling.add(open);
     const written =
-      item.turn.role === "assistant" && !called
+      item.turn.role === "assistant"
         ? writtenQuestions(item.turn.text)
         : undefined;
     if (written !== undefined)
       open.cards.push({
         kind: "questions",
-        callId: `written-${String(item.index)}`,
+        callId: `${WRITTEN}${String(item.index)}`,
         ask: written,
       });
   }
+  // Only a stretch that made no ask_questions call, in any of its turns, keeps questions written in its text.
+  for (const section of calling)
+    section.cards = section.cards.filter(
+      (card) => card.kind !== "questions" || !card.callId.startsWith(WRITTEN),
+    );
   return sections;
 }
 
+const WRITTEN = "written-";
+
 /**
  * A piece of work still at work is current, so it sits after the reports and decisions that came
  * while it ran rather than at the call that started it. The design choice waits on the person, so it
diff --git a/apps/web/src/pages/workspace/visible-chat.ts b/apps/web/src/pages/workspace/visible-chat.ts
index 25bedf8c..cfce1ccf 100644
--- a/apps/web/src/pages/workspace/visible-chat.ts
+++ b/apps/web/src/pages/workspace/visible-chat.ts
@@ -6,6 +6,10 @@
  * the question, the decision, the document, or a plain failure — not the
  * coordinator's bookkeeping.
  */
+import {
+  AskQuestions,
+  askQuestionsIssue,
+} from "@corbits-builder/schemas/questions";
 import { wording } from "../../config.ts";
 import type { ConversationPart } from "@corbits/react-ui";
 
@@ -157,10 +161,9 @@ export function jsonValues(text: string): unknown[] {
   return values;
 }
 
-const asksQuestions = (value: unknown) =>
-  typeof value === "object" &&
-  value !== null &&
-  Array.isArray((value as { questions?: unknown }).questions);
+/** Whether a JSON value is ask_questions data the hub would accept, so it can stand as a card. */
+export const isAskQuestions = (value: unknown): value is AskQuestions =>
+  AskQuestions.allows(value) && askQuestionsIssue(value) === undefined;
 
 /** Drop JSON values. A status object is not a sentence. */
 function stripJson(text: string): string {
@@ -299,7 +302,7 @@ export function visibleChatText(text: string): string {
     .trim();
   if (prose.length > 0) return prose;
   // Questions written as JSON become a card of their own; words in them are not a failure to report.
-  if (jsonValues(text).some(asksQuestions)) return "";
+  if (jsonValues(text).some(isAskQuestions)) return "";
   return plainFallback(text);
 }
 

From 52d4b6dd4f34b771c4ccae3afabbcd6ce7c8a896 Mon Sep 17 00:00:00 2001
From: Sawyer 
Date: Tue, 3 Nov 2026 23:23:53 -0800
Subject: [PATCH 11/11] fix(web): drop the written-questions fallback

---
 .../src/pages/workspace/questions-card.tsx    |   9 -
 .../src/pages/workspace/transcript.test.tsx   | 177 ------------------
 apps/web/src/pages/workspace/transcript.tsx   |  25 +--
 apps/web/src/pages/workspace/visible-chat.ts  |  36 +---
 4 files changed, 3 insertions(+), 244 deletions(-)

diff --git a/apps/web/src/pages/workspace/questions-card.tsx b/apps/web/src/pages/workspace/questions-card.tsx
index 6dffa1e4..467fb598 100644
--- a/apps/web/src/pages/workspace/questions-card.tsx
+++ b/apps/web/src/pages/workspace/questions-card.tsx
@@ -21,7 +21,6 @@ import {
 } from "@corbits-builder/schemas/questions";
 import { wording } from "../../config.ts";
 import type { CallResult } from "./live-run.ts";
-import { isAskQuestions, jsonValues } from "./visible-chat.ts";
 import "./questions-card.css";
 
 const text = wording.questions;
@@ -40,14 +39,6 @@ export function askedQuestions(
   return askQuestionsIssue(part.input) === undefined ? part.input : undefined;
 }
 
-/**
- * Questions the coordinator wrote into its reply as ask_questions JSON instead of calling the tool.
- * The transcript drops JSON from the reply, so without a card the questions would not show at all.
- */
-export function writtenQuestions(text: string): AskQuestions | undefined {
-  return jsonValues(text).find(isAskQuestions);
-}
-
 type Answer = {
   readonly chosen: readonly string[];
   readonly other: boolean;
diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx
index fb604135..d62c89d2 100644
--- a/apps/web/src/pages/workspace/transcript.test.tsx
+++ b/apps/web/src/pages/workspace/transcript.test.tsx
@@ -372,181 +372,4 @@ describe("conversation transcript", () => {
     expect(several).not.toContain("--radius");
     expect(several).not.toContain(">Start Clear Skies<");
   });
-
-  const writtenAsk = JSON.stringify({
-    questions: [
-      {
-        id: "audience",
-        prompt: "Who is it for?",
-        kind: "single",
-        options: [
-          { value: "teams", label: "Small teams" },
-          { value: "solo", label: "Just me" },
-        ],
-      },
-    ],
-  });
-
-  test("questions written as JSON in a reply become a card", () => {
-    for (const json of [writtenAsk, "```json\n" + writtenAsk + "\n```"]) {
-      const html = renderToStaticMarkup(
-         Promise.resolve(true)}
-        />,
-      );
-      expect(html).toContain("First questions:");
-      expect(html).toContain("Who is it for?");
-      expect(html).toContain("Small teams");
-      // Text content escapes quotes, so leaked JSON would show as "questions".
-      expect(html).not.toContain(""questions"");
-      expect(html).not.toContain("
");
-    }
-  });
-
-  test("a written questions card settles once the person answers", () => {
-    const html = renderToStaticMarkup(
-       Promise.resolve(true)}
-      />,
-    );
-    expect(html).toContain("questions-card-settled");
-    expect(html).not.toContain(wording.questions.send);
-  });
-
-  test("written questions add no card beside an ask_questions call", () => {
-    const html = renderToStaticMarkup(
-       Promise.resolve(true)}
-      />,
-    );
-    expect(html.split("Who is it for?")).toHaveLength(2);
-  });
-
-  test("written questions add no card while an ask_questions call is still running", () => {
-    const html = renderToStaticMarkup(
-       Promise.resolve(true)}
-      />,
-    );
-    expect(html).not.toContain("Who is it for?");
-  });
-
-  test("questions written as JSON in a stretch that calls ask_questions add no second card", () => {
-    const call = {
-      role: "assistant" as const,
-      text: "",
-      at: 1,
-      parts: [
-        {
-          kind: "tool-trace" as const,
-          toolCallId: "call-ask",
-          name: "ask_questions",
-          status: "output-available" as const,
-          input: JSON.parse(writtenAsk) as unknown,
-        },
-      ],
-    };
-    const written = { role: "assistant" as const, text: writtenAsk, at: 2 };
-    for (const turns of [
-      [call, written],
-      [written, call],
-    ]) {
-      const html = renderToStaticMarkup(
-         Promise.resolve(true)}
-        />,
-      );
-      expect(html.split("Who is it for?")).toHaveLength(2);
-      expect(html).not.toContain(wording.questions.replaced);
-    }
-  });
-
-  test("questions JSON the card cannot take is not hidden", () => {
-    const html = renderToStaticMarkup(
-       Promise.resolve(true)}
-      />,
-    );
-    expect(html).toContain(wording.message.stepFailed);
-  });
-
-  test("a reply that is only written questions shows no failure", () => {
-    const failing = writtenAsk.replace(
-      "Who is it for?",
-      "What should an error say?",
-    );
-    const html = renderToStaticMarkup(
-       Promise.resolve(true)}
-      />,
-    );
-    expect(html).toContain("What should an error say?");
-    expect(html).not.toContain(wording.message.stepFailed);
-  });
 });
diff --git a/apps/web/src/pages/workspace/transcript.tsx b/apps/web/src/pages/workspace/transcript.tsx
index 4991e19a..2e79437b 100644
--- a/apps/web/src/pages/workspace/transcript.tsx
+++ b/apps/web/src/pages/workspace/transcript.tsx
@@ -75,11 +75,9 @@ import { workUnits, type WorkUnit } from "./work-unit.ts";
 import { WorkUnitView } from "./work-unit-view.tsx";
 import type { CallResult } from "./live-run.ts";
 import {
-  ASK_QUESTIONS,
   askedQuestions,
   QuestionsCard,
   type QuestionsState,
-  writtenQuestions,
 } from "./questions-card.tsx";
 import type { AskQuestions } from "@corbits-builder/schemas/questions";
 
@@ -375,8 +373,6 @@ function threadSections(
 ): Section[] {
   const sections: Section[] = [];
   let open: Extract | null = null;
-  // Stretches that made an ask_questions call in any turn, running or failed included.
-  const calling = new Set>();
   for (const item of items) {
     if (item.kind === "turn" && item.turn.role === "person") {
       sections.push({ kind: "person", turn: item.turn, index: item.index });
@@ -392,34 +388,15 @@ function threadSections(
       continue;
     }
     open.turns.push({ turn: item.turn, index: item.index });
-    const tools = toolsOf(visibleTurnParts(item.turn));
-    for (const tool of tools) {
+    for (const tool of toolsOf(visibleTurnParts(item.turn))) {
       const ask = askedQuestions(tool, results.get(tool.toolCallId));
       if (ask !== undefined)
         open.cards.push({ kind: "questions", callId: tool.toolCallId, ask });
     }
-    if (tools.some((tool) => tool.name === ASK_QUESTIONS)) calling.add(open);
-    const written =
-      item.turn.role === "assistant"
-        ? writtenQuestions(item.turn.text)
-        : undefined;
-    if (written !== undefined)
-      open.cards.push({
-        kind: "questions",
-        callId: `${WRITTEN}${String(item.index)}`,
-        ask: written,
-      });
   }
-  // Only a stretch that made no ask_questions call, in any of its turns, keeps questions written in its text.
-  for (const section of calling)
-    section.cards = section.cards.filter(
-      (card) => card.kind !== "questions" || !card.callId.startsWith(WRITTEN),
-    );
   return sections;
 }
 
-const WRITTEN = "written-";
-
 /**
  * A piece of work still at work is current, so it sits after the reports and decisions that came
  * while it ran rather than at the call that started it. The design choice waits on the person, so it
diff --git a/apps/web/src/pages/workspace/visible-chat.ts b/apps/web/src/pages/workspace/visible-chat.ts
index cfce1ccf..8f0df425 100644
--- a/apps/web/src/pages/workspace/visible-chat.ts
+++ b/apps/web/src/pages/workspace/visible-chat.ts
@@ -6,10 +6,6 @@
  * the question, the decision, the document, or a plain failure — not the
  * coordinator's bookkeeping.
  */
-import {
-  AskQuestions,
-  askQuestionsIssue,
-} from "@corbits-builder/schemas/questions";
 import { wording } from "../../config.ts";
 import type { ConversationPart } from "@corbits/react-ui";
 
@@ -142,29 +138,6 @@ function jsonEnd(text: string, start: number): number {
   return -1;
 }
 
-/** The JSON objects and arrays written in the text, outermost only. */
-export function jsonValues(text: string): unknown[] {
-  const values: unknown[] = [];
-  for (let index = 0; index < text.length; index += 1) {
-    const char = text.charAt(index);
-    if (char !== "{" && char !== "[") continue;
-    const end = jsonEnd(text, index);
-    if (end === -1) continue;
-    try {
-      values.push(JSON.parse(text.slice(index, end)) as unknown);
-      index = end - 1;
-    } catch (error) {
-      // Not a payload: keep scanning. Anything else is a real failure.
-      if (!(error instanceof SyntaxError)) throw error;
-    }
-  }
-  return values;
-}
-
-/** Whether a JSON value is ask_questions data the hub would accept, so it can stand as a card. */
-export const isAskQuestions = (value: unknown): value is AskQuestions =>
-  AskQuestions.allows(value) && askQuestionsIssue(value) === undefined;
-
 /** Drop JSON values. A status object is not a sentence. */
 function stripJson(text: string): string {
   let out = "";
@@ -283,9 +256,7 @@ export function visibleChatText(text: string): string {
   const kept: string[] = [];
   for (const block of source.split(/(```[^\n]*\n[\s\S]*?```)/)) {
     if (block.startsWith("```")) {
-      // A fence that held only JSON is empty once the JSON is dropped.
-      if (block.replace(/```[^\n]*/g, "").trim().length > 0)
-        kept.push(block.trim());
+      kept.push(block.trim());
       continue;
     }
     for (const paragraph of block.split(/\n{2,}/)) {
@@ -300,10 +271,7 @@ export function visibleChatText(text: string): string {
     .join("\n\n")
     .replace(/(\S)[ \t]{2,}/g, "$1 ")
     .trim();
-  if (prose.length > 0) return prose;
-  // Questions written as JSON become a card of their own; words in them are not a failure to report.
-  if (jsonValues(text).some(isAskQuestions)) return "";
-  return plainFallback(text);
+  return prose.length > 0 ? prose : plainFallback(text);
 }
 
 const roleLabels: Readonly> = wording.activity.role;