| name | pkfire |
| description | Author and maintain `Taskfile.pkl` files for the `pkf` task runner. Use when adding, editing, or troubleshooting tasks in a project that has `pkf` installed (or is choosing it over `just` / `Taskfile.yml`). Covers the Pkl schema, the typed `deps` model, cache semantics (action key, local CAS, remote HTTP backend, symlink support), watch mode, and the common pitfalls. Recipes under `assets/recipes/` are copy-paste starting points for the patterns this skill describes. |
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
justfile or Taskfile.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's
amends semantics forbid adding new top-level fields, so
reflection-based auto-collection cannot work. A local foo = new Task { ... } that is not listed in tasks is dead code from the
runner's perspective.
deps are real Task references, not strings. Write
deps { build }, not deps { "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
pkf run -f Taskfile.pkl lint
Why this beats (B) at scale:
- A team owning
services/api/ only edits services/api/Taskfile.pkl;
cross-team reviews stay scoped.
pkl:reflect can derive the leaf's identity (e.g. api, web) from
its file path โ see the upstream blog post for a findRootModule
helper that walks the amends chain.
- 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 run invocation only sees one Taskfile. There is no
"umbrella ci that runs every leaf" without a small wrapper script
(or a deliberately authored services/Taskfile.pkl that imports
the leaves).
- Leaf authors must remember the
amends URI 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
cd ../../..
pkf run lint
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
pkf up --watch api
Rules of thumb:
- Use
exec in the cmd so the real binary replaces the shell
and receives signals directly โ gives one fewer pid in ps and
clearer logs. The runner sets the cmd's process group regardless,
so non-exec cmds still get reaped.
pkf run rejects services in v1. The runner happily blocks
forever, but pkf up is the supervisor that knows about the
service lifecycle. Use pkf run for tasks that terminate.
- Non-service tasks may not depend on services. A
build
shouldn't deps { db } โ that means "wait for db to finish",
which a service never does. The reverse (db as a service that
needs migrate to 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
is just run *ARGS.
params { Param ... } โ typed named flags. The caller passes
pkf run <task> --<name>=<value>; inside cmd the value is
available as $NAME (uppercased). Four types:
"string": any value.
"enum": must match one of choices.
"int": parsable as a signed decimal integer.
"bool": --flag alone = true, --flag=true|false explicit.
Bool params never consume the next token as a value, so
--watch --port=80 is unambiguous.
default = "..." makes the flag optional (the string itself is
validated against type); omitting default makes 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
pkf run script -- src/main.ts arg1
pkf run script --lang=ts -- src/main.ts arg1
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 /, so lint:rust/fmt is also fine. Avoids collision
with the consumer's own task names.
allTasks: Listing<Task> at module level โ let consumers do
tasks { ...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" when X is unset, breaking eval in
environments (CI containers, sandboxes) that don't have your
developer-machine env. Rely on inheritEnv = true (the default)
for SSH_AUTH_SOCK, GPG_AGENT_INFO, locale, etc. Use read()
only when the value should be part of the action key (a release
channel, a git SHA, a NODE_ENV you want to cache against).
- Pin pkfire as a dependency, not via
amends to a URL. Library
authors should declare pkfire in their PklProject.dependencies
and reference it as @pkfire/Taskfile.pkl โ that way the consumer
gets a single Pkl package graph and pkl project resolve keeps
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
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
pkf hooks install --force
pkf hooks list
pkf hooks uninstall
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:
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 = false on every hook task. Hooks fire on a tree that
shifts every commit; the action key (which is keyed on declared
inputs) is not a meaningful cache for "did the staged set
change". Set cache = false explicitly. pkfire warns when an
installable hook task has cache = true.
- Don't reuse the same task name for the hook target and the
underlying gate. Declare a thin
pre-commit task that
deps { 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 in inputs.
pkfire does not currently inject a $STAGED_FILES env or filter
inputs to the staged subset. If you want lint-only-staged-files
behavior, write git diff --cached --name-only inside the cmd
and 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 wrote field: Task = ... inside an amends. Use a local
binding and add it to tasks { ... }.
- Self-shadowing names cause stack overflow โ a function with
(p, deps: Listing<Task>): Task = new { deps = deps } recurses
forever because deps resolves to the field on the value being
built. Rename the parameter (taskDeps).
new {} inside Listing<Task> is ambiguous โ write
new Listing<Task> { taskA; taskB } to disambiguate.
outputs parents are not auto-created โ your cmd must
mkdir -p bin && ... if it writes to bin/app and bin/ does not
exist yet (some tools, like go build -o, do this themselves).
- Inputs with literal paths that do not exist are silently
ignored โ useful tolerance for optional files (e.g.
go.sum in
a no-deps Go project), surprising when you misspelled a real file.
Use pkf 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
pkf run -- a b c
pkf run a b c
pkf run --refresh build
pkf run --no-cache test
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
pkf affected --since=origin/main test:unit
pkf affected --since=HEAD~1 --dry-run
pkf affected --files src/main.go --explain --dry-run
pkf affected --check
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
pkf list --unsorted
pkf list --all
pkf list --color=always
pkf list -v
pkf list --long --all
pkf list --json
pkf graph
pkf graph --format mermaid
pkf graph --format tree
pkf graph --target build
pkf affected --files PATH --explain --dry-run
pkf affected --check
pkf lint
pkf lint --json
pkf lint --fix --dry-run
pkf doctor
pkf doctor --json
pkf doctor --fix --dry-run
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
pkf run --print-hash build
pkf run --no-cache --dry-run build
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
pkf run --watch build
pkf run -j 8 build
pkf run --timing build
pkf up dev
pkf up --watch dev
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
pkf format --check pkl examples skills
pkf hooks install
pkf hooks list
pkf hooks uninstall
pkf init
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
- 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. |