diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f91771..eb86e14d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Stream merge redirects `2>&1` (stderr to stdout's destination) and `1>&2` + (stdout to stderr's destination). Each is a single token with no internal + spaces, and works on command lists, pipeline stages, and quotations. + Unlike POSIX, they are not order-sensitive fd duplication: the merged + stream follows the other stream's *final* destination, so `2>&1 *` captures + both streams interleaved and `[[make] 2>&1 [grep err]] |;` sends stderr + through the pipe, cross-platform. + +### Changed + +- Each of stdout/stderr now has exactly one destination: combining two + destinations on the same stream (e.g. capture `*` plus file redirect `>`, + `&>` plus `2>`, a second `>`, or a merge plus anything else on that stream) + is now an error, caught both by the static type checker and at runtime. + Previously the extra redirect was silently ignored (capture won over file + redirects) or last-wins. `2>&1` combined with `<>` is also rejected. +- `loop` quotations now honor stderr redirects, append mode, and merges, and + inherit the enclosing quotation's redirected streams (previously a loop + body wrote straight to the terminal even inside a redirected quotation). +- Quotations accept only the redirects that don't change the stack: file + redirects, stdin, and the merges. Captures (`*`, `*b`, `^`, `^b`) on a + quotation now give a clear error at both layers instead of falling into + the multiplication error, and the type checker now rejects `<>` on a + quotation (the runtime always did). Capture individual command lists + instead, e.g. `[[cmd1] [cmd2]] (* !) map`. + +### Added + - CLI completions for `cargo`: subcommands (including installed third-party ones via `cargo --list`), per-subcommand options, and dynamic values for `--target`, `--features`, `-p`/`--package`, `--bin`/`--example`/`--test`/`--bench` diff --git a/code/syntaxes/mshell.textmate.json b/code/syntaxes/mshell.textmate.json index b7bfb655..0cd82fd0 100644 --- a/code/syntaxes/mshell.textmate.json +++ b/code/syntaxes/mshell.textmate.json @@ -12,6 +12,10 @@ "name": "constant.language.bool", "match": "\\b(true|false)\\b" }, + { + "name": "keyword.operator.redirect.merge.mshell", + "match": "2>&1|1>&2" + }, { "name": "string.quoted.single.mshell", "begin": "'", diff --git a/doc/execution.inc.html b/doc/execution.inc.html index b9b3f920..e47bab2b 100644 --- a/doc/execution.inc.html +++ b/doc/execution.inc.html @@ -142,6 +142,36 @@ +

Merging one stream into the other

+ +

+Use 2>&1 to send standard error to wherever standard output ends up, +or 1>&2 to send standard output to wherever standard error ends up. +These are complete tokens: no spaces are allowed inside them. +Unlike POSIX shells, they are not order-sensitive file descriptor duplication: +a merge means "this stream goes to the other stream's final destination", +so there is no > file 2>&1 vs 2>&1 > file ordering trap. +Both streams share a single destination, preserving output order, and this works the same on every platform. +

+ +
+[yourCommand] 2>&1 * ! # Captures stdout and stderr interleaved as one string.
+[[make] 2>&1 [grep -i error]] |; # stderr flows through the pipe.
+[yourCommand] 1>&2 ; # stdout appears on stderr, e.g. visible even when the surrounding output is captured.
+[yourCommand] `output.log` > 2>&1 ! # Both streams into output.log; equivalent to &>.
+
+
+ +

Each stream has exactly one destination

+ +

+A stream's destination can be set once: a file redirect, a capture, an in-place redirect, or a merge. +Applying a second destination to the same stream is an error, caught both by the static type checker and at runtime. +For example, [cmd] * `f` > (capture and file redirect on stdout), [cmd] 2>&1 ^ (merge and capture on stderr), +and [cmd] 2>&1 1>&2 (circular merge) are all rejected. +2>&1 also cannot be combined with <>, since stderr text would be written back into the edited file. +

+ @@ -161,6 +191,8 @@ + + @@ -170,11 +202,36 @@

Redirection on quotations

-All of the redirection operators above also work on quotations. +The redirection operators that don't change the stack also work on quotations: +file redirects (>, >>, 2>, 2>>, &>, &>>), +stdin (<), and the merges (2>&1, 1>&2). This is useful when you want to redirect the output of mshell code that uses wl, wle, or other built-in functions that write to stdout or stderr. It is also useful when you have many commands that you want to run while appending all the outputs to a single file, without having to put the redirection on each command invocation.

+

+The captures (*, *b, ^, ^b) and the in-place redirect (<>) are not allowed on quotations, +because they would change the quotation's stack effect. +Capture the individual command lists inside the quotation instead — for example, run a list of commands and collect each stdout: +

+ +
+[[cmd1] [cmd2]] (* !) map # Run each command, collect stdouts as a list of strings.
+
+
+ +

+Destination conflicts on quotations (e.g. two > redirects, or 2>&1 plus 2>) are caught at runtime. +Since redirects never change a quotation's stack effect, they are invisible to the static type checker. +

+ +

+A quotation's redirect is resolved each time the quotation executes. +So a >-redirected quotation run repeatedly — stored and executed with x several times, or passed to each or map — truncates the file on every execution, leaving only the last run's output. +Use >> when a repeatedly-executed quotation should accumulate output. +The exception is loop: the file is opened once when the loop starts, so all iterations write to the same open file. +

+
 (
     "Hello from stdout" wl
diff --git a/doc/mshell.md b/doc/mshell.md
index 4b64c3e1..3e87699d 100644
--- a/doc/mshell.md
+++ b/doc/mshell.md
@@ -106,6 +106,30 @@ So the following are equivalent:
 [yourCommand] ^b *b !
 ```
 
+#### Merging one stream into the other
+
+Use `2>&1` to send stderr to wherever stdout ends up, or `1>&2` to send stdout to wherever stderr ends up.
+These are complete tokens: no spaces are allowed inside them.
+Unlike POSIX shells, they are not order-sensitive fd duplication:
+a merge means "this stream goes to the other stream's *final* destination",
+so there is no `> file 2>&1` vs `2>&1 > file` ordering trap.
+Both streams share a single destination, preserving output order, on every platform.
+
+```mshell
+[yourCommand] 2>&1 * !                 # Captures stdout and stderr interleaved as one string.
+[[make] 2>&1 [grep -i error]] |;       # stderr flows through the pipe.
+[yourCommand] 1>&2 ;                   # stdout appears on stderr.
+[yourCommand] `output.log` > 2>&1 !    # Both streams into output.log; equivalent to &>.
+```
+
+#### Each stream has exactly one destination
+
+A stream's destination can be set once: a file redirect, a capture, an in-place redirect, or a merge.
+Applying a second destination to the same stream is an error, caught both by the static type checker and at runtime.
+For example, ``[cmd] * `f` >`` (capture and file redirect on stdout), `[cmd] 2>&1 ^` (merge and capture on stderr),
+and `[cmd] 2>&1 1>&2` (circular merge) are all rejected.
+`2>&1` also cannot be combined with `<>`, since stderr text would be written back into the edited file.
+
 Summary of external command operators:
 
 Operator | Effect on external commands                | Notes
@@ -123,13 +147,30 @@ Operator | Effect on external commands                | Notes
 `&>>`    | Redirect both stdout and stderr to a file. | Appends to the file.
 `^`      | Capture stderr to the stack.               | As a string.
 `^b`     | Capture stderr to the stack.               | As binary.
+`2>&1`   | Merge stderr into stdout's destination.    | Single token, no spaces. Not order-sensitive: stderr follows stdout's final destination.
+`1>&2`   | Merge stdout into stderr's destination.    | Single token, no spaces.
 `<`      | Feed stdin from a value.                   | String, path, or binary.
 `<>`     | In-place file modification.                | Reads file to stdin, writes stdout back on success.
 `&`      | Mark the command list to run asynchronously. | Marks the list; the trailing `;`/`!` starts the subprocess and returns immediately without waiting. Stdout and stderr default to discarded.
 
 ### Redirection on quotations
 
-All of the redirection operators above also work on quotations. This is useful when you want to redirect the output of mshell code that uses `wl`, `wle`, or other built-in functions that write to stdout or stderr. It is also useful when you have many commands that you want to run while appending all the outputs to a single file, without having to put the redirection on each command invocation.
+The redirection operators that don't change the stack also work on quotations:
+file redirects (`>`, `>>`, `2>`, `2>>`, `&>`, `&>>`), stdin (`<`), and the merges (`2>&1`, `1>&2`).
+This is useful when you want to redirect the output of mshell code that uses `wl`, `wle`, or other built-in functions that write to stdout or stderr.
+It is also useful when you have many commands that you want to run while appending all the outputs to a single file, without having to put the redirection on each command invocation.
+
+The captures (`*`, `*b`, `^`, `^b`) and the in-place redirect (`<>`) are *not* allowed on quotations,
+because they would change the quotation's stack effect.
+Capture the individual command lists inside the quotation instead, e.g. `[[cmd1] [cmd2]] (* !) map` to run each command and collect stdouts.
+
+Destination conflicts on quotations (e.g. two `>` redirects, or `2>&1` plus `2>`) are caught at runtime.
+Since redirects never change a quotation's stack effect, they are invisible to the static type checker.
+
+A quotation's redirect is resolved each time the quotation executes.
+So a `>`-redirected quotation run repeatedly — stored and executed with `x` several times, or passed to `each` or `map` — truncates the file on every execution, leaving only the last run's output.
+Use `>>` when a repeatedly-executed quotation should accumulate output.
+The exception is `loop`: the file is opened once when the loop starts, so all iterations write to the same open file.
 
 ```mshell
 (
diff --git a/mshell/Evaluator.go b/mshell/Evaluator.go
index ab2f8854..dd151dba 100644
--- a/mshell/Evaluator.go
+++ b/mshell/Evaluator.go
@@ -1523,55 +1523,32 @@ func (state *EvalState) processLoop(t Token, frame *EvaluationFrame, frames *[]E
 		return state.FailWithMessage(fmt.Sprintf("%d:%d: Loop quotation needs a minimum of one token.\n", t.Line, t.Column))
 	}
 
-	// Build loop context
-	loopContext := ExecuteContext{
-		StandardInput:  nil,
-		StandardOutput: nil,
-		Variables:      context.Variables,
-		Pbm:            context.Pbm,
-	}
-
-	if quotation.StdinBehavior != STDIN_NONE {
-		switch quotation.StdinBehavior {
-		case STDIN_CONTENT:
-			loopContext.StandardInput = strings.NewReader(quotation.StandardInputContents)
-		case STDIN_BINARY:
-			loopContext.StandardInput = bytes.NewReader(quotation.StandardInputBinary)
-		case STDIN_FILE:
-			file, err := os.Open(quotation.StandardInputFile)
-			if err != nil {
-				return state.FailWithMessage(fmt.Sprintf("%d:%d: Error opening file %s for reading: %s\n", t.Line, t.Column, quotation.StandardInputFile, err.Error()))
-			}
-			loopContext.StandardInput = file
-			loopContext.ShouldCloseInput = true
-		}
-	}
-
-	if quotation.StandardOutputFile != "" {
-		file, err := os.Create(quotation.StandardOutputFile)
-		if err != nil {
-			return state.FailWithMessage(fmt.Sprintf("%d:%d: Error opening file %s for writing: %s\n", t.Line, t.Column, quotation.StandardOutputFile, err.Error()))
-		}
-		loopContext.StandardOutput = file
-		loopContext.ShouldCloseOutput = true
+	// Build loop context. BuildExecutionContext handles all the quotation's
+	// redirections (stdin, stdout, stderr, merges) and propagates the outer
+	// context's streams when the quotation has none of its own.
+	loopContext, err := quotation.BuildExecutionContext(&context)
+	if err != nil {
+		return state.FailWithMessage(err.Error())
 	}
+	loopContext.Variables = context.Variables
 
 	// Push FRAME_LOOP
 	state.LoopDepth++
 	callStackItem := CallStackItem{MShellParseItem: quotation, Name: "loop", CallStackType: CALLSTACKQUOTE}
 	state.CallStack.Push(callStackItem)
 	newFrame := EvaluationFrame{
-		Objects:          quotation.Tokens,
-		Index:            0,
-		Context:          loopContext,
-		Stack:            stack,
-		Definitions:      definitions,
-		CallStackItem:    callStackItem,
-		FrameType:        FRAME_LOOP,
-		LoopMax:          15000000,
-		LoopCount:        0,
-		LoopQuotation:    quotation,
-		InitialStackSize: len(*stack),
+		Objects:            quotation.Tokens,
+		Index:              0,
+		Context:            *loopContext,
+		Stack:              stack,
+		Definitions:        definitions,
+		CallStackItem:      callStackItem,
+		FrameType:          FRAME_LOOP,
+		LoopMax:            15000000,
+		LoopCount:          0,
+		LoopQuotation:      quotation,
+		InitialStackSize:   len(*stack),
+		ShouldCloseContext: true,
 	}
 	*frames = append(*frames, newFrame)
 	return SimpleSuccess()
@@ -3350,6 +3327,33 @@ type Executable interface {
 	GetStandardOutputFile() string
 }
 
+// stdoutDestinationDescOf / stderrDestinationDescOf describe the operator
+// that already claimed the stream on a redirectable object, or "" when the
+// stream is unclaimed (or the object is not redirectable).
+func stdoutDestinationDescOf(obj MShellObject) string {
+	switch o := obj.(type) {
+	case *MShellList:
+		return o.StdoutDestinationDesc()
+	case *MShellQuotation:
+		return o.StdoutDestinationDesc()
+	case *MShellPipe:
+		return o.StdoutDestinationDesc()
+	}
+	return ""
+}
+
+func stderrDestinationDescOf(obj MShellObject) string {
+	switch o := obj.(type) {
+	case *MShellList:
+		return o.StderrDestinationDesc()
+	case *MShellQuotation:
+		return o.StderrDestinationDesc()
+	case *MShellPipe:
+		return o.StderrDestinationDesc()
+	}
+	return ""
+}
+
 func (list *MShellList) Execute(state *EvalState, context ExecuteContext, stack *MShellStack) (EvalResult, int, []byte, []byte) {
 	result, exitCode, stdoutResult, stderrResult := RunProcess(*list, context, state)
 	return result, exitCode, stdoutResult, stderrResult
@@ -4019,6 +4023,17 @@ func RunProcess(list MShellList, context ExecuteContext, state *EvalState) (Eval
 		}
 	}
 
+	// MERGE REDIRECTS: at most one of these is set (enforced when the token
+	// is applied). The merged stream aliases the other stream's resolved
+	// destination. os/exec passes a duplicated fd when both are the same
+	// *os.File and serializes Writes when both are the same writer, so this
+	// is safe on every platform.
+	if list.StdoutToStderr {
+		cmd.Stdout = cmd.Stderr
+	} else if list.StderrToStdout {
+		cmd.Stderr = cmd.Stdout
+	}
+
 	var startErr error
 	var exitCode int
 
@@ -11608,11 +11623,19 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 				}
 
 				if asList, ok := obj1.(*MShellList); ok {
+					if desc := asList.StdoutDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					asList.StdoutBehavior = STDOUT_COMPLETE
 					stack.Push(asList)
 				} else if asPipe, ok := obj1.(*MShellPipe); ok {
+					if desc := asPipe.StdoutDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					asPipe.StdoutBehavior = STDOUT_COMPLETE
 					stack.Push(asPipe)
+				} else if _, ok := obj1.(*MShellQuotation); ok {
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: '%s' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with '>'.\n", t.Line, t.Column, t.Lexeme))
 				} else {
 					obj2, err := stack.Pop()
 					if err != nil {
@@ -11648,9 +11671,17 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 
 				switch objTyped := obj1.(type) {
 				case *MShellList:
+					if desc := objTyped.StdoutDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					objTyped.StdoutBehavior = STDOUT_BINARY
 				case *MShellPipe:
+					if desc := objTyped.StdoutDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					objTyped.StdoutBehavior = STDOUT_BINARY
+				case *MShellQuotation:
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: '%s' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with '>'.\n", t.Line, t.Column, t.Lexeme))
 				default:
 					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot capture binary stdout for a %s.\n", t.Line, t.Column, obj1.TypeName()))
 				}
@@ -11665,9 +11696,17 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 
 				switch objTyped := obj1.(type) {
 				case *MShellList:
+					if desc := objTyped.StderrDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					objTyped.StderrBehavior = STDERR_COMPLETE
 				case *MShellPipe:
+					if desc := objTyped.StderrDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					objTyped.StderrBehavior = STDERR_COMPLETE
+				case *MShellQuotation:
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: '%s' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with '2>'.\n", t.Line, t.Column, t.Lexeme))
 				default:
 					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot capture stderr for a %s.\n", t.Line, t.Column, obj1.TypeName()))
 				}
@@ -11680,9 +11719,17 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 
 				switch objTyped := obj1.(type) {
 				case *MShellList:
+					if desc := objTyped.StderrDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					objTyped.StderrBehavior = STDERR_BINARY
 				case *MShellPipe:
+					if desc := objTyped.StderrDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					objTyped.StderrBehavior = STDERR_BINARY
+				case *MShellQuotation:
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: '%s' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with '2>'.\n", t.Line, t.Column, t.Lexeme))
 				default:
 					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot capture binary stderr for a %s.\n", t.Line, t.Column, obj1.TypeName()))
 				}
@@ -11704,10 +11751,16 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 
 				switch obj2Typed := obj2.(type) {
 				case *MShellList:
+					if desc := obj2Typed.StdoutDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					obj2Typed.StandardOutputFile = path
 					obj2Typed.AppendOutput = true
 					stack.Push(obj2Typed)
 				case *MShellQuotation:
+					if desc := obj2Typed.StdoutDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					obj2Typed.StandardOutputFile = path
 					obj2Typed.AppendOutput = true
 					stack.Push(obj2Typed)
@@ -12110,6 +12163,11 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s' across numeric types %s and %s. Use 'toFloat' / 'toInt' to convert explicitly.\n", t.Line, t.Column, t.Lexeme, obj2.TypeName(), obj1.TypeName()))
 					}
 				} else {
+					if t.Type == GREATERTHAN {
+						if desc := stdoutDestinationDescOf(obj2); desc != "" {
+							return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+						}
+					}
 					switch obj1.(type) {
 					case MShellString:
 						path := obj1.(MShellString).Content
@@ -12247,10 +12305,16 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 
 				switch obj2Typed := obj2.(type) {
 				case *MShellList:
+					if desc := obj2Typed.StderrDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					obj2Typed.StandardErrorFile = redirectFile
 					obj2Typed.AppendError = t.Type == STDERRAPPEND
 					stack.Push(obj2Typed)
 				case *MShellQuotation:
+					if desc := obj2Typed.StderrDestinationDesc(); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
 					obj2Typed.StandardErrorFile = redirectFile
 					obj2Typed.AppendError = t.Type == STDERRAPPEND
 					stack.Push(obj2Typed)
@@ -12278,6 +12342,13 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 
 				appendMode := t.Type == STDOUTANDSTDERRAPPEND
 
+				if desc := stdoutDestinationDescOf(obj2); desc != "" {
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+				}
+				if desc := stderrDestinationDescOf(obj2); desc != "" {
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+				}
+
 				switch obj2Typed := obj2.(type) {
 				case *MShellList:
 					obj2Typed.StandardOutputFile = redirectFile
@@ -12314,6 +12385,15 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 						t.Line, t.Column, obj2.TypeName()))
 				}
 
+				if desc := list.StdoutDestinationDesc(); desc != "" {
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+				}
+				// stderr merged into stdout would write error text back into
+				// the edited file; reject the combination.
+				if list.StderrToStdout {
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr is merged into stdout ('2>&1'), which would write stderr back into the edited file.\n", t.Line, t.Column, t.Lexeme))
+				}
+
 				// Read file contents immediately
 				fileContents, err := os.ReadFile(pathObj.Path)
 				if err != nil {
@@ -12326,6 +12406,59 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 				list.StandardInputBinary = fileContents
 				list.InPlaceFile = pathObj.Path
 				stack.Push(list)
+			} else if t.Type == STDERRTOSTDOUT || t.Type == STDOUTTOSTDERR { // Token Type
+				obj, err := stack.Pop()
+				if err != nil {
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot do '%s' operation on an empty stack.\n", t.Line, t.Column, t.Lexeme))
+				}
+
+				if t.Type == STDERRTOSTDOUT {
+					if desc := stderrDestinationDescOf(obj); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stderr already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
+				} else {
+					if desc := stdoutDestinationDescOf(obj); desc != "" {
+						return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s': stdout already has %s. Each stream has exactly one destination.\n", t.Line, t.Column, t.Lexeme, desc))
+					}
+				}
+
+				// The two merges together are circular: neither stream would
+				// have a real destination.
+				circularMsg := "%d:%d: Cannot apply '%s': the other stream is already merged with '%s'; merging both streams into each other is circular.\n"
+
+				switch objTyped := obj.(type) {
+				case *MShellList:
+					if t.Type == STDERRTOSTDOUT {
+						if objTyped.StdoutToStderr {
+							return state.FailWithMessage(fmt.Sprintf(circularMsg, t.Line, t.Column, t.Lexeme, "1>&2"))
+						}
+						if objTyped.InPlaceFile != "" {
+							return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s' with an in-place redirect ('<>'): stderr would be written back into the edited file.\n", t.Line, t.Column, t.Lexeme))
+						}
+						objTyped.StderrToStdout = true
+					} else {
+						if objTyped.StderrToStdout {
+							return state.FailWithMessage(fmt.Sprintf(circularMsg, t.Line, t.Column, t.Lexeme, "2>&1"))
+						}
+						objTyped.StdoutToStderr = true
+					}
+					stack.Push(objTyped)
+				case *MShellQuotation:
+					if t.Type == STDERRTOSTDOUT {
+						if objTyped.StdoutToStderr {
+							return state.FailWithMessage(fmt.Sprintf(circularMsg, t.Line, t.Column, t.Lexeme, "1>&2"))
+						}
+						objTyped.StderrToStdout = true
+					} else {
+						if objTyped.StderrToStdout {
+							return state.FailWithMessage(fmt.Sprintf(circularMsg, t.Line, t.Column, t.Lexeme, "2>&1"))
+						}
+						objTyped.StdoutToStderr = true
+					}
+					stack.Push(objTyped)
+				default:
+					return state.FailWithMessage(fmt.Sprintf("%d:%d: Cannot apply '%s' to a %s; expected a list or quotation.\n", t.Line, t.Column, t.Lexeme, obj.TypeName()))
+				}
 			} else if t.Type == ENVSTORE { // Token Type
 				obj, err := stack.Pop()
 				if err != nil {
@@ -12402,41 +12535,15 @@ func (state *EvalState) evaluateToken(t Token, stack *MShellStack, context Execu
 					return state.FailWithMessage(fmt.Sprintf("%d:%d: Loop quotation needs a minimum of one token.\n", t.Line, t.Column))
 				}
 
-				loopContext := ExecuteContext{
-					StandardInput:  nil,
-					StandardOutput: nil,
-					Variables:      context.Variables,
-					Pbm:            context.Pbm,
-				}
-
-				if quotation.StdinBehavior != STDIN_NONE {
-					switch quotation.StdinBehavior {
-					case STDIN_CONTENT:
-						loopContext.StandardInput = strings.NewReader(quotation.StandardInputContents)
-					case STDIN_BINARY:
-						loopContext.StandardInput = bytes.NewReader(quotation.StandardInputBinary)
-					case STDIN_FILE:
-						file, err := os.Open(quotation.StandardInputFile)
-						if err != nil {
-							return state.FailWithMessage(fmt.Sprintf("%d:%d: Error opening file %s for reading: %s\n", t.Line, t.Column, quotation.StandardInputFile, err.Error()))
-						}
-						loopContext.StandardInput = file
-						// TODO: This probably shouldn't be done here like this
-						defer file.Close()
-					default:
-						panic("Unknown stdin behavior")
-					}
-				}
-
-				if quotation.StandardOutputFile != "" {
-					file, err := os.Create(quotation.StandardOutputFile)
-					if err != nil {
-						return state.FailWithMessage(fmt.Sprintf("%d:%d: Error opening file %s for writing: %s\n", t.Line, t.Column, quotation.StandardOutputFile, err.Error()))
-					}
-					loopContext.StandardOutput = file
-					// TODO: This probably shouldn't be done here like this
-					defer file.Close()
+				// BuildExecutionContext handles all the quotation's
+				// redirections (stdin, stdout, stderr, merges).
+				builtContext, err := quotation.BuildExecutionContext(&context)
+				if err != nil {
+					return state.FailWithMessage(err.Error())
 				}
+				builtContext.Variables = context.Variables
+				loopContext := *builtContext
+				defer loopContext.Close()
 
 				maxLoops := 15000000
 				loopCount := 0
diff --git a/mshell/Lexer.go b/mshell/Lexer.go
index 7a641f4b..14dcc35a 100644
--- a/mshell/Lexer.go
+++ b/mshell/Lexer.go
@@ -99,6 +99,8 @@ const (
 	PREFIXQUOTE              // .functionName
 	MATCH
 	VER
+	STDERRTOSTDOUT           // 2>&1, merge stderr into stdout's destination
+	STDOUTTOSTDERR           // 1>&2, merge stdout into stderr's destination
 
 	// Reserved for the static type checker (Phase 5/10) and Phase 2 of the
 	// effect system. AS and TYPE are user-visible from Phase 10 onward;
@@ -269,6 +271,10 @@ func (t TokenType) String() string {
 		return "STDOUTANDSTDERRAPPEND"
 	case INPLACEREDIRECT:
 		return "INPLACEREDIRECT"
+	case STDERRTOSTDOUT:
+		return "STDERRTOSTDOUT"
+	case STDOUTTOSTDERR:
+		return "STDOUTTOSTDERR"
 	case GRID_OPEN:
 		return "GRID_OPEN"
 	case GRID_CLOSE:
@@ -424,6 +430,13 @@ func (l *Lexer) peekNext() rune {
 	return l.input[l.current+1]
 }
 
+func (l *Lexer) peekAt(offset int) rune {
+	if l.current+offset >= len(l.input) {
+		return 0
+	}
+	return l.input[l.current+offset]
+}
+
 // Increments line, resets col. Make sure this is called after the newline has been consumed.
 // Otherwise, when the newline is consumed, the column will be incremented to 2 on the l.Advance()
 func (l *Lexer) handleNewline() {
@@ -997,6 +1010,23 @@ func (l *Lexer) parseNumberOrStartIndexer() Token {
 			l.advance()
 			return l.makeToken(STDERRAPPEND)
 		}
+		// Merge redirects are complete tokens with no internal spaces:
+		// exactly "2>&1" (stderr to stdout) or "1>&2" (stdout to stderr).
+		if l.peekNext() == '&' {
+			intPart := l.curLexeme()
+			if intPart == "2" && l.peekAt(2) == '1' {
+				l.advance()
+				l.advance()
+				l.advance()
+				return l.makeToken(STDERRTOSTDOUT)
+			}
+			if intPart == "1" && l.peekAt(2) == '2' {
+				l.advance()
+				l.advance()
+				l.advance()
+				return l.makeToken(STDOUTTOSTDERR)
+			}
+		}
 		l.advance()
 		return l.makeToken(STDERRREDIRECT)
 	} else if peek == '.' {
diff --git a/mshell/MShellObject.go b/mshell/MShellObject.go
index f68309f6..c237d434 100644
--- a/mshell/MShellObject.go
+++ b/mshell/MShellObject.go
@@ -613,6 +613,8 @@ type MShellQuotation struct {
 	AppendOutput          bool
 	StandardErrorFile     string
 	AppendError           bool
+	StdoutToStderr        bool // 1>&2: stdout goes to stderr's destination
+	StderrToStdout        bool // 2>&1: stderr goes to stdout's destination
 	Variables             map[string]MShellObject
 }
 
@@ -718,6 +720,17 @@ func (q *MShellQuotation) BuildExecutionContext(context *ExecuteContext) (*Execu
 		quoteContext.StandardError = os.Stderr
 	}
 
+	// Merge redirects: at most one is set (enforced when the token is
+	// applied). The merged stream aliases the other stream's resolved
+	// destination. The merged side never opened its own file (a file
+	// redirect on it would have been a conflict), so the ShouldClose flags
+	// are already correct.
+	if q.StdoutToStderr {
+		quoteContext.StandardOutput = quoteContext.StandardError
+	} else if q.StderrToStdout {
+		quoteContext.StandardError = quoteContext.StandardOutput
+	}
+
 	return "eContext, nil
 }
 
@@ -772,6 +785,8 @@ type MShellList struct {
 	StderrBehavior  StderrBehavior
 	RunInBackground bool
 	InPlaceFile     string // File path for in-place modification with <>
+	StdoutToStderr  bool   // 1>&2: stdout goes to stderr's destination
+	StderrToStdout  bool   // 2>&1: stderr goes to stdout's destination
 }
 
 // initLength creates list like: make([]MShellObject, initLength)
@@ -853,11 +868,67 @@ func CopyListParams(copyFromList *MShellList, copyToList *MShellList) {
 	copyToList.StandardInputBinary = copyFromList.StandardInputBinary
 	copyToList.StandardInputFile = copyFromList.StandardInputFile
 	copyToList.StandardOutputFile = copyFromList.StandardOutputFile
+	copyToList.AppendOutput = copyFromList.AppendOutput
 	copyToList.StandardErrorFile = copyFromList.StandardErrorFile
 	copyToList.AppendError = copyFromList.AppendError
 	copyToList.StdoutBehavior = copyFromList.StdoutBehavior
 	copyToList.StderrBehavior = copyFromList.StderrBehavior
 	copyToList.InPlaceFile = copyFromList.InPlaceFile
+	copyToList.StdoutToStderr = copyFromList.StdoutToStderr
+	copyToList.StderrToStdout = copyFromList.StderrToStdout
+}
+
+// StdoutDestinationDesc describes the operator that already claimed stdout,
+// or "" when stdout is still unclaimed. Each stream has exactly one
+// destination; redirect ops use this to reject a second claim.
+func (list *MShellList) StdoutDestinationDesc() string {
+	switch {
+	case list.StdoutBehavior != STDOUT_NONE:
+		return "a capture ('*')"
+	case list.InPlaceFile != "":
+		return "an in-place redirect ('<>')"
+	case list.StandardOutputFile != "":
+		return "a file redirect ('>')"
+	case list.StdoutToStderr:
+		return "a merge to stderr ('1>&2')"
+	}
+	return ""
+}
+
+// StderrDestinationDesc describes the operator that already claimed stderr,
+// or "" when stderr is still unclaimed.
+func (list *MShellList) StderrDestinationDesc() string {
+	switch {
+	case list.StderrBehavior != STDERR_NONE:
+		return "a capture ('^')"
+	case list.StandardErrorFile != "":
+		return "a file redirect ('2>')"
+	case list.StderrToStdout:
+		return "a merge to stdout ('2>&1')"
+	}
+	return ""
+}
+
+// StdoutDestinationDesc for quotations; quotations support file redirects
+// and merges but not captures or in-place redirects.
+func (q *MShellQuotation) StdoutDestinationDesc() string {
+	switch {
+	case q.StandardOutputFile != "":
+		return "a file redirect ('>')"
+	case q.StdoutToStderr:
+		return "a merge to stderr ('1>&2')"
+	}
+	return ""
+}
+
+func (q *MShellQuotation) StderrDestinationDesc() string {
+	switch {
+	case q.StandardErrorFile != "":
+		return "a file redirect ('2>')"
+	case q.StderrToStdout:
+		return "a merge to stdout ('2>&1')"
+	}
+	return ""
 }
 
 type MShellString struct {
@@ -874,6 +945,21 @@ type MShellPipe struct {
 	StderrBehavior StderrBehavior
 }
 
+// Pipes only support captures as stream destinations.
+func (p *MShellPipe) StdoutDestinationDesc() string {
+	if p.StdoutBehavior != STDOUT_NONE {
+		return "a capture ('*')"
+	}
+	return ""
+}
+
+func (p *MShellPipe) StderrDestinationDesc() string {
+	if p.StderrBehavior != STDERR_NONE {
+		return "a capture ('^')"
+	}
+	return ""
+}
+
 type MShellInt struct {
 	Value int
 }
diff --git a/mshell/Type.go b/mshell/Type.go
index 674a9e03..f2075db6 100644
--- a/mshell/Type.go
+++ b/mshell/Type.go
@@ -118,6 +118,12 @@ type TypeNode struct {
 	Extra uint32
 }
 
+// CommandCaptureMode is really a per-stream *destination state*: unset,
+// captured to the stack (str/bytes/lines), redirected to a file, used for an
+// in-place edit, or merged into the other stream. The checker uses it to
+// enforce that each stream has exactly one destination, mirroring the
+// runtime's conflict errors. Only the capture states produce stack outputs
+// at `;` / `!` / `?`.
 type CommandCaptureMode uint32
 
 const (
@@ -125,6 +131,9 @@ const (
 	CommandCaptureStr
 	CommandCaptureBytes
 	CommandCaptureLines
+	CommandDestFile    // stream redirected to a file (>, >>, 2>, 2>>, &>, &>>)
+	CommandDestInPlace // stdout claimed by an in-place redirect (<>)
+	CommandDestMerged  // stream merged into the other stream (2>&1 / 1>&2)
 )
 
 // TypeVarId identifies a generic type variable. Fresh ids are issued at
diff --git a/mshell/TypeChecker.go b/mshell/TypeChecker.go
index 2ac50e88..57a48236 100644
--- a/mshell/TypeChecker.go
+++ b/mshell/TypeChecker.go
@@ -325,6 +325,13 @@ func (c *Checker) checkOne(tok Token) {
 		return
 	}
 
+	// Merge redirects are nullary state transforms on the command value;
+	// they have no sig representation.
+	if tok.Type == STDERRTOSTDOUT || tok.Type == STDOUTTOSTDERR {
+		c.applyMergeRedirect(tok)
+		return
+	}
+
 	if sigs, ok := c.builtins[tok.Type]; ok {
 		switch tok.Type {
 		case IFF:
@@ -348,7 +355,7 @@ func (c *Checker) checkOne(tok Token) {
 		if tok.Type == LOOP && c.tryLoop(tok) {
 			return
 		}
-		if tok.Type == PIPE && c.stack.Len() > 0 {
+		if (tok.Type == PIPE || tok.Type == AMPERSAND) && c.stack.Len() > 0 {
 			top := c.subst.Apply(c.arena, c.stack.Top())
 			if c.arena.Kind(top) == TKCommand {
 				return
@@ -357,6 +364,14 @@ func (c *Checker) checkOne(tok Token) {
 		if c.tryRedirect(tok) {
 			return
 		}
+		if c.tryCapture(tok) {
+			return
+		}
+		if tok.Type == EXECUTE || tok.Type == BANG || tok.Type == QUESTION {
+			if c.tryExecCommand(tok) {
+				return
+			}
+		}
 		c.resolveAndApply(sigs, tok)
 		return
 	}
@@ -764,12 +779,65 @@ func (c *Checker) tryPivot(tok Token) bool {
 	return true
 }
 
-// tryRedirect handles a redirect op (`>`, `<`, `>e`, ...) applied to a
-// ` ` stack, where operand is a quote (single or overloaded)
-// or a command-like value (list/command) and target is a writable path
-// (str/path/bytes). It pops the target, leaving the operand on top. Returns
-// false (no effect) when the op or stack shape doesn't match, so the caller
-// falls through to ordinary builtin dispatch.
+// commandParts decomposes a command-like operand into its argv type and
+// per-stream destination states. A plain list is a command with both
+// streams unclaimed.
+func (c *Checker) commandParts(t TypeId) (argv TypeId, stdout, stderr CommandCaptureMode, ok bool) {
+	n := c.arena.Node(t)
+	switch n.Kind {
+	case TKList:
+		return t, CommandCaptureNone, CommandCaptureNone, true
+	case TKCommand:
+		return TypeId(n.A), CommandCaptureMode(n.B), CommandCaptureMode(n.Extra), true
+	}
+	return 0, 0, 0, false
+}
+
+// streamStateDesc mirrors the runtime's StdoutDestinationDesc /
+// StderrDestinationDesc strings so static and runtime conflict errors read
+// identically. Returns "" for an unclaimed stream.
+func streamStateDesc(mode CommandCaptureMode, isStdout bool) string {
+	switch mode {
+	case CommandCaptureStr, CommandCaptureBytes, CommandCaptureLines:
+		if isStdout {
+			return "a capture ('*')"
+		}
+		return "a capture ('^')"
+	case CommandDestFile:
+		if isStdout {
+			return "a file redirect ('>')"
+		}
+		return "a file redirect ('2>')"
+	case CommandDestInPlace:
+		return "an in-place redirect ('<>')"
+	case CommandDestMerged:
+		if isStdout {
+			return "a merge to stderr ('1>&2')"
+		}
+		return "a merge to stdout ('2>&1')"
+	}
+	return ""
+}
+
+func (c *Checker) streamConflictError(tok Token, stream string, desc string) {
+	c.errors = append(c.errors, TypeError{
+		Kind: TErrTypeMismatch,
+		Pos:  tok,
+		Hint: fmt.Sprintf("Cannot apply '%s': %s already has %s. Each stream has exactly one destination.", tok.Lexeme, stream, desc),
+	})
+}
+
+// tryRedirect handles a redirect op (`>`, `<`, `2>`, `&>`, `<>`, ...) applied
+// to a ` ` stack, where operand is a quote (single or
+// overloaded) or a command-like value (list/command) and target is a writable
+// path (str/path/bytes). It pops the target and replaces the operand with a
+// command type whose stream destination states reflect the redirect, erroring
+// on a stream that already has a destination (mirroring the runtime).
+// Returns false (no effect) when the op or stack shape doesn't match, so the
+// caller falls through to ordinary builtin dispatch.
+//
+// Quote operands are still a known gap: the target is popped but no
+// destination state is tracked, so conflicts on quotations are runtime-only.
 func (c *Checker) tryRedirect(tok Token) bool {
 	switch tok.Type {
 	case LESSTHAN, GREATERTHAN, STDERRREDIRECT, STDERRAPPEND,
@@ -785,23 +853,239 @@ func (c *Checker) tryRedirect(tok Token) bool {
 		return false
 	}
 	operand := c.subst.Apply(c.arena, c.stack.items[c.stack.Len()-2])
-	if !isQuoteKind(c.arena.Kind(operand)) && !c.isCommandLike(operand) {
+	if isQuoteKind(c.arena.Kind(operand)) {
+		// Quotations accept only the redirects that don't change their
+		// stack effect; destination conflicts on quotations are enforced
+		// at runtime. In-place redirects are list-only, as at runtime.
+		if tok.Type == INPLACEREDIRECT {
+			c.errors = append(c.errors, TypeError{
+				Kind: TErrTypeMismatch,
+				Pos:  tok,
+				Hint: "In-place redirect (<>) requires a List, found a quotation.",
+			})
+		}
+		c.stack.items = c.stack.items[:c.stack.Len()-1]
+		return true
+	}
+	argv, stdoutState, stderrState, ok := c.commandParts(operand)
+	if !ok {
 		return false
 	}
+
+	conflict := false
+	switch tok.Type {
+	case GREATERTHAN, STDAPPEND:
+		if desc := streamStateDesc(stdoutState, true); desc != "" {
+			c.streamConflictError(tok, "stdout", desc)
+			conflict = true
+		}
+		stdoutState = CommandDestFile
+	case STDERRREDIRECT, STDERRAPPEND:
+		if desc := streamStateDesc(stderrState, false); desc != "" {
+			c.streamConflictError(tok, "stderr", desc)
+			conflict = true
+		}
+		stderrState = CommandDestFile
+	case STDOUTANDSTDERRREDIRECT, STDOUTANDSTDERRAPPEND:
+		if desc := streamStateDesc(stdoutState, true); desc != "" {
+			c.streamConflictError(tok, "stdout", desc)
+			conflict = true
+		} else if desc := streamStateDesc(stderrState, false); desc != "" {
+			c.streamConflictError(tok, "stderr", desc)
+			conflict = true
+		}
+		stdoutState = CommandDestFile
+		stderrState = CommandDestFile
+	case INPLACEREDIRECT:
+		// The runtime requires a Path for <>.
+		if target != TidPath {
+			c.errors = append(c.errors, TypeError{
+				Kind: TErrTypeMismatch,
+				Pos:  tok,
+				Hint: "In-place redirect (<>) requires a Path target (`...`).",
+			})
+		}
+		if desc := streamStateDesc(stdoutState, true); desc != "" {
+			c.streamConflictError(tok, "stdout", desc)
+			conflict = true
+		} else if stderrState == CommandDestMerged {
+			c.errors = append(c.errors, TypeError{
+				Kind: TErrTypeMismatch,
+				Pos:  tok,
+				Hint: fmt.Sprintf("Cannot apply '%s' with '2>&1': stderr would be written back into the edited file.", tok.Lexeme),
+			})
+			conflict = true
+		}
+		stdoutState = CommandDestInPlace
+	case LESSTHAN:
+		// stdin redirect: no stream destination state change.
+	}
+
 	c.stack.items = c.stack.items[:c.stack.Len()-1]
+	if !conflict && tok.Type != LESSTHAN {
+		c.stack.items[c.stack.Len()-1] = c.arena.MakeCommand(argv, stdoutState, stderrState)
+	}
 	return true
 }
 
-func (c *Checker) isCommandLike(t TypeId) bool {
-	n := c.arena.Node(t)
-	switch n.Kind {
-	case TKList:
-		return true
-	case TKCommand:
-		return true
+// tryCapture handles `*` / `*b` / `^` / `^b` applied to a command-like
+// operand, transforming its stream destination state and erroring when the
+// stream already has a destination. Returns false for non-command operands
+// (e.g. numeric `*` multiplication) so the caller falls through to overload
+// dispatch.
+func (c *Checker) tryCapture(tok Token) bool {
+	var isStdout bool
+	var mode CommandCaptureMode
+	switch tok.Type {
+	case ASTERISK:
+		isStdout, mode = true, CommandCaptureStr
+	case ASTERISKBINARY:
+		isStdout, mode = true, CommandCaptureBytes
+	case CARET:
+		isStdout, mode = false, CommandCaptureStr
+	case CARETBINARY:
+		isStdout, mode = false, CommandCaptureBytes
 	default:
 		return false
 	}
+	if c.stack.Len() < 1 {
+		return false
+	}
+	operand := c.subst.Apply(c.arena, c.stack.items[c.stack.Len()-1])
+	if isQuoteKind(c.arena.Kind(operand)) {
+		// Captures are not allowed on quotations: they would change the
+		// quotation's stack effect. Mirrors the runtime rejection.
+		redirectOp := "'>'"
+		if !isStdout {
+			redirectOp = "'2>'"
+		}
+		c.errors = append(c.errors, TypeError{
+			Kind: TErrTypeMismatch,
+			Pos:  tok,
+			Hint: fmt.Sprintf("'%s' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with %s.", tok.Lexeme, redirectOp),
+		})
+		return true
+	}
+	argv, stdoutState, stderrState, ok := c.commandParts(operand)
+	if !ok {
+		return false
+	}
+
+	if isStdout {
+		if desc := streamStateDesc(stdoutState, true); desc != "" {
+			c.streamConflictError(tok, "stdout", desc)
+			return true
+		}
+		stdoutState = mode
+	} else {
+		if desc := streamStateDesc(stderrState, false); desc != "" {
+			c.streamConflictError(tok, "stderr", desc)
+			return true
+		}
+		stderrState = mode
+	}
+	c.stack.items[c.stack.Len()-1] = c.arena.MakeCommand(argv, stdoutState, stderrState)
+	return true
+}
+
+// applyMergeRedirect handles the nullary merge tokens `2>&1` and `1>&2`,
+// mirroring the runtime conflict rules: the merged stream must be unclaimed,
+// the two merges are mutually exclusive (circular), and `2>&1` cannot be
+// combined with an in-place redirect.
+func (c *Checker) applyMergeRedirect(tok Token) {
+	if c.stack.Len() < 1 {
+		c.errors = append(c.errors, TypeError{
+			Kind: TErrStackUnderflow,
+			Pos:  tok,
+		})
+		return
+	}
+	operand := c.subst.Apply(c.arena, c.stack.items[c.stack.Len()-1])
+	if isQuoteKind(c.arena.Kind(operand)) {
+		// Known gap, same as tryRedirect: no destination tracking on quotes.
+		return
+	}
+	argv, stdoutState, stderrState, ok := c.commandParts(operand)
+	if !ok {
+		c.errors = append(c.errors, TypeError{
+			Kind: TErrTypeMismatch,
+			Pos:  tok,
+			Hint: fmt.Sprintf("Cannot apply '%s' to a %s; expected a list or quotation.", tok.Lexeme, FormatType(c.arena, c.names, operand)),
+		})
+		return
+	}
+
+	if tok.Type == STDERRTOSTDOUT {
+		if stdoutState == CommandDestMerged {
+			c.errors = append(c.errors, TypeError{
+				Kind: TErrTypeMismatch,
+				Pos:  tok,
+				Hint: fmt.Sprintf("Cannot apply '%s': the other stream is already merged with '1>&2'; merging both streams into each other is circular.", tok.Lexeme),
+			})
+			return
+		}
+		if stdoutState == CommandDestInPlace {
+			c.errors = append(c.errors, TypeError{
+				Kind: TErrTypeMismatch,
+				Pos:  tok,
+				Hint: fmt.Sprintf("Cannot apply '%s' with an in-place redirect ('<>'): stderr would be written back into the edited file.", tok.Lexeme),
+			})
+			return
+		}
+		if desc := streamStateDesc(stderrState, false); desc != "" {
+			c.streamConflictError(tok, "stderr", desc)
+			return
+		}
+		stderrState = CommandDestMerged
+	} else {
+		if stderrState == CommandDestMerged {
+			c.errors = append(c.errors, TypeError{
+				Kind: TErrTypeMismatch,
+				Pos:  tok,
+				Hint: fmt.Sprintf("Cannot apply '%s': the other stream is already merged with '2>&1'; merging both streams into each other is circular.", tok.Lexeme),
+			})
+			return
+		}
+		if desc := streamStateDesc(stdoutState, true); desc != "" {
+			c.streamConflictError(tok, "stdout", desc)
+			return
+		}
+		stdoutState = CommandDestMerged
+	}
+	c.stack.items[c.stack.Len()-1] = c.arena.MakeCommand(argv, stdoutState, stderrState)
+}
+
+// tryExecCommand types `;` / `!` / `?` applied to a command value with
+// destination states. Stack outputs come only from capture states; file,
+// in-place, and merge destinations produce nothing. `?` additionally pushes
+// the exit code. Returns false when the top of stack is not a command, so
+// plain lists and Maybe (`?` as fromJust) fall through to the sig overloads.
+func (c *Checker) tryExecCommand(tok Token) bool {
+	if c.stack.Len() < 1 {
+		return false
+	}
+	top := c.subst.Apply(c.arena, c.stack.items[c.stack.Len()-1])
+	n := c.arena.Node(top)
+	if n.Kind != TKCommand {
+		return false
+	}
+	c.stack.items = c.stack.items[:c.stack.Len()-1]
+	pushCapture := func(mode CommandCaptureMode) {
+		switch mode {
+		case CommandCaptureLines:
+			c.stack.Push(c.arena.MakeList(TidStr))
+		case CommandCaptureStr:
+			c.stack.Push(TidStr)
+		case CommandCaptureBytes:
+			c.stack.Push(TidBytes)
+		}
+	}
+	pushCapture(CommandCaptureMode(n.B))
+	pushCapture(CommandCaptureMode(n.Extra))
+	if tok.Type == QUESTION {
+		c.stack.Push(TidInt)
+	}
+	return true
 }
 
 // applySig is the hot path. It validates arity, unifies each input against
diff --git a/mshell/TypeError.go b/mshell/TypeError.go
index 9eabbf53..d9979b48 100644
--- a/mshell/TypeError.go
+++ b/mshell/TypeError.go
@@ -294,6 +294,12 @@ func formatCommandCapture(mode CommandCaptureMode) string {
 		return "bytes"
 	case CommandCaptureLines:
 		return "[str]"
+	case CommandDestFile:
+		return "file"
+	case CommandDestInPlace:
+		return "in-place"
+	case CommandDestMerged:
+		return "merged"
 	default:
 		return "none"
 	}
diff --git a/tests/fail/capture_on_quote.msh b/tests/fail/capture_on_quote.msh
new file mode 100644
index 00000000..9d4293ea
--- /dev/null
+++ b/tests/fail/capture_on_quote.msh
@@ -0,0 +1,2 @@
+# Captures are not allowed on quotations; they would change the stack effect
+( "a" wl ) * x
diff --git a/tests/fail/capture_on_quote.msh.stderr b/tests/fail/capture_on_quote.msh.stderr
new file mode 100644
index 00000000..2b57ad6e
--- /dev/null
+++ b/tests/fail/capture_on_quote.msh.stderr
@@ -0,0 +1 @@
+2:12: '*' capture is not supported on quotations; it would change the quotation's stack effect. Capture the individual command lists inside instead, or redirect the quotation to a file with '>'.
diff --git a/tests/fail/redirect_conflict_both_redirect.msh b/tests/fail/redirect_conflict_both_redirect.msh
new file mode 100644
index 00000000..8154bfed
--- /dev/null
+++ b/tests/fail/redirect_conflict_both_redirect.msh
@@ -0,0 +1,2 @@
+# Conflict: &> then 2>&1 must error
+[ls] `conflict_both.txt` &> 2>&1 ;
diff --git a/tests/fail/redirect_conflict_both_redirect.msh.stderr b/tests/fail/redirect_conflict_both_redirect.msh.stderr
new file mode 100644
index 00000000..cc6389f7
--- /dev/null
+++ b/tests/fail/redirect_conflict_both_redirect.msh.stderr
@@ -0,0 +1 @@
+2:29: Cannot apply '2>&1': stderr already has a file redirect ('2>'). Each stream has exactly one destination.
diff --git a/tests/fail/redirect_conflict_capture_file.msh b/tests/fail/redirect_conflict_capture_file.msh
new file mode 100644
index 00000000..f5b5d969
--- /dev/null
+++ b/tests/fail/redirect_conflict_capture_file.msh
@@ -0,0 +1,2 @@
+# Conflict: stdout capture then file redirect must error
+[ls] * `conflict_out.txt` > ;
diff --git a/tests/fail/redirect_conflict_capture_file.msh.stderr b/tests/fail/redirect_conflict_capture_file.msh.stderr
new file mode 100644
index 00000000..7ab76684
--- /dev/null
+++ b/tests/fail/redirect_conflict_capture_file.msh.stderr
@@ -0,0 +1 @@
+2:27: Cannot apply '>': stdout already has a capture ('*'). Each stream has exactly one destination.
diff --git a/tests/fail/redirect_conflict_circular_merge.msh b/tests/fail/redirect_conflict_circular_merge.msh
new file mode 100644
index 00000000..d885d837
--- /dev/null
+++ b/tests/fail/redirect_conflict_circular_merge.msh
@@ -0,0 +1,2 @@
+# Conflict: merging both streams into each other is circular
+[ls] 2>&1 1>&2 ;
diff --git a/tests/fail/redirect_conflict_circular_merge.msh.stderr b/tests/fail/redirect_conflict_circular_merge.msh.stderr
new file mode 100644
index 00000000..56697d58
--- /dev/null
+++ b/tests/fail/redirect_conflict_circular_merge.msh.stderr
@@ -0,0 +1 @@
+2:11: Cannot apply '1>&2': the other stream is already merged with '2>&1'; merging both streams into each other is circular.
diff --git a/tests/fail/redirect_conflict_double_file.msh b/tests/fail/redirect_conflict_double_file.msh
new file mode 100644
index 00000000..60763680
--- /dev/null
+++ b/tests/fail/redirect_conflict_double_file.msh
@@ -0,0 +1,2 @@
+# Conflict: two stdout file redirects must error
+[ls] `conflict_f.txt` > `conflict_g.txt` > ;
diff --git a/tests/fail/redirect_conflict_double_file.msh.stderr b/tests/fail/redirect_conflict_double_file.msh.stderr
new file mode 100644
index 00000000..859f43b1
--- /dev/null
+++ b/tests/fail/redirect_conflict_double_file.msh.stderr
@@ -0,0 +1 @@
+2:42: Cannot apply '>': stdout already has a file redirect ('>'). Each stream has exactly one destination.
diff --git a/tests/fail/redirect_conflict_merge_capture.msh b/tests/fail/redirect_conflict_merge_capture.msh
new file mode 100644
index 00000000..f26a4e46
--- /dev/null
+++ b/tests/fail/redirect_conflict_merge_capture.msh
@@ -0,0 +1,2 @@
+# Conflict: stderr merged to stdout, then stderr capture must error
+[ls] 2>&1 ^ ;
diff --git a/tests/fail/redirect_conflict_merge_capture.msh.stderr b/tests/fail/redirect_conflict_merge_capture.msh.stderr
new file mode 100644
index 00000000..8470cfa8
--- /dev/null
+++ b/tests/fail/redirect_conflict_merge_capture.msh.stderr
@@ -0,0 +1 @@
+2:11: Cannot apply '^': stderr already has a merge to stdout ('2>&1'). Each stream has exactly one destination.
diff --git a/tests/success/merge_file.msh b/tests/success/merge_file.msh
new file mode 100644
index 00000000..90faa050
--- /dev/null
+++ b/tests/success/merge_file.msh
@@ -0,0 +1,3 @@
+# FILE:merge_file.txt
+# Test 2>&1 with a stdout file redirect: both streams into the file
+[sh -c "echo file-out; echo file-err 1>&2"] `merge_file.txt` > 2>&1 !
diff --git a/tests/success/merge_file.txt b/tests/success/merge_file.txt
new file mode 100644
index 00000000..a8f49049
--- /dev/null
+++ b/tests/success/merge_file.txt
@@ -0,0 +1,2 @@
+file-out
+file-err
diff --git a/tests/success/merge_file.txt.expected b/tests/success/merge_file.txt.expected
new file mode 100644
index 00000000..a8f49049
--- /dev/null
+++ b/tests/success/merge_file.txt.expected
@@ -0,0 +1,2 @@
+file-out
+file-err
diff --git a/tests/success/merge_pipeline.msh b/tests/success/merge_pipeline.msh
new file mode 100644
index 00000000..06272b2b
--- /dev/null
+++ b/tests/success/merge_pipeline.msh
@@ -0,0 +1,2 @@
+# Test 2>&1 on a pipeline stage: stderr flows through the pipe
+[[sh -c "echo keep-ERROR 1>&2; echo drop"] 2>&1 [grep ERROR]] | * ; chomp wl
diff --git a/tests/success/merge_pipeline.msh.stdout b/tests/success/merge_pipeline.msh.stdout
new file mode 100644
index 00000000..42317cc8
--- /dev/null
+++ b/tests/success/merge_pipeline.msh.stdout
@@ -0,0 +1 @@
+keep-ERROR
diff --git a/tests/success/merge_stderr_capture.msh b/tests/success/merge_stderr_capture.msh
new file mode 100644
index 00000000..db6eefb9
--- /dev/null
+++ b/tests/success/merge_stderr_capture.msh
@@ -0,0 +1,2 @@
+# Test 2>&1: combined interleaved capture of stdout and stderr
+[sh -c "echo out1; echo err1 1>&2; echo out2"] 2>&1 * ; chomp wl
diff --git a/tests/success/merge_stderr_capture.msh.stdout b/tests/success/merge_stderr_capture.msh.stdout
new file mode 100644
index 00000000..df6e0532
--- /dev/null
+++ b/tests/success/merge_stderr_capture.msh.stdout
@@ -0,0 +1,3 @@
+out1
+err1
+out2
diff --git a/tests/success/merge_stdout_to_stderr.msh b/tests/success/merge_stdout_to_stderr.msh
new file mode 100644
index 00000000..548f810b
--- /dev/null
+++ b/tests/success/merge_stdout_to_stderr.msh
@@ -0,0 +1,2 @@
+# Test 1>&2: stdout merged into stderr's destination (captured via ^)
+[sh -c "echo through-stderr"] 1>&2 ^ ; chomp wl
diff --git a/tests/success/merge_stdout_to_stderr.msh.stdout b/tests/success/merge_stdout_to_stderr.msh.stdout
new file mode 100644
index 00000000..d4f24447
--- /dev/null
+++ b/tests/success/merge_stdout_to_stderr.msh.stdout
@@ -0,0 +1 @@
+through-stderr
diff --git a/tests/success/quote_merge.msh b/tests/success/quote_merge.msh
new file mode 100644
index 00000000..1bdd76e1
--- /dev/null
+++ b/tests/success/quote_merge.msh
@@ -0,0 +1,2 @@
+# Test 2>&1 on a quotation: wle output joins stdout
+( "q-out" wl "q-err" wle ) 2>&1 x
diff --git a/tests/success/quote_merge.msh.stdout b/tests/success/quote_merge.msh.stdout
new file mode 100644
index 00000000..be49eb56
--- /dev/null
+++ b/tests/success/quote_merge.msh.stdout
@@ -0,0 +1,2 @@
+q-out
+q-err
diff --git a/tests/success/quote_merge_err.txt b/tests/success/quote_merge_err.txt
new file mode 100644
index 00000000..cc86ee6c
--- /dev/null
+++ b/tests/success/quote_merge_err.txt
@@ -0,0 +1,2 @@
+q2-out
+q2-err
diff --git a/tests/success/quote_merge_err.txt.expected b/tests/success/quote_merge_err.txt.expected
new file mode 100644
index 00000000..cc86ee6c
--- /dev/null
+++ b/tests/success/quote_merge_err.txt.expected
@@ -0,0 +1,2 @@
+q2-out
+q2-err
diff --git a/tests/success/quote_merge_to_stderr.msh b/tests/success/quote_merge_to_stderr.msh
new file mode 100644
index 00000000..786acb08
--- /dev/null
+++ b/tests/success/quote_merge_to_stderr.msh
@@ -0,0 +1,3 @@
+# FILE:quote_merge_err.txt
+# Test 1>&2 on a quotation with a stderr file redirect: wl output follows
+( "q2-out" wl "q2-err" wle ) 1>&2 `quote_merge_err.txt` 2> x
diff --git a/tests/typecheck_fail/capture_on_quote.msh b/tests/typecheck_fail/capture_on_quote.msh
new file mode 100644
index 00000000..e781ee9b
--- /dev/null
+++ b/tests/typecheck_fail/capture_on_quote.msh
@@ -0,0 +1,2 @@
+# Static: captures are not allowed on quotations
+( "a" wl ) * x
diff --git a/tests/typecheck_fail/inplace_merge_conflict.msh b/tests/typecheck_fail/inplace_merge_conflict.msh
new file mode 100644
index 00000000..f4ac84af
--- /dev/null
+++ b/tests/typecheck_fail/inplace_merge_conflict.msh
@@ -0,0 +1,2 @@
+# Static conflict: each stream has exactly one destination
+[cat] 2>&1 `p.txt` <> ;
diff --git a/tests/typecheck_fail/inplace_on_quote.msh b/tests/typecheck_fail/inplace_on_quote.msh
new file mode 100644
index 00000000..57d5e918
--- /dev/null
+++ b/tests/typecheck_fail/inplace_on_quote.msh
@@ -0,0 +1,2 @@
+# Static: in-place redirect requires a List
+( "a" wl ) `f.txt` <> x
diff --git a/tests/typecheck_fail/merge_conflict_capture.msh b/tests/typecheck_fail/merge_conflict_capture.msh
new file mode 100644
index 00000000..52327f4b
--- /dev/null
+++ b/tests/typecheck_fail/merge_conflict_capture.msh
@@ -0,0 +1,2 @@
+# Static conflict: each stream has exactly one destination
+[ls] 2>&1 ^ ;
diff --git a/tests/typecheck_fail/merge_conflict_circular.msh b/tests/typecheck_fail/merge_conflict_circular.msh
new file mode 100644
index 00000000..04188225
--- /dev/null
+++ b/tests/typecheck_fail/merge_conflict_circular.msh
@@ -0,0 +1,2 @@
+# Static conflict: each stream has exactly one destination
+[ls] 2>&1 1>&2 ;
diff --git a/tests/typecheck_fail/merge_conflict_stdout.msh b/tests/typecheck_fail/merge_conflict_stdout.msh
new file mode 100644
index 00000000..4b273b24
--- /dev/null
+++ b/tests/typecheck_fail/merge_conflict_stdout.msh
@@ -0,0 +1,2 @@
+# Static conflict: each stream has exactly one destination
+[ls] 1>&2 * ;
diff --git a/tests/typecheck_fail/redirect_conflict_both.msh b/tests/typecheck_fail/redirect_conflict_both.msh
new file mode 100644
index 00000000..1cb4e24b
--- /dev/null
+++ b/tests/typecheck_fail/redirect_conflict_both.msh
@@ -0,0 +1,2 @@
+# Static conflict: each stream has exactly one destination
+[ls] `f.txt` &> 2>&1 ;
diff --git a/tests/typecheck_fail/redirect_conflict_capture_file.msh b/tests/typecheck_fail/redirect_conflict_capture_file.msh
new file mode 100644
index 00000000..1a9502bc
--- /dev/null
+++ b/tests/typecheck_fail/redirect_conflict_capture_file.msh
@@ -0,0 +1,2 @@
+# Static conflict: each stream has exactly one destination
+[ls] * `f.txt` > ;
diff --git a/tests/typecheck_fail/redirect_conflict_double_file.msh b/tests/typecheck_fail/redirect_conflict_double_file.msh
new file mode 100644
index 00000000..da797a1f
--- /dev/null
+++ b/tests/typecheck_fail/redirect_conflict_double_file.msh
@@ -0,0 +1,2 @@
+# Static conflict: each stream has exactly one destination
+[ls] `f.txt` > `g.txt` > ;
diff --git a/tests/typecheck_fail/redirect_conflict_stderr_file.msh b/tests/typecheck_fail/redirect_conflict_stderr_file.msh
new file mode 100644
index 00000000..ea59fc26
--- /dev/null
+++ b/tests/typecheck_fail/redirect_conflict_stderr_file.msh
@@ -0,0 +1,2 @@
+# Static conflict: each stream has exactly one destination
+[ls] ^ `g.txt` 2> ;
Summary of external command operators
&>> Redirect both stdout and stderr to a file. Appends to the file.
^ Capture stderr to the stack. As a string.
^b Capture stderr to the stack. As binary.
2>&1 Merge stderr into stdout's destination. Single token, no spaces. Not order-sensitive: stderr follows stdout's final destination.
1>&2 Merge stdout into stderr's destination. Single token, no spaces.
< Feed stdin from a value. String, path, or binary.
<> In-place file modification. Reads file to stdin, writes stdout back on success.
& Mark the command list to run asynchronously. Marks the list; the trailing ;/! starts the subprocess and returns immediately without waiting. Stdout and stderr default to discarded.