Decisions

ADR-0013: Taskfile Contract v2 — Prescriptive Naming and Recipes

Decisions to extend the Taskfile contract with prescriptive task naming, CI namespace, developer lifecycle, environment handling, and CodeArtifact recipes.

ApprovedNo MCP

Status: Accepted

Date: 2026-03-14

Deciders: Engineering Leadership

Supersedes: Extends ADR-0003: Taskfile as Contract (still valid, not replaced)


Context

ADR-0003 established Taskfile as the mandatory operational contract. It defined the principle ("Taskfile is the single interface") and listed suggested namespaces, but intentionally left task naming loose to encourage adoption.

After 12+ months of adoption across 15+ repositories, we observed:

Naming inconsistency:

  • Flat task names (install, test, lint, ci) coexist with namespaced ones (lint:check, test:unit) — sometimes within the same repo
  • Synonyms for the same operation: sandbox:start vs sandbox:up, validate:all vs ci:
  • No shared vocabulary for task name segments — developers have no framework to decide how to name a new task

CI/validation confusion:

  • 7 repos use bare ci: as a flat rollup (lint + test + typecheck)
  • 3 repos use validate:all for the same purpose
  • Neither is decomposed — developers can't run just the lint step of CI locally
  • No repo uses ci:* for CI-specific operations (its intended purpose per the original pattern)

No standard developer onboarding path:

  • Repos use install, setup, bootstrap, deps:install, or nothing for first-use setup
  • No "clone → one command → working" pattern exists

Environment variable handling:

  • Some repos hardcode AWS_PROFILE: ontopix-dev with no override mechanism
  • Inconsistent use of .env vs .envrc vs inline values
  • No documented precedence chain

CodeArtifact login — three incompatible approaches:

  • Method A: eval $(task deps:login:npm) — outputs token to stdout, no system pollution
  • Method B: aws codeartifact login --tool npm — writes to ~/.npmrc, pollutes system config
  • Method C: Token written to .env file — persists across tasks but mutates a file

Infrastructure formatting:

  • Some repos use terraform fmt -recursive, some don't — leading to inconsistent CI results

Decisions

Decision 1: Task Name Terminology

Task names use colons to separate up to 3 hierarchical segments:

namespace:action          → lint:check, test:unit, sandbox:start
namespace:action:target   → deploy:lambda:mcp-erp, deps:login:npm
SegmentRoleExamples
namespaceConcern arealint, test, ci, sandbox, infra, deploy, release, deps, dev
actionThe operationcheck, fix, start, plan, install, login
target (optional)Specific scopenpm, mcp-erp, terraform

Rules:

  • Maximum 3 segments (typically 2, 4 allowed in rare cases)
  • Hyphens within segments for multi-word names (agent-support, not agentSupport)
  • No abbreviations (integration, not int)
  • Flat tasks (no namespace) reserved for meta/utility: default, help, clean

Rationale: This formalizes what ~80% of repos already do organically. The remaining repos using flat names (audit-utils, audit-service, schemas-sdk) are the exception, not the norm.

Decision 2: ci:* as CI Namespace

ci:* is the namespace for tasks that mirror CI pipeline steps. A developer runs them locally to verify what CI will run.

ci:lint    → Same lint checks CI runs
ci:test    → Same tests CI runs
ci:build   → Same build CI runs (if applicable)
ci:all     → Full CI pipeline: ci:lint + ci:test + ci:build

Rule: If task ci:all passes locally, CI must pass. No surprises.

What ci:* is NOT for: CI-environment-specific setup (credential injection, cache config, runner provisioning). Those belong in GitHub Actions workflow YAML.

Rationale: 7 repos already use ci: for this purpose, but as a flat rollup without decomposition. Decomposing into ci:lint, ci:test, ci:build maps 1:1 to CI workflow steps, letting developers run the full pipeline or just one slice. The existing validate:all tasks that serve as CI rollups migrate to ci:all.

Decision 3: validate:* Retained for Domain Validation

validate:* is for domain-specific conformance checks — not CI rollup.

Examples: validate:structure (required files), validate:schemas (data conformance), validate:config (configuration files).

Some validate:* tasks are naturally called by ci:lint or ci:all (e.g., validate:structure). The task lives in validate:, the CI step references it.

Rationale: 10+ repos already use validate:structure. The namespace has a clear purpose distinct from CI rollup.

Decision 4: dev:setup as Clone-to-Working Entry Point

dev:setup    → Full first-use setup. Idempotent.
               Calls dev:install, copies .env.example → .env if missing,
               runs validate:structure, prints next-steps.
dev:install  → Install language dependencies (uv sync / pnpm install).
dev:run      → Start the application locally.

Rationale: The "I just cloned this repo, now what?" question has no standard answer today. Repos use install, setup, bootstrap, or nothing. dev:setup is the single entry point. It's idempotent — safe to re-run. dev:install stays useful standalone for "just update my deps after a pull."

Decision 5: sandbox:start / sandbox:stop as Canonical Names

Not up/down. This is what the original pattern prescribed and what the sandbox-environments pattern uses.

Rationale: start/stop is tool-agnostic. up/down is Docker Compose jargon. Repos using sandbox:up/down (notably maxcolchon) should migrate when next touched.

Decision 6: infra:fmt Must Use -recursive

infra:fmt MUST use terraform fmt -check -recursive.

Rationale: Without -recursive, subdirectories (modules, environments) are silently skipped. This has caused inconsistent CI results where local checks pass but CI fails, or vice versa.

Decision 7: CodeArtifact Login — Method A with Optional .env Persistence

Recommended: eval $(task deps:login:npm) — outputs an export statement, no system pollution.

Optional complement: A deps:login:npm:env variant that writes the token to .env for persistence across task invocations without re-eval.

Forbidden: aws codeartifact login --tool npm/pip — this writes to ~/.npmrc or ~/.config/pip/pip.conf, polluting the developer's system-wide configuration. The pollution persists after the session ends and can cause conflicts with other projects.

Rationale: Method A keeps credentials in the shell session (ephemeral, clean). The optional .env persistence solves the practical problem of tokens not propagating across task subprocess boundaries. Method B was used in some repos but is explicitly forbidden going forward due to system-wide side effects.

Decision 8: .env as Standard Override Mechanism

Precedence chain: Taskfile defaults → .env file → shell environment (last wins).

  • Every repo MUST use dotenv: ['.env'] in Taskfile
  • Every repo MUST ship .env.example with all supported variables and comments
  • .env MUST be in .gitignore
  • .envrc is an optional developer convenience (direnv), not a requirement

Never hardcode values that differ per developer or environment at the top-level env: block. Use vars: with default instead:

# ❌ Hardcoded
env:
  AWS_PROFILE: ontopix-dev

# ✅ Overridable
vars:
  AWS_PROFILE: '{{.AWS_PROFILE | default "ontopix-dev"}}'

Rationale: Without a standard override mechanism, developers either hardcode values (breaking other developers' setups) or each repo invents its own convention. .env is universally understood and supported by Taskfile natively.

Decision 9: Flat Task Names Reserved for Meta/Utility

Only meta/utility tasks may be flat (no namespace): default, help, clean, clean:deep.

All operational tasks MUST be namespaced.

Rationale: Flat names like install, test, build are ambiguous — install what? test what? Namespacing (deps:install, test:unit, build:dist) makes intent explicit and enables task --list to group related operations.

Decision 10: release:* as Library/Artifact Release Namespace

release:* is the namespace for publishing libraries and packages to artifact repositories (CodeArtifact, npm, PyPI).

release:publish    → Publish built artifacts to registry
release:version    → Sync VERSION to package manifests (package.json, pyproject.toml, etc.)
release:all        → Full release pipeline: release:version + build:dist + release:publish

release:* mirrors deploy:* symmetry: deploy puts services in production, release puts libraries in artifact registries.

release:all chains build:dist — artifacts are always rebuilt from the current version before publishing. Individual tasks (release:publish, release:version) remain independently callable for partial workflows.

Not in scope: The versioning protocol itself (VERSION file as source of truth, sync mechanics, validation of version consistency across monorepo packages) is a cross-cutting concern for a separate pattern. This decision only prescribes the task namespace and composition.

Rationale: Library repos (schemas-sdk, stylebook, audit-utils, and any future shared library) follow a bump → build → publish cycle that had no prescribed namespace. release:* fills this gap without overloading deploy:* (which targets running services, not package registries).

Consequences

Positive

  • Predictable naming — any developer can guess task names in an unfamiliar repo
  • Local CI paritytask ci:all eliminates "works on my machine" surprises
  • One-command onboardingtask dev:setup from clone to working state
  • Clean credentials — no system-wide config pollution from private registries
  • Override chain — developers customize behavior without modifying committed files

Negative

  • Migration effort — existing repos need task renames (see explainer for migration guide)
  • Breaking change for validate:all users — repos calling task validate:all as CI rollup must rename to ci:all

Mitigations

  • Migration happens incrementally — when a repo is next touched for meaningful work, not as a big-bang
  • New repos MUST follow the pattern from day one
  • Migration guide and compliance checklist provided as explainer

References