Skip to content

About

Generic declarative task runner for charly — kind:task entities + command:task + verb:task

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

61 Commits

Folders and files

Repository files navigation

plugin-task

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.

What it provides

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)

Generic maintenance verbs

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.

Authoring a task

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 order

A param reaches a step as DATA

A 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_FILE stay 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.

Full Go-Task parity

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:.

Design

The engine reuses the SDK's existing plan machinery — no second execution engine:

  • kit.RunPlan walks the plan; checkkit.PlanGrammar supplies the do-mode/context grammar.
  • checkkit.VerbResolver dispatches 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, and agent-* 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}/$VAR in dir: and env expand against the task's vars, params, and the process environment.

About

Generic declarative task runner for charly — kind:task entities + command:task + verb:task

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages