Package Context Resolution (Variable Flow)
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:
- Explicit - every variable has a clear source of truth
- Fail-fast - missing required variables produce immediate, clear errors
- No silent defaults - if a value is wrong, it must never silently work with a fallback
- 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):
- Variables declared in the task definition
- Variables passed via
task: callwithvars: - Variables declared in the included Taskfile (local
vars:section) - Variables passed by the inclusion (
includes: ... vars:) - Global variables of the parent Taskfile
- 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)
- NEVER declare a variable you cannot resolve yourself (only
TASKFILE_DIR-relative paths and constants) - NEVER use
pwdorrealpath .insh:for path resolution - use{{.TASKFILE_DIR}}in templates instead (sh: runs from root dir, not from includedir:) - DO NOT export received paths via
env:- they come from the root'senv:section already in the process env - Use
requires: vars:for non-path vars that must be passed via includes - Namespace all vars with a package-specific prefix to prevent sibling leaks
Rules for the project Taskfile (root)
- Resolve ROOT_DIR via
git rev-parse --show-toplevel(always stable) - Derive all paths from ROOT_DIR via template (
'{{.ROOT_DIR}}/workspace') - Export paths + identity via
env:- this puts them in process env for compose - Pass non-path vars via
includes: vars:(CLI, network, user identity) - Load secrets via
dotenv:with relative path ('./env/.env') - Never put secrets in
vars:- only indotenv:
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 withdir: ./enginewill resolveTASKFILE_DIRas the engine directory, not the root.- Even
AUGR_PATH_REPOS: '{{.TASKFILE_DIR}}/../../repos'(template, no sh:) will be re-evaluated with the child'sTASKFILE_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):
- Remove namespacing workarounds (scoping will prevent leaks)
- Move secrets loading to package-level dotenv
- 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.