Workflow state CLI for AI agent session and pipeline tracking.
wfctl manages session and pipeline state for AI coding agents (Claude Code, Codex, Copilot). It tracks where you are in a feature development pipeline — design → specify → plan → tasks → implement — and tells the agent what to do next.
wfctl operationalizes spec-driven development — keeping agents on the specify → plan → implement track instead of jumping straight to code:
- Persistent by design — session state on disk; step recoverable even if lost
- Truth from artifacts — step read from real spec files, not from an agent's report;
implementadditionally gates on a definition of done wfctl runs itself (wfctl verify), so "done" is a recorded verdict rather than a claim - Enforced order — always points to the next required step, blocking code before spec and plan
- Design before spec —
design-levelsruns design as four gated passes, so who owns what is decided out loud, not buried in code - Scope-aware — tracks your position in the pipeline
- Ships with skills — installs spec-kit skills + slash commands into the project
- Python 3.11+
- uv (recommended) or pip
- Git
# Recommended: uv tool (isolated, always up-to-date)
uv tool install git+https://github.com/aamarin/wfctl.git
# Upgrade an existing install
uv tool install --upgrade git+https://github.com/aamarin/wfctl.git
# Or pip
pip install git+https://github.com/aamarin/wfctl.gitInstalls from the default branch, which always tracks the latest release. Append
@<tag> to either command if you need to pin a fixed version.
1. Install wfctl (see Installation for pip and pinning):
uv tool install git+https://github.com/aamarin/wfctl.git2. Install the skills into your project. Once per repo:
cd your-project
wfctl install-skills --agent claude # drop --agent if you are not on Claude CodeThe first interactive run asks two questions — which issue tracker to wire up, and where specs should live — and records both, so it never asks again.
3. Drive the pipeline from inside your agent, with slash commands:
/start-session # session context + freshness check
/speckit.brainstorm "add manual transaction entry" # design, gated in four levels
/speckit.specify # turn the design into a spec
/speckit.plan # design the implementation
/speckit.tasks # break into ordered tasks
/speckit.implement # build it
/end-session # summary + memory candidates
Anywhere along the way, wfctl status shows your position and wfctl resume
says what to run next. Those two are the only commands you type by hand often.
wfctl is driven by your coding agent, not typed by hand. You install a set of skills and slash commands into the repo once, then the agent runs the spec-driven pipeline while wfctl tracks position and enforces order.
Setup is install-skills (plus an optional install-config workmux for
isolated worktree envs, below); everything after that runs from inside the
agent, as in the quick start above.
Each step reads and writes real files under specs/<branch>/ (spec.md,
plan.md, tasks.md), so wfctl status infers where you are from artifacts on
disk — a step can't be faked or skipped. wfctl resume (or /speckit.orchestrate)
re-infers the current step and tells the agent the next command to run.
The pipeline, in order (not every step is required for every change — wfctl status shows which are done):
| Step | Slash command | Produces |
|---|---|---|
| brainstorm | /speckit.brainstorm |
specs/<branch>/design.md |
| specify | /speckit.specify |
specs/<branch>/spec.md |
| clarify | /speckit.clarify |
a ## Clarifications section in spec.md — written on every run, including one that finds nothing to ask, since that section is what marks the step done |
| plan | /speckit.plan |
plan.md |
| tasks | /speckit.tasks |
tasks.md |
| analyze | /speckit.analyze |
cross-artifact consistency check |
| decompose | /speckit.decompose |
PR / issue breakdown |
| implement | /speckit.implement |
the code |
brainstorm is where the design-levels skill runs, and it is the step most
worth not skipping. It descends four levels — behavior, architecture, data and
ownership, implementation — presenting one per approval instead of a finished
design in one pass, and each has a gate that has to be answered out loud. Level
2 lands in design.md's required Boundaries and Ownership section, and
/speckit.plan's Constitution Check verifies it was stated. When a lower level
invalidates a boundary drawn above it, the rule is to go back up, not to work
around it in the spec.
After install-skills (and optionally install-config):
| Path | What | Committed? |
|---|---|---|
.agents/skills/, .agents/commands/ |
installed skills + /speckit.* command wrappers, agent-agnostic |
no (gitignored) |
.claude/, .bob/, .github/skills/ |
one assistant's native paths, only if --agent asked for them |
no (gitignored) |
.specify/ |
speckit runtime (scripts + templates the skills call) | no (gitignored) |
.wf-skills-manifest.json |
install record: wfctl version + content hash + backups | no (gitignored) |
specs/<branch>/ |
your spec.md / plan.md / tasks.md |
your call — see below |
.workmux.yaml |
worktree config, from install-config workmux |
yes |
.github/pull_request_template.md |
PR template, from install-config github |
yes |
The gitignored paths are install artifacts — regenerate them any time with
install-skills. Only your specs and .workmux.yaml are project source.
install-skills never touches specs/ either way, so committing it is a
project decision: commit it and the plan is reviewable in the PR, or gitignore
it and only the implementation ships. This repo does the latter.
| Command | Description |
|---|---|
start |
Initialize agent session context (idempotent) |
status |
Show pipeline progress inferred from spec artifacts |
resume |
Re-infer step from filesystem, write next-step.md, print current state |
next |
Write next actionable step to next-step.md (automation shortcut) |
end |
End the current session and write summary scaffold |
archive-specs |
Rescue a story's spec artifacts before its worktree is deleted (wired into workmux's pre_remove) |
log |
Print color-coded event timeline for the current session |
state-dir |
Print the active XDG state directory path |
feature-paths |
Print the active feature's spec.md/plan.md/tasks.md paths (used by the installed speckit scripts) |
spec-root |
Show, set, or clear the directory this repo's spec dirs live under |
arch-root |
Show the directory this repo's architecture records live under |
arch context |
Print the in-force architectural contract — accepted records only |
issue |
Run the active issue tracker for a verb (list/view/close/comment/create/label) |
change |
List/view code changes — GitHub PRs, Gerrit patchsets — via the tracker's changes backend |
install-skills |
Copy the skills, commands and speckit .specify/ runtime wfctl ships into the current project |
uninstall-skills |
Remove what install-skills installed for --agent, restoring anything it overwrote |
install-config |
Seed a standardized repo config wfctl ships into the project (workmux, github) |
tracker-check |
Validate a .agents/trackers/<name>.json tracker config |
doctor |
Check the installed skills against the ones this wfctl ships |
wfctl --version prints the installed package version and exits.
$ wfctl start
✓ Session started — step: analyze, next: /speckit.analyze
$ wfctl status
#436 436-manual-transaction-entry
────────────────────────────────────
brainstorm ●
specify ●
clarify ●
plan ●
tasks ●
analyze ○ ← current
decompose ○
implement ○
$ wfctl resume
↺ Resumed — step: analyze, next: /speckit.analyze (auto: false)
$ wfctl log
2026-07-15 09:12 start branch=436-manual-transaction-entry step=analyze
2026-07-15 11:03 resume step=analyze command=/speckit.analyze auto=False
$ wfctl end
✓ Session ended. Summary written to ~/.local/state/wfctl/.../session-summary.md
Install skills into a project:
$ wfctl install-skills
✓ Installed from wfctl 0.15.0
base 27 skills · 24 commands · 8 runtime
Installed to .agents/ — skills and commands in their canonical, agent-agnostic
form. If your agent needs its own native paths:
claude wfctl install-skills --agent claude
bob wfctl install-skills --agent bob
copilot wfctl install-skills --agent copilot
$ wfctl install-skills --agent claude
✓ Installed from wfctl 0.15.0
base 27 skills · 24 commands · 8 runtime
claude 4 skills · 24 commands
The skills ship inside wfctl, so an install copies from the wheel and needs no network. Upgrade wfctl, rerun to update.
Installation is layered. The base layer always installs: skills and command
wrappers in their canonical, agent-agnostic form under .agents/, plus the
speckit .specify/ runtime. --agent adds one assistant's native paths on top
— it never replaces the base.
--agent |
Adds on top of .agents/ |
|---|---|
(omitted) / none |
nothing — the base layer only |
claude |
command wrappers → .claude/commands/, plus .claude/skills/ for skills marked deployment: skill |
bob |
skills → .bob/skills/, command wrappers → .bob/commands/ |
copilot |
skills → .github/skills/ (Copilot CLI reads these directly — no transform, the files are already SKILL.md) |
codex |
nothing. Codex reads no repo-local command path: its prompts live in ~/.codex/prompts and its repo entry point is AGENTS.md. Says so and installs the base layer; exits 0 |
Every layer owns a unique root, so two assistants can coexist in one repo without their bookkeeping colliding.
Breaking change in 0.12.0.
--agentused to default toclaude, so every repo got.claude/shims whether or not Claude was in use. Bareinstall-skillsnow writes.agents/only; pass--agent claudefor the old behavior.uninstall-skills --agentfollows it, defaulting tobaserather thanclaude. Existing repos upgrade silently — no prompt, no backups — and nothing needs to be run by hand.
Overwrite safety: if install-skills would overwrite a file it didn't
install itself — e.g. hand-authored speckit commands already in the
project — it lists them and asks for confirmation first. Pass --yes/-y
to skip the prompt (for scripts/CI). Whatever gets overwritten is backed up,
and:
$ wfctl uninstall-skills --agent claude
✓ Removed 27 item(s), restored 1 pre-existing file(s) for layer 'claude'
removes that layer and restores anything it overwrote to its original content.
Files installed fresh (nothing to restore) are just deleted. Only the named
layer is touched — uninstalling claude leaves .agents/ intact, because the
base layer owns it. --agent defaults to base, mirroring install-skills, so
a bare install and a bare uninstall round-trip. State lives in
.wf-skills-manifest.json and .wf-skills-backup/ at the repo root — both are
cleaned up once nothing references them.
wfctl doctor is the single "am I current?" check — it reports the wfctl tool
and the installed skills (the hash on record vs the bundle this wfctl ships).
Colour-coded: green ✓ current, cyan ⬆ upgrade available, yellow ⚠
warning, red ✗ error.
$ wfctl doctor
⬆ wfctl 0.14.0 → 0.15.0 available
upgrade: uv tool install --upgrade git+https://github.com/aamarin/wfctl.git
⬆ claude: skills stale — installed by wfctl 0.14.0, running 0.15.0
update: wfctl install-skills
The tool half asks two questions, because the version string alone cannot answer the one that matters. A newer release tag gets the upgrade line above. Separately, if your build is behind the tip of the branch it was installed from — the ordinary case, since the install above tracks the default branch — you get:
✓ wfctl 0.15.0 — latest release
⬆ build behind main — d8688f6 → 271bb2c
bundled skills are from this build too
reinstall: uv tool install --force git+https://github.com/aamarin/wfctl.git
Without that second question a build could sit several merges behind and still
report ✓ latest, since the version in pyproject.toml only changes at release
time. It matters more since skills became part of the package: stale build,
stale skills, and the skills check cannot see it because a bundle always matches
itself.
The build's commit comes from the install metadata Python already records, so this costs no extra network call and nothing needs stamping at build time. Installs that cannot drift are left alone — a pinned tag, an editable checkout, or an install from a package index. Every printed command names the repository you installed from, so a fork is never told to reinstall from upstream.
install-skills records the wfctl version and a hash of the whole bundle, which
is what makes staleness detectable without a network call. Four verdicts per
layer: current; stale across versions, as above; stale at the same version —
⬆ claude: bundled skills changed since install, which is what an editable
checkout with edited skills looks like; and, for a record written before hashing
existed, ⚠ claude: installed before content hashing, which warns without
failing since the layer may well be current. Only the tool check needs the
network, and it degrades to a single ⚠ line naming whichever comparison could
not run — ⚠ wfctl 0.15.0 — couldn't check releases or branch (offline?) —
without weakening the skills verdict. A check that could not run always says so;
silence would be indistinguishable from a pass.
Exits non-zero when an upgrade is available or a layer is stale — so wfctl doctor doubles as a freshness gate in scripts, and the start-session skill
runs it so you see freshness every session.
install-config drops a standardized config file into your repo, from the same
bundle install-skills reads. Unlike install-skills — a managed mirror it
keeps in sync — this is seed-once: the file becomes yours, committed and
owned. No manifest, no drift-check, no uninstall.
$ wfctl install-config workmux
✓ Seeded workmux config (1 file(s)) from wfctl 0.15.0
workmux seeds a repo-agnostic .workmux.yaml
starter (worktrees under wt/, session mode, agent + term windows, an
issue-number pre_create branch guard; project-specific port/env hooks ship
commented). For workmux it also idempotently adds wt/ to .gitignore and
sets the config's agent: to the resolved agent — --agent if given, else the
sole agent install-skills recorded. If the repo installed no agent layer, or
several, the key is left commented out rather than guessed: .workmux.yaml is
committed, so naming one would push a per-developer preference into everyone's
checkout. workmux then resolves <agent> from ~/.config/workmux/config.yaml.
It refuses to overwrite an existing file unless you pass --force (the file is
git-tracked, so git is your undo):
$ wfctl install-config workmux
✗ Would overwrite existing file(s): .workmux.yaml. Pass --force to overwrite (git is your undo).
(workmux runs each branch as an isolated git worktree + tmux session, so agents work in parallel without stepping on each other. The seeded config makes new worktrees come up ready.)
github seeds .github/pull_request_template.md:
a summary that reads on its own, then issue links, implementation rationale and
what was actually verified. A config source keeps its own directory structure, so
this one lands inside the .github/ your repo already has — the workflows beside
it are untouched, and only a template already at that path is a conflict.
wfctl issue <verb> runs your project's issue tracker through a small backend,
so skills can reconcile work against real issues without knowing which tracker
you use:
wfctl issue list
wfctl issue view 71
wfctl issue close 71 --comment "Done in abc123"
Verbs: list, view, close, comment, create, label. The backend is
chosen at install time (install-skills --tracker <name>) and defined by
.agents/trackers/<name>.json — a map of verb → command. GitHub ships built
in: a repo's first interactive install-skills offers to install it, and
declining (or any non-interactive run — piped, CI, --yes) leaves the repo
without a tracker until you pass --tracker. Declining is remembered, so the
question is asked once and not on every upgrade. Once a repo has a tracker,
later installs leave the choice and your edits to its config alone — re-copy the
shipped one with an explicit --tracker github. --tracker none clears the
choice entirely, which also re-opens the question on the next interactive
install.
For anything else — a private
Jira/Linear CLI — author a config with the scaffold-tracker skill and validate
it with wfctl tracker-check <name>. Non-numeric issue keys (e.g. PROJ-123)
are supported via the config's key_pattern, which also drives how wfctl maps a
branch to its specs/ folder.
Code changes (wfctl change) run through a parallel changes section of the
same config, so PRs/patchsets go through one abstraction regardless of forge:
wfctl change list # your open PRs / patchsets
wfctl change view 128 # one change
"changes": {
"list": ["gh", "pr", "list", "--state", "open", "--author", "{me}"],
"view": ["gh", "pr", "view", "{id}"]
}Scoping lists to you ({me}) — set a top-level "identity" (e.g. "@me", a
username, or an email) and use {me} in any command. wfctl substitutes it, so
list returns your items. Each backend keys on what it needs — GitHub
--author @me, Gerrit owner:self — configured once per adapter.
The first interactive wfctl install-skills in a project asks this, beside
the tracker question, and records that you answered so it is never asked again.
Keeping specs in the repo records no spec_root — the default is the absence of
that setting, so artifacts resolve exactly as they do in a project that predates
the question. Non-interactive installs, and --yes, never ask; nor does a
project that already ran wfctl spec-root.
Two setups. The first needs no configuration at all.
| You want | Do this | Survives git worktree remove? |
|---|---|---|
| Specs alongside the code | nothing — this is the default | only if you commit them |
| Specs in a durable location | wfctl spec-root <dir> |
yes |
If you commit your specs, you probably want the default. The problem
spec-root solves is worktree teardown destroying gitignored specs; committed
specs survive by being in git. Moving them out would take them out of version
control for no gain.
spec-root takes any directory. Where you point it is your project's call — a
sibling directory, a specs repo cloned into the main checkout (which keeps them
in version control, on their own remote), or anywhere else durable. wfctl only
resolves the path; it never creates or clones anything.
By default a feature's artifacts live in <repo>/specs/<branch>/. In a worktree
that is a problem: specs/ is conventionally gitignored, so removing the
worktree destroys the spec, plan, and tasks with it.
Point the project somewhere durable instead — once, from anywhere in it:
wfctl spec-root ~/Development/myproject-specs
wfctl spec-root # show the current root and where it came from
wfctl spec-root --unset # back to <repo>/specsThe value is stored as spec_root in .wf-skills-manifest.json. Because that
file is gitignored and regenerated in every fresh worktree, spec-root writes
the main checkout's manifest and tells you which file it wrote; worktrees
then inherit the setting with no per-worktree setup.
Resolution order:
WFCTL_SPEC_DIR— a per-invocation override, not configuration. It is process-global, so exporting it from a shell profile redirects every repo.spec_rootin this repo's manifest.spec_rootin the main checkout's manifest — how worktrees inherit it.<repo>/specs— the default.
Paths are stored exactly as typed. ~ is expanded when read, so the manifest
stays portable across machines; a relative path anchors to the directory of the
manifest that declared it, never your shell's working directory.
Recording a root does not move anything. Existing <repo>/specs/* stop being
found, since the recorded root is the only one consulted — no fallback, so one
feature's artifacts can never split across two locations. Move them yourself;
wfctl doctor reports the leftovers until you do.
A repo in a bare-clone or separate-gitdir layout has no main checkout to inherit from, and nothing outside the repository is read in that case.
An architecture record is one decision, written down: what was decided, what was
rejected, and — the field this exists for — who owns the truth it settles.
Records live in docs/architecture/, one file per decision, named by a slug
rather than a number so two worktrees never collide.
wfctl arch-root # where this repo's records live
wfctl arch context # the in-force set, for an agent to loadarch context is the one an agent reads. It prints only records whose
frontmatter says status: accepted, so a superseded decision cannot be mistaken
for a live one:
# Architectural contract — 5 accepted decisions
layer-model
Source is committed package data under `wfctl/agents/` and
`wfctl/specify/`. Every dotted directory at the repo root is generated,
gitignored, and never edited by hand.
Anything other than accepted — proposed, superseded, an unrecognized
value, or no status at all — is left out. The default is deliberately the
conservative one: presenting an unreviewed decision as binding is the failure
the status field exists to prevent.
Resolution is the same four steps as spec-root: WFCTL_ARCH_DIR, then
arch_root in this repo's manifest, then the main checkout's manifest, then
<repo>/docs/architecture. arch-root is read-only — the root is declared in
.wf-skills-manifest.json, and the default needs no command to reach it.
Unlike specs, records are committed. They are the project's own documentation rather than session state, so there is nothing to rescue before a worktree is torn down.
resume is the primary automation entry point: it re-infers the pipeline step
from the filesystem, updates current.json, writes next-step.md, and logs a
resume event. Use it when returning to a session or when a skill needs to
advance the pipeline.
next is a lighter variant that writes next-step.md without requiring a prior
wfctl start. Useful for one-shot step queries.
Run wfctl <command> --help for all options.
| Variable | Description |
|---|---|
WFCTL_STATE_DIR |
Override XDG state directory for the current session |
WFCTL_BRANCH |
Override branch detection |
WFCTL_SPEC_DIR |
Override spec directory root for one invocation (default: unset — falls through to the repo's spec_root, then <repo>/specs; see spec-root) |
WFCTL_ARCH_DIR |
Override architecture record root for one invocation (default: unset — falls through to the repo's arch_root, then <repo>/docs/architecture) |
WFCTL_REPO_ROOT |
Override git repo root detection |
XDG_STATE_HOME |
Base for XDG state path (default: ~/.local/state) |
git clone https://github.com/aamarin/wfctl.git
cd wfctl
pip install -e ".[dev]"
pytestThe skills, commands and speckit runtime install-skills writes are committed
here as package data under wfctl/agents/ and wfctl/specify/ — edit them in
place, then wfctl install-skills to try them in a repo. They carry no leading
dot on purpose: .gitignore ignores .agents/ and .specify/ unanchored, so a
dotted vendored copy would be silently untracked. The dotted directories at the
repo root are this repo's own install output, not the source.
Issues and PRs welcome. Please open an issue first for significant changes.
MIT — see LICENSE.