tasktree 0.0.27
A task automation tool with incremental execution
Requires Python: >=3.11

# Task Tree (tt)

[![Tests](https://github.com/kevinchannon/task-tree/actions/workflows/test.yml/badge.svg)](https://github.com/kevinchannon/task-tree/actions/workflows/test.yml)

A task automation tool that combines simple command execution with dependency tracking and incremental execution.

## Motivation
In any project of even moderate size, various scripts inevitably come into being along the way. These scripts often must be run in a particular order, or at a particular time. For historical reasons, this almost certainly a problem if your project is developed in a Linux environment; in Windows, an IDE like Visual Studio may be taking care of a significant proportion of your build, packaging and deployment tasks. Then again, it may not...

The various incantations that have to be issued to build, package, test and deploy a project can build up and then all of a sudden there's only a few people that remember which to invoke and when and then people start making helpful readme guides on what to do with the scripts and then those become out of date and start telling lies about things and so on.

Then there's the scripts themselves. In Linux, they're probably a big pile of Bash and Python, or something (Ruby, Perl, you name it). You can bet the house on people solving the problem of passing parameters to their scripts in a whole bunch of different and inconsistent ways.

```bash
#!/usr/bin/env bash
# It's an environment variable defined.... somewhere?
echo "FOO is: $FOO"
```
```bash
#!/usr/bin/env bash
# Using simple positional arguments... guess what means what when you're invoking it!
echo "First: $1, Second: $2"
```
```bash
#!/usr/bin/env bash
# Oooooh fancy "make me look like a proper app" named option parsing... don't try and do --foo=bar though!
FOO=""
while [[ $# -gt 0 ]]; do
    case "$1" in
        --foo) FOO=$2; shift ;;
        --)    break ;;
        *)     echo "Unknown: $1";;
    esac
    shift
done
```
```bash
#!/usr/bin/env bash
# This thing...
ARGS=$(getopt -o f:b --long foo:,bar: -n 'myscript' -- "$@")
eval set -- "$ARGS"
while true; do
    case "$1" in
        -b|--bar) echo "Bar: $2"; shift 2 ;;
        -f|--foo) echo "Foo: $2"; shift 2 ;;
        --) shift; break ;;
        *) break ;;
    esac
done
```

What about help info? Who has time to wire that in?

### The point
Is this just whining and moaning? Should we just man up and revel in our own ability to memorize all the right incantations like some kind of scripting shaman?

... No. That's **a dumb idea**.

Task Tree allows you to pile all the knowledge of **what** to run, **when** to run it, **where** to run it and **how** to run it into a single, readable place. Then you can delete all the scripts that no-one knows how to use and all the readme docs that lie to the few people that actually waste their time reading them.

The tasks you need to perform to deliver your project become summarised in an executable file that looks like:
```yaml
tasks:
  build:
    desc: Compile stuff
    outputs: [target/release/bin]
    cmd: cargo build --release

  package:
     desc: build installers
     deps: [build]
     outputs: [awesome.deb]
     cmd: |
        for bin in target/release/*; do
            if [[ -x "$bin" && ! -d "$bin" ]]; then
                install -Dm755 "$bin" "debian/awesome/usr/bin/$(basename "$bin")"
            fi
        done

        dpkg-buildpackage -us -uc

  test:
    desc: Run tests
    deps: [package]
    inputs: [tests/**/*.py]
    cmd: PYTHONPATH=src python3 -m pytest tests/ -v
```

If you want to run the tests then:
```bash
tt test
```
Boom! Done. `build` will always run, because there's no sensible way to know what Cargo did. However, if Cargo decided that nothing needed to be done and didn't touch the binaries, then `package` will realize that and not do anything. Then `test` will just run with the new tests that you just wrote. If you then immediately run `test` again, then `test` will figure out that none of the dependencies did anything and that none of the test files have changed and then just _do nothing_ - as it should.

This is a toy example, but you can image how it plays out on a more complex project.

## Installation

### From PyPI (Recommended)

```bash
pipx install tasktree
```

If you have multiple Python interpreter versions installed, and the _default_ interpreter is a version <3.11, then you can use `pipx`'s `--python` option to specify an interpreter with a version >=3.11:

```bash
# If the target version is on the PATH
pipx install --python python3.12 tasktree

# With a path to an interpreter
pipx install --python /path/to/python3.12 tasktree
```

### From Source

For the latest unreleased version from GitHub:

```bash
pipx install git+https://github.com/kevinchannon/task-tree.git
```

Or to install from a local clone:

```bash
git clone https://github.com/kevinchannon/task-tree.git
cd tasktree
pipx install .
```

## Quick Start

Create a `tasktree.yaml` (or `tt.yaml`) in your project:

```yaml
tasks:
  build:
    desc: Compile the application
    outputs: [target/release/bin]
    cmd: cargo build --release

  test:
    desc: Run tests
    deps: [build]
    cmd: cargo test
```

Run tasks:

```bash
tt                # Print the help
tt --help         # ...also print the help
tt --list         # Show all available tasks
tt build          # Build the application (assuming this is in your tasktree.yaml)
tt test           # Run tests (builds first if needed)
```

## Editor Support

Task Tree includes a [JSON Schema](https://github.com/kevinchannon/tasktree/blob/main/schema/tasktree-schema.json) that provides autocomplete, validation, and documentation in modern editors.

### VS Code

Install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml), then add to your workspace `.vscode/settings.json`:

```json
{
  "yaml.schemas": {
    "https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json": [
      "tasktree.yaml",
      "tt.yaml"
    ]
  }
}
```

Or add a comment at the top of your `tasktree.yaml`:

```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/kevinchannon/tasktree/main/schema/tasktree-schema.json

tasks:
  build:
    cmd: cargo build
```

See [schema/README.md](https://github.com/kevinchannon/tasktree/blob/main/schema/README.md) for IntelliJ/PyCharm and command-line validation.

## Core Concepts

### Intelligent Incremental Execution

Task Tree only runs tasks when necessary. A task executes if:

- Its definition (command, outputs, working directory, runner) has changed
- Any input files have changed since the last run
- Any dependencies have re-run
- It has never been executed before
- It has no inputs (always runs)
- The execution runner has changed (CLI override or runner config change)

### Automatic Input Inheritance

Tasks automatically inherit inputs from dependencies, eliminating redundant declarations:

```yaml
tasks:
  build:
    outputs: [dist/app]
    cmd: go build -o dist/app

  package:
    deps: [build]
    outputs: [dist/app.tar.gz]
    cmd: tar czf dist/app.tar.gz dist/app
    # Automatically tracks dist/app as an input
```

### Single State File

All state lives in `.tasktree-state` at your project root. Stale entries are automatically pruned—no manual cleanup needed.

## Task Definition

### Basic Structure

```yaml
tasks:
  task-name:
    desc: Human-readable description (optional)
    deps: [other-task]                     # Task dependencies
    inputs: [src/**/*.go]                  # Explicit input files (glob patterns)
    outputs: [dist/binary]                 # Output files (glob patterns)
    working_dir: subproject/               # Execution directory (default: project root)
    run_in: bash-strict                    # Execution runner (optional)
    private: false                         # Hide from --list output (default: false)
    task_output: all                       # Control task output: all, out, err, on-err, none (default: all)
    args:                                   # Task parameters
      - param1                              # Simple argument
      - param2: { type: path, default: "." }  # With type and default
    cmd: go build -o dist/binary           # Command to execute
```

**Task name constraints:**
- Task names cannot contain dots (`.`) - they are reserved for namespacing imported tasks
- Example: `build.release` is invalid as a task name, but valid as a reference to task `release` in namespace `build`

### Commands

All commands are executed by writing them to temporary script files. This provides consistent behavior and better shell syntax support:

```yaml
tasks:
  build:
    cmd: cargo build --release

  deploy:
    cmd: |
      mkdir -p dist
      cp build/* dist/
      rsync -av dist/ server:/opt/app/
```

Commands preserve shell syntax (line continuations, heredocs, etc.) and support shebangs on Unix/macOS.

Or use folded blocks for long single-line commands:

```yaml
tasks:
  compile:
    cmd: >
      gcc -o bin/app
      src/*.c
      -I include
      -L lib -lm
```

### Execution Runners

Configure custom shell runners for task execution. Use the `preamble` field to add initialization code to all commands:

```yaml
runners:
  default: bash-strict

  bash-strict:
    shell: bash
    preamble: |               # Prepended to all commands
      set -euo pipefail

  python:
    shell: python

  powershell:
    shell: powershell
    preamble: |
      $ErrorActionPreference = 'Stop'

tasks:
  build:
    # Uses 'default' runner (bash-strict)
    cmd: cargo build --release

  analyze:
    run_in: python
    cmd: |
      import sys
      print(f"Analyzing with Python {sys.version}")
      # ... analysis code ...

  windows-task:
    run_in: powershell
    cmd: |
      Compress-Archive -Path dist/* -DestinationPath package.zip
```

**Runner resolution priority:**
1. CLI override: `tt --runner python build`
2. Task's `run_in` field
3. Recipe's `default` runner
4. Project config (`.tasktree-config.yml`)
5. User config (`~/.config/tasktree/config.yml`)
6. Machine config (`/etc/tasktree/config.yml`)
7. Platform default (bash on Unix, cmd on Windows)

**Platform defaults** when no runners are configured:
- **Unix/macOS**: bash
- **Windows**: cmd

### Configuration Files

Default runner settings can be configured outside of task files using configuration files at three levels:

**1. Project-level config** (`.tasktree-config.yml` at project root)
```yaml
runners:
  default:
    shell: bash
    preamble: |
      set -euo pipefail
```

**2. User-level config** (`~/.config/tasktree/config.yml` on Linux/macOS, `%APPDATA%\tasktree\config.yml` on Windows)
```yaml
runners:
  default:
    shell: zsh
    preamble: |
      # User-specific shell initialization
      export PATH="$HOME/.local/bin:$PATH"
```

**3. Machine-level config** (`/etc/tasktree/config.yml` on Linux/macOS, `C:\ProgramData\tasktree\config.yml` on Windows)
```yaml
runners:
  default:
    shell: bash
    preamble: |
      # System-wide shell configuration
      set -euo pipefail
```

**Key points:**
- Config files use the same runner schema as `tasktree.yaml`
- The runner **must** be named `default` in config files
- Project config overrides user config, which overrides machine config
- Config files are optional; missing configs are not errors
- Relative paths in configs (e.g., dockerfile paths) are resolved relative to the project root at execution time
- Invalid configs are handled gracefully:
  - A warning message is displayed with the error details
  - Task Tree falls back to the next level in the hierarchy
  - Execution continues normally with the fallback runner

**Use cases:**
- **Project config**: Set consistent shell defaults for all contributors
- **User config**: Customize shell environment across all your projects
- **Machine config**: Set system-wide defaults for all users (requires admin/root)

**Example workflow:**
```bash
# Machine admin sets system-wide strict bash
$ sudo tee /etc/tasktree/config.yml > /dev/null << 'EOF'
runners:
  default:
    shell: bash
    preamble: set -euo pipefail
EOF

# User prefers zsh for their work
$ mkdir -p ~/.config/tasktree
$ cat > ~/.config/tasktree/config.yml << 'EOF'
runners:
  default:
    shell: zsh
EOF

# Project requires specific Python environment
$ cat > .tasktree-config.yml << 'EOF'
runners:
  default:
    dockerfile: dev.dockerfile
    context: .
EOF

# Result: Project's dockerfile runner is used (highest precedence)
```

### Docker Runners

Execute tasks inside Docker containers for reproducible builds and isolated execution:

```yaml
runners:
  builder:
    dockerfile: build.dockerfile
    context: .
    volumes:
      - .:/workspace
    working_dir: /workspace

tasks:
  build:
    run_in: builder
    cmd: cargo build --release
```

#### User Mapping

By default, tasks run inside Docker containers execute as your current host user (UID:GID) rather than root. This ensures files created in mounted volumes have correct ownership on the host filesystem.

To run as root inside the container (e.g., for package installation or privileged operations), set `run_as_root: true`:

```yaml
runners:
  privileged:
    dockerfile: admin.dockerfile
    context: .
    run_as_root: true
    volumes:
      - .:/workspace
```

**Note**: On Windows, user mapping is handled automatically by Docker Desktop and this setting has no effect.

#### Use Cases for `run_as_root: true`

You may need to set `run_as_root: true` when:
- Container process needs to bind to privileged ports (<1024)
- Installing packages during task execution
- Software explicitly requires root privileges

#### Working Directory Resolution

When executing tasks in Docker containers, the working directory is determined by three factors (in order of precedence):

1. **Task's `working_dir`** - If specified, overrides all other settings
2. **Runner's `working_dir`** - If specified and task doesn't specify, uses runner's directory
3. **Dockerfile's `WORKDIR`** - If neither task nor runner specify, Docker uses the Dockerfile's WORKDIR directive

**Examples:**

```yaml
runners:
  builder:
    dockerfile: build.dockerfile
    context: .
    volumes:
      - .:/workspace
    working_dir: /workspace  # Sets default for all tasks using this runner

tasks:
  # Uses /workspace (from runner)
  build:
    run_in: builder
    cmd: cargo build --release

  # Uses /workspace/tests (combines runner + task)
  test:
    run_in: builder
    working_dir: tests
    cmd: cargo test

  # Uses /workspace/docs (overrides runner)
  docs:
    run_in: builder
    working_dir: /workspace/docs
    cmd: make html
```

```yaml
runners:
  # No working_dir specified - relies on Dockerfile
  builder:
    dockerfile: Dockerfile  # Contains: WORKDIR /app
    context: .
    volumes:
      - .:/code

tasks:
  # Uses /app (from Dockerfile's WORKDIR)
  build:
    run_in: builder
    cmd: make build

  # Uses /app/tests (Dockerfile WORKDIR + task working_dir)
  test:
    run_in: builder
    working_dir: tests
    cmd: pytest

  # Uses /code (absolute path, overrides Dockerfile)
  analyze:
    run_in: builder
    working_dir: /code
    cmd: pylint src/
```

**Key Points:**

- If both runner and task specify `working_dir`, they are **combined** (runner/task)
- If neither specifies `working_dir`, Docker uses the Dockerfile's `WORKDIR` directive
- Absolute paths (`/path`) override and are not combined
- Relative paths are combined with the runner's `working_dir` (if set)
- The `working_dir` setting determines where commands execute, not where volume mounts map files

#### Windows Containers and Shell Behavior

Task Tree supports both Linux and Windows containers. The script file extension and behavior are determined by the `shell` configured for the runner, **not** the host platform:

**Linux Containers (default):**
```yaml
runners:
  linux-builder:
    dockerfile: Dockerfile
    context: .
    shell: bash  # or sh, zsh - uses .sh extension with shebang
```

**Windows Containers:**
```yaml
runners:
  windows-builder:
    dockerfile: Dockerfile.windows
    context: .
    shell: cmd.exe  # Uses .bat extension, no shebang
```

**PowerShell Containers:**
```yaml
runners:
  powershell-builder:
    dockerfile: Dockerfile.windows
    context: .
    shell: powershell  # or pwsh - uses .ps1 extension, no shebang
```

**Script Execution Details:**

When a task runs in a Docker container:
1. Task Tree creates a temporary script file on the host
2. The script extension is determined from the `shell` field:
   - `bash`, `sh`, `zsh`, etc. → `.sh` (with `#!/usr/bin/env {shell}` shebang)
   - `cmd.exe`, `cmd` → `.bat` (no shebang)
   - `powershell`, `pwsh` → `.ps1` (no shebang)
3. The script is mounted into the container at a unique path (e.g., `/tmp/tt-script-{uuid}.sh`)
4. The container executes the script using the specified shell
5. The script is automatically cleaned up after execution

**Important Notes:**

- The script type is determined by the **container's shell**, not the host OS
- You can run Windows containers from a Linux host (and vice versa) - the script will use the appropriate format
- Script paths are unique per execution (using UUIDs) to prevent collisions in concurrent runs
- Scripts are mounted read-only for security

**Example: Windows container on any host:**
```yaml
runners:
  win-container:
    dockerfile: Dockerfile.windows
    context: .
    shell: cmd.exe
    volumes:
      - .:/workspace

tasks:
  windows-task:
    run_in: win-container
    cmd: |
      echo Building on Windows
      dir C:\workspace
      copy file.txt C:\output\file.txt
```

Even if running on a Linux or macOS host, this task will create a `.bat` script (no shebang) and execute it with `cmd.exe` inside the Windows container.

### Parameterised Tasks

Tasks can accept arguments with optional type annotations and defaults:

```yaml
tasks:
  deploy:
    args:
      - environment: { choices: ["dev", "staging", "production"] }
      - region: { choices: ["us-east-1", "eu-west-1"], default: "eu-west-1" }
    deps: [build]
    cmd: |
      aws s3 cp dist/app.zip s3://{{ arg.environment }}-{{ arg.region }}/
      aws lambda update-function-code --function-name app-{{ arg.environment }}
```

Invoke with: `tt deploy production` or `tt deploy staging us-east-1` or `tt deploy staging region=us-east-1`.

If you try an invalid environment like `tt deploy testing`, you'll get a clear error showing the valid choices.

**Argument syntax:**

```yaml
args:
  - name                                          # Simple argument (type: str, no default)
  - port: { type: int }                           # With type annotation
  - region: { default: "eu-west-1" }              # With default (type inferred as str)
  - count: { type: int, default: 10 }             # With both type and default
  - replicas: { min: 1, max: 100 }                # Type inferred as int from min/max
  - timeout: { min: 0.5, max: 30.0, default: 10.0 }  # Type inferred as float
  - environment: { choices: ["dev", "prod"] }     # Type inferred as str from choices
  - priority: { type: int, choices: [1, 2, 3], default: 2 }  # With choices and default
```

**Range constraints** (min/max):

For `int` and `float` arguments, you can specify `min` and/or `max` constraints to validate values at parse time:

```yaml
args:
  - replicas: { min: 1, max: 100 }              # Type inferred as int from min/max
  - port: { min: 1024 }                         # Type inferred as int from min
  - percentage: { max: 100.0 }                  # Type inferred as float from max
  - workers: { min: 1, max: 16, default: 4 }    # Type inferred as int (all consistent)
```

**Discrete choices**:

For arguments with a specific set of valid values, use the `choices` field to specify allowed values. This provides clear validation errors and self-documenting task definitions:

```yaml
args:
  - environment: { choices: ["dev", "staging", "prod"] }  # Type inferred as str from choices
  - priority: { choices: [1, 2, 3] }                      # Type inferred as int from choices
  - region: { type: str, choices: ["us-east-1", "eu-west-1"], default: "us-east-1" }
```

**Choices features:**
- Type is automatically inferred from the first choice value if not explicitly specified
- All choices must have the same type
- Default value (if provided) must be one of the valid choices
- `choices` and `min`/`max` are mutually exclusive
- Boolean types cannot have choices (already limited to true/false)
- Validation happens after type conversion, producing clear error messages showing valid options

* Both bounds are **inclusive**: `min` is the smallest allowable value, `max` is the largest
* Can specify `min` alone, `max` alone, or both together
* Type can be inferred from `min`, `max`, or `default` - all provided values must have consistent types
* When explicit `type` is specified, all `min`, `max`, and `default` values must match that type
* Default values must satisfy the min/max constraints
* Validation happens at parse time with clear error messages
* Not supported for non-numeric types (str, bool, path, etc.)

When no explicit type is provided, the type is inferred from `default`, `min`, or `max` values (all must have consistent types). Valid argument types are:

* int - an integer value (e.g. 0, 10, 123, -9)
* float - a floating point value (e.g. 1.234, -3.1415, 2e-4)
* bool - Boolean-ish value (e.g. true, false, yes, no, 1, 0, etc)
* str - a string
* path - a pathlike string
* datetime - a datetime in the format 2025-12-17T16:56:12
* ip - an ip address (v4 or v6)
* ipv4 - an IPv4 value
* ipv6 - an IPv6 value
* email - String validated, but not positively confirmed to be a reachable address.
* hostname - looks like a hostname, resolution of the name is not attempted as part of the validation

Different argument values are tracked separately—tasks re-run when invoked with new arguments.

### Exported Arguments

Arguments can be prefixed with `$` to export them as environment variables instead of using template substitution. This mimics Justfile behavior and is cleaner for shell-heavy commands:

```yaml
tasks:
  deploy:
    args:
      - $server
      - $user: { default: "admin" }
      - port: { type: int, default: 8080 }
    cmd: |
      echo "Deploying to $server as $user on port {{ arg.port }}"
      ssh $user@$server "systemctl restart app --port {{ arg.port }}"
```

**Key differences between regular and exported arguments:**

| Feature | Regular Argument | Exported Argument |
|---------|-----------------|-------------------|
| **Syntax** | `- name` | `- $name` |
| **Usage in commands** | `{{ arg.name }}` | `$name` (shell variable) |
| **Type annotations** | Allowed: `{ type: int }` | **Not allowed** (always strings) |
| **Defaults** | `{ default: 8080 }` | `- $port: { default: "8080" }` |
| **Availability** | Template substitution only | Environment variable (all subprocesses) |
| **Case handling** | N/A | Preserves exact case as written |

**Invocation examples:**

```bash
# Positional arguments (exported and regular mixed)
tt deploy prod-server admin port=9000

# Named arguments
tt deploy server=prod-server user=admin port=9000

# Using defaults
tt deploy prod-server  # user defaults to "admin"
```

**Important notes:**

- **Exported arguments are always strings**: Even numeric-looking defaults like `$port=8080` result in the string `"8080"`. When using boolean-like values in shell scripts, use string comparison: `[ "$verbose" = "true" ]`
- **Case preservation**: Environment variable names preserve the case exactly as written. `$Server` and `$server` are distinct variables (except on Windows where environment variables are case-insensitive)
- **Environment variable precedence**: Exported arguments override any existing environment variables with the same name
- **Cannot use template substitution**: Exported arguments are **not** available for `{{ arg.name }}` substitution. Attempting to use `{{ arg.server }}` when `server` is defined as `$server` results in an error

**Use cases for exported arguments:**

- Shell-heavy commands with many environment variable references
- Passing credentials to subprocesses
- Commands that spawn multiple subshells (exported vars available in all)
- Integration with tools that expect environment variables

**Example with Docker runners:**

```yaml
runners:
  docker-build:
    dockerfile: Dockerfile
    context: .
    volumes:
      - .:/workspace

tasks:
  build:
    run_in: docker-build
    args: [$BUILD_TAG, $REGISTRY]
    cmd: |
      docker build -t $REGISTRY/app:$BUILD_TAG .
      docker push $REGISTRY/app:$BUILD_TAG
```

Exported arguments are passed through to Docker containers as environment variables, overriding any Docker runner configuration.

#### Troubleshooting Exported Arguments

**Problem: Exported argument appears undefined in script**

If your script reports an undefined variable:

1. Verify the argument is prefixed with `$` in the `args` list
2. Check that you're passing the argument when invoking the task:
   ```bash
   tt deploy prod-server  # If server is required
   ```
3. On Windows, use `%VAR%` syntax instead of `$VAR`:
   ```yaml
   tasks:
     test:
       args: [$server]
       cmd: echo %server%  # Windows
       # cmd: echo $server  # Unix/macOS
   ```

**Problem: How to debug which args are exported vs regular**

Use `tt --show <task-name>` to view the task definition:
```bash
tt --show deploy
```

This displays the task with its argument specifications. Exported arguments have the `$` prefix.

**Problem: Case-sensitive variable confusion**

On Unix systems, `$Server` and `$server` are different variables. If you see unexpected behavior:

1. Check that all references use the exact same case
2. Task Tree will warn during parsing if it detects arguments that differ only in case
3. Consider using lowercase consistently for environment variables to avoid confusion

**Problem: Exported argument with default value not set**

If an exported argument with a default isn't available as an environment variable:

1. Ensure you're running on the latest version (this was a bug in earlier versions)
2. The CLI automatically applies defaults before execution
3. You can explicitly provide the value: `tt deploy prod-server port=8080`

### Parameterized Dependencies

Dependencies can invoke tasks with specific arguments, enabling flexible and reusable task graphs:

**Syntax:**

```yaml
tasks:
  # Task with parameters
  process:
    args:
      - mode
      - verbose: { default: false }
    cmd: echo "mode={{arg.mode}} verbose={{arg.verbose}}"

  # Simple dependency (uses defaults)
  consumer1:
    deps: [process]  # Equivalent to: process(mode must be provided)
    cmd: echo "done"

  # Positional arguments
  consumer2:
    deps:
      - process: [debug, true]  # Maps to: mode=debug, verbose=true
    cmd: echo "done"

  # Named arguments
  consumer3:
    deps:
      - process: {mode: release, verbose: false}
    cmd: echo "done"

  # Multiple invocations with different args
  multi_build:
    deps:
      - process: [debug]
      - process: [release]
    cmd: echo "All builds complete"
```

**Key behaviors:**

- **Simple string form** (`- task_name`): Uses task defaults for all arguments. Required arguments must have defaults or task invocation fails.
- **Positional form** (`- task_name: [arg1, arg2]`): Arguments mapped by position. Can omit trailing args if they have defaults.
- **Named form** (`- task_name: {arg1: val1}`): Arguments mapped by name. Can omit any arg with a default.
- **Multiple invocations**: Same task with different arguments creates separate graph nodes, each executing independently.
- **Normalization**: All forms normalized to named arguments with defaults filled before execution.
- **Cache separation**: `process(debug)` and `process(release)` cache independently.

**Restrictions:**

- **No empty lists**: `- task: []` is invalid (use `- task` instead)
- **No mixed positional and named**: Choose one form per dependency
- **Single-key dicts**: `{task1: [x], task2: [y]}` is invalid (multi-key not allowed)

**Validation:**

Validation happens at graph construction time with clear error messages:

```
Task 'process' takes 2 arguments, got 3
Task 'build' has no argument named 'mode'
Task 'deploy' requires argument 'environment' (no default provided)
```

**Example use cases:**

```yaml
tasks:
  # Compile for different platforms
  compile:
    args: [target]
    cmd: cargo build --target {{arg.target}}

  dist:
    deps:
      - compile: [x86_64-unknown-linux-gnu]
      - compile: [aarch64-unknown-linux-gnu]
    cmd: tar czf dist.tar.gz target/*/release/app

  # Run tests with different configurations
  test:
    args: [config]
    cmd: pytest --config={{arg.config}}

  ci:
    deps:
      - test: [unit]
      - test: [integration]
      - test: [e2e]
    cmd: echo "All tests passed"
```

### Dependency Output References

Tasks can reference named outputs from their dependencies, enabling dynamic workflows where build artifacts, generated filenames, and other values are passed between tasks.

**Named Outputs:**

Tasks can define outputs with names for easy referencing:

```yaml
tasks:
  build:
    outputs:
      - bundle: "dist/app.js"        # Named output
      - sourcemap: "dist/app.js.map" # Named output
    cmd: webpack build

  deploy:
    deps: [build]
    cmd: |
      echo "Deploying {{ dep.build.outputs.bundle }}"
      scp {{ dep.build.outputs.bundle }} server:/var/www/
      scp {{ dep.build.outputs.sourcemap }} server:/var/www/
```

**Syntax:**

- **Defining named outputs**: `outputs: [{ name: "path/to/file" }]`
- **Referencing outputs**: `{{ dep.task_name.outputs.output_name }}`
- **Mixed format**: Can combine named and anonymous outputs in the same task

**Examples:**

```yaml
tasks:
  # Generate a config file
  generate-config:
    outputs:
      - config: "build/config.json"
    cmd: |
      mkdir -p build
      echo '{"version": "1.0.0"}' > build/config.json

  # Compile using the generated config
  compile:
    deps: [generate-config]
    outputs:
      - binary: "build/app"
      - symbols: "build/app.sym"
    cmd: |
      echo "Using config: {{ dep.generate-config.outputs.config }}"
      gcc -o build/app src/*.c

  # Package multiple dependency outputs
  package:
    deps: [compile]
    outputs:
      - archive: "dist/app.tar.gz"
    cmd: |
      mkdir -p dist
      tar czf {{ dep.package.outputs.archive }} \
        {{ dep.compile.outputs.binary }} \
        {{ dep.compile.outputs.symbols }}
```

**Mixed Named and Anonymous Outputs:**

Tasks can have both named and anonymous outputs:

```yaml
tasks:
  build:
    outputs:
      - binary: "build/app"      # Named - can be referenced
      - "build/app.debug"        # Anonymous - tracked but not referenceable
      - manifest: "build/manifest.json"  # Named - can be referenced
    cmd: make all
```

**Transitive References:**

Output references work across multiple levels of dependencies:

```yaml
tasks:
  base:
    outputs:
      - lib: "out/libbase.a"
    cmd: gcc -c base.c -o out/libbase.a

  middleware:
    deps: [base]
    outputs:
      - lib: "out/libmiddleware.a"
    cmd: |
      # Reference the base library
      gcc -c middleware.c {{ dep.base.outputs.lib }} -o out/libmiddleware.a

  app:
    deps: [middleware]
    cmd: |
      # Reference middleware, which transitively used base
      gcc main.c {{ dep.middleware.outputs.lib }} -o app
```

**Key Behaviors:**

- **Template resolution**: Output references are resolved during dependency graph planning (in topological order)
- **Fail-fast validation**: Errors are caught before execution begins
- **Clear error messages**: If an output name doesn't exist, you get a list of available named outputs
- **Backward compatible**: Existing anonymous outputs (`outputs: ["file.txt"]`) work unchanged
- **Automatic input tracking**: Named outputs are automatically tracked as implicit inputs for dependent tasks

**Error Messages:**

If you reference a non-existent output:

```yaml
tasks:
  build:
    outputs:
      - bundle: "dist/app.js"
    cmd: webpack build

  deploy:
    deps: [build]
    cmd: echo "{{ dep.build.outputs.missing }}"  # Error!
```

You'll get a clear error before execution:

```
Task 'deploy' references output 'missing' from task 'build',
but 'build' has no output named 'missing'.
Available named outputs in 'build': bundle
Hint: Define named outputs like: outputs: [{ missing: 'path/to/file' }]
```

**Use Cases:**

- **Dynamic artifact names**: Pass generated filenames between tasks
- **Build metadata**: Reference manifests, checksums, or version files
- **Multi-stage builds**: Chain compilation steps with specific output references
- **Deployment pipelines**: Reference exact artifacts to deploy
- **Configuration propagation**: Pass generated config files through build stages

### Self-References

Tasks can reference their own inputs and outputs using `{{ self.inputs.name }}` (named access) or `{{ self.inputs.0 }}` (positional access) templates. This eliminates repetition when paths contain variables or when tasks have multiple inputs/outputs.

**Named Inputs and Outputs:**

Just like dependency output references, inputs and outputs can have names:

```yaml
tasks:
  process:
    inputs:
      - src: "data/input.json"      # Named input
      - config: "config.yaml"        # Named input
    outputs:
      - result: "output/result.json" # Named output
      - log: "output/process.log"    # Named output
    cmd: |
      process-tool \
        --input {{ self.inputs.src }} \
        --config {{ self.inputs.config }} \
        --output {{ self.outputs.result }} \
        --log {{ self.outputs.log }}
```

**Syntax:**

- **Defining named inputs**: `inputs: [{ name: "path/to/file" }]`
- **Defining named outputs**: `outputs: [{ name: "path/to/file" }]`
- **Defining anonymous inputs**: `inputs: ["path/to/file"]`
- **Defining anonymous outputs**: `outputs: ["path/to/file"]`
- **Referencing by name**: `{{ self.inputs.input_name }}` or `{{ self.outputs.output_name }}`
- **Referencing by index**: `{{ self.inputs.0 }}` or `{{ self.outputs.1 }}` (0-based)
- **Mixed format**: Can combine named and anonymous inputs/outputs in the same task

**Why Use Self-References?**

Self-references follow the DRY (Don't Repeat Yourself) principle:

```yaml
# Without self-references - repetitive
tasks:
  build:
    inputs: [src/app-{{ var.version }}.c]
    outputs: [build/app-{{ var.version }}.o]
    cmd: gcc src/app-{{ var.version }}.c -o build/app-{{ var.version }}.o
```
```yaml
# With self-references - DRY
tasks:
  build:
    inputs:
      - source: src/app-{{ var.version }}.c
    outputs:
      - object: build/app-{{ var.version }}.o
    cmd: gcc {{ self.inputs.source }} -o {{ self.outputs.object }}
```

**Working with Variables:**

Self-references work seamlessly with variables:

```yaml
variables:
  platform: linux
  arch: x86_64

tasks:
  compile:
    inputs:
      - src: src/{{ var.platform }}/main.c
      - header: include/{{ var.arch }}/defs.h
    outputs:
      - binary: build/{{ var.platform }}-{{ var.arch }}/app
    cmd: |
      gcc {{ self.inputs.src }} \
        -include {{ self.inputs.header }} \
        -o {{ self.outputs.binary }}
```

Variables are evaluated first, then self-references substitute the expanded paths.

**Working with Arguments:**

Self-references work with parameterized tasks:

```yaml
tasks:
  deploy:
    args: [environment]
    inputs:
      - config: configs/{{ arg.environment }}/app.yaml
    outputs:
      - deployed: deployed-{{ arg.environment }}.yaml
    cmd: |
      validate {{ self.inputs.config }}
      deploy {{ self.inputs.config }} > {{ self.outputs.deployed }}
```

```bash
tt deploy production  # Uses configs/production/app.yaml
tt deploy staging     # Uses configs/staging/app.yaml
```

**Working with Dependency Outputs:**

Self-references and dependency references can be used together:

```yaml
tasks:
  build:
    outputs:
      - artifact: dist/app.js
    cmd: webpack build

  package:
    deps: [build]
    inputs:
      - manifest: package.json
    outputs:
      - tarball: release.tar.gz
    cmd: tar czf {{ self.outputs.tarball }} \
           {{ self.inputs.manifest }} \
           {{ dep.build.outputs.artifact }}
```

**Mixed Named and Anonymous:**

Tasks can mix named and anonymous inputs/outputs:

```yaml
tasks:
  build:
    inputs:
      - config: build.yaml  # Named - can reference
      - src/**/*.c          # Anonymous - tracked but not referenceable
    outputs:
      - binary: bin/app     # Named - can reference
      - bin/app.debug       # Anonymous - tracked but not referenceable
    cmd: build-tool --config {{ self.inputs.config }} --output {{ self.outputs.binary }}
```

**Positional Index References:**

In addition to named references, you can access inputs and outputs by their positional index using `{{ self.inputs.0 }}`, `{{ self.inputs.1 }}`, etc. This provides an alternative way to reference items, especially useful for:

- **Anonymous inputs/outputs**: Reference items that don't have names
- **Simple sequential access**: When order is more important than naming
- **Mixed with named access**: Use both styles in the same task

**Syntax:**

```yaml
tasks:
  process:
    inputs: ["file1.txt", "file2.txt", "file3.txt"]
    outputs: ["output1.txt", "output2.txt"]
    cmd: |
      cat {{ self.inputs.0 }} {{ self.inputs.1 }} > {{ self.outputs.0 }}
      cat {{ self.inputs.2 }} > {{ self.outputs.1 }}
```

Indices follow YAML declaration order, starting from 0 (zero-based indexing):
- First input/output = index 0
- Second input/output = index 1
- Third input/output = index 2, etc.

**Works with Both Named and Anonymous:**

```yaml
tasks:
  build:
    inputs:
      - config: "build.yaml"  # Index 0, also accessible as {{ self.inputs.config }}
      - "src/**/*.c"          # Index 1, ONLY accessible as {{ self.inputs.1 }}
      - headers: "include/*.h" # Index 2, also accessible as {{ self.inputs.headers }}
    outputs:
      - "dist/app.js"         # Index 0
      - bundle: "dist/bundle.js" # Index 1, also accessible as {{ self.outputs.bundle }}
    cmd: |
      # Mix positional and named references
      build-tool \
        --config {{ self.inputs.0 }} \
        --sources {{ self.inputs.1 }} \
        --headers {{ self.inputs.headers }} \
        --output {{ self.outputs.bundle }}
```

**Same Item, Multiple Ways:**

Named items can be accessed by both name and index:

```yaml
tasks:
  copy:
    inputs:
      - source: data.txt
    cmd: |
      # These are equivalent:
      cat {{ self.inputs.source }} > copy1.txt
      cat {{ self.inputs.0 }} > copy2.txt
```

**With Variables and Arguments:**

Positional references work with variable-expanded paths:

```yaml
variables:
  version: "1.0"

tasks:
  package:
    args: [platform]
    inputs:
      - "dist/app-{{ var.version }}.js"
      - "dist/lib-{{ arg.platform }}.so"
    outputs: ["release-{{ var.version }}-{{ arg.platform }}.tar.gz"]
    cmd: tar czf {{ self.outputs.0 }} {{ self.inputs.0 }} {{ self.inputs.1 }}
```

**Index Boundaries:**

Indices are validated before execution:

```yaml
tasks:
  build:
    inputs: ["file1.txt", "file2.txt"]
    cmd: cat {{ self.inputs.5 }}  # Error: index 5 out of bounds!
```

Error message:
```
Task 'build' references input index '5' but only has 2 inputs (indices 0-1)
```

**Empty Lists:**

Referencing an index when no inputs/outputs exist:

```yaml
tasks:
  generate:
    cmd: echo "test" > {{ self.outputs.0 }}  # Error: no outputs defined!
```

Error message:
```
Task 'generate' references output index '0' but has no outputs defined
```

**When to Use Index References:**

- **Anonymous items**: Only way to reference inputs/outputs without names
- **Order-based processing**: When the sequence matters more than naming
- **Simple tasks**: Quick access w