Summary
Replace the type: 'command' | 'pipeline' discriminated union in StepSchema with a unified commands array. Length 1 = single command, length 2+ = pipeline. Eliminates a proven failure mode where Claude picks the wrong type.
Problem
Claude in the wild produced:
{ "type": "pipeline", "commands": [{ "program": "grep", "args": [...] }] }
Rejected by minItems: 2 on pipeline commands. Valid JSON, valid structure, wrong type selection. Claude knew the schema (fetched via ToolSearch), still reached for pipeline due to pattern matching.
The discriminated union adds a choice that:
- Has a proven failure mode (Claude picks wrong type)
- Provides zero additional type safety (no fields exclusive to either variant)
- Maps 1:1 to a derivable property (
commands.length)
- Is not strict (extra fields like
chaining on a single command are silently ignored)
Proposed Change
// Before: discriminated union, Claude must choose
const StepSchema = z.discriminatedUnion('type', [SingleCommandSchema, PipelineSchema]);
// After: one shape, length determines behaviour
const StepSchema = z.object({
commands: z.array(CommandSchema).min(1)
.describe('Commands to execute. Length 1 = single command. Length 2+ = pipeline (stdout → stdin).'),
});
Executor branches on commands.length instead of type. Everything else stays the same.
Why this is safe
SingleCommandSchema spreads CommandSchema.shape — same fields
PipelineSchema.commands contains CommandSchema[] — same fields
- No pipeline-specific fields exist that need to be absent on single commands
chaining is on ExecInputSchema (between steps), not on steps themselves
- The
type field is pure documentation with no validation benefit beyond what length provides
Design principle
"Discriminated unions are ceremony when the variants share the same fields and the discriminator maps 1:1 to a derivable property. For LLM callers, derive instead of discriminate."
Schema should make wrong output impossible, not just invalid. With one shape, Claude literally cannot make this mistake.
🤖 Filed by BananaBot9000 — observed in the wild by Hellcar, analysed over bananas 🍌⚔️
Summary
Replace the
type: 'command' | 'pipeline'discriminated union inStepSchemawith a unifiedcommandsarray. Length 1 = single command, length 2+ = pipeline. Eliminates a proven failure mode where Claude picks the wrong type.Problem
Claude in the wild produced:
{ "type": "pipeline", "commands": [{ "program": "grep", "args": [...] }] }Rejected by
minItems: 2on pipeline commands. Valid JSON, valid structure, wrong type selection. Claude knew the schema (fetched via ToolSearch), still reached forpipelinedue to pattern matching.The discriminated union adds a choice that:
commands.length)chainingon a single command are silently ignored)Proposed Change
Executor branches on
commands.lengthinstead oftype. Everything else stays the same.Why this is safe
SingleCommandSchemaspreadsCommandSchema.shape— same fieldsPipelineSchema.commandscontainsCommandSchema[]— same fieldschainingis onExecInputSchema(between steps), not on steps themselvestypefield is pure documentation with no validation benefit beyond what length providesDesign principle
"Discriminated unions are ceremony when the variants share the same fields and the discriminator maps 1:1 to a derivable property. For LLM callers, derive instead of discriminate."
Schema should make wrong output impossible, not just invalid. With one shape, Claude literally cannot make this mistake.
🤖 Filed by BananaBot9000 — observed in the wild by Hellcar, analysed over bananas 🍌⚔️