@bastani/atomic 0.8.20 → 0.8.21-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/builtin/intercom/package.json +1 -1
  3. package/dist/builtin/mcp/package.json +1 -1
  4. package/dist/builtin/subagents/agents/code-simplifier.md +78 -22
  5. package/dist/builtin/subagents/agents/debugger.md +4 -3
  6. package/dist/builtin/subagents/package.json +1 -1
  7. package/dist/builtin/web-access/package.json +1 -1
  8. package/dist/builtin/workflows/CHANGELOG.md +19 -0
  9. package/dist/builtin/workflows/package.json +1 -1
  10. package/dist/builtin/workflows/skills/create-spec/SKILL.md +169 -125
  11. package/dist/builtin/workflows/skills/impeccable/SKILL.md +89 -80
  12. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_asset_producer.toml +92 -0
  13. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_manual_edit_applier.toml +95 -0
  14. package/dist/builtin/workflows/skills/impeccable/agents/openai.yaml +4 -0
  15. package/dist/builtin/workflows/skills/impeccable/reference/adapt.md +122 -1
  16. package/dist/builtin/workflows/skills/impeccable/reference/animate.md +38 -12
  17. package/dist/builtin/workflows/skills/impeccable/reference/audit.md +5 -5
  18. package/dist/builtin/workflows/skills/impeccable/reference/bolder.md +7 -7
  19. package/dist/builtin/workflows/skills/impeccable/reference/brand.md +4 -14
  20. package/dist/builtin/workflows/skills/impeccable/reference/clarify.md +115 -1
  21. package/dist/builtin/workflows/skills/impeccable/reference/codex.md +3 -3
  22. package/dist/builtin/workflows/skills/impeccable/reference/colorize.md +109 -6
  23. package/dist/builtin/workflows/skills/impeccable/reference/craft.md +7 -7
  24. package/dist/builtin/workflows/skills/impeccable/reference/critique.md +623 -94
  25. package/dist/builtin/workflows/skills/impeccable/reference/delight.md +2 -2
  26. package/dist/builtin/workflows/skills/impeccable/reference/distill.md +2 -2
  27. package/dist/builtin/workflows/skills/impeccable/reference/document.md +16 -14
  28. package/dist/builtin/workflows/skills/impeccable/reference/extract.md +1 -1
  29. package/dist/builtin/workflows/skills/impeccable/reference/harden.md +1 -1
  30. package/dist/builtin/workflows/skills/impeccable/reference/init.md +172 -0
  31. package/dist/builtin/workflows/skills/impeccable/reference/interaction-design.md +0 -6
  32. package/dist/builtin/workflows/skills/impeccable/reference/layout.md +33 -13
  33. package/dist/builtin/workflows/skills/impeccable/reference/live.md +96 -19
  34. package/dist/builtin/workflows/skills/impeccable/reference/onboard.md +1 -1
  35. package/dist/builtin/workflows/skills/impeccable/reference/optimize.md +1 -1
  36. package/dist/builtin/workflows/skills/impeccable/reference/overdrive.md +1 -1
  37. package/dist/builtin/workflows/skills/impeccable/reference/polish.md +3 -4
  38. package/dist/builtin/workflows/skills/impeccable/reference/product.md +1 -3
  39. package/dist/builtin/workflows/skills/impeccable/reference/quieter.md +2 -2
  40. package/dist/builtin/workflows/skills/impeccable/reference/shape.md +5 -5
  41. package/dist/builtin/workflows/skills/impeccable/reference/typeset.md +158 -3
  42. package/dist/builtin/workflows/skills/impeccable/scripts/cleanup-deprecated.mjs +1 -1
  43. package/dist/builtin/workflows/skills/impeccable/scripts/command-metadata.json +2 -2
  44. package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +225 -0
  45. package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +266 -0
  46. package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +17 -1
  47. package/dist/builtin/workflows/skills/impeccable/scripts/design-parser.mjs +16 -1
  48. package/dist/builtin/workflows/skills/impeccable/scripts/detect.mjs +21 -0
  49. package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +1725 -0
  50. package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +244 -0
  51. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +4543 -0
  52. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns.mjs +43 -0
  53. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +252 -0
  54. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +535 -0
  55. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +986 -0
  56. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +208 -0
  57. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +189 -0
  58. package/dist/builtin/workflows/skills/impeccable/scripts/detector/findings.mjs +12 -0
  59. package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +198 -0
  60. package/dist/builtin/workflows/skills/impeccable/scripts/detector/profile/profiler.mjs +166 -0
  61. package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +419 -0
  62. package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +2316 -0
  63. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/color.mjs +124 -0
  64. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/constants.mjs +101 -0
  65. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/page.mjs +7 -0
  66. package/dist/builtin/workflows/skills/impeccable/scripts/impeccable-paths.mjs +17 -1
  67. package/dist/builtin/workflows/skills/impeccable/scripts/is-generated.mjs +2 -2
  68. package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +139 -96
  69. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +4491 -526
  70. package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +1241 -0
  71. package/dist/builtin/workflows/skills/impeccable/scripts/live-copy-edit-agent.mjs +683 -0
  72. package/dist/builtin/workflows/skills/impeccable/scripts/live-discard-manual-edits.mjs +51 -0
  73. package/dist/builtin/workflows/skills/impeccable/scripts/live-event-validation.mjs +136 -0
  74. package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +22 -9
  75. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert-ui.mjs +458 -0
  76. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +232 -0
  77. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +363 -0
  78. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edits-buffer.mjs +152 -0
  79. package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +288 -110
  80. package/dist/builtin/workflows/skills/impeccable/scripts/live-resume.mjs +47 -1
  81. package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +1443 -100
  82. package/dist/builtin/workflows/skills/impeccable/scripts/live-session-store.mjs +17 -0
  83. package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +17 -3
  84. package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +216 -6
  85. package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +2 -3
  86. package/dist/builtin/workflows/skills/impeccable/scripts/palette.mjs +633 -0
  87. package/dist/builtin/workflows/skills/impeccable/scripts/pin.mjs +1 -1
  88. package/dist/builtin/workflows/src/extension/index.ts +67 -3
  89. package/dist/builtin/workflows/src/extension/render-result.ts +26 -1
  90. package/dist/builtin/workflows/src/runs/foreground/executor.ts +227 -3
  91. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +94 -7
  92. package/dist/builtin/workflows/src/shared/stage-prompt.ts +326 -0
  93. package/dist/builtin/workflows/src/shared/stage-ui-broker.ts +62 -7
  94. package/dist/builtin/workflows/src/shared/store-types.ts +43 -0
  95. package/dist/builtin/workflows/src/shared/store.ts +37 -0
  96. package/dist/builtin/workflows/src/tui/chat-surface-message.ts +22 -4
  97. package/dist/builtin/workflows/src/tui/graph-view.ts +47 -0
  98. package/dist/builtin/workflows/src/tui/overlay-adapter.ts +43 -1
  99. package/dist/builtin/workflows/src/tui/run-detail.ts +10 -4
  100. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +117 -15
  101. package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +9 -0
  102. package/dist/core/skills.d.ts.map +1 -1
  103. package/dist/core/skills.js +2 -5
  104. package/dist/core/skills.js.map +1 -1
  105. package/dist/core/system-prompt.d.ts.map +1 -1
  106. package/dist/core/system-prompt.js +11 -29
  107. package/dist/core/system-prompt.js.map +1 -1
  108. package/dist/index.d.ts +1 -0
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +3 -0
  111. package/dist/index.js.map +1 -1
  112. package/docs/quickstart.md +1 -2
  113. package/package.json +4 -4
  114. package/dist/builtin/workflows/skills/impeccable/reference/cognitive-load.md +0 -106
  115. package/dist/builtin/workflows/skills/impeccable/reference/color-and-contrast.md +0 -105
  116. package/dist/builtin/workflows/skills/impeccable/reference/heuristics-scoring.md +0 -234
  117. package/dist/builtin/workflows/skills/impeccable/reference/motion-design.md +0 -109
  118. package/dist/builtin/workflows/skills/impeccable/reference/personas.md +0 -179
  119. package/dist/builtin/workflows/skills/impeccable/reference/responsive-design.md +0 -114
  120. package/dist/builtin/workflows/skills/impeccable/reference/spatial-design.md +0 -100
  121. package/dist/builtin/workflows/skills/impeccable/reference/teach.md +0 -156
  122. package/dist/builtin/workflows/skills/impeccable/reference/typography.md +0 -159
  123. package/dist/builtin/workflows/skills/impeccable/reference/ux-writing.md +0 -107
  124. package/dist/builtin/workflows/skills/impeccable/scripts/load-context.mjs +0 -141
@@ -27,7 +27,7 @@ const HARNESS_DIRS = [
27
27
 
28
28
  // Valid sub-command names
29
29
  const VALID_COMMANDS = [
30
- 'craft', 'teach', 'extract', 'document', 'shape',
30
+ 'craft', 'init', 'extract', 'document', 'shape',
31
31
  'critique', 'audit',
32
32
  'polish', 'bolder', 'quieter', 'distill', 'harden', 'onboard', 'live',
33
33
  'animate', 'colorize', 'typeset', 'layout', 'delight', 'overdrive',
@@ -11,6 +11,12 @@ import { renderRunBanner, renderRunSummary } from "./renderers.js";
11
11
  import type { RunEndPayload, RunStartPayload } from "./renderers.js";
12
12
  import type { StageSnapshot, StageStatus, ToolEvent } from "../shared/store-types.js";
13
13
  import { store } from "../shared/store.js";
14
+ import { stageUiBroker } from "../shared/stage-ui-broker.js";
15
+ import {
16
+ coerceStageInputAnswer,
17
+ hasStageInputAnswerContent,
18
+ type StageInputAnswer,
19
+ } from "../shared/stage-prompt.js";
14
20
  import { restoreOnSessionStart } from "../shared/persistence-restore.js";
15
21
  import type { SessionManager } from "../shared/persistence-restore.js";
16
22
  import { installCompactionHook } from "../shared/persistence-compaction-policy.js";
@@ -646,6 +652,10 @@ function renderStagesToolContent(
646
652
  lines.push("pendingPrompt:");
647
653
  lines.push(JSON.stringify(stage.pendingPrompt, null, 2));
648
654
  }
655
+ if (stage.inputRequest !== undefined) {
656
+ lines.push("inputRequest:");
657
+ lines.push(JSON.stringify(stage.inputRequest, null, 2));
658
+ }
649
659
  });
650
660
  return lines.join("\n");
651
661
  }
@@ -745,6 +755,7 @@ type WorkflowStageSummary = {
745
755
  error?: string;
746
756
  awaitingInputSince?: number;
747
757
  pendingPrompt?: StageSnapshot["pendingPrompt"];
758
+ inputRequest?: StageSnapshot["inputRequest"];
748
759
  };
749
760
 
750
761
  type WorkflowTranscriptEntry = {
@@ -781,6 +792,9 @@ function summarizeStage(stage: StageSnapshot): WorkflowStageSummary {
781
792
  pendingPrompt: stage.pendingPrompt === undefined
782
793
  ? undefined
783
794
  : structuredClone(stage.pendingPrompt),
795
+ inputRequest: stage.inputRequest === undefined
796
+ ? undefined
797
+ : structuredClone(stage.inputRequest),
784
798
  };
785
799
  }
786
800
 
@@ -860,6 +874,23 @@ function promptPayloadFromArgs(args: WorkflowToolArgs): unknown {
860
874
  return args.message;
861
875
  }
862
876
 
877
+ /**
878
+ * Shape a `send` payload into a headless answer for a brokered stage prompt
879
+ * (ask_user_question / readiness gate). A structured `response` (object or
880
+ * JSON string) is normalized so it matches the question's options instead of
881
+ * being forwarded verbatim as a result that violates the QuestionnaireResult
882
+ * contract; otherwise the plain text / message payload is matched against
883
+ * option labels / indices by the stage prompt adapter.
884
+ */
885
+ function brokerAnswerFromArgs(args: WorkflowToolArgs): StageInputAnswer {
886
+ if (args.response !== undefined) {
887
+ const coerced = coerceStageInputAnswer(args.response);
888
+ if (hasStageInputAnswerContent(coerced)) return coerced;
889
+ }
890
+ const text = textPayloadFromArgs(args);
891
+ return text !== undefined ? { text } : {};
892
+ }
893
+
863
894
  function textPayloadFromArgs(args: WorkflowToolArgs): string | undefined {
864
895
  if (args.text !== undefined) return args.text;
865
896
  if (typeof args.response === "string") {
@@ -1289,6 +1320,30 @@ export function makeExecuteWorkflowTool(
1289
1320
  }
1290
1321
  const run = store.runs().find((r) => r.id === target.runId);
1291
1322
  const snapshot = run?.stages.find((s) => s.id === stage.stageId);
1323
+ // Brokered structured prompts (in-stage ask_user_question / readiness
1324
+ // gate) resolve through StageUiBroker rather than store.pendingPrompt.
1325
+ // Answer those first when one is pending and the promptId (if any) lines
1326
+ // up — otherwise fall through to the store-prompt / live-handle paths.
1327
+ const brokerPrompt = stageUiBroker.peekStagePrompt(target.runId, stage.stageId);
1328
+ const targetsBrokerPrompt =
1329
+ brokerPrompt !== undefined &&
1330
+ (args.promptId === undefined || args.promptId === brokerPrompt.id) &&
1331
+ (requestedDelivery === "answer" ||
1332
+ args.promptId !== undefined ||
1333
+ requestedDelivery === "auto");
1334
+ if (targetsBrokerPrompt && brokerPrompt !== undefined) {
1335
+ if (!hasPayloadProperty(args)) {
1336
+ return workflowSendResult(target.runId, stage.stageId, "answer", "noop", "Send requires text, response, or message.");
1337
+ }
1338
+ const ok = stageUiBroker.answerStagePrompt(target.runId, stage.stageId, brokerAnswerFromArgs(args));
1339
+ return workflowSendResult(
1340
+ target.runId,
1341
+ stage.stageId,
1342
+ "answer",
1343
+ ok ? "ok" : "noop",
1344
+ ok ? `Answered input request ${brokerPrompt.id}.` : `No matching pending input request ${brokerPrompt.id}.`,
1345
+ );
1346
+ }
1292
1347
  const targetsPrompt =
1293
1348
  requestedDelivery === "answer" ||
1294
1349
  args.promptId !== undefined ||
@@ -2284,14 +2339,23 @@ function factory(pi: ExtensionAPI): void {
2284
2339
  },
2285
2340
  renderCall: (args, _theme, _context) =>
2286
2341
  dynamicTextRenderComponent((width) => renderCall(args, { width })),
2287
- renderResult: (result, opts, _theme, context) =>
2288
- dynamicTextRenderComponent((width) =>
2342
+ renderResult: (result, opts, _theme, context) => {
2343
+ // Capture wall-clock ONCE per chat entry. The lambda below is
2344
+ // invoked on every TUI re-render; without a captured `now`, every
2345
+ // tick would recompute elapsed/running durations and trigger
2346
+ // pi-tui's full-redraw path for any entry above the viewport —
2347
+ // visible as whole-screen flicker on terminals without
2348
+ // synchronized output support (e.g. mosh).
2349
+ const capturedNow = Date.now();
2350
+ return dynamicTextRenderComponent((width) =>
2289
2351
  renderResult(result.details, {
2290
2352
  ...opts,
2291
2353
  width,
2354
+ now: capturedNow,
2292
2355
  runInputs: (context as { args?: WorkflowToolArgs }).args?.inputs,
2293
2356
  }),
2294
- ),
2357
+ );
2358
+ },
2295
2359
  });
2296
2360
  }
2297
2361
 
@@ -11,7 +11,7 @@
11
11
  * - pi-subagents src/extension/index.ts renderResult slot
12
12
  */
13
13
 
14
- import type { PendingPrompt, RunSnapshot, StageSnapshot, StageStatus } from "../shared/store-types.js";
14
+ import type { PendingPrompt, RunSnapshot, StageInputRequest, StageSnapshot, StageStatus } from "../shared/store-types.js";
15
15
  import type { WorkflowDetails } from "../shared/types.js";
16
16
  import type { RunDetail } from "../runs/background/status.js";
17
17
  import { renderInputsSchema } from "../shared/render-inputs-schema.js";
@@ -107,6 +107,7 @@ type StageListItem = {
107
107
  error?: string;
108
108
  awaitingInputSince?: number;
109
109
  pendingPrompt?: PendingPrompt;
110
+ inputRequest?: StageInputRequest;
110
111
  };
111
112
  type StageListResult = { action: "stages"; runId: string; filter: string; stages: StageListItem[]; error?: string };
112
113
  type StageDetailResult = { action: "stage"; runId: string; stage?: StageSnapshot; error?: string };
@@ -161,6 +162,15 @@ export interface RenderResultOpts {
161
162
  * When false/undefined the canonical Catppuccin chrome is rendered.
162
163
  */
163
164
  plain?: boolean;
165
+ /**
166
+ * Stable wall-clock used for elapsed-time labels in scrollback. Capture
167
+ * once when the chat entry is created so subsequent host re-renders do
168
+ * not silently tick `elapsed` / `running` durations — every off-viewport
169
+ * tick forces pi-tui's full-redraw path (CSI 2J + CSI H + CSI 3J) and
170
+ * is visible as whole-screen flicker under terminal emulators that do
171
+ * not implement synchronized output (e.g. mosh).
172
+ */
173
+ now?: number;
164
174
  }
165
175
 
166
176
  /**
@@ -213,6 +223,19 @@ export function renderResult(result: WorkflowToolResult, opts?: RenderResultOpts
213
223
  const partial = opts?.isPartial === true;
214
224
  const themed = opts?.plain !== true;
215
225
 
226
+ // Runtime guard. The tool-result renderer passes `result.details`, which can
227
+ // be absent or not yet shaped during streaming/partial renders or on error
228
+ // paths that return content without a structured payload. Dereferencing a
229
+ // missing `action` here previously threw and crashed the TUI render loop, so
230
+ // degrade gracefully instead.
231
+ if (
232
+ result === null ||
233
+ typeof result !== "object" ||
234
+ typeof (result as { action?: unknown }).action !== "string"
235
+ ) {
236
+ return partial ? "" : renderNotice("WORKFLOW", "no result", opts, themed);
237
+ }
238
+
216
239
  switch (result.action) {
217
240
  case "list": {
218
241
  const r = result as ListResult;
@@ -227,6 +250,7 @@ export function renderResult(result: WorkflowToolResult, opts?: RenderResultOpts
227
250
  return renderStatusList(r.snapshots, {
228
251
  theme: themed ? deriveGraphTheme({}) : undefined,
229
252
  width: opts?.width,
253
+ now: opts?.now,
230
254
  });
231
255
  }
232
256
 
@@ -239,6 +263,7 @@ export function renderResult(result: WorkflowToolResult, opts?: RenderResultOpts
239
263
  return renderRunDetail(r.detail, {
240
264
  theme: themed ? deriveGraphTheme({}) : undefined,
241
265
  width: opts?.width,
266
+ now: opts?.now,
242
267
  });
243
268
  }
244
269
 
@@ -5,7 +5,9 @@
5
5
  import { createHash } from "node:crypto";
6
6
  import { mkdir, writeFile } from "node:fs/promises";
7
7
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
8
- import { CONFIG_DIR_NAME } from "@bastani/atomic";
8
+ import { CONFIG_DIR_NAME, createAskUserQuestionToolDefinition } from "@bastani/atomic";
9
+ import { stageUiBroker } from "../../shared/stage-ui-broker.js";
10
+ import { buildStagePromptAdapter } from "../../shared/stage-prompt.js";
9
11
  import type {
10
12
  WorkflowDefinition,
11
13
  WorkflowRunContext,
@@ -89,6 +91,21 @@ export interface RunOpts {
89
91
  ui?: WorkflowUIAdapter;
90
92
  /** Internal detached-run mode: surface ctx.ui.* as node-local workflow prompt stages. */
91
93
  usePromptNodesForUi?: boolean;
94
+ /**
95
+ * Readiness-gate confirmation seam (#1099). When an ask_user_question tool
96
+ * call is observed during a stage, the executor calls this after the model
97
+ * turn ends to ask whether to advance. Returning false keeps execution in the
98
+ * stage (the executor steers the stage to continue and re-gates after the
99
+ * next turn); true advances. When omitted, runs with usePromptNodesForUi
100
+ * render the gate through the stage UI broker, and other runs proceed without
101
+ * gating (tests/headless).
102
+ */
103
+ confirmStageReadiness?: (request: {
104
+ readonly runId: string;
105
+ readonly stageId: string;
106
+ readonly stageName: string;
107
+ readonly signal: AbortSignal;
108
+ }) => Promise<boolean>;
92
109
  /** Store override (for testing; defaults to singleton store) */
93
110
  store?: Store;
94
111
  /** Persistence port for writing session entries (run.start, stage.start, etc.). */
@@ -285,7 +302,7 @@ function makeUnavailableUIContext(): WorkflowUIContext {
285
302
  }
286
303
 
287
304
  type AskUserQuestionToolEvent =
288
- | { phase: "start"; callId?: string }
305
+ | { phase: "start"; callId?: string; args?: unknown }
289
306
  | { phase: "end"; callId?: string; nameMatched: boolean };
290
307
 
291
308
  function stringField(value: Record<string, unknown>, keys: readonly string[]): string | undefined {
@@ -309,7 +326,7 @@ function askUserQuestionToolEvent(event: unknown): AskUserQuestionToolEvent | un
309
326
  const callId = stringField(record, ["toolCallId", "tool_call_id", "toolUseId", "tool_use_id", "id"]);
310
327
 
311
328
  if (type === "tool_execution_start" && isAskUserQuestionToolName(toolName)) {
312
- return { phase: "start", callId };
329
+ return { phase: "start", callId, args: record["args"] };
313
330
  }
314
331
  if (type === "tool_execution_end" || type === "tool_execution_error" || type === "tool_result") {
315
332
  return { phase: "end", callId, nameMatched: isAskUserQuestionToolName(toolName) };
@@ -317,6 +334,127 @@ function askUserQuestionToolEvent(event: unknown): AskUserQuestionToolEvent | un
317
334
  return undefined;
318
335
  }
319
336
 
337
+ // ---------------------------------------------------------------------------
338
+ // Readiness gate (#1099)
339
+ // ---------------------------------------------------------------------------
340
+ // A stage's agent turn returns control to the user when it ends. If that turn
341
+ // issued no ask_user_question call, the stage completes and the workflow
342
+ // advances automatically. If the turn DID ask the user something, a
343
+ // deterministic readiness gate (the structured ask_user_question UI, rendered
344
+ // inline in the attached stage chat via the broker) is shown when the turn
345
+ // ends. Choosing "I'm ready to move on…" advances; anything else (the
346
+ // keep-exploring option, a typed answer, "Chat about this", or cancelling)
347
+ // returns control to the user, who keeps working in the normal stage composer.
348
+ // The same per-turn check re-applies after each subsequent user-driven turn.
349
+
350
+ export const READINESS_GATE_ADVANCE_LABEL = "I'm ready to move on to the next workflow stage.";
351
+
352
+ const READINESS_GATE_ADVANCE_NORMALIZED = READINESS_GATE_ADVANCE_LABEL.trim().toLowerCase();
353
+
354
+ export const READINESS_GATE_QUESTION_PARAMS = {
355
+ questions: [
356
+ {
357
+ question: "Any additional points to explore before moving on?",
358
+ header: "Continue?",
359
+ options: [
360
+ {
361
+ label: READINESS_GATE_ADVANCE_LABEL,
362
+ description: "Complete this stage and advance the workflow.",
363
+ },
364
+ {
365
+ label: "I have more to explore or ask about.",
366
+ description: "Stay in this stage and keep working in the chat composer.",
367
+ },
368
+ ],
369
+ },
370
+ ],
371
+ };
372
+
373
+ /**
374
+ * Decide whether a brokered readiness-gate result selected the "advance"
375
+ * option. Tolerant of case/whitespace and of the advance label arriving via a
376
+ * multi-select `selected[]` entry, so a structured answer that canonicalized to
377
+ * the advance option still completes the stage. Anything else (the explore
378
+ * option, a typed answer, a cancelled/empty result) means "stay".
379
+ */
380
+ export function readinessResultMeansAdvance(result: unknown): boolean {
381
+ if (result === null || typeof result !== "object") return false;
382
+ const details = (result as {
383
+ details?: {
384
+ answers?: ReadonlyArray<{ answer?: unknown; selected?: ReadonlyArray<unknown> }>;
385
+ cancelled?: boolean;
386
+ };
387
+ }).details;
388
+ if (details === undefined || details.cancelled === true) return false;
389
+ const first = details.answers?.[0];
390
+ if (first === undefined) return false;
391
+ const candidates: unknown[] = [first.answer];
392
+ if (Array.isArray(first.selected)) candidates.push(...first.selected);
393
+ return candidates.some(
394
+ (candidate) =>
395
+ typeof candidate === "string" &&
396
+ candidate.trim().toLowerCase() === READINESS_GATE_ADVANCE_NORMALIZED,
397
+ );
398
+ }
399
+
400
+ let cachedReadinessGateTool: ReturnType<typeof createAskUserQuestionToolDefinition> | undefined;
401
+ function readinessGateTool(): ReturnType<typeof createAskUserQuestionToolDefinition> {
402
+ return (cachedReadinessGateTool ??= createAskUserQuestionToolDefinition());
403
+ }
404
+
405
+ /**
406
+ * Render the readiness gate inline in the attached stage chat by invoking the
407
+ * ask_user_question tool with a pre-filled body, routing its custom UI through
408
+ * the stage UI broker for (runId, stageId). Returns "advance" only when the
409
+ * user chooses the move-on option; the keep-exploring option, "Chat about
410
+ * this", a typed answer, or cancellation all mean "stay". If no stage chat host
411
+ * is attached the broker request stays pending (the stage shows awaiting_input)
412
+ * exactly like the tool itself.
413
+ */
414
+ export async function askReadinessViaStageBroker(
415
+ runId: string,
416
+ stageId: string,
417
+ signal: AbortSignal,
418
+ ): Promise<"advance" | "stay"> {
419
+ const execute = readinessGateTool().execute;
420
+ if (execute === undefined) return "advance";
421
+ const gateContext = {
422
+ hasUI: true,
423
+ ui: {
424
+ custom: (factory: unknown, options?: unknown): Promise<unknown> =>
425
+ stageUiBroker.requestCustomUi(
426
+ runId,
427
+ stageId,
428
+ factory as Parameters<typeof stageUiBroker.requestCustomUi>[2],
429
+ options as Parameters<typeof stageUiBroker.requestCustomUi>[3],
430
+ signal,
431
+ ),
432
+ },
433
+ };
434
+ // Expose a headless-answer adapter for the gate so it can be answered
435
+ // programmatically (e.g. `workflow send`) without a TUI host. The gate
436
+ // question params are known statically here.
437
+ const gateAdapter = buildStagePromptAdapter(
438
+ `readiness-gate-${stageId}`,
439
+ "readiness_gate",
440
+ READINESS_GATE_QUESTION_PARAMS,
441
+ Date.now(),
442
+ );
443
+ if (gateAdapter) stageUiBroker.provideStagePrompt(runId, stageId, gateAdapter);
444
+ try {
445
+ const result = await execute(
446
+ `readiness-gate-${stageId}`,
447
+ READINESS_GATE_QUESTION_PARAMS as Parameters<typeof execute>[1],
448
+ signal,
449
+ undefined,
450
+ gateContext as unknown as Parameters<typeof execute>[4],
451
+ );
452
+ return readinessResultMeansAdvance(result) ? "advance" : "stay";
453
+ } finally {
454
+ stageUiBroker.clearStagePrompt(runId, stageId);
455
+ }
456
+ }
457
+
320
458
  // ---------------------------------------------------------------------------
321
459
  // raceAbort — races a promise against an AbortSignal
322
460
  // ---------------------------------------------------------------------------
@@ -2061,15 +2199,32 @@ export async function run<TInputs extends Record<string, unknown>>(
2061
2199
  });
2062
2200
  const activeAskUserQuestionCalls = new Set<string>();
2063
2201
  let activeAskUserQuestionAnonymousCalls = 0;
2202
+ // Set whenever an ask_user_question tool call is observed during the
2203
+ // current model turn. Drives the deterministic readiness gate (#1099):
2204
+ // after a turn that asked the user a question ends, the workflow must
2205
+ // confirm readiness before completing/advancing the stage.
2206
+ let askUserQuestionObservedThisTurn = false;
2064
2207
  const hasActiveAskUserQuestion = (): boolean =>
2065
2208
  activeAskUserQuestionCalls.size > 0 || activeAskUserQuestionAnonymousCalls > 0;
2066
2209
  const unsubscribeAskUserQuestionWatcher = innerCtx.subscribe((event) => {
2067
2210
  const toolEvent = askUserQuestionToolEvent(event);
2068
2211
  if (!toolEvent) return;
2069
2212
  if (toolEvent.phase === "start") {
2213
+ askUserQuestionObservedThisTurn = true;
2070
2214
  if (toolEvent.callId !== undefined) activeAskUserQuestionCalls.add(toolEvent.callId);
2071
2215
  else activeAskUserQuestionAnonymousCalls += 1;
2072
2216
  activeStore.recordStageAwaitingInput(runId, stageId, true);
2217
+ // Expose a headless-answer adapter so the prompt can be answered
2218
+ // programmatically (e.g. `workflow send`) without a TUI host. The
2219
+ // (runId, stageId) key joins this to the broker request the tool's
2220
+ // ctx.ui.custom() call raises.
2221
+ const adapter = buildStagePromptAdapter(
2222
+ toolEvent.callId ?? `ask-user-question-${stageId}`,
2223
+ "ask_user_question",
2224
+ toolEvent.args,
2225
+ Date.now(),
2226
+ );
2227
+ if (adapter) stageUiBroker.provideStagePrompt(runId, stageId, adapter);
2073
2228
  return;
2074
2229
  }
2075
2230
 
@@ -2083,6 +2238,7 @@ export async function run<TInputs extends Record<string, unknown>>(
2083
2238
 
2084
2239
  if (!hasActiveAskUserQuestion()) {
2085
2240
  activeStore.recordStageAwaitingInput(runId, stageId, false);
2241
+ stageUiBroker.clearStagePrompt(runId, stageId);
2086
2242
  }
2087
2243
  });
2088
2244
  const disposeInnerContext = async (): Promise<void> => {
@@ -2090,6 +2246,7 @@ export async function run<TInputs extends Record<string, unknown>>(
2090
2246
  activeAskUserQuestionCalls.clear();
2091
2247
  activeAskUserQuestionAnonymousCalls = 0;
2092
2248
  activeStore.recordStageAwaitingInput(runId, stageId, false);
2249
+ stageUiBroker.clearStagePrompt(runId, stageId);
2093
2250
  await innerCtx.__dispose();
2094
2251
  };
2095
2252
  let unregisterStageHandle = (): void => {};
@@ -2271,6 +2428,31 @@ export async function run<TInputs extends Record<string, unknown>>(
2271
2428
  }
2272
2429
  };
2273
2430
 
2431
+ // Deterministic readiness gate (#1099). After a model turn that issued an
2432
+ // ask_user_question tool call ends, confirm with the user before the stage
2433
+ // completes/advances. "No" keeps execution in this stage (steer + re-gate
2434
+ // after the next turn); "Yes" resumes progression. The gate engages only
2435
+ // when a confirmation seam is available, so headless/test runs proceed.
2436
+ const readinessGateEnabled =
2437
+ opts.confirmStageReadiness !== undefined || opts.usePromptNodesForUi === true;
2438
+ const confirmReadiness = async (): Promise<"advance" | "stay"> => {
2439
+ try {
2440
+ if (opts.confirmStageReadiness !== undefined) {
2441
+ const ready = await opts.confirmStageReadiness({
2442
+ runId,
2443
+ stageId,
2444
+ stageName: name,
2445
+ signal: ownController.signal,
2446
+ });
2447
+ return ready ? "advance" : "stay";
2448
+ }
2449
+ return await askReadinessViaStageBroker(runId, stageId, ownController.signal);
2450
+ } catch {
2451
+ // A gate failure must not strand the workflow; proceed on error.
2452
+ return "advance";
2453
+ }
2454
+ };
2455
+
2274
2456
  const runTrackedStageCall = async (call: () => Promise<string>): Promise<string> => {
2275
2457
  await waitForStageRelease();
2276
2458
  if (stageFinalized) {
@@ -2320,7 +2502,49 @@ export async function run<TInputs extends Record<string, unknown>>(
2320
2502
  else ownController.signal.addEventListener("abort", abortSession, { once: true });
2321
2503
  let result = "";
2322
2504
  try {
2505
+ // Run the stage's initial agent turn.
2506
+ askUserQuestionObservedThisTurn = false;
2323
2507
  result = await raceAbort(call(), ownController.signal);
2508
+
2509
+ // Per-turn readiness gate (#1099). When an agent turn ENDS (control
2510
+ // returns to the user): if the turn issued no ask_user_question
2511
+ // call, complete/advance automatically; if it DID, show the gate.
2512
+ // "advance" completes the stage; anything else hands control back to
2513
+ // the user, who keeps working in the normal stage composer — we wait
2514
+ // for their next turn to end (the session's agent_end event) and
2515
+ // re-apply the same check. No canned auto-steer, so the user is
2516
+ // never trapped re-gating and the stage never auto-drives a hidden
2517
+ // turn that could strand the stream.
2518
+ if (!ownController.signal.aborted && readinessGateEnabled) {
2519
+ let resolveNextTurnEnd: (() => void) | null = null;
2520
+ const unsubscribeTurnWatcher = innerCtx.subscribe((event) => {
2521
+ if ((event as { type?: unknown }).type === "agent_end" && resolveNextTurnEnd) {
2522
+ const resolve = resolveNextTurnEnd;
2523
+ resolveNextTurnEnd = null;
2524
+ resolve();
2525
+ }
2526
+ });
2527
+ try {
2528
+ while (askUserQuestionObservedThisTurn) {
2529
+ if ((await confirmReadiness()) === "advance") break;
2530
+ if (ownController.signal.aborted) break;
2531
+ // Stay: return control to the user and await their next
2532
+ // composer-driven turn end before re-checking.
2533
+ askUserQuestionObservedThisTurn = false;
2534
+ await raceAbort(
2535
+ new Promise<void>((resolve) => {
2536
+ resolveNextTurnEnd = resolve;
2537
+ }),
2538
+ ownController.signal,
2539
+ );
2540
+ if (ownController.signal.aborted) break;
2541
+ result = innerCtx.__getLastAssistantText() ?? result;
2542
+ }
2543
+ } finally {
2544
+ resolveNextTurnEnd = null;
2545
+ unsubscribeTurnWatcher();
2546
+ }
2547
+ }
2324
2548
  } finally {
2325
2549
  ownController.signal.removeEventListener("abort", abortSession);
2326
2550
  }