pkfire — authoring Taskfile.pkl
pkf is a typed task runner: tasks declare their inputs, outputs, and
dependencies in Pkl, and a content-addressed cache decides what
actually runs. This skill is for whoever is editing the Taskfile.pkl.
When to invoke this skill
- A new task needs to be added or an existing task changed.
- A task is "always re-running" or "always hitting cache when it shouldn't" — that is a hashing question, not a runner bug.
- A team wants to share cache hits across machines / CI (remote cache).
- Migrating from
justfileorTaskfile.yml.
Do not invoke for unrelated build-system questions (Make, Bazel, Gradle, etc.) or for editing Pkl that does not amend pkfire's schema.
Mental model
A Taskfile is a Pkl module that amends the pkfire schema and
declares one local Task per unit of work, then lists them in
tasks { ... }. The runner builds a DAG over deps, executes only
the tasks whose action key changed, and restores the cached
outputs of every other task from a CAS.
Action key = BLAKE3 over (cmd, shell, shellFlags, sorted env,
sorted tools, sorted input file digests, the Pkl module's canonical
form, plus any resolved CLI params and tail args when the task is
the invocation target). Two invocations with the same key are
guaranteed to produce the same outputs (assuming honest inputs
declarations).
Above the pure run-it-once DAG, pkfire grows three orthogonal layers:
service: true+pkf up— long-running processes (databases, dev servers) with process-group cleanup, readiness probes, and reuse-or-spawn semantics.acceptsArgs+params { ... }— typed CLI input (pkf run task --bump=minor -- file1 file2) folded into the action key so different invocations cache as different entries.pkf hooks install+pkf affected— git-aware orchestration: convention-based hook installation and changed-files-driven plan computation.
Each layer is opt-in via a schema field or a CLI subcommand; the base "one cached task per change" model is unchanged.
The two non-obvious rules
- Every authored task must appear in
tasks { ... }. Pkl'samendssemantics forbid adding new top-level fields, so reflection-based auto-collection cannot work. Alocal foo = new Task { ... }that is not listed intasksis dead code from the runner's perspective. depsare real Task references, not strings. Writedeps { build }, notdeps { "build" }. A typo in a name is then a Pkl evaluation error, not a runtime DAG error.
Schema cheat sheet
class Task {
name: String // required, regex-checked
cmd: String(length > 0)? = null // null = deps-only umbrella task
shell: String = "bash"
shellFlags: List<String> = List("-c") // args before cmd, e.g. bash -c or node -e
inputs: Listing<String> = new {} // glob; missing files silently ignored
outputs: Listing<String> = new {} // restored on cache hit
deps: Listing<Task> = new {} // direct references
env: Mapping<String, String> = new {} // contributes to action key
tools: Mapping<String, String> = new {} // declared toolchain versions
cache: Boolean = true // false = always run
workdir: String? = null // relative to Taskfile dir
description: String? = null
visibility: "public"|"internal" = "public" // hidden from list/graph unless --all
quiet: Boolean = false // suppress pkfire per-task diagnostic lines
service: Boolean = false // long-running, supervised by `pkf up`
shutdownTimeoutSeconds: Int = 5 // SIGTERM grace before SIGKILL
services: Listing<Task> = new {} // services to bring up while this task runs
readyPort: Int = 0 // TCP port to probe; doubles as a reuse detector
readyCmd: String = "" // shell snippet that exits 0 when ready
readyTimeoutSeconds: Int = 30 // wait budget for the probe after spawning
inheritEnv: Boolean = true // false = hermetic (PATH/HOME/LANG/... only)
acceptsArgs: Boolean = false // forwards `pkf run task -- a b` as $@
params: Listing<Param> = new {} // typed --name=value flags (see below)
}
class Param {
name: String // lower-case; exposed to cmd as $NAME (uppercased)
type: "string"|"enum"|"int"|"bool"
choices: Listing<String> // enum only
default: String? // null = required; "10" for int, "true"/"false" for bool
description: String?
}
class WorkflowTest {
name: String
changed: Listing<String> // repo-relative files to simulate
tasks: Listing<String> // expected affected run plan, in order
direct: Listing<String> = new {} // optional: direct input matches
}
Authoring template (always start from this)
amends "package://pkg.pkl-lang.org/github.com/mizchi/pkfire/pkfire@0.11.0#/Taskfile.pkl"
local sources: Listing<String> = new {
// file globs your build reads from
}
local build: Task = new {
name = "build"
cmd = "go build -o bin/app ./cmd/app"
inputs = sources
outputs { "bin/app" }
}
local test: Task = new {
name = "test"
cmd = "go test ./..."
inputs = sources
deps { build } // direct Task reference
}
tasks { build; test }
The package://... URI is already version-pinned, so the same
checkout reproduces across machines and CI. To upgrade, bump the
@<version> segment — Pkl re-resolves and re-fetches on the next
run, then caches the new package locally.
Recipes (copy-paste starters)
| File | Pattern |
|---|---|
assets/recipes/01-build-and-test.pkl |
Minimum viable: one build, one test, deps chain |
assets/recipes/02-multi-platform-matrix.pkl |
Cross-compile matrix via local function + for |
assets/recipes/03-monorepo.pkl |
Per-package tasks generated from a Package template |
assets/recipes/04-dev-watch.pkl |
A long-running dev task plus a quick lint pre-flight |
assets/recipes/05-remote-cache.pkl |
Same Taskfile, configured via env to use a remote cache |
assets/recipes/06-split-and-import.pkl |
Single entry point, definitions split into shared/ + tasks/ |
assets/recipes/07-hierarchical-amends.pkl |
Per-service Taskfiles that amends a project-root template |
assets/recipes/08-services.pkl |
pkf up: multiple long-running services with shared lifecycle |
assets/recipes/09-test-against-services.pkl |
pkf run e2e with services { api } — ephemeral stack for one-shot test |
assets/recipes/10-args-passthrough.pkl |
acceptsArgs = true: forward pkf run task -- a b to cmd as $@ |
assets/recipes/11-named-params.pkl |
Typed flags: params { ... } with enum validation, defaults, required params |
assets/recipes/12-task-library.pkl |
Library-author skeleton: abstract module + extends polymorphism, allTasks export, runtime dispatch, release-tag scheme |
assets/recipes/13-git-hooks.pkl |
pkf hooks install: tasks named after git events (pre-commit, pre-push, commit-msg) wired to .git/hooks/<event> shims |
assets/recipes/14-secretlint-pre-push.pkl |
secretlint as a pkf run pre-push task (scans the outgoing diff, not every commit) — drops the prek dep for the "secretlint-only" hook case |
assets/recipes/15-diagnostics-and-lint.pkl |
list --long, lint --json/--fix, doctor --json/--fix, internal audit tasks, quiet wrappers, strict shell flags |
assets/recipes/16-release-version-bump.pkl |
pkf run bump-version --from=X --to=Y [--commit=true] — sed-rewrites a pinned file list (flake.nix, action.yml, README, docs, …) so the release-tag prelude collapses to one command |
assets/recipes/17-pkspec-checks.pkl |
spec-check, spec-lint, spec-next, spec-orphans, spec-coverage, pkspec-doctor wrappers around the pkspec CLI, cached on the declared specSources / markerSources so pkf affected --since can skip the gate |
assets/recipes/18-pkspec-check-pre-push.pkl |
pkspec check --strict + pkspec lint --scan as a pkf run pre-push task — drop-in companion to recipe 14 for repos that own a pkspec spec set |
assets/recipes/19-spec-task-link.pkl |
Bidirectional pkfire ↔ pkspec link: Task.specRef (task → spec) paired with Implementation { kind = "task"; at = "Taskfile.pkl#<task>" } (spec → task). Enables pkf affected --with-specs and pkspec check --strict cross-check. Requires pkfire 0.11.0+ |
Project layout
Three layouts cover the realistic spectrum of Taskfile sizes. Adopt them in this order — upgrade only when the previous layout hurts, not before.
A. Single Taskfile (default; up to ~30 tasks)
project/
└── Taskfile.pkl
Pkl's local function + for keep even matrix-heavy single files
readable; see recipe 02. Stay here unless one of B / C is solving a
real pain.
B. Split + import (single entry point, definitions distributed)
project/
├── Taskfile.pkl # entry: amends pkfire schema, imports + spreads
├── shared/
│ ├── sources.pkl # `sources: Listing<String>`, `toolchain: Mapping<...>`
│ └── env.pkl
└── tasks/
├── build.pkl # `import "../shared/..."`; exports `tasks: Listing<Task>`
└── test.pkl
The root Taskfile.pkl imports each fragment and spreads its
tasks Listing:
amends "package://pkg.pkl-lang.org/github.com/mizchi/pkfire/pkfire@0.11.0#/Taskfile.pkl"
import "tasks/build.pkl" as bt
import "tasks/test.pkl" as tt
tasks { ...bt.tasks; ...tt.tasks }
Use this when one file is fine for the runner but humans want smaller
files. Pkfire still consumes a single pkf run -f Taskfile.pkl <task>
invocation, and Task references work across files because import
brings real values across the boundary.
C. Hierarchical amends ("Know Your Place" pattern)
project/
├── Taskfile.pkl # root: shared sources/tools, project-wide tasks (lint, format)
└── services/
├── api/
│ └── Taskfile.pkl # amends "../../Taskfile.pkl"; adds build:api, test:api
└── web/
└── Taskfile.pkl # amends "../../Taskfile.pkl"; adds build:web, test:web
Inspired by Know Your Place
from the Pkl team: the directory tree itself encodes structure.
Each leaf Taskfile amends the root and tasks { ...new local tasks }
appends to the inherited Listing. Run a service in isolation:
pkf run -f services/api/Taskfile.pkl ci # only api's subgraph
pkf run -f Taskfile.pkl lint # project-wide root tasks
Why this beats (B) at scale:
- A team owning
services/api/only editsservices/api/Taskfile.pkl; cross-team reviews stay scoped. pkl:reflectcan derive the leaf's identity (e.g.api,web) from its file path — see the upstream blog post for afindRootModulehelper that walks theamendschain.- The root file stays the canonical place for shared
sources,tools, and policy tasks, so changing the lint command propagates everywhere by editing one file.
Trade-offs:
- One
pkf runinvocation only sees one Taskfile. There is no "umbrella ci that runs every leaf" without a small wrapper script (or a deliberately authoredservices/Taskfile.pklthat imports the leaves). - Leaf authors must remember the
amendsURI is relative, and changing the root file's location breaks every leaf at once. Pin the root path, or have leaves amend the pkfire package URI directly if they don't actually need anything from a project-root Taskfile.
Picking between B and C
| Situation | Use |
|---|---|
| Many tasks, single team | B (split, one entry) |
| Many services, many teams | C (hierarchical amends) |
| Mix: shared + per-service | C with B inside each leaf is fine |
Discovery (walk-up)
pkf discovers Taskfile.pkl the way git finds .git/: when
-f / --file is not specified, it walks up from the current
working directory and uses the nearest ancestor that has one. Layout C
benefits the most from this:
cd services/api/internal
pkf run ci # finds services/api/Taskfile.pkl automatically
cd ../../..
pkf run lint # finds the project-root Taskfile.pkl
An explicit -f opts out of walk-up — useful when the path is
relative to the invocation directory rather than to the Taskfile.
Long-running services (pkf up)
Tasks marked service = true are long-running processes — dev
servers, databases, watchers — that pkfire supervises rather than
caches. pkf up <task> starts every service in the target's
subgraph, blocks until Ctrl+C, then sends SIGTERM to each service's
process group (so bash -c "node server.js" does not leak its
node child) and escalates to SIGKILL after shutdownTimeoutSeconds
(default 5).
local db: Task = new {
name = "db"
cmd = "exec postgres -D ./data"
service = true
shutdownTimeoutSeconds = 15
}
local api: Task = new {
name = "api"
// `until pg_isready ...` is a hand-rolled readiness wait. v1 of
// `pkf up` starts every service simultaneously; dependent services
// must retry until upstream is ready.
cmd = """
until pg_isready -q; do sleep 0.2; done
exec node server.js
"""
service = true
deps { db }
}
tasks { db; api }
pkf up api # starts db + api, blocks until Ctrl+C
pkf up --watch api # also restarts both on a source save
Rules of thumb:
- Use
execin thecmdso the real binary replaces the shell and receives signals directly — gives one fewer pid inpsand clearer logs. The runner sets the cmd's process group regardless, so non-execcmds still get reaped. pkf runrejects services in v1. The runner happily blocks forever, butpkf upis the supervisor that knows about the service lifecycle. Usepkf runfor tasks that terminate.- Non-service tasks may not depend on services. A
buildshouldn'tdeps { db }— that means "wait for db to finish", which a service never does. The reverse (dbas a service that needsmigrateto run first) is fine. - Cache is implicitly disabled for service tasks — you never want to "skip" starting a server because its inputs haven't changed.
Tests that need live servers (services { ... } on the task)
For one-shot commands that need backing services for the duration
of their cmd (e2e tests, smoke scripts, migration checks),
declare them via services { ... } on the body task instead of
running a separate pkf up:
local e2e: Task = new {
name = "e2e"
cmd = """
until curl -fsS http://localhost:3000/health >/dev/null; do sleep 0.2; done
pnpm exec playwright test
"""
inputs { "tests/**/*.ts" }
cache = false
services { api } // api in turn declares services { db }
}
pkf run e2e brings up api (and recursively api's own
services like db), runs the test cmd, then stops everything in
reverse order — same SIGTERM-grace-SIGKILL flow as pkf up.
services { ... } differs from deps { ... } along the
"finishing" axis: deps { build } waits for build to exit
successfully before this task starts; services { db } waits for
db to start (a service is never expected to exit) and keeps it
running for the duration. The two compose — the same task can
have both.
Recipe 09 has the full picture, including a Drizzle migration
check that uses just services { db } without api.
Reuse vs spawn (readyPort / readyCmd)
A service with a readiness probe is reused when the probe already passes:
local db = new Task {
name = "db"
cmd = "exec postgres -D ./data"
service = true
readyPort = 5432
readyTimeoutSeconds = 15
}
When pkf run e2e (or any task with services { db }) starts,
pkfire dials localhost:5432 once. If the dial succeeds, db is
already up — typically because pkf up dev is running in another
shell, or you started postgres yourself. pkfire logs
reusing existing service "db", skips the spawn, and skips the
teardown so the existing process keeps running after this run
finishes.
If the dial fails, pkfire spawns the cmd and then polls the
probe every 250ms for up to readyTimeoutSeconds before letting
dependent services or the body task proceed. Without a probe the
runner would race — the body would pnpm exec playwright against
a server that hasn't bound its port yet.
readyCmd covers the cases TCP can't:
readyCmd = "pg_isready -h localhost" (a port may be open before
postgres has finished crash-recovery), or redis-cli ping, or any
exit-0-when-ready shell snippet. readyPort and readyCmd
compose — set both and both must pass.
Runtime args (acceptsArgs and params)
Tasks can take command-line input. Two shapes:
acceptsArgs = true— positional tail args after--land on$1,$2, ... and$@(always quote:"$@"). The just analogue isjust run *ARGS.params { Param ... }— typed named flags. The caller passespkf run <task> --<name>=<value>; insidecmdthe value is available as$NAME(uppercased). Four types:"string": any value."enum": must match one ofchoices."int": parsable as a signed decimal integer."bool":--flagalone = true,--flag=true|falseexplicit. Bool params never consume the next token as a value, so--watch --port=80is unambiguous.default = "..."makes the flag optional (the string itself is validated againsttype); omittingdefaultmakes it required.
local bumpVersion = new Task {
name = "bump-version"
cmd = "npm version $BUMP --no-git-tag-version"
cache = false
params {
new { name = "bump"; type = "enum"; choices { "patch"; "minor"; "major" }; default = "patch" }
}
}
local script = new Task {
name = "script"
cmd = "node \"$@\""
acceptsArgs = true
cache = false
}
pkf run bump-version --bump=minor # validated, sets $BUMP
pkf run script -- src/main.ts arg1 # $@ = "src/main.ts arg1"
pkf run script --lang=ts -- src/main.ts arg1 # combined
Both forms participate in the action key when cache = true, so
different invocations produce different cache entries — convenient
for codegen-with-flags but typically you want cache = false for
generic command wrappers (vitest filters, single-file scripts).
The CLI rejects unknown flags and missing required params before
the cmd runs, so a typo like --bump=mahor fails fast with a clear
message.
Environment inheritance (inheritEnv)
Default: inheritEnv = true. The cmd sees every var in pkfire's own
environment (same semantics as just/make/npm run), so
SSH_AUTH_SOCK, GPG_AGENT_INFO, your locale, editor preferences,
and developer tokens all pass through without ceremony.
Switch to inheritEnv = false for hermetic tasks (release builds,
reproducibility-sensitive CI steps). In that mode only a small
allowlist passes through (PATH, HOME, USER, LOGNAME, SHELL,
TERM, TMPDIR, LANG, LC_ALL, LC_CTYPE, TZ) — the pre-0.4
behavior.
In both modes the action key only hashes the env { ... }
declared in Pkl, never the host environment. So a NODE_ENV shift
in your shell never silently invalidates cache; if you want a host
var to affect caching, copy it into env explicitly:
env { ["NODE_ENV"] = read("env:NODE_ENV") }
Publishing a task library on top of pkfire
Several patterns recur when a project grows beyond one Taskfile and
becomes a library of reusable tasks — shared across repos, or
published as its own Pkl package on pkg.pkl-lang.org for other
projects to consume. The worked example is
kawaz/pkf-tasks; the patterns
below are extracted from its design records (DR-0001 … DR-0004).
Package layout
my-task-lib/
├── PklProject # name, version, dependencies { ["pkfire"] {...} }
├── PklProject.deps.json # generated by `pkl project resolve`
└── tasks/ # one .pkl module per "topic"
├── vcs/
│ ├── iface.pkl # abstract module
│ ├── jj.pkl # extends iface.pkl
│ ├── git.pkl # extends iface.pkl
│ └── auto.pkl # extends iface.pkl — runtime dispatch
└── docs/translations.pkl
Consumers import:
amends "package://pkg.pkl-lang.org/github.com/mizchi/pkfire/pkfire@0.11.0#/Taskfile.pkl"
import "package://pkg.pkl-lang.org/github.com/<you>/my-task-lib/my-task-lib@0.1.0#/vcs/auto.pkl" as vcs
tasks { ...vcs.allTasks }
Polymorphic modules: abstract module + extends
When the same logical task has multiple implementations (jj vs git,
docker vs podman, npm vs pnpm), declare an abstract module with
abstract task properties, then extends it from each implementation.
Use extends, not amends — amends against an abstract module
fails with Cannot instantiate abstract class.
// vcs/iface.pkl
@ModuleInfo { minPklVersion = "0.31.0" }
abstract module com.example.vcs.iface
import "@pkfire/Taskfile.pkl"
abstract commit: Taskfile.Task
abstract push: Taskfile.Task
allTasks: Listing<Taskfile.Task> = new { commit; push }
// vcs/jj.pkl
module com.example.vcs.jj
extends "iface.pkl"
import "@pkfire/Taskfile.pkl"
commit = new Taskfile.Task { name = "vcs:commit"; cmd = "jj describe ..." }
push = new Taskfile.Task { name = "vcs:push"; cmd = "jj git push" }
The abstract property list acts as the interface contract: every implementation must fill in every abstract entry, enforced at Pkl evaluation time.
Field-level override: (base) { cmd = ...; description = ... }
To inherit one task's structure (params, env, cache, shell, ...) and only override specific fields, use Pkl's object-amends syntax:
// auto.pkl picks one impl as the base and overrides cmd:
commit = (jj.commit) {
description = "vcs commit (auto-detect: jj or git)"
cmd = autoCmd(jj.commit.cmd, git.commit.cmd)
}
(jj.commit) { ... } returns a new Task with the listed fields
replaced and everything else (params, env, cache, ...) inherited.
Cleaner than rebuilding a Task from scratch when only the cmd differs.
Runtime dispatch (Pkl can't see CWD)
Pkl evaluation is intentionally hermetic — it cannot read the working
directory's filesystem state. read("file:.jj") is a syntax error
(file URIs must be absolute), and absolute paths can't be portable
across consumers. So "detect what the user is running on" must happen
at task execution time, inside the cmd:
// auto.pkl
local function autoCmd(jjCmd: String, gitCmd: String): String = """
if jj root >/dev/null 2>&1; then
\(indent(jjCmd))
elif git rev-parse --git-dir >/dev/null 2>&1; then
\(indent(gitCmd))
else
echo "vcs: no jj or git repository found" >&2; exit 1
fi
"""
Use the underlying tool's own ancestor-search (jj root,
git rev-parse --git-dir, npm prefix, cargo locate-project)
instead of [ -d .jj ] style cwd-only checks — the latter break
when invoked from a sub-directory of the workspace.
Conventions worth following
name = "<scope>:<action>"— namespace tasks by domain (vcs:commit,docs:check-translations,lint:pkl). The schema's name regex allows:and/, solint:rust/fmtis also fine. Avoids collision with the consumer's own task names.allTasks: Listing<Task>at module level — let consumers dotasks { ...mod.allTasks }instead of listing each task by name. Keeps the consumer's Taskfile small and lets you add new tasks in the library without every consumer updating their import line.- Avoid
read("env:X")for ergonomics-only env vars. It fails with "resource not found" whenXis unset, breaking eval in environments (CI containers, sandboxes) that don't have your developer-machine env. Rely oninheritEnv = true(the default) forSSH_AUTH_SOCK,GPG_AGENT_INFO, locale, etc. Useread()only when the value should be part of the action key (a release channel, a git SHA, aNODE_ENVyou want to cache against). - Pin pkfire as a dependency, not via
amendsto a URL. Library authors should declare pkfire in theirPklProject.dependenciesand reference it as@pkfire/Taskfile.pkl— that way the consumer gets a single Pkl package graph andpkl project resolvekeeps things consistent.
Releasing the library
Mirror pkfire's tag scheme: <name>@<version> as the release tag,
because packageZipUrl in PklProject expects that shape. Once
v0.1.0 of your package ships, the major-only pin (@<name>@1) becomes
resolvable by Pkl's package resolver. Stay exact-pinned during 0.0.x.
CI cache for Pkl downloads
Pkl caches resolved packages under ~/.pkl/cache. Add this to any
workflow that runs pkl project resolve or evaluates remote Pkl
packages — first-run network cost stays in the cache instead of
hitting pkg.pkl-lang.org on every CI run:
- uses: actions/cache@v4
with:
path: ~/.pkl/cache
key: pkl-cache-${{ hashFiles('**/PklProject.deps.json') }}
restore-keys: pkl-cache-
- uses: mizchi/pkfire@v0.11.0
- run: pkl project resolve
Git hooks (pkf hooks install)
pkf hooks install is a thin opt-in convenience: any task whose
name matches a git client-side hook event (pre-commit,
pre-push, commit-msg, prepare-commit-msg, post-commit,
post-checkout, post-merge, pre-rebase, post-rewrite)
becomes installable. The install step writes
.git/hooks/<event> as a 3-line shim:
#!/bin/sh
# managed by pkf hooks install
exec pkf run pre-commit -- "$@"
The trailing -- "$@" forwards git's per-hook args. commit-msg
receives the message-file path as $1 (your task should declare
acceptsArgs = true to receive it inside cmd); pre-push gets
the remote name and URL; etc. See git help githooks for the per-
event arg list.
pkf hooks install # write shims for every matching task
pkf hooks install --force # also replace hand-written or other-tool hooks
pkf hooks list # report which events have tasks + which are installed
pkf hooks uninstall # remove only pkfire-managed shims
The shim is marked with a comment so uninstall and reinstall are
idempotent and will not touch hooks installed by other tools
(husky, lefthook, hk, etc.) unless you pass --force.
Auto-install on cd (direnv)
pkf hooks install is silent on no-op: when every matching
shim is already present and bit-identical to what pkfire would
write, the command produces no output. That makes it safe to drop
into .envrc so the hooks always get re-installed (and
re-aligned to the current Taskfile) the first time you cd into
the repo on a fresh checkout:
# .envrc
if command -v pkf >/dev/null; then
pkf hooks install
fi
The first direnv reload after the Taskfile changes writes a line
to stdout. Every subsequent reload is quiet. File writes go
through tempfile + rename so a concurrent reload (two terminals
hitting cd at the same moment) won't tear the shim.
Rules of thumb
cache = falseon every hook task. Hooks fire on a tree that shifts every commit; the action key (which is keyed on declaredinputs) is not a meaningful cache for "did the staged set change". Setcache = falseexplicitly. pkfire warns when an installable hook task hascache = true.- Don't reuse the same task name for the hook target and the
underlying gate. Declare a thin
pre-committask thatdeps { lint; typecheck }instead of inlining everything — it keeps the hooks layer (config: which event → which task) separable from the gate logic. - Staged-file scoping happens in
cmd, not ininputs. pkfire does not currently inject a$STAGED_FILESenv or filterinputsto the staged subset. If you want lint-only-staged-files behavior, writegit diff --cached --name-onlyinside thecmdand feed the result to your linter. Recipe 13 shows the pattern.
Relationship to hk / lefthook / pre-commit
hk (by the mise author) is a purpose-built
git-hook manager configured in Pkl. It has features pkfire
deliberately doesn't: staged-file globs with {{files}}
templating, separate check vs fix commands, exclusive locks,
file-set scoping, fail-fast. If you need those, use hk and
have its steps shell out to pkf run <task> for the actual work —
the two compose cleanly.
pkf hooks install exists for the case where the project already
runs everything through pkfire and a single pkf run pre-commit
is enough; adding a second config tool just to dispatch a hook is
overhead. Same logic for lefthook
and pre-commit — pkfire's built-in is
the dependency-light path; the dedicated managers are the
feature-rich path.
Common pitfalls
A non-local object property cannot have a type annotation— you wrotefield: Task = ...inside anamends. Use alocalbinding and add it totasks { ... }.- Self-shadowing names cause stack overflow — a function with
(p, deps: Listing<Task>): Task = new { deps = deps }recurses forever becausedepsresolves to the field on the value being built. Rename the parameter (taskDeps). new {}insideListing<Task>is ambiguous — writenew Listing<Task> { taskA; taskB }to disambiguate.outputsparents are not auto-created — yourcmdmustmkdir -p bin && ...if it writes tobin/appandbin/does not exist yet (some tools, likego build -o, do this themselves).- Inputs with literal paths that do not exist are silently
ignored — useful tolerance for optional files (e.g.
go.sumin a no-deps Go project), surprising when you misspelled a real file. Usepkf run --print-hash <task>to see what actually contributed.
Selective execution
Real workflows rarely "run every task". A few patterns cover the common selection questions:
pkf run # no args → the task named `default` (errors clearly when absent)
pkf run -- a b c # no task name + tail args → `default` receives "$@"
pkf run a b c # multi-target: topological union of all subgraphs
pkf run --refresh build # skip cache lookup but re-store result (re-baseline)
pkf run --no-cache test # skip lookup AND store for this run
Monorepo CI: pkf affected --since=<ref>. Runs only the tasks
whose declared inputs glob matches a file in the asymmetric diff
<since>...HEAD, plus their transitive dependents in the deps
DAG. The unaffected deps that come along in the plan hit cache, so
the actual work is minimal.
pkf affected --since=origin/main # everything affected by the PR
pkf affected --since=origin/main test:unit # filter to specific targets (exact names)
pkf affected --since=HEAD~1 --dry-run # preview without running
pkf affected --files src/main.go --explain --dry-run # check file -> task matching
pkf affected --check # run workflowTests from Taskfile.pkl
Default --since chain: origin/main → origin/master → HEAD~1.
Tasks with empty inputs are never affected by file changes (they
explicitly declared no file dependencies). Tasks with cache = false
are still subject to the same affected-set test — they don't get
auto-pulled in just because they're always-run.
Use workflowTests { ... } next to the tasks when authoring a new
Taskfile or changing inputs / deps; it is the Taskfile-level
equivalent of a unit test for the affected workflow.
CLI tools
Inspection (read-only)
pkf list # public tasks, one per line + description
pkf list --unsorted # Taskfile declaration order
pkf list --all # include visibility = "internal" tasks
pkf list --color=always # force ANSI color (auto, always, never)
pkf list -v # cmd preview + deps + cache status
pkf list --long --all # compact audit table: visibility, cache, quiet, deps, io, shell
pkf list --json # machine-readable; for editor / CI tooling
pkf graph # Graphviz DOT
pkf graph --format mermaid # GitHub-renderable
pkf graph --format tree # terminal-readable dependency tree
pkf graph --target build # only the subgraph rooted at `build`
pkf affected --files PATH --explain --dry-run # show matching inputs + affected closure
pkf affected --check # verify workflowTests
pkf lint # dead local tasks + suspicious task definitions
pkf lint --json # structured findings for editor / CI tooling
pkf lint --fix --dry-run # preview safe edits; currently cache=false for outputs-without-inputs
pkf doctor # diagnose pkf PATH / pkl / cache / remote / Taskfile setup
pkf doctor --json # structured environment checks
pkf doctor --fix --dry-run # preview replacing a stale pkf on PATH
pkf lint --fix is deliberately narrow. It only edits a task when
the safe default is unambiguous: a cacheable task declares outputs
but no inputs, so pkfire adds cache = false. Findings such as
dead local tasks, no-op tasks, and services without readiness probes
remain suggestions because automatic edits could change intent.
pkf doctor --fix can replace the pkf binary found on PATH with
the currently running binary, backing up the old file first. Always
start with --dry-run and inspect the path before applying it.
Execution preview
pkf run --dry-run build # table: per-task hit/will-run/uncached + action key + cmd
pkf run --print-hash build # raw action keys for the subgraph
pkf run --no-cache --dry-run build # "what would --refresh re-run?" introspection
The --dry-run table classifies each task as hit (cache will
short-circuit), will run (cacheable but no entry), uncached
(cache = false), or service (skipped by pkf run — preview
only). With --no-cache / --refresh, the lookup is inert so
every cacheable task shows will run. Remote cache is NOT
consulted during dry-run to keep it fast.
Execution
pkf run build # run + cache as usual
pkf run --watch build # re-run on input change (Ctrl+C to stop)
pkf run -j 8 build # cap parallelism (default: NumCPU)
pkf run --timing build # add per-task duration breakdown at the end
pkf up dev # supervise every `service: true` task in the subgraph
pkf up --watch dev # plus restart-on-change
After every non-watch run, pkfire prints a single summary line:
[pkf] done: 6 tasks · 3 hit · 3 ran · 2 uncached (11.3s wall, 12.0s CPU)
--timing follows with a table sorted by duration descending —
pinpoints the slow task without a profiler.
Maintenance / hooks
pkf format # pkl format -w (defaults to Taskfile's dir)
pkf format --check pkl examples skills # exit 11 on unformatted (CI-friendly)
pkf hooks install # write .git/hooks/<event> shims for hook-named tasks
pkf hooks list # show which events are wired
pkf hooks uninstall # remove only pkfire-managed shims
pkf init # write a starter Taskfile.pkl
pkf hooks install is silent on no-op, so it's safe in .envrc
— see the Git hooks section.
Remote cache (optional)
export PKFIRE_REMOTE_CACHE=https://pkfire-cache.<account>.workers.dev
export PKFIRE_REMOTE_TOKEN=<bearer token>
Local cache stays the source of truth; the remote is consulted on a
local miss and warmed on a remote hit. See examples/remote-cache-worker/
in the upstream repo for a Cloudflare Worker reference backend that
GCs entries older than CACHE_TTL_DAYS (default 7) on a daily cron.
Running pkfire in GitHub Actions
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: mizchi/pkfire@v0.11.0 # or @v0 to track latest 0.x
- run: pkf run ci
The mizchi/pkfire action is a setup-only composite that installs
the matching pkf binary plus the Pkl CLI on the runner
(linux/darwin × amd64/arm64) and adds them to PATH. After it
runs, the rest of the workflow uses pkf directly — no extra
tooling steps.
Do not write uses: mizchi/pkfire@pkfire@0.11.0. GHA's parser
treats the @ in the ref as a syntax break and fails the whole
workflow file, with no jobs run and a generic "workflow file
issue" error. Pkl release tags happen to be pkfire@<ver> (because
the package URI requires it), so the Release workflow also pushes
v<ver> + a floating v<major> tag at the same commit — use
those for uses:. Or SHA-pin: uses: mizchi/pkfire@<sha> # v0.5.0.
To share cache hits across CI runs and developer machines, point
pkf at a remote cache via env:
- uses: mizchi/pkfire@v0.11.0
- run: pkf run ci
env:
PKFIRE_REMOTE_CACHE: ${{ vars.PKFIRE_REMOTE_CACHE }}
PKFIRE_REMOTE_TOKEN: ${{ secrets.PKFIRE_REMOTE_TOKEN }}
Inputs:
| Input | Default | Notes |
|---|---|---|
version |
inferred from ${{ github.action_ref }}, falls back to latest release |
Accepts v0.5.0, 0.4.0, v0 (floating major), or the underlying pkfire@0.11.0. Pinning via uses: mizchi/pkfire@v0.11.0 is the recommended form. |
pkl-version |
0.31.1 |
Set to none to skip Pkl install (e.g. when only pkf is needed). |
install-dir |
${{ runner.temp }}/pkfire-bin |
Both binaries land here, and the directory is appended to GITHUB_PATH. |
Source: mizchi/pkfire — distributed by TomeVault.