@bastani/atomic 0.8.20-0 → 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 (127) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/builtin/intercom/package.json +1 -1
  3. package/dist/builtin/mcp/CHANGELOG.md +5 -0
  4. package/dist/builtin/mcp/package.json +1 -1
  5. package/dist/builtin/subagents/CHANGELOG.md +5 -0
  6. package/dist/builtin/subagents/agents/code-simplifier.md +78 -22
  7. package/dist/builtin/subagents/agents/debugger.md +4 -3
  8. package/dist/builtin/subagents/package.json +1 -1
  9. package/dist/builtin/web-access/CHANGELOG.md +5 -0
  10. package/dist/builtin/web-access/package.json +1 -1
  11. package/dist/builtin/workflows/CHANGELOG.md +25 -0
  12. package/dist/builtin/workflows/package.json +1 -1
  13. package/dist/builtin/workflows/skills/create-spec/SKILL.md +169 -125
  14. package/dist/builtin/workflows/skills/impeccable/SKILL.md +89 -80
  15. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_asset_producer.toml +92 -0
  16. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_manual_edit_applier.toml +95 -0
  17. package/dist/builtin/workflows/skills/impeccable/agents/openai.yaml +4 -0
  18. package/dist/builtin/workflows/skills/impeccable/reference/adapt.md +122 -1
  19. package/dist/builtin/workflows/skills/impeccable/reference/animate.md +38 -12
  20. package/dist/builtin/workflows/skills/impeccable/reference/audit.md +5 -5
  21. package/dist/builtin/workflows/skills/impeccable/reference/bolder.md +7 -7
  22. package/dist/builtin/workflows/skills/impeccable/reference/brand.md +4 -14
  23. package/dist/builtin/workflows/skills/impeccable/reference/clarify.md +115 -1
  24. package/dist/builtin/workflows/skills/impeccable/reference/codex.md +3 -3
  25. package/dist/builtin/workflows/skills/impeccable/reference/colorize.md +109 -6
  26. package/dist/builtin/workflows/skills/impeccable/reference/craft.md +7 -7
  27. package/dist/builtin/workflows/skills/impeccable/reference/critique.md +623 -94
  28. package/dist/builtin/workflows/skills/impeccable/reference/delight.md +2 -2
  29. package/dist/builtin/workflows/skills/impeccable/reference/distill.md +2 -2
  30. package/dist/builtin/workflows/skills/impeccable/reference/document.md +16 -14
  31. package/dist/builtin/workflows/skills/impeccable/reference/extract.md +1 -1
  32. package/dist/builtin/workflows/skills/impeccable/reference/harden.md +1 -1
  33. package/dist/builtin/workflows/skills/impeccable/reference/init.md +172 -0
  34. package/dist/builtin/workflows/skills/impeccable/reference/interaction-design.md +0 -6
  35. package/dist/builtin/workflows/skills/impeccable/reference/layout.md +33 -13
  36. package/dist/builtin/workflows/skills/impeccable/reference/live.md +96 -19
  37. package/dist/builtin/workflows/skills/impeccable/reference/onboard.md +1 -1
  38. package/dist/builtin/workflows/skills/impeccable/reference/optimize.md +1 -1
  39. package/dist/builtin/workflows/skills/impeccable/reference/overdrive.md +1 -1
  40. package/dist/builtin/workflows/skills/impeccable/reference/polish.md +3 -4
  41. package/dist/builtin/workflows/skills/impeccable/reference/product.md +1 -3
  42. package/dist/builtin/workflows/skills/impeccable/reference/quieter.md +2 -2
  43. package/dist/builtin/workflows/skills/impeccable/reference/shape.md +5 -5
  44. package/dist/builtin/workflows/skills/impeccable/reference/typeset.md +158 -3
  45. package/dist/builtin/workflows/skills/impeccable/scripts/cleanup-deprecated.mjs +1 -1
  46. package/dist/builtin/workflows/skills/impeccable/scripts/command-metadata.json +2 -2
  47. package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +225 -0
  48. package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +266 -0
  49. package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +17 -1
  50. package/dist/builtin/workflows/skills/impeccable/scripts/design-parser.mjs +16 -1
  51. package/dist/builtin/workflows/skills/impeccable/scripts/detect.mjs +21 -0
  52. package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +1725 -0
  53. package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +244 -0
  54. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +4543 -0
  55. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns.mjs +43 -0
  56. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +252 -0
  57. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +535 -0
  58. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +986 -0
  59. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +208 -0
  60. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +189 -0
  61. package/dist/builtin/workflows/skills/impeccable/scripts/detector/findings.mjs +12 -0
  62. package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +198 -0
  63. package/dist/builtin/workflows/skills/impeccable/scripts/detector/profile/profiler.mjs +166 -0
  64. package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +419 -0
  65. package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +2316 -0
  66. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/color.mjs +124 -0
  67. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/constants.mjs +101 -0
  68. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/page.mjs +7 -0
  69. package/dist/builtin/workflows/skills/impeccable/scripts/impeccable-paths.mjs +17 -1
  70. package/dist/builtin/workflows/skills/impeccable/scripts/is-generated.mjs +2 -2
  71. package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +139 -96
  72. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +4491 -526
  73. package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +1241 -0
  74. package/dist/builtin/workflows/skills/impeccable/scripts/live-copy-edit-agent.mjs +683 -0
  75. package/dist/builtin/workflows/skills/impeccable/scripts/live-discard-manual-edits.mjs +51 -0
  76. package/dist/builtin/workflows/skills/impeccable/scripts/live-event-validation.mjs +136 -0
  77. package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +22 -9
  78. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert-ui.mjs +458 -0
  79. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +232 -0
  80. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +363 -0
  81. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edits-buffer.mjs +152 -0
  82. package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +288 -110
  83. package/dist/builtin/workflows/skills/impeccable/scripts/live-resume.mjs +47 -1
  84. package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +1443 -100
  85. package/dist/builtin/workflows/skills/impeccable/scripts/live-session-store.mjs +17 -0
  86. package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +17 -3
  87. package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +216 -6
  88. package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +2 -3
  89. package/dist/builtin/workflows/skills/impeccable/scripts/palette.mjs +633 -0
  90. package/dist/builtin/workflows/skills/impeccable/scripts/pin.mjs +1 -1
  91. package/dist/builtin/workflows/src/extension/index.ts +67 -3
  92. package/dist/builtin/workflows/src/extension/render-result.ts +26 -1
  93. package/dist/builtin/workflows/src/runs/foreground/executor.ts +227 -3
  94. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +94 -7
  95. package/dist/builtin/workflows/src/shared/stage-prompt.ts +326 -0
  96. package/dist/builtin/workflows/src/shared/stage-ui-broker.ts +62 -7
  97. package/dist/builtin/workflows/src/shared/store-types.ts +43 -0
  98. package/dist/builtin/workflows/src/shared/store.ts +37 -0
  99. package/dist/builtin/workflows/src/tui/chat-surface-message.ts +22 -4
  100. package/dist/builtin/workflows/src/tui/graph-view.ts +47 -0
  101. package/dist/builtin/workflows/src/tui/overlay-adapter.ts +43 -1
  102. package/dist/builtin/workflows/src/tui/run-detail.ts +10 -4
  103. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +117 -15
  104. package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +9 -0
  105. package/dist/core/skills.d.ts.map +1 -1
  106. package/dist/core/skills.js +2 -5
  107. package/dist/core/skills.js.map +1 -1
  108. package/dist/core/system-prompt.d.ts.map +1 -1
  109. package/dist/core/system-prompt.js +11 -29
  110. package/dist/core/system-prompt.js.map +1 -1
  111. package/dist/index.d.ts +1 -0
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +3 -0
  114. package/dist/index.js.map +1 -1
  115. package/docs/quickstart.md +1 -2
  116. package/package.json +4 -4
  117. package/dist/builtin/workflows/skills/impeccable/reference/cognitive-load.md +0 -106
  118. package/dist/builtin/workflows/skills/impeccable/reference/color-and-contrast.md +0 -105
  119. package/dist/builtin/workflows/skills/impeccable/reference/heuristics-scoring.md +0 -234
  120. package/dist/builtin/workflows/skills/impeccable/reference/motion-design.md +0 -109
  121. package/dist/builtin/workflows/skills/impeccable/reference/personas.md +0 -179
  122. package/dist/builtin/workflows/skills/impeccable/reference/responsive-design.md +0 -114
  123. package/dist/builtin/workflows/skills/impeccable/reference/spatial-design.md +0 -100
  124. package/dist/builtin/workflows/skills/impeccable/reference/teach.md +0 -156
  125. package/dist/builtin/workflows/skills/impeccable/reference/typography.md +0 -159
  126. package/dist/builtin/workflows/skills/impeccable/reference/ux-writing.md +0 -107
  127. package/dist/builtin/workflows/skills/impeccable/scripts/load-context.mjs +0 -141
@@ -199,16 +199,63 @@ function extractMessageText(message: AgentSession["messages"][number]): string {
199
199
  return "";
200
200
  }
201
201
 
202
- function lastOutputTextFromMessages(messages: AgentSession["messages"]): string | undefined {
202
+ function lastAssistantTextFromMessages(messages: AgentSession["messages"]): string | undefined {
203
203
  for (let index = messages.length - 1; index >= 0; index -= 1) {
204
204
  const message = messages[index];
205
- if (!message || (message.role !== "assistant" && message.role !== "toolResult")) continue;
205
+ // Only assistant prose is a valid non-terminating turn output. A tool
206
+ // result is the turn output ONLY when its tool terminated the turn, which
207
+ // is handled separately by `terminatingToolResultText`.
208
+ if (!message || message.role !== "assistant") continue;
206
209
  const text = extractMessageText(message).trim();
207
210
  if (text) return text;
208
211
  }
209
212
  return undefined;
210
213
  }
211
214
 
215
+ /**
216
+ * When an agent turn ends on a tool that returned `terminate: true`, control
217
+ * returns with the tool result as the final conversational message and no
218
+ * trailing assistant response (see the structured-output contract in the
219
+ * Atomic extension docs). That tool result is the deterministic output of the
220
+ * turn, so it must win over any prose the model emitted *before* the tool call
221
+ * in the same assistant message (which `getLastAssistantText()` would otherwise
222
+ * surface). This keeps terminating structured-output tools such as the `goal`
223
+ * and `ralph` review gates' `review_decision` tool deterministic regardless of
224
+ * surrounding narration.
225
+ *
226
+ * Returns the trailing tool-result text ONLY when the most recent
227
+ * conversational message is a tool result whose tool call actually returned
228
+ * `terminate: true` (tracked at runtime from the session's tool_execution_end
229
+ * events — see `terminatingToolCallIds`). Returns `undefined` for a turn that
230
+ * ended on an assistant response, OR on a tool result from a non-terminating
231
+ * tool (e.g. a turn aborted/errored right after a tool call) — both fall back
232
+ * to the last assistant message.
233
+ */
234
+ function terminatingToolResultText(
235
+ messages: AgentSession["messages"],
236
+ terminatingToolCallIds: ReadonlySet<string>,
237
+ ): string | undefined {
238
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
239
+ const message = messages[index];
240
+ if (!message) continue;
241
+ if (message.role === "toolResult") {
242
+ // The trailing message is a tool result. It is the deterministic turn
243
+ // output only if THIS tool call returned `terminate: true`; otherwise the
244
+ // turn ended on this tool for another reason (abort/error) and the last
245
+ // assistant message is the real output.
246
+ const toolCallId = (message as { toolCallId?: unknown }).toolCallId;
247
+ if (typeof toolCallId !== "string" || !terminatingToolCallIds.has(toolCallId)) {
248
+ return undefined;
249
+ }
250
+ const text = extractMessageText(message).trim();
251
+ return text.length > 0 ? text : undefined;
252
+ }
253
+ if (message.role === "assistant") return undefined;
254
+ // Skip non-conversational roles (system/user) while locating the tail.
255
+ }
256
+ return undefined;
257
+ }
258
+
212
259
  function asAgentSession(activeSession: StageSessionRuntime | undefined): AgentSession | undefined {
213
260
  if (!activeSession) return undefined;
214
261
  const candidate = activeSession as StageSessionRuntime & Partial<Pick<AgentSession, "state" | "sessionManager" | "modelRegistry" | "getContextUsage">>;
@@ -226,11 +273,21 @@ function asAgentSession(activeSession: StageSessionRuntime | undefined): AgentSe
226
273
  function lastAssistantTextFromSession(
227
274
  activeSession: StageSessionRuntime | undefined,
228
275
  fallback: string | undefined,
276
+ terminatingToolCallIds: ReadonlySet<string>,
229
277
  ): string | undefined {
230
278
  if (!activeSession) return fallback;
279
+ // A tool that returned `terminate: true` ends the turn with its tool result
280
+ // as the final message; that result is the deterministic stage output and
281
+ // wins over prose emitted before the terminating tool call. Detection is by
282
+ // the tool call's actual runtime terminate flag — NOT merely "the last
283
+ // message is a tool result".
284
+ const terminatingText = terminatingToolResultText(activeSession.messages, terminatingToolCallIds);
285
+ if (terminatingText !== undefined) return terminatingText;
286
+ // Otherwise the turn output is the last assistant message — never a tool
287
+ // result from a non-terminating tool.
231
288
  const direct = activeSession.getLastAssistantText?.();
232
289
  if (direct !== undefined && direct.trim()) return direct;
233
- return lastOutputTextFromMessages(activeSession.messages) ?? direct ?? fallback;
290
+ return lastAssistantTextFromMessages(activeSession.messages) ?? direct ?? fallback;
234
291
  }
235
292
 
236
293
  const DEFAULT_MAX_OUTPUT_BYTES = 200 * 1024;
@@ -378,6 +435,26 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext
378
435
  let session: StageSessionRuntime | undefined;
379
436
  let sessionPromise: Promise<StageSessionRuntime> | undefined;
380
437
  let lastAssistantText: string | undefined;
438
+ // Tool-call ids whose tool returned `terminate: true` at runtime, observed
439
+ // from the session's `tool_execution_end` events. The SDK ends the turn on a
440
+ // terminating tool, so its tool result is the deterministic stage output; a
441
+ // trailing tool result from any other tool is NOT. See
442
+ // `lastAssistantTextFromSession`. The tool result *message* does not carry the
443
+ // terminate flag, so it must be tracked from the live event stream.
444
+ const terminatingToolCallIds = new Set<string>();
445
+ let unsubscribeTerminateWatcher: (() => void) | undefined;
446
+ const recordTerminatingToolCall = (event: unknown): void => {
447
+ if (event === null || typeof event !== "object") return;
448
+ const record = event as Record<string, unknown>;
449
+ if (record["type"] !== "tool_execution_end") return;
450
+ const result = record["result"];
451
+ if (result === null || typeof result !== "object") return;
452
+ if ((result as Record<string, unknown>)["terminate"] !== true) return;
453
+ const callId = record["toolCallId"];
454
+ if (typeof callId === "string" && callId.length > 0) {
455
+ terminatingToolCallIds.add(callId);
456
+ }
457
+ };
381
458
  let adapterMessages: AgentSession["messages"] = [];
382
459
  let disposed = false;
383
460
  let pendingThinkingLevel: Parameters<StageContext["setThinkingLevel"]>[0] | undefined;
@@ -463,6 +540,10 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext
463
540
  for (const listener of pendingListeners) {
464
541
  listenerUnsubscribes.set(listener, created.subscribe(listener));
465
542
  }
543
+ // Track terminating tool calls for this session so the stage result text is
544
+ // derived deterministically from a tool that actually ended the turn.
545
+ unsubscribeTerminateWatcher?.();
546
+ unsubscribeTerminateWatcher = created.subscribe((event) => recordTerminatingToolCall(event));
466
547
  return created;
467
548
  }
468
549
 
@@ -501,6 +582,9 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext
501
582
  sessionPromise = undefined;
502
583
  for (const unsubscribe of listenerUnsubscribes.values()) unsubscribe();
503
584
  listenerUnsubscribes.clear();
585
+ unsubscribeTerminateWatcher?.();
586
+ unsubscribeTerminateWatcher = undefined;
587
+ terminatingToolCallIds.clear();
504
588
  await current?.dispose();
505
589
  }
506
590
 
@@ -604,7 +688,7 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext
604
688
  return lastAssistantText;
605
689
  }
606
690
  await promptWithFallback(text, sdkOptions);
607
- const rawText = lastAssistantTextFromSession(session, lastAssistantText) ?? "";
691
+ const rawText = lastAssistantTextFromSession(session, lastAssistantText, terminatingToolCallIds) ?? "";
608
692
  lastAssistantText = await finalizePromptOutput(rawText, outputOptions, runtimeCwd);
609
693
  return lastAssistantText;
610
694
  },
@@ -628,7 +712,7 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext
628
712
  // `ctx.complete()` can still use the stage AgentSession for simple text
629
713
  // completions. Completion-specific options require the dedicated adapter.
630
714
  await promptWithFallback(text, undefined, "complete");
631
- lastAssistantText = lastAssistantTextFromSession(session, lastAssistantText) ?? "";
715
+ lastAssistantText = lastAssistantTextFromSession(session, lastAssistantText, terminatingToolCallIds) ?? "";
632
716
  return lastAssistantText;
633
717
  },
634
718
 
@@ -717,15 +801,18 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext
717
801
  for (const unsubscribe of listenerUnsubscribes.values()) unsubscribe();
718
802
  listenerUnsubscribes.clear();
719
803
  pendingListeners.clear();
804
+ unsubscribeTerminateWatcher?.();
805
+ unsubscribeTerminateWatcher = undefined;
806
+ terminatingToolCallIds.clear();
720
807
  await session?.dispose();
721
808
  },
722
809
 
723
810
  __getLastAssistantText() {
724
- return lastAssistantTextFromSession(session, lastAssistantText);
811
+ return lastAssistantTextFromSession(session, lastAssistantText, terminatingToolCallIds);
725
812
  },
726
813
 
727
814
  getLastAssistantText() {
728
- return lastAssistantTextFromSession(session, lastAssistantText);
815
+ return lastAssistantTextFromSession(session, lastAssistantText, terminatingToolCallIds);
729
816
  },
730
817
 
731
818
  async __ensureSession() {
@@ -0,0 +1,326 @@
1
+ /**
2
+ * stage-prompt — headless answering for brokered in-stage prompts.
3
+ *
4
+ * A workflow stage's agent can call the `ask_user_question` tool, and the
5
+ * executor raises a deterministic readiness gate after such a turn. Both
6
+ * render their UI through {@link StageUiBroker.requestCustomUi} and resolve the
7
+ * tool's `ctx.ui.custom<QuestionnaireResult>()` promise with a
8
+ * `QuestionnaireResult`-shaped value. That value is normally produced by the
9
+ * interactive TUI component.
10
+ *
11
+ * For programmatic / non-interactive control (e.g. an orchestrating agent
12
+ * answering via `workflow send`), we need to synthesize the same value WITHOUT
13
+ * the TUI. This module:
14
+ * 1. Parses the `ask_user_question` tool args into a serializable
15
+ * {@link StageInputRequest} descriptor (surfaced on the stage snapshot so
16
+ * `workflow send` / status can show the questions + options).
17
+ * 2. Produces a {@link StagePromptAdapter} whose `buildResult` maps a simple
18
+ * answer (free text, an option label, an option index, or a pre-built raw
19
+ * result) into the `QuestionnaireResult` the tool expects.
20
+ *
21
+ * cross-ref:
22
+ * - packages/coding-agent/src/core/tools/ask-user-question/tool/types.ts
23
+ * QuestionnaireResult / QuestionAnswer (the result shape mirrored here)
24
+ * - src/shared/stage-ui-broker.ts provideStagePrompt / answerStagePrompt
25
+ * - src/runs/foreground/executor.ts ask_user_question watcher + readiness gate
26
+ */
27
+
28
+ import type {
29
+ StageInputKind,
30
+ StageInputQuestion,
31
+ StageInputRequest,
32
+ } from "./store-types.js";
33
+
34
+ /**
35
+ * A simple, transport-friendly answer to a brokered stage prompt. Exactly one
36
+ * of the fields is typically populated:
37
+ * - `raw`: a pre-built `QuestionnaireResult`-shaped value, forwarded verbatim.
38
+ * - `optionLabels`: explicit option label(s) (one for single-select, many for
39
+ * multi-select).
40
+ * - `text`: free text — matched against option labels / 1-based indices, and
41
+ * otherwise treated as a typed ("custom") answer.
42
+ */
43
+ export interface StageInputAnswer {
44
+ readonly text?: string;
45
+ readonly optionLabels?: readonly string[];
46
+ readonly raw?: unknown;
47
+ }
48
+
49
+ /** Result shape mirrored from the coding-agent ask_user_question tool. */
50
+ interface BuiltAnswer {
51
+ questionIndex: number;
52
+ question: string;
53
+ kind: "option" | "custom" | "chat" | "multi";
54
+ answer: string | null;
55
+ selected?: string[];
56
+ }
57
+ interface BuiltResult {
58
+ answers: BuiltAnswer[];
59
+ cancelled: boolean;
60
+ }
61
+
62
+ /**
63
+ * Couples the serializable {@link StageInputRequest} descriptor with a
64
+ * `buildResult` that turns a {@link StageInputAnswer} into the value used to
65
+ * resolve the brokered `ctx.ui.custom` promise.
66
+ */
67
+ export interface StagePromptAdapter {
68
+ readonly prompt: StageInputRequest;
69
+ buildResult(answer: StageInputAnswer): unknown;
70
+ }
71
+
72
+ function normalizeLabel(value: string): string {
73
+ return value.trim().toLowerCase();
74
+ }
75
+
76
+ function asString(value: unknown): string | undefined {
77
+ return typeof value === "string" ? value : undefined;
78
+ }
79
+
80
+ function readStringArray(value: unknown): readonly string[] | undefined {
81
+ if (!Array.isArray(value)) return undefined;
82
+ const out = value.filter(
83
+ (item): item is string => typeof item === "string" && item.trim().length > 0,
84
+ );
85
+ return out.length > 0 ? out : undefined;
86
+ }
87
+
88
+ function readFirstString(...values: readonly unknown[]): string | undefined {
89
+ for (const value of values) {
90
+ if (typeof value === "string" && value.length > 0) return value;
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ /** True when the answer carries a value the adapter can resolve. */
96
+ export function hasStageInputAnswerContent(answer: StageInputAnswer): boolean {
97
+ return (
98
+ answer.text !== undefined || answer.optionLabels !== undefined || answer.raw !== undefined
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Coerce a loosely-typed answer payload — as delivered by `workflow send`
104
+ * (`text` / `response` / `message`) — into a normalized {@link StageInputAnswer}
105
+ * the {@link StagePromptAdapter} can resolve against a question's options.
106
+ *
107
+ * Recognized inputs:
108
+ * - a plain string (option label, 1-based index, or free text);
109
+ * - a JSON-encoded string of any structured shape below;
110
+ * - a fully-formed `QuestionnaireResult` (every answer carries a numeric
111
+ * `questionIndex`), forwarded raw;
112
+ * - the orchestrator-friendly `{ answers: [{ answer | label | selected }] }`
113
+ * or `{ questions: [{ answer | label | selected }] }`;
114
+ * - a flat `{ answer | response | label | selected | optionLabels }`;
115
+ * - a string array (multi-select labels).
116
+ *
117
+ * Without this, a structured `response` was forwarded verbatim as the brokered
118
+ * result, violating the `QuestionnaireResult` contract and leaving the readiness
119
+ * gate (and any brokered prompt) unable to resolve to a matching option.
120
+ */
121
+ export function coerceStageInputAnswer(value: unknown): StageInputAnswer {
122
+ if (typeof value === "string") {
123
+ const trimmed = value.trim();
124
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
125
+ try {
126
+ const parsed: unknown = JSON.parse(trimmed);
127
+ if (parsed !== null && typeof parsed === "object") {
128
+ const coerced = coerceStageInputAnswer(parsed);
129
+ if (hasStageInputAnswerContent(coerced)) return coerced;
130
+ }
131
+ } catch {
132
+ // Not JSON — fall through and treat the string as a literal answer.
133
+ }
134
+ }
135
+ return { text: value };
136
+ }
137
+ if (Array.isArray(value)) {
138
+ const labels = readStringArray(value);
139
+ return labels !== undefined ? { optionLabels: labels } : {};
140
+ }
141
+ if (value === null || typeof value !== "object") return {};
142
+
143
+ const record = value as Record<string, unknown>;
144
+
145
+ // Forward verbatim ONLY a fully-formed QuestionnaireResult — every answer
146
+ // entry must carry a numeric `questionIndex`. A loosely-shaped `answers[]`
147
+ // (e.g. `{ answers: [{ question, answer }] }`) is NOT genuine: forwarding it
148
+ // raw makes the tool envelope match no question index and reply
149
+ // "User declined to answer questions", stranding the prompt.
150
+ const answersArray = Array.isArray(record["answers"])
151
+ ? (record["answers"] as readonly unknown[])
152
+ : undefined;
153
+ if (
154
+ answersArray !== undefined &&
155
+ answersArray.length > 0 &&
156
+ answersArray.every(
157
+ (entry) =>
158
+ entry !== null &&
159
+ typeof entry === "object" &&
160
+ typeof (entry as Record<string, unknown>)["questionIndex"] === "number",
161
+ )
162
+ ) {
163
+ return { raw: value };
164
+ }
165
+
166
+ // Otherwise locate the answer-bearing record — the first `answers[]` or
167
+ // `questions[]` entry when present, else the object itself — and extract a
168
+ // value the adapter can resolve against the question's options (so it assigns
169
+ // the correct questionIndex), rather than forwarding an unusable result.
170
+ const source = answerBearingRecord(record);
171
+ const labels = readStringArray(source["optionLabels"]) ?? readStringArray(source["selected"]);
172
+ if (labels !== undefined) return { optionLabels: labels };
173
+
174
+ const text = readFirstString(
175
+ source["answer"],
176
+ source["response"],
177
+ source["label"],
178
+ source["text"],
179
+ );
180
+ if (text !== undefined) return { text };
181
+
182
+ // Nothing resolvable — decline rather than forward an unusable result.
183
+ return {};
184
+ }
185
+
186
+ function firstObjectOf(value: unknown): Record<string, unknown> | undefined {
187
+ const first = Array.isArray(value) ? value[0] : undefined;
188
+ return first !== null && typeof first === "object"
189
+ ? (first as Record<string, unknown>)
190
+ : undefined;
191
+ }
192
+
193
+ function answerBearingRecord(record: Record<string, unknown>): Record<string, unknown> {
194
+ return firstObjectOf(record["answers"]) ?? firstObjectOf(record["questions"]) ?? record;
195
+ }
196
+
197
+ function parseOption(value: unknown): StageInputQuestion["options"][number] | undefined {
198
+ if (value === null || typeof value !== "object") return undefined;
199
+ const record = value as Record<string, unknown>;
200
+ const label = asString(record["label"]);
201
+ if (label === undefined) return undefined;
202
+ const description = asString(record["description"]);
203
+ return description !== undefined ? { label, description } : { label };
204
+ }
205
+
206
+ function parseQuestion(value: unknown): StageInputQuestion | undefined {
207
+ if (value === null || typeof value !== "object") return undefined;
208
+ const record = value as Record<string, unknown>;
209
+ const question = asString(record["question"]);
210
+ if (question === undefined) return undefined;
211
+ const rawOptions = Array.isArray(record["options"]) ? record["options"] : [];
212
+ const options = rawOptions
213
+ .map(parseOption)
214
+ .filter((option): option is StageInputQuestion["options"][number] => option !== undefined);
215
+ const header = asString(record["header"]);
216
+ const multiSelect = record["multiSelect"] === true;
217
+ return {
218
+ question,
219
+ ...(header !== undefined ? { header } : {}),
220
+ ...(multiSelect ? { multiSelect: true } : {}),
221
+ options,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Parse `ask_user_question` tool args (`{ questions: [...] }`) into the
227
+ * serializable question descriptors. Returns `undefined` when no well-formed
228
+ * question is present.
229
+ */
230
+ export function parseAskUserQuestionArgs(
231
+ args: unknown,
232
+ ): readonly StageInputQuestion[] | undefined {
233
+ if (args === null || typeof args !== "object") return undefined;
234
+ const rawQuestions = (args as Record<string, unknown>)["questions"];
235
+ if (!Array.isArray(rawQuestions)) return undefined;
236
+ const questions = rawQuestions
237
+ .map(parseQuestion)
238
+ .filter((question): question is StageInputQuestion => question !== undefined);
239
+ return questions.length > 0 ? questions : undefined;
240
+ }
241
+
242
+ /**
243
+ * Resolve a desired answer string against a single-select question's options.
244
+ * Matches a case-insensitive option label, then a 1-based option index, then
245
+ * falls back to a typed ("custom") answer.
246
+ */
247
+ function answerSingle(question: StageInputQuestion, desired: string): BuiltAnswer {
248
+ const normalized = normalizeLabel(desired);
249
+ const byLabel = question.options.find((option) => normalizeLabel(option.label) === normalized);
250
+ if (byLabel) {
251
+ return { questionIndex: 0, question: question.question, kind: "option", answer: byLabel.label };
252
+ }
253
+ const asIndex = Number.parseInt(desired.trim(), 10);
254
+ if (
255
+ Number.isInteger(asIndex) &&
256
+ asIndex >= 1 &&
257
+ asIndex <= question.options.length &&
258
+ String(asIndex) === desired.trim()
259
+ ) {
260
+ const option = question.options[asIndex - 1]!;
261
+ return { questionIndex: 0, question: question.question, kind: "option", answer: option.label };
262
+ }
263
+ return { questionIndex: 0, question: question.question, kind: "custom", answer: desired };
264
+ }
265
+
266
+ function answerMulti(question: StageInputQuestion, candidates: readonly string[]): BuiltAnswer {
267
+ const selected: string[] = [];
268
+ for (const candidate of candidates) {
269
+ const normalized = normalizeLabel(candidate);
270
+ const byLabel = question.options.find((option) => normalizeLabel(option.label) === normalized);
271
+ if (byLabel) {
272
+ if (!selected.includes(byLabel.label)) selected.push(byLabel.label);
273
+ continue;
274
+ }
275
+ const asIndex = Number.parseInt(candidate.trim(), 10);
276
+ if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= question.options.length) {
277
+ const option = question.options[asIndex - 1]!;
278
+ if (!selected.includes(option.label)) selected.push(option.label);
279
+ }
280
+ }
281
+ return { questionIndex: 0, question: question.question, kind: "multi", answer: null, selected };
282
+ }
283
+
284
+ function buildResult(
285
+ questions: readonly StageInputQuestion[],
286
+ answer: StageInputAnswer,
287
+ ): unknown {
288
+ if (answer.raw !== undefined) return answer.raw;
289
+ const question = questions[0];
290
+ if (question === undefined) return { answers: [], cancelled: true } satisfies BuiltResult;
291
+
292
+ const multi = question.multiSelect === true;
293
+ if (multi) {
294
+ const candidates =
295
+ answer.optionLabels !== undefined
296
+ ? answer.optionLabels
297
+ : (answer.text ?? "").split(",").map((part) => part.trim()).filter((part) => part.length > 0);
298
+ return { answers: [answerMulti(question, candidates)], cancelled: false } satisfies BuiltResult;
299
+ }
300
+
301
+ const desired = answer.optionLabels?.[0] ?? answer.text;
302
+ if (desired === undefined || desired.length === 0) {
303
+ return { answers: [], cancelled: true } satisfies BuiltResult;
304
+ }
305
+ return { answers: [answerSingle(question, desired)], cancelled: false } satisfies BuiltResult;
306
+ }
307
+
308
+ /**
309
+ * Build a {@link StagePromptAdapter} from `ask_user_question` tool args. Returns
310
+ * `undefined` when the args contain no parseable question (the prompt then
311
+ * stays TUI-only).
312
+ */
313
+ export function buildStagePromptAdapter(
314
+ id: string,
315
+ kind: StageInputKind,
316
+ args: unknown,
317
+ createdAt: number,
318
+ ): StagePromptAdapter | undefined {
319
+ const questions = parseAskUserQuestionArgs(args);
320
+ if (questions === undefined) return undefined;
321
+ const prompt: StageInputRequest = { id, kind, questions, createdAt };
322
+ return {
323
+ prompt,
324
+ buildResult: (answer) => buildResult(questions, answer),
325
+ };
326
+ }
@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
2
2
  import type { Component, Focusable, TUI } from "@earendil-works/pi-tui";
3
3
  import type { Store } from "./store.js";
4
4
  import { store as defaultStore } from "./store.js";
5
+ import type { StageInputRequest } from "./store-types.js";
6
+ import type { StageInputAnswer, StagePromptAdapter } from "./stage-prompt.js";
5
7
  import type { PiCustomOverlayFactory, PiCustomOverlayOptions, PiKeybindings, PiTheme } from "../extension/wiring.js";
6
8
 
7
9
  export interface StageCustomUiRequest<T = unknown> {
@@ -32,11 +34,61 @@ export class StageUiBroker {
32
34
  private readonly store: Store;
33
35
  private readonly pending = new Map<string, StageCustomUiRequest>();
34
36
  private readonly hosts = new Map<string, StageCustomUiHost>();
37
+ // Headless-answer adapters keyed by (runId, stageId). Populated by the
38
+ // executor's ask_user_question watcher and the readiness gate so a brokered
39
+ // custom-UI prompt can be answered programmatically (e.g. via `workflow
40
+ // send`) without a TUI host rendering the interactive component.
41
+ private readonly adapters = new Map<string, StagePromptAdapter>();
35
42
 
36
43
  constructor(store: Store = defaultStore) {
37
44
  this.store = store;
38
45
  }
39
46
 
47
+ /**
48
+ * Register the structured descriptor + headless result builder for the
49
+ * prompt a stage is about to raise (or has just raised) via
50
+ * `requestCustomUi`. Surfaces the descriptor on the stage snapshot so
51
+ * `workflow send` / status can see and answer it. Safe to call before or
52
+ * after the matching `requestCustomUi`; the (runId, stageId) key joins them.
53
+ */
54
+ provideStagePrompt(runId: string, stageId: string, adapter: StagePromptAdapter): void {
55
+ this.adapters.set(key(runId, stageId), adapter);
56
+ this.store.recordStageInputRequest(runId, stageId, adapter.prompt);
57
+ }
58
+
59
+ /** Drop a stage's headless-answer adapter and clear its snapshot descriptor. */
60
+ clearStagePrompt(runId: string, stageId: string): void {
61
+ if (this.adapters.delete(key(runId, stageId))) {
62
+ this.store.clearStageInputRequest(runId, stageId);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Return the structured descriptor for a stage's brokered prompt when BOTH a
68
+ * headless-answer adapter and a live pending request exist for it — i.e. when
69
+ * `answerStagePrompt` can actually resolve something right now.
70
+ */
71
+ peekStagePrompt(runId: string, stageId: string): StageInputRequest | undefined {
72
+ const hostKey = key(runId, stageId);
73
+ const adapter = this.adapters.get(hostKey);
74
+ if (adapter && this.pending.has(hostKey)) return adapter.prompt;
75
+ return undefined;
76
+ }
77
+
78
+ /**
79
+ * Headlessly answer a stage's pending brokered prompt. Resolves the awaiting
80
+ * `ctx.ui.custom` promise with the adapter-built result. Returns `false` when
81
+ * there is no adapter+request pair for the stage.
82
+ */
83
+ answerStagePrompt(runId: string, stageId: string, answer: StageInputAnswer): boolean {
84
+ const hostKey = key(runId, stageId);
85
+ const adapter = this.adapters.get(hostKey);
86
+ const request = this.pending.get(hostKey);
87
+ if (!adapter || !request) return false;
88
+ this.resolve(request, adapter.buildResult(answer));
89
+ return true;
90
+ }
91
+
40
92
  private hideHost(host: StageCustomUiHost | undefined, request: StageCustomUiRequest, reason: unknown): void {
41
93
  try {
42
94
  host?.hideCustomUi?.(request, reason);
@@ -70,13 +122,12 @@ export class StageUiBroker {
70
122
  return () => {
71
123
  if (this.hosts.get(hostKey) !== host) return;
72
124
  this.hosts.delete(hostKey);
73
- const pendingRequest = this.pending.get(hostKey);
74
- if (pendingRequest) {
75
- this.reject(
76
- pendingRequest,
77
- new Error(`pi-workflows: stage ${stageId} custom UI host unregistered`),
78
- );
79
- }
125
+ // Unregistering a host means it stops *displaying* the request — not that
126
+ // the request is cancelled. A stage-scoped human-input request (e.g.
127
+ // ask_user_question / readiness gate) outlives any one attached chat:
128
+ // detaching leaves it pending (the stage stays awaiting_input) and a
129
+ // future host re-displays it. The request is settled only by the user
130
+ // answering (resolve) or the run aborting (its AbortSignal -> reject).
80
131
  };
81
132
  }
82
133
 
@@ -132,6 +183,8 @@ export class StageUiBroker {
132
183
  const hostKey = key(request.runId, request.stageId);
133
184
  if (this.pending.get(hostKey)?.id !== request.id) return;
134
185
  this.pending.delete(hostKey);
186
+ this.adapters.delete(hostKey);
187
+ this.store.clearStageInputRequest(request.runId, request.stageId);
135
188
  this.store.recordStageAwaitingInput(request.runId, request.stageId, false);
136
189
  this.hideHost(this.hosts.get(hostKey), request, undefined);
137
190
  request.resolve(value);
@@ -141,6 +194,8 @@ export class StageUiBroker {
141
194
  const hostKey = key(request.runId, request.stageId);
142
195
  if (this.pending.get(hostKey)?.id !== request.id) return;
143
196
  this.pending.delete(hostKey);
197
+ this.adapters.delete(hostKey);
198
+ this.store.clearStageInputRequest(request.runId, request.stageId);
144
199
  this.store.recordStageAwaitingInput(request.runId, request.stageId, false);
145
200
  this.hideHost(this.hosts.get(hostKey), request, reason);
146
201
  request.reject(reason);
@@ -42,6 +42,41 @@ export interface PendingPrompt {
42
42
  readonly createdAt: number;
43
43
  }
44
44
 
45
+ /** Discriminates the brokered structured-prompt source. */
46
+ export type StageInputKind = "ask_user_question" | "readiness_gate";
47
+
48
+ /** One selectable option in a {@link StageInputQuestion}. */
49
+ export interface StageInputOption {
50
+ readonly label: string;
51
+ readonly description?: string;
52
+ }
53
+
54
+ /** One question in a {@link StageInputRequest}. */
55
+ export interface StageInputQuestion {
56
+ readonly question: string;
57
+ readonly header?: string;
58
+ readonly multiSelect?: boolean;
59
+ readonly options: readonly StageInputOption[];
60
+ }
61
+
62
+ /**
63
+ * Serializable descriptor of an in-stage `ask_user_question` (or readiness
64
+ * gate) prompt brokered through `StageUiBroker`. Unlike {@link PendingPrompt}
65
+ * (the simple input/confirm/select/editor HIL model), this mirrors the richer
66
+ * structured ask_user_question shape so `workflow send` and status inspection
67
+ * can see the questions/options and answer the prompt without the TUI.
68
+ *
69
+ * Resolution lives in `StageUiBroker` (the awaiting `ctx.ui.custom` promise);
70
+ * only this JSON-cloneable descriptor lives on the snapshot.
71
+ */
72
+ export interface StageInputRequest {
73
+ readonly id: string;
74
+ readonly kind: StageInputKind;
75
+ readonly questions: readonly StageInputQuestion[];
76
+ /** Issue timestamp (ms since epoch). */
77
+ readonly createdAt: number;
78
+ }
79
+
45
80
  export interface ToolEvent {
46
81
  name: string;
47
82
  input?: Record<string, unknown>;
@@ -95,6 +130,14 @@ export interface StageSnapshot {
95
130
  awaitingInputSince?: number;
96
131
  /** Pending human-in-the-loop prompt owned by this workflow stage/node. */
97
132
  pendingPrompt?: PendingPrompt;
133
+ /**
134
+ * Structured descriptor of a brokered ask_user_question / readiness-gate
135
+ * prompt awaiting an answer. Set while the stage's `ctx.ui.custom` promise is
136
+ * pending; resolution lives in `StageUiBroker`. Lets `workflow send` answer
137
+ * the prompt headlessly. Distinct from {@link pendingPrompt}, which models
138
+ * the simpler input/confirm/select/editor HIL prompts.
139
+ */
140
+ inputRequest?: StageInputRequest;
98
141
  blockedByStageId?: string;
99
142
  notices?: StageNotice[];
100
143
  /**