dev
← All ADRs

Package Context Resolution (Variable Flow)

ACCEPTEDEngine2026-06-01

Context

autograph is modular: a project includes an engine (submodule), which itself pulls core packages (via West), and the project may add third-party packages. Each component needs contextual information to function:

  • Paths (workspace, engine, deps, repos) - only the project knows these
  • Host identity (user, UID, GID) - resolved from the host at runtime
  • Secrets (API keys, tokens) - stored in env/.env, loaded at runtime
  • Infrastructure (network name, container CLI) - project-level decisions

The mechanism for passing this context must be:

  1. Explicit - every variable has a clear source of truth
  2. Fail-fast - missing required variables produce immediate, clear errors
  3. No silent defaults - if a value is wrong, it must never silently work with a fallback
  4. Compatible with Taskfile includes - respects Task's variable resolution mechanics

Dependency patterns to support

Pattern Example Description
Project → Engine AUGR_PATH_REPOS Project passes structural vars to the engine Taskfile
Project → Package AUGR_PATH_WORKSPACE Project passes context to a package Taskfile (via include)
Engine → Package transitive via Project Engine does NOT pass context directly - Project is always the source
Secrets → any consumer AUGR_ADE_OPENCODE_* Sensitive vars from env/.env to compose at runtime
Internal resolution AUGR_ENGINE_DOCKER_PATH Component resolves its own internal paths from TASKFILE_DIR

Taskfile behavior (experimentally validated)

The following behavior was characterized through a test prototype (see package-context-resolution.d/) using Task v3.41.0 on 2026-06-01.

Variable resolution - reference

Per the Task documentation, variables resolve in this priority order (highest first):

  1. Variables declared in the task definition
  2. Variables passed via task: call with vars:
  3. Variables declared in the included Taskfile (local vars: section)
  4. Variables passed by the inclusion (includes: ... vars:)
  5. Global variables of the parent Taskfile
  6. Environment variables

Critical implication: Level 3 overrides level 4. If the included Taskfile declares a variable locally, it wins over any value the parent tries to pass via includes: vars:.

Observed behavior report (Task v3.41.0)

Mechanism Works? Constraint
Template var references earlier template var (same vars: block) ✓ -
sh: references a template var (same block) ✓ -
sh: references another sh: var (same block) ✓ -
sh: references TASKFILE_DIR ✓ -
sh: in child: pwd ⚠️ Returns ROOT Taskfile dir, NOT the dir: of the include
TASKFILE_DIR in child template expressions ✓ Points to the child's Taskfile directory
dotenv: with template var in path ✗ Templates not resolved in dotenv paths - use relative paths only
dotenv: with relative path ✓ Relative to the Taskfile that declares it
dotenv: precedence (multiple files) ✓ First file listed wins
dotenv: vars available in child process env ✓ Inherited via process environment
includes: vars: passing to child ✓ Only if child does NOT redeclare the var
Child redeclares var (local wins) confirmed Local vars: always overrides includes: vars:
Variable leak between siblings confirmed Vars with same name leak across sibling includes - namespacing required
requires: vars: validation ✓ Fails fast with clear message
Transitive passing (root→child→grandchild) ⚠️ Works but affected by sibling leak if names collide
sh: vars re-evaluated in include context confirmed CRITICAL: sh: vars passed via includes: vars: are re-evaluated in the include's dir: context. TASKFILE_DIR in the sh: points to the child dir, not the parent.
env: with empty {{.VAR}} overwrites process env confirmed If template resolves to empty, it overwrites dotenv values in process env
Parent env: visible as {{.VAR}} in child ✓ Parent env: exports are available as template vars in children

Known issues in Task (upstream)

Issue Status Impact on autograph
#2035 - Variable scope and inheritance experiment Proposed (open) Would fix sibling leak and add proper scoping
#1075 - dotenv in included Taskfiles Requested Would allow packages to load their own env files
#2636 - Support dotenv in included taskfiles (PR) Closed/Abandoned Was an attempt to implement #1075
#2034 - Variables Megathread Open (epic) Tracks all variable-related improvements

Decision

Pragmatic approach: ROOT_DIR pattern + env: exports for paths, dotenv for secrets, includes: vars: for non-path config only.

The project root Taskfile resolves ROOT_DIR via git rev-parse --show-toplevel (stable regardless of cwd or include context). All paths are derived from ROOT_DIR via template expressions. Paths and identity are exported to the process environment via the root env: section - compose files read them directly via ${VAR:?}.

includes: vars: is used ONLY for non-path variables (container CLI, network name, host identity) that don't suffer from the re-evaluation bug.

Secrets are loaded from env/.env via dotenv: (relative path).

Variable names are strictly namespaced to prevent sibling leak contamination.

Rules for included Taskfiles (engine, packages)

  1. NEVER declare a variable you cannot resolve yourself (only TASKFILE_DIR-relative paths and constants)
  2. NEVER use pwd or realpath . in sh: for path resolution - use {{.TASKFILE_DIR}} in templates instead (sh: runs from root dir, not from include dir:)
  3. DO NOT export received paths via env: - they come from the root's env: section already in the process env
  4. Use requires: vars: for non-path vars that must be passed via includes
  5. Namespace all vars with a package-specific prefix to prevent sibling leaks

Rules for the project Taskfile (root)

  1. Resolve ROOT_DIR via git rev-parse --show-toplevel (always stable)
  2. Derive all paths from ROOT_DIR via template ('{{.ROOT_DIR}}/workspace')
  3. Export paths + identity via env: - this puts them in process env for compose
  4. Pass non-path vars via includes: vars: (CLI, network, user identity)
  5. Load secrets via dotenv: with relative path ('./env/.env')
  6. Never put secrets in vars: - only in dotenv:

Variable classification

Category Examples Mechanism Namespace
Structural (paths) AUGR_PATH_WORKSPACE, AUGR_PATH_ENGINE, AUGR_PATH_REPOS Root env: (derived from ROOT_DIR) AUGR_PATH_*
Identity (host) AUGR_RUNTIME_USER_NAME/UID/GID includes: vars: + root env: AUGR_RUNTIME_*
Infrastructure AUGR_CONTAINER_CLI, AUGR_NETWORK_* includes: vars: + root env: AUGR_*
Engine internal AUGR_VERSION, AUGR_ENGINE_DOCKER_PATH Local vars ({{.TASKFILE_DIR}}-based) AUGR_ENGINE_*
Package internal PKG_AGENT_COMPOSE_CMD Local vars ({{.TASKFILE_DIR}}-based) AUGR_PKG_<NAME>_*
Secrets AUGR_ADE_OPENCODE_PROVIDER_ID, AUTH_TOKEN, MODEL_* dotenv: → process env → compose ${VAR:?} AUGR_ADE_*

Path resolution rules

Context Use this NOT this
Root Taskfile vars: sh: pwd, realpath . (works at root level) -
Root Taskfile vars: template {{.TASKFILE_DIR}}/subdir -
Child Taskfile vars: template {{.TASKFILE_DIR}}/subdir (child's own dir) -
Child Taskfile vars: sh: AVOID - use template instead pwd (returns root dir!), realpath . (returns root!)
dotenv: paths './relative/path.env' '{{.VAR}}/path.env' (templates not resolved)
Passing paths to includes Resolve once in setup, write to env/.env, load via dotenv includes: vars: with sh: or {{.TASKFILE_DIR}} (both get re-evaluated!)

Critical finding: sh: AND templates are re-evaluated in includes

Both sh: vars and {{.TASKFILE_DIR}} template expressions passed via includes: vars: are re-evaluated in the include's dir: context. This means:

  • AUGR_PATH_REPOS: sh: 'realpath "{{.TASKFILE_DIR}}/../../repos"' passed to an include with dir: ./engine will resolve TASKFILE_DIR as the engine directory, not the root.
  • Even AUGR_PATH_REPOS: '{{.TASKFILE_DIR}}/../../repos' (template, no sh:) will be re-evaluated with the child's TASKFILE_DIR.

Only literal strings pass correctly through includes: vars:.

Solution adopted: Resolve paths once during setup (post-clone), write absolute paths to env/.env, load them via dotenv:. They become literal strings in the process environment, available everywhere without re-evaluation. See ADR lifecycle-tasks for the full bootstrap sequence.

Consequences

Positive

  • Simple to understand and debug - same pattern as existing ADE (ROOT_DIR + env:)
  • Fail-fast at compose level: ${VAR:?} catches missing vars
  • Secrets never appear in Task variable chain (security)
  • No custom tooling needed (ROOT_DIR via git rev-parse, paths via template)
  • Namespacing prevents sibling leak contamination
  • Paths in env: are immune to the sh:/template re-evaluation bug in includes

Negative / Accepted trade-offs

  • Root Taskfile env: section is verbose (lists all exported vars)
  • No standalone mode for engine or packages (always need a parent project)
  • includes: vars: cannot carry paths (re-evaluation bug) - only non-path config
  • Will benefit from refactoring when Task #2035 lands

Future migration path

When Task implements proper scoping (#2035) and dotenv in includes (#1075):

  1. Remove namespacing workarounds (scoping will prevent leaks)
  2. Move secrets loading to package-level dotenv
  3. Potentially pass paths via includes: vars: if re-evaluation is fixed

Test prototype

The experimental validation lives in package-context-resolution.d/ alongside this ADR. It contains executable Taskfiles that demonstrate each behavior. Run task test:all from that directory to reproduce the findings.