The generic declarative task runner for opencharly/charly:
the replacement for a Taskfile. It is a standalone plugin candy repo (the candy
de-submodule cutover); the Go module lives at candy/plugin-task/ with module path
github.com/opencharly/plugin-task/candy/plugin-task, fetched at the pinned tag and
compiled into charly.
| Capability | Surface | Purpose |
|---|---|---|
kind:task |
a task: node in charly.yml |
a named, reusable plan using the same #Step/#Op grammar a candy's plan: uses |
command:task |
charly task [list] [<name>] … |
run a task (and its depends_on closure) on the host |
verb:task |
task: verb step in a plan |
compose a declared task into a candy/box/task plan |
verb:git-submodules |
git-submodules: verb step |
status / bump / verify / currency over .gitmodules pins. verify asserts CONSISTENCY (each checkout matches the recorded gitlink); currency asserts CURRENCY (each pin is the ref its INTENT names), which no other mode answers |
verb:file-parity |
file-parity: verb step |
check / sync that paired files are byte-identical |
verb:splice-region |
splice-region: verb step |
splice a marked region from a fragment into a target |
verb:module-pins |
module-pins: verb step |
adopt a source go.mod's require pins across every module a glob matches, then tidy |
verb:prune |
prune: verb step |
garbage-collect MERGED-UPSTREAM session worktrees + branches across the project root and every submodule (a dry run by default) |
The five maintenance verbs replace repository shell scripts. Each is domain-neutral and parameterized by the repo's OWN data (paths, pairs, pins) carried in its authored input, so the plugin stays reusable by any repository — no org name, repo name, or distro list is baked in. They are host-native: like the task engine they act on the repository checkout the charly process runs in.
# Bump every submodule pin to the ref its INTENT names: everything not listed in
# pin_map rolls to its own default-branch HEAD (a LEAF), and every pin_map entry is
# then derived from its pinned_from twin (an EDGE).
sync:
task:
description: Bump all submodule pins to the ref each pin's intent names
plan:
- run: bump the pins
git-submodules:
mode: bump
pinned_from: charly
pin_map:
box/arch: box/arch
box/fedora: box/fedora
context: [deploy]
# Ask the OTHER question — the one `verify` does not answer: is every recorded pin
# the ref its owning repo actually SERVES? It reads the recorded gitlinks and the
# REMOTES only, so it needs no checkout and cannot inherit an index-vs-checkout seam.
#
# `intent` says where each pin's value is supposed to come from:
# "upstream:<ref>" a LEAF, judged against the remote ref
# (a pin_map entry) an EDGE, judged by whether the SOURCE's own pin at the twin
# path is current — one hop further than its gitlink
# An edge over an edge is REFUSED (the graph is acyclic by construction); a source that
# declares no ref for the twin is a failure naming what to declare. A pin that is merely
# behind is reported as a LEG, with the producer-first order to work through.
currency:
task:
description: Report every pin that is not at the ref its intent names
plan:
- check: every pin is current
git-submodules:
mode: currency
pinned_from: charly
pin_map:
box/arch: box/arch
context: [runtime]
# `stale_after` is OPT-IN and bounds NEGLECT, not churn. A pin that is not current
# AND that this repo has not advanced for LONGER than the bound is STALE, and a
# stale pin FAILS. The bound is on the AGE of this repo's last advance of that
# path — never on the pin's distance from the served ref: a busy upstream is behind
# between every wave, so a gate that failed on the distance would be permanently
# red on a healthy tree, which trains every reader to ignore it. Leave it unset and
# the verdict is the structural one alone.
#
# The bound must be a POSITIVE duration: zero (or less) is refused rather than
# honoured, because it would mark EVERY not-current pin stale — the permanently-red
# gate this knob exists to avoid.
#
# The age is read from this repo's OWN history, and an UNREADABLE history is never
# reported as neglect: it is a structural failure that names the read error, because
# an unknown age is not a claim that the owner neglected the pin.
#
# git-submodules: {mode: currency, stale_after: "168h"}
# Assert the harness config files are identical to charly's twins.
harness:
task:
description: Check harness config parity
plan:
- check: the shared harness files are identical
file-parity:
pairs:
- {left: .claude/hooks/pre-commit-gate.sh, right: charly/.claude/hooks/pre-commit-gate.sh}
context: [runtime]
# Splice the generated skill dispatcher into AGENTS.md.
skills:
task:
description: Splice the generated R0 dispatcher into AGENTS.md
plan:
- run: splice the dispatcher region
splice-region:
target: AGENTS.md
fragment: marketplace/DISPATCHER.md
begin: "<!-- BEGIN GENERATED SKILL DISPATCHER -->"
end: "<!-- END GENERATED SKILL DISPATCHER -->"
context: [deploy]
# Adopt the shared sdk/spec pins across every tools/* module.
mods-tidy:
task:
description: Adopt the shared sdk/spec pins, then tidy
plan:
- run: adopt and tidy
module-pins:
source_go_mod: charly/go.mod
glob: tools/*
keys: [github.com/opencharly/sdk, github.com/opencharly/spec]
context: [deploy]
# Reap session worktrees + branches whose work is already merged upstream.
# report (default) is a dry run; prune performs it.
prune:
task:
description: Remove merged-upstream session worktrees + branches (dry run by default)
params:
MODE: {description: report|prune, default: report}
plan:
- run: prune merged-upstream worktrees and branches
prune:
mode: "${MODE}"
context: [deploy]Each verb's input is validated against its #<Name>Input CUE def at load; the handler
returns a pass/fail/skip verdict the plan harness decodes. A git-submodules verify FAILS LOUDLY on a missing gitlink on either side (never a vacuous empty==empty
pass); module-pins check reports every stale module.
prune is fail-safe by construction: it removes a worktree/branch only when the
merge is proven — the tip is an ancestor of base (origin/main), or its PR is
MERGED and its tip is contained in the merged head (the squash-merge case git ancestry
alone cannot see). A branch carrying commits beyond a merged head, or a worktree with
modified tracked files, is never touched. If gh is unavailable (no auth, offline), it
prunes strictly less; local_only: true skips GitHub entirely and prunes only
ancestry-merged branches. include_closed: true additionally reaps abandoned CLOSED-PR
worktrees. base defaults to origin/main. A submodule is swept only when it is an
INITIALIZED checkout — an uninitialized one contributes nothing, because git -C on it
would walk up and resolve to the project itself, reporting the project's own worktrees
and branches.
build:
task:
description: Build the charly binary
dir: "$HOME/src/charly" # $HOME, the task vars, and resolved params all expand
vars: {CALVER: "2026.267.1200"}
depends_on: [tidy]
sources: ["charly/**/*.go", "go.work.sum"]
generates: ["bin/charly"]
params: {TARGET: {description: "build target", default: ""}}
plan:
- check: go is available
command: go version
- run: build the binary
command: ./scripts/bootstrap-charly.sh
- check: the binary reports its version
command: bin/charly version
stdout: [{matches: "^[0-9]{4}\\.[0-9]{3}\\.[0-9]{4}$"}]Run it:
charly task build # run (with its depends_on closure)
charly task build --param TARGET=x
charly task build --dry-run # resolve + validate, execute nothing
charly task list --json # every declared task as JSON
charly task --all # every task in dependency orderA step's command runs through a shell, so anything substituted into the command's
text is parsed by that shell: a $(…) inside a value EXECUTES. A value that is
DATA must not be delivered as SOURCE.
So a task's resolved values — its vars, its env, its params (declared
defaults overlaid with --param overrides) and the task's TASK_DIR — also reach
every step as the step's process environment:
publish:
task:
description: Publish a build
params:
out: {description: the artifact path}
plan:
- run: write the artifact
command: 'printf "%s" "$out" > "$TASK_DIR/artifact"'$out is read by the shell (an environment variable), so the value arrives as
one value whatever it contains — a space, a ;, a $(…) — and the shell never
re-parses it. A param whose name is lowercase is reachable this way; the ${…}
text form is upper-case-only, so a lowercase param was previously unreachable from a
step.
Three rules bound the channel:
- the step's own
env:wins a name collision — the author spoke last about their own step, and the task's value is a default; - a name the shell cannot export (outside
[A-Za-z_][A-Za-z0-9_]*) is disclosed on the step's message, never dropped silently; - a piped stdin is not a value —
TASK_STDIN/TASK_STDIN_FILEstay reachable through the text form they have always had, and are not exported, because the command verb renders an exported value into the command text and a NUL byte would make the exec un-runnable.
description, dir, env, vars, depends_on, sources, generates, status,
preconditions, silent, interactive (refuses to run without a TTY rather than
silently capturing output), platforms, exclude_platforms, timeout (a ceiling
over the walk), continue_on_error, params (declared defaults overlaid with
--param overrides; required: true is enforced), and the ordered plan:.
The engine reuses the SDK's existing plan machinery — no second execution engine:
kit.RunPlanwalks the plan;checkkit.PlanGrammarsupplies the do-mode/context grammar.checkkit.VerbResolverdispatches each step's verb through the host's provider registry over the reverse channel (InvokeProvider), so a task can use any verb, any plugin verb,include:composition, andagent-*steps.kit.ShellExecutor{}is the host venue;dir:is applied by chdir around the sequential walk (the plan grammar has no cwd field), and${VAR}/$VARindir:and env expand against the task's vars, params, and the process environment.