@bastani/atomic 0.5.12-3 → 0.5.12-5

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 (27) hide show
  1. package/.agents/skills/workflow-creator/SKILL.md +24 -17
  2. package/.agents/skills/workflow-creator/references/agent-sessions.md +67 -24
  3. package/.agents/skills/workflow-creator/references/computation-and-validation.md +5 -3
  4. package/.agents/skills/workflow-creator/references/control-flow.md +25 -11
  5. package/.agents/skills/workflow-creator/references/discovery-and-verification.md +3 -2
  6. package/.agents/skills/workflow-creator/references/failure-modes.md +35 -36
  7. package/.agents/skills/workflow-creator/references/getting-started.md +25 -12
  8. package/.agents/skills/workflow-creator/references/session-config.md +26 -5
  9. package/.agents/skills/workflow-creator/references/state-and-data-flow.md +3 -3
  10. package/.agents/skills/workflow-creator/references/workflow-inputs.md +52 -47
  11. package/README.md +63 -41
  12. package/package.json +2 -4
  13. package/src/commands/cli/workflow.ts +1 -1
  14. package/src/sdk/components/workflow-picker-panel.tsx +109 -47
  15. package/src/sdk/define-workflow.test.ts +58 -0
  16. package/src/sdk/define-workflow.ts +48 -30
  17. package/src/sdk/providers/claude.ts +234 -233
  18. package/src/sdk/runtime/discovery.ts +2 -3
  19. package/src/sdk/runtime/executor.ts +6 -1
  20. package/src/sdk/types.ts +24 -19
  21. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +11 -30
  22. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +7 -4
  23. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +6 -2
  24. package/src/sdk/workflows/builtin/ralph/claude/index.ts +32 -38
  25. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +5 -1
  26. package/src/sdk/workflows/builtin/ralph/opencode/index.ts +5 -1
  27. package/src/sdk/workflows/index.ts +2 -2
@@ -391,7 +391,7 @@ async function runPickerMode(
391
391
 
392
392
  /**
393
393
  * Execute a workflow selected via the picker. The picker already stores
394
- * free-form prompts under the `prompt` key (via `DEFAULT_PROMPT_INPUT`),
394
+ * free-form prompts under the canonical `prompt` key,
395
395
  * so we can hand the inputs record straight through — no split between
396
396
  * "prompt" and "structured inputs" is needed.
397
397
  */
@@ -30,6 +30,7 @@ import {
30
30
  createCliRenderer,
31
31
  type CliRenderer,
32
32
  type KeyEvent,
33
+ type ScrollBoxRenderable,
33
34
  type TextareaRenderable,
34
35
  } from "@opentui/core";
35
36
  import {
@@ -117,19 +118,6 @@ export interface WorkflowPickerResult {
117
118
  inputs: Record<string, string>;
118
119
  }
119
120
 
120
- /** Fallback field used when a workflow has no structured input schema. */
121
- const DEFAULT_PROMPT_INPUT: WorkflowInput = {
122
- name: "prompt",
123
- type: "text",
124
- required: true,
125
- description: "what do you want this workflow to do?",
126
- placeholder: "describe your task…",
127
- };
128
-
129
- /** Stable single-element array for free-form workflows — avoids allocating
130
- * a new `[DEFAULT_PROMPT_INPUT]` on every useMemo recomputation. */
131
- const DEFAULT_FIELDS: WorkflowInput[] = [DEFAULT_PROMPT_INPUT];
132
-
133
121
  // ─── Helpers ────────────────────────────────────
134
122
 
135
123
  const SOURCE_DISPLAY: Record<Source, string> = {
@@ -477,8 +465,7 @@ const Preview = memo(function Preview({
477
465
  wf: WorkflowWithMetadata;
478
466
  }) {
479
467
  const theme = usePickerTheme();
480
- const args: readonly WorkflowInput[] =
481
- wf.inputs.length > 0 ? wf.inputs : DEFAULT_FIELDS;
468
+ const args = wf.inputs;
482
469
 
483
470
  return (
484
471
  <box
@@ -512,13 +499,16 @@ const Preview = memo(function Preview({
512
499
  </span>
513
500
  </text>
514
501
 
515
- <box height={2} />
516
-
517
- <SectionLabel label="ARGUMENTS" />
518
- <box height={1} />
519
- {args.map((f) => (
520
- <ArgumentRow key={f.name} field={f} />
521
- ))}
502
+ {args.length > 0 && (
503
+ <>
504
+ <box height={2} />
505
+ <SectionLabel label="ARGUMENTS" />
506
+ <box height={1} />
507
+ {args.map((f) => (
508
+ <ArgumentRow key={f.name} field={f} />
509
+ ))}
510
+ </>
511
+ )}
522
512
  </box>
523
513
  );
524
514
  });
@@ -651,7 +641,7 @@ function EnumContent({
651
641
  selected,
652
642
  focused,
653
643
  }: {
654
- values: string[];
644
+ values: readonly string[];
655
645
  selected: string;
656
646
  focused: boolean;
657
647
  }) {
@@ -690,12 +680,14 @@ function EnumContent({
690
680
  }
691
681
 
692
682
  const Field = memo(function Field({
683
+ id,
693
684
  field,
694
685
  value,
695
686
  focused,
696
687
  onFieldInput,
697
688
  onTextChangeRef,
698
689
  }: {
690
+ id?: string;
699
691
  field: WorkflowInput;
700
692
  value: string;
701
693
  focused: boolean;
@@ -719,7 +711,7 @@ const Field = memo(function Field({
719
711
  );
720
712
 
721
713
  return (
722
- <box flexDirection="column">
714
+ <box id={id} flexDirection="column">
723
715
  <box
724
716
  border
725
717
  borderStyle="rounded"
@@ -789,6 +781,54 @@ function InputPhase({
789
781
  }) {
790
782
  const theme = usePickerTheme();
791
783
  const isStructured = workflow.inputs.length > 0;
784
+ const scrollboxRef = useRef<ScrollBoxRenderable>(null);
785
+ const [scrollTop, setScrollTop] = useState(0);
786
+
787
+ // Auto-scroll to keep the focused field visible.
788
+ // Sync scrollTop immediately so the visibility check below
789
+ // marks the field as visible on the same render pass.
790
+ useEffect(() => {
791
+ const sb = scrollboxRef.current;
792
+ const field = fields[focusedFieldIdx];
793
+ if (!sb || !field) return;
794
+ sb.scrollChildIntoView(`field-${field.name}`);
795
+ setScrollTop(sb.scrollTop);
796
+ }, [focusedFieldIdx, fields]);
797
+
798
+ // Sync scrollTop on every OpenTUI render frame via renderBefore.
799
+ // This replaces a polling timer — it fires at the renderer's native
800
+ // frame rate so the focused field defocuses within one frame of
801
+ // scrolling out of view, preventing the terminal cursor from
802
+ // bleeding into the fixed header above.
803
+ const syncScrollFrame = useCallback(function (this: unknown) {
804
+ const sb = scrollboxRef.current;
805
+ if (!sb) return;
806
+ setScrollTop((prev) => {
807
+ const cur = sb.scrollTop;
808
+ return cur !== prev ? cur : prev;
809
+ });
810
+ }, []);
811
+
812
+ // The bordered content box (where the cursor lives) must be fully
813
+ // inside the viewport. If even one row is clipped the field loses
814
+ // focus so the cursor can never land in a clipped row.
815
+ const isFocusedFieldVisible = useMemo(() => {
816
+ const sb = scrollboxRef.current;
817
+ if (!sb) return true;
818
+ const vpH = sb.viewport.height;
819
+ if (vpH <= 0) return true;
820
+ let y = 0;
821
+ for (let i = 0; i < fields.length; i++) {
822
+ const f = fields[i]!;
823
+ const inputH = f.type === "text" ? TEXT_FIELD_LINES + 2 : 3;
824
+ if (i === focusedFieldIdx) {
825
+ return y >= scrollTop && y + inputH <= scrollTop + vpH;
826
+ }
827
+ // Caption row (1) + spacer row (1) below the bordered box.
828
+ y += inputH + 2;
829
+ }
830
+ return true;
831
+ }, [fields, focusedFieldIdx, scrollTop]);
792
832
 
793
833
  return (
794
834
  <box
@@ -839,7 +879,7 @@ function InputPhase({
839
879
  <box flexDirection="row" height={1}>
840
880
  <text>
841
881
  <span fg={theme.textDim}>
842
- <strong>{isStructured ? "INPUTS" : "PROMPT"}</strong>
882
+ <strong>INPUTS</strong>
843
883
  </span>
844
884
  </text>
845
885
  <box flexGrow={1} />
@@ -851,20 +891,48 @@ function InputPhase({
851
891
  </box>
852
892
  <box height={1} />
853
893
 
854
- {fields.map((f, i) => (
855
- <Field
856
- key={f.name}
857
- field={f}
858
- value={values[f.name] ?? ""}
859
- focused={i === focusedFieldIdx}
860
- onFieldInput={onFieldInput}
861
- onTextChangeRef={
862
- f.type === "text" && i === focusedFieldIdx
863
- ? onTextChangeRef
864
- : undefined
865
- }
866
- />
867
- ))}
894
+ <scrollbox
895
+ ref={scrollboxRef}
896
+ scrollY
897
+ viewportCulling
898
+ flexGrow={1}
899
+ renderBefore={syncScrollFrame}
900
+ style={{
901
+ rootOptions: {
902
+ backgroundColor: "transparent",
903
+ border: false,
904
+ },
905
+ contentOptions: {
906
+ flexDirection: "column",
907
+ },
908
+ verticalScrollbarOptions: {
909
+ showArrows: false,
910
+ trackOptions: {
911
+ foregroundColor: theme.border,
912
+ backgroundColor: theme.backgroundElement,
913
+ },
914
+ },
915
+ }}
916
+ >
917
+ {fields.map((f, i) => {
918
+ const active = i === focusedFieldIdx && isFocusedFieldVisible;
919
+ return (
920
+ <Field
921
+ key={f.name}
922
+ id={`field-${f.name}`}
923
+ field={f}
924
+ value={values[f.name] ?? ""}
925
+ focused={active}
926
+ onFieldInput={onFieldInput}
927
+ onTextChangeRef={
928
+ f.type === "text" && active
929
+ ? onTextChangeRef
930
+ : undefined
931
+ }
932
+ />
933
+ );
934
+ })}
935
+ </scrollbox>
868
936
  </box>
869
937
  );
870
938
  }
@@ -1171,12 +1239,8 @@ function usePickerKeyboard(state: PickerKeyboardState): void {
1171
1239
  key.stopPropagation();
1172
1240
  const wf = focusedWfRef.current;
1173
1241
  if (wf) {
1174
- const inputs: readonly WorkflowInput[] =
1175
- wf.inputs.length > 0
1176
- ? wf.inputs
1177
- : DEFAULT_FIELDS;
1178
1242
  const initial: Record<string, string> = {};
1179
- for (const f of inputs) {
1243
+ for (const f of wf.inputs) {
1180
1244
  initial[f.name] =
1181
1245
  f.default ??
1182
1246
  (f.type === "enum" ? (f.values?.[0] ?? "") : "");
@@ -1290,9 +1354,7 @@ export function WorkflowPicker({
1290
1354
  const focusedWf = entries[clampedEntryIdx]?.workflow;
1291
1355
 
1292
1356
  const currentFields = useMemo<readonly WorkflowInput[]>(
1293
- () => focusedWf && focusedWf.inputs.length > 0
1294
- ? focusedWf.inputs
1295
- : DEFAULT_FIELDS,
1357
+ () => focusedWf?.inputs ?? [],
1296
1358
  [focusedWf],
1297
1359
  );
1298
1360
  const currentField = currentFields[focusedFieldIdx];
@@ -170,3 +170,61 @@ describe("WorkflowBuilder.compile()", () => {
170
170
  expect(() => builder.compile()).toThrow("has no run callback");
171
171
  });
172
172
  });
173
+
174
+ describe("WorkflowBuilder.for()", () => {
175
+ test("returns the same builder instance (type-only narrowing)", () => {
176
+ const builder = defineWorkflow({ name: "test" });
177
+ const narrowed = builder.for<"copilot">();
178
+ // Same instance — .for() only changes the TypeScript type, not the value
179
+ expect(narrowed === (builder as unknown)).toBe(true);
180
+ });
181
+
182
+ test("chains with run and compile", () => {
183
+ const def = defineWorkflow({
184
+ name: "test",
185
+ inputs: [{ name: "greeting", type: "string" }],
186
+ })
187
+ .for<"copilot">()
188
+ .run(async () => {})
189
+ .compile();
190
+ expect(def.__brand).toBe("WorkflowDefinition");
191
+ expect(def.inputs[0]?.name).toBe("greeting");
192
+ });
193
+ });
194
+
195
+ describe("typed inputs (compile-time)", () => {
196
+ test("structured inputs restrict ctx.inputs keys", () => {
197
+ // This test validates that the type system correctly narrows
198
+ // ctx.inputs to only declared field names. The assertions below
199
+ // are runtime no-ops — the real check is that tsc compiles this
200
+ // file without errors (or produces errors only where expected).
201
+ defineWorkflow({
202
+ name: "typed-test",
203
+ inputs: [
204
+ { name: "greeting", type: "string", required: true },
205
+ { name: "style", type: "enum", values: ["formal", "casual"] },
206
+ ],
207
+ })
208
+ .for<"copilot">()
209
+ .run(async (ctx) => {
210
+ // Declared keys are valid
211
+ const _g: string | undefined = ctx.inputs.greeting;
212
+ const _s: string | undefined = ctx.inputs.style;
213
+ // Undeclared key — would be a compile error without @ts-expect-error
214
+ // @ts-expect-error — "prompt" is not a declared input
215
+ ctx.inputs.prompt;
216
+ expect(true).toBe(true);
217
+ })
218
+ .compile();
219
+ });
220
+
221
+ test("free-form workflows allow any key", () => {
222
+ defineWorkflow({ name: "freeform-test" })
223
+ .for<"copilot">()
224
+ .run(async (ctx) => {
225
+ const _p: string | undefined = ctx.inputs.prompt;
226
+ expect(true).toBe(true);
227
+ })
228
+ .compile();
229
+ });
230
+ });
@@ -2,7 +2,8 @@
2
2
  * Workflow Builder — defines a workflow with a single `.run()` entry point.
3
3
  *
4
4
  * Usage:
5
- * defineWorkflow<"copilot">({ name: "my-workflow", description: "..." })
5
+ * defineWorkflow({ name: "my-workflow", inputs: [...] })
6
+ * .for<"copilot">()
6
7
  * .run(async (ctx) => {
7
8
  * await ctx.stage({ name: "research" }, {}, {}, async (s) => { ... });
8
9
  * await ctx.stage({ name: "plan" }, {}, {}, async (s) => { ... });
@@ -58,16 +59,42 @@ function validateWorkflowInput(input: WorkflowInput, workflowName: string): void
58
59
  * Chainable workflow builder. Records the run callback,
59
60
  * then .compile() seals it into a WorkflowDefinition.
60
61
  */
61
- export class WorkflowBuilder<A extends AgentType = AgentType> {
62
+ export class WorkflowBuilder<A extends AgentType = AgentType, N extends string = string> {
62
63
  /** @internal Brand for detection across package boundaries */
63
64
  readonly __brand = "WorkflowBuilder" as const;
64
65
  private readonly options: WorkflowOptions;
65
- private runFn: ((ctx: WorkflowContext<A>) => Promise<void>) | null = null;
66
+ private runFn: ((ctx: WorkflowContext<A, N>) => Promise<void>) | null = null;
66
67
 
67
68
  constructor(options: WorkflowOptions) {
68
69
  this.options = options;
69
70
  }
70
71
 
72
+ /**
73
+ * Narrow the agent type for this workflow while preserving typed inputs.
74
+ *
75
+ * Use `.for<"copilot">()` **before** `.run()` instead of passing the
76
+ * agent as a type parameter to `defineWorkflow`. This allows TypeScript
77
+ * to infer input names from the `inputs` array AND narrow the agent
78
+ * type for `stage()` callbacks.
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * defineWorkflow({
83
+ * name: "my-workflow",
84
+ * inputs: [{ name: "greeting", type: "string" }],
85
+ * })
86
+ * .for<"copilot">()
87
+ * .run(async (ctx) => {
88
+ * ctx.inputs.greeting; // ✓ typed
89
+ * ctx.inputs.prompt; // ✗ compile error
90
+ * })
91
+ * .compile();
92
+ * ```
93
+ */
94
+ for<B extends AgentType>(): WorkflowBuilder<B, N> {
95
+ return this as unknown as WorkflowBuilder<B, N>;
96
+ }
97
+
71
98
  /**
72
99
  * Set the workflow's entry point.
73
100
  *
@@ -76,7 +103,7 @@ export class WorkflowBuilder<A extends AgentType = AgentType> {
76
103
  * reading completed session outputs. Use native TypeScript control flow
77
104
  * (loops, conditionals, `Promise.all()`) for orchestration.
78
105
  */
79
- run(fn: (ctx: WorkflowContext<A>) => Promise<void>): this {
106
+ run(fn: (ctx: WorkflowContext<A, N>) => Promise<void>): this {
80
107
  if (this.runFn) {
81
108
  throw new Error("run() can only be called once per workflow.");
82
109
  }
@@ -93,7 +120,7 @@ export class WorkflowBuilder<A extends AgentType = AgentType> {
93
120
  * After calling compile(), the returned object is consumed by the
94
121
  * Atomic CLI runtime.
95
122
  */
96
- compile(): WorkflowDefinition<A> {
123
+ compile(): WorkflowDefinition<A, N> {
97
124
  if (!this.runFn) {
98
125
  throw new Error(
99
126
  `Workflow "${this.options.name}" has no run callback. ` +
@@ -133,45 +160,36 @@ export class WorkflowBuilder<A extends AgentType = AgentType> {
133
160
  /**
134
161
  * Entry point for defining a workflow.
135
162
  *
136
- * Pass a type parameter to narrow all context types to a specific agent:
163
+ * Write the `inputs` array inline so TypeScript infers literal field
164
+ * names and enforces them on `ctx.inputs`. Use `.for<Agent>()` to
165
+ * narrow the agent type while keeping typed inputs:
137
166
  *
138
167
  * @example
139
168
  * ```typescript
140
169
  * import { defineWorkflow } from "@bastani/atomic/workflows";
141
170
  *
142
- * export default defineWorkflow<"copilot">({
171
+ * export default defineWorkflow({
143
172
  * name: "hello",
144
173
  * description: "Two-session demo",
174
+ * inputs: [
175
+ * { name: "greeting", type: "string", required: true },
176
+ * ],
145
177
  * })
178
+ * .for<"copilot">()
146
179
  * .run(async (ctx) => {
147
- * const describe = await ctx.stage(
148
- * { name: "describe" },
149
- * {},
150
- * {},
151
- * async (s) => {
152
- * // s.client: CopilotClient, s.session: CopilotSession
153
- * await s.session.send({ prompt: s.inputs.prompt ?? "" });
154
- * s.save(await s.session.getMessages());
155
- * },
156
- * );
157
- * await ctx.stage(
158
- * { name: "summarize" },
159
- * {},
160
- * {},
161
- * async (s) => {
162
- * const research = await s.transcript(describe);
163
- * // ...
164
- * },
165
- * );
180
+ * ctx.inputs.greeting; // string | undefined
181
+ * ctx.inputs.prompt; // compile error — not declared
166
182
  * })
167
183
  * .compile();
168
184
  * ```
169
185
  */
170
- export function defineWorkflow<A extends AgentType = AgentType>(
171
- options: WorkflowOptions,
172
- ): WorkflowBuilder<A> {
186
+ export function defineWorkflow<
187
+ const I extends readonly WorkflowInput[] = readonly WorkflowInput[],
188
+ >(
189
+ options: WorkflowOptions<I>,
190
+ ): WorkflowBuilder<AgentType, I[number]["name"]> {
173
191
  if (!options.name || options.name.trim() === "") {
174
192
  throw new Error("Workflow name is required.");
175
193
  }
176
- return new WorkflowBuilder<A>(options);
194
+ return new WorkflowBuilder<AgentType, I[number]["name"]>(options);
177
195
  }