onetaskgraph
One interface over the ticketing systems your work actually lives in.
Tasks, projects, labels and the dependencies between them are spread across Linear, GitHub
Projects and a folder of Markdown files, and every tool that wants to reach them ends up
reimplementing all three. onetaskgraph implements them once, behind a single query
surface: a command-line tool, the Rust engine crate's library API, and SDKs for Python and
TypeScript. Every consumer reaches the same engine, so their query semantics cannot drift.
Two properties make it different from a lowest-common-denominator wrapper:
-
A rich source is not reduced to a poor one's floor. Each source declares what it can do natively. The engine pushes those predicates down and compensates in memory for the rest — and every response carries the plan it ran, so
--explainshows you which source filtered server-side and which one the engine narrowed for. -
Nothing of your work is kept outside the system that owns it. No cache, no index, no local mirror. The engine holds at most one source page at a time and writes nothing down — enforced by a supply-chain gate that refuses every embedded store and cache crate, a sandboxed journey that fails if any file written during a run contains your data, and an assertion that the same query asked twice reaches the source twice.
copyis not an exception to that, and the difference is worth stating plainly. A destination write is at your explicit request, names its destination, goes through that source's own write interface into that source's own store, and is never read back to answer a query. A cache is a write nobody asked for that the engine reads back. Copying a task into a folder of Markdown puts it in the plugin that now owns it; nothing is kept anywhere else, and the sandboxed journey above drivescopyand fails, naming the path, if any file outside that destination's own store changes.
Status. The plugin contract, the workspace, the gate, the configuration layer and the query engine are in place, and the binary answers every verb below. The three sources this product ships for your work —
local-md,linearandgithub-projects— can all be read and written; none is read-only. A copy can still refuse a configured destination with no write side, such as a subprocess source whose capability handshake declares no writes, but that is a property of that configured source rather than of these plugins.
Using it
|||||done||
An <ID> is qualified: work:ENG-142. Repeating --label narrows — a second one is a
second requirement — and --not-label excludes. --project takes a qualified id, which
narrows the query to that project's own source, or a bare native id, which is asked of
every selected source.
A document is what lives in a project and is not work — a design note, a runbook, a
page somebody has to read. It carries no status and takes part in no dependency graph, so
document has no --status filter and no deps verb. What it does carry is a
location: where a reader can actually open it, as a link (url …) or as an absolute
path on the machine its source runs on (path …). task show, project show and
document show all print it, and --json carries the contract type's own shape —
{"url": …} or {"path": …} — so a program branches on which key is present. Not every
source has documents; one that says it has none is reported as holding none rather than as
having failed, and a copy naming it is refused before anything is read.
A comment is added to a task after the task exists, without rewriting it — evidence
appended to an issue somebody else opened. task comment add and edit read the body from
--body-file, or from standard input when that flag is absent, and never from a word of the
command line, so a body quoting a command never passes through a shell; it is stored and
returned byte for byte, and an empty one is refused. --author is for a source that records
what it is given, such as a folder of Markdown; GitHub and Linear record the signed-in
account themselves and refuse it rather than drop it. task show prints a task's comments
after its body, and its --json carries them as a top-level comments list for a source
whose tasks have comments — absent, rather than empty, for one whose tasks have none. A
task copy, project copy or document copy never reads or writes a comment at either end.
A task's status is set on its own with task status set, which writes that one field —
title, body, labels, metadata, dependencies and comments stay exactly as they are — and
answers with the status as the source reads it back. queued is the category for work
claimed by something that will do it and not yet started, between todo (ready, and nothing
has claimed it) and in-progress. A folder of Markdown reads the word queued; a GitHub
Projects board sends it to its Queued column by default, status_mapping.queued naming
another; Linear, which has no such state, refuses it by name.
A task can name the tasks it delivers: finishing it finishes them. In a folder of
Markdown that is a delivers: list in the front matter — a bare id names a task of the same
folder and <source>:<id> a task anywhere — and on a GitHub board it is the
onetaskgraph.delivers key of the issue's metadata block. The delivered task's
delivered_by is onetaskgraph's to keep. Whenever it writes a task that delivers anything,
or delivered something before — a copy, or task status set — each delivered task gains the
deliverer's qualified id, a task it dropped loses it, and the delivered task's status follows
its deliverers while it is at todo, queued or in-progress: in-progress while any runs,
queued while any is queued, done once every one is done or cancelled and at least one is
done, and todo when they release it. A delivered task at draft, backlog, unknown,
done or cancelled is left alone, a deliverer its source no longer holds is dropped from
delivered_by, and a delivered task that cannot be read or written is reported failed while
the deliverer's own write stands.
A copy rewrites a delivers entry naming another item it copies to that item's new id,
counts those under delivers_rewritten, and never carries delivered_by. Every verb that
writes a deliverer reports each delivered task it evaluated under delivered. A --dry-run
copy writes nothing and so keeps no delivered task in step: its delivered list is empty,
which says nothing about whether a delivered task would have moved.
How a source spells a document is its own business. A GitHub Projects board has no
document type, so github-projects reads one as an ordinary issue whose title begins
DESIGN: — the title you see has that prefix taken off, and writing a document puts it
back, so a design note copied out of a board and back returns the title it started with.
docs/metadata.md records the whole of that rule.
Seeing which plan you got
--explain renders the plan the query ran, per source:
$ onetaskgraph task list --label bug --explain
work:ENG-142 in-progress Rate-limit the sync loop
notes:2026-08 todo Write up the migration
plan:
work (linear) 1 page(s)
pushed down: label
notes (local-md) 3 page(s)
applied locally: label
Linear filtered server-side; the folder of Markdown could not, so the engine pulled pages
and narrowed them itself. Both answers are correct and you can see which you got.
--json carries the same plan as a field, so a script does not have to parse the prose.
Writing tasks: Markdown in, ticket out
Authoring against a ticketing API is not something a person or an agent does well, and a folder of Markdown files is. So that is how work is created here: write the files, read them back through the CLI to be sure they parse, then copy them where your team works.
With notes configured as a local-md source rooted at ./notes, create its task folder
and write notes/tasks/rate-limit.md:
title: Rate-limit the sync loop
status: Todo
labels: [{id: local-reliability, name: reliability}]
metadata: {onepipeline.turn_budget: 12}
repositories: [github.com/acme/sync]
Back off when the API asks us to slow down.
Read it through the CLI before writing to the permanent linear destination, then copy
the qualified id the read returned:
$ onetaskgraph task list --source notes
notes:rate-limit todo Rate-limit the sync loop
$ onetaskgraph task copy notes:rate-limit --to linear
The read is intentional: a malformed front matter block caught in a local file is cheaper
to fix than a parse failure first discovered while writing to somebody's ticketing system.
Projects use the same flow under notes/projects/, followed by project list and
project copy.
Editing is the same road in reverse — copy out, edit, copy back:
$ onetaskgraph task copy work:T-1 --to notes
$ grep -A2 '^metadata:' notes/tasks/T-1.md
metadata:
onetaskgraph.origin: work:T-1
$ $EDITOR notes/tasks/T-1.md
$ onetaskgraph task copy notes:T-1 --to work
The copy back updates rather than duplicating because the copied file carries the id
it came from, under the reserved metadata key onetaskgraph.origin. Nothing anywhere
holds a mapping: the correspondence lives on the item, inside the plugin that owns it.
Two rules find the counterpart, in this order. If the item's origin names the destination, that origin is the destination item and the copy updates it. Otherwise the destination is searched for an item whose origin is the id being copied; found, it is updated, and not found, one is created carrying that origin.
Which rule found it decides what the copy records there. A copy that got its counterpart from the first rule is a copy back: the destination is the original, and the item being copied is the one that came out of it — so the destination keeps the origin it already holds, holding none included. Stamping it with the id of its own copy would destroy the original's provenance, and the next ordinary copy from the store it was authored in would match nothing and create a second item beside it. Every other copy records the id it was copied from, which is what makes the next copy of it an update.
| Flag | What it is for |
|---|---|
--dry-run |
Every read, no write, and the action each item would have got. |
--recreate |
An origin naming an item the destination no longer holds refuses by default, because creating there would duplicate work somebody deleted. This says create instead. |
--match-by KEY |
Delete or corrupt the origin key and neither rule can find the counterpart, so the next copy back creates a new item. This re-establishes the lost correspondence by matching on title, or on a metadata key of your choosing, without hand-editing ids. |
--no-tasks |
Copy a project on its own. By default project copy copies the project and every task in it, matching each task independently. |
--member TASK-ID |
Copy the project and exactly the tasks named, repeating the flag for each, when you know which of them changed. A task not named is not read at the destination, not written and not reported, so a one-task change costs what one task costs rather than a read of the whole project. A named task the destination does not hold yet is still created. An edge to a task not named is written to the destination id that task records at onetaskgraph.origin, and a copy whose edge names a task recording none is refused before anything is written, naming that task. A copy naming members was not told about the rest, so it reports nothing orphaned. |
Every field a copy read is written — title, content, status, labels, project,
repositories, metadata and the edges — except url, location, created_at and
updated_at, which are the destination's own. Nothing is silently dropped: a field the destination cannot
represent, or a metadata key it cannot carry, refuses the write and names it. A copy never
deletes work either, so a destination item the source no longer holds is left exactly as it
is and reported as orphaned.
A copy either completes or leaves the destination as it found it. A copy that cannot finish — a field the destination refuses, a credential that expires, a rate limiter — undoes what it has already written and takes back the items it created in that run, so the retry starts from the destination you started from. That is what stops a half-written project having to be re-run, and the re-run is the burst of writes that trips a hosted destination's rate limiter. When the destination will not take one of them back, the refusal says so and names what is still there rather than leaving you to find it.
--json gives one entry per item for a script to read:
action says which of the four things above happened to that item, and destination is
null only for a dry run that would have created something. The vocabulary itself is
published rather than restated here: it is the CopyAction root of onetaskgraph schema,
which is what both SDKs are generated from and what the journeys validate this output
against.
A copy that reaches a source which counts its own requests — github-projects does — also
says what it spent: the requests those sources sent for the command, and what that cost
each budget they draw on, with a flag on any amount that is partly a modelled lower bound
rather than a figure the backend reported. It is the source's own account, summed over the
command, and a copy whose sources count nothing carries no spent at all rather than a
zero. Its shape is the spent property of the CopyReport root of onetaskgraph schema.
Exit codes
| Code | Meaning |
|---|---|
0 |
Success — every source asked, every source answered. |
1 |
The command failed while running: an id that names nothing, a configuration it will not run on, a source name nothing configures. |
2 |
The invocation itself was wrong — an unknown flag, a value out of range. |
4 |
The query ran and at least one source did not answer. The others' results still stand and the failure is named on standard error. A write — a copy, or task status set — also exits 4 when it landed and a task it delivers could not be kept in step; its delivered list says which. |
--allow-partial says a partial answer is acceptable and turns 4 into 0. Nothing else
does: a run that lost a source never exits 0 unless you asked for that.
With machine output selected — --json, --output json, or output: json set in a
document, by --set or by ONETASKGRAPH_OUTPUT — a command that exits 1 also writes
exactly one document to standard output, and nothing else goes there. Its shape is
published rather than restated here: it is the FailureDocument root of onetaskgraph schema, which describes every member and is what both SDKs are generated from and what
the journeys validate this output against.
Its class is the member to branch on: refused means asking again unchanged gets the
same answer, and transient — a rate limit, or a source that could not be reached — means
it may not. The standard error line and the exit code are the same with
or without it, and exit 2 writes no document. A partial answer at exit 4 carries the
same class on each entry of its errors.
Paging
--limit N gives you a page and, when there is more, the token for the next one:
# ... rows ...
# next page: --page 5b7b22736f7572...
Rows are interleaved across the selected sources — one from each in configured-name order,
then the next from each — and within a source they keep that source's own order. The turns
carry on across page boundaries, so a walk returns the same rows in the same order whatever
page size you choose: --limit 3 and --limit 50 differ in how many round trips they cost
and in nothing else. The token is the engine's own; a source's cursor travels inside it
untouched, and a walk returns every row exactly once.
A token belongs to the query that produced it. Resuming it from a query that asks for
something else — another --label, another --search, another --source, the other
--direction — is refused rather than answered, because every cursor inside it is a
position in the result set the original query returned, and picking up there under new
filters returns real rows from a walk you are not doing. Change the query, drop --page.
Install
The command-line tool ships as a self-contained binary. Once a release is cut, install it whichever way suits your machine:
For Rust, the SDK surface is the engine crate itself rather than a separate wrapper package. Add it when the application should link the engine, or add either subprocess SDK when it should drive the installed binary:
This complete example constructs an engine over two in-memory sources, copies a task through
Engine::copy, inspects the outcome, and reads the destination back through the engine:
use ;
use SourceName;
use json;
async
Unlike the Python and TypeScript SDKs, which spawn the compiled binary, a Rust consumer
links onetaskgraph-core and calls Engine in process. The engine and its copy semantics
remain the single implementation in either case.
To work on the repository instead, clone it and run just bootstrap; just --list shows
the rest.
Configure
One YAML document, onetaskgraph.yaml, discovered upward from the working directory and
layered over a user-level file at $XDG_CONFIG_HOME/onetaskgraph/config.yaml:
default_sources: # omitted means every configured source
page_size: 50
output: text # text | json
sources:
work:
plugin: linear
config:
notes:
plugin: local-md
config:
Every setting is reachable at three layers, lowest precedence first: the file, then the environment, then a command-line flag.
An environment variable is ONETASKGRAPH_ followed by the config path, each segment
upper-cased with - replaced by _ and segments joined by a double underscore; a list is
comma-separated:
| Variable | Sets |
|---|---|
ONETASKGRAPH_PAGE_SIZE=100 |
top-level page_size |
ONETASKGRAPH_DEFAULT_SOURCES=work,notes |
top-level default_sources |
ONETASKGRAPH_SOURCES__WORK__CONFIG__ROOT=/tmp/tasks |
the root of the source named work |
ONETASKGRAPH_SOURCES__GH_MAIN__PLUGIN=github-projects |
the plugin of the source named gh-main |
The mapping is unambiguous because a source name may not contain an underscore.
On the command line the same dotted path is --set, and a few common settings have named
flags of their own:
onetaskgraph config show is what makes precedence something you can see rather than
something you have to reason about: it prints every setting, its value, and the layer it
came from — which file, which environment variable, or which flag — and --json renders
the same thing for a script.
Relative paths in a configuration document
A relative filesystem path a configuration document supplies is resolved against the
directory holding that document. onetaskgraph.yaml is discovered by walking upward
from the working directory, so the document is very often not in the directory you ran the
command in — a local-md root: of plans in a checkout's own document means that
checkout's plans, whether you run from the checkout, from a crate three levels inside it,
or from a worktree beside it.
A relative path the environment layer or a flag supplies keeps resolving against the
process working directory, because there is no document to rebase it on:
ONETASKGRAPH_SOURCES__PLANS__CONFIG__ROOT=plans and --set sources.plans.config.root=plans both mean plans under wherever the command is running.
Naming the directory outright is still how a launcher points a run at a store that is
neither beside its own document nor beside its working directory:
ONETASKGRAPH_SOURCES__PLANS__CONFIG__ROOT=/srv/plans
onetaskgraph config show reports the resolved path beside the document that supplied it,
so what a run will really read is something you can see rather than something you have to
work out.
Two limits are worth stating. The rule reaches only the configuration fields a plugin
itself declares as paths, and each plugin's own page says which of its fields those are.
And it stops at the subprocess seam: what a settings: block holds belongs to a plugin
this binary may never have compiled, so a relative path in there is resolved by the child
against its own working directory.
Credentials
A configuration document never holds a credential — it names the environment variable that
does (api_key_env: LINEAR_API_KEY). Before resolving sources the CLI reads
$XDG_CONFIG_HOME/onetaskgraph/secrets.env (override with ONETASKGRAPH_SECRETS_FILE) as
KEY=VALUE lines with # comments, and applies each value only where the process
environment does not already define that name — so anything you exported wins. A missing
file is not an error. A credential is never printed, never in debug output, and never in a
log line.
There is exactly one name per credential everywhere — in that file, in the configuration,
in the documentation and in CI: LINEAR_API_KEY and GH_PROJECTS_TOKEN. Nothing anywhere
translates between spellings.
Addressing
Every item is qualified by the source it came from and rendered <source>:<native> —
work:ENG-142, notes:2026-08-inbox. Parsing splits on the first colon, so a native
id may contain colons freely.
Custom metadata and repositories
A task and a project each carry a caller-defined metadata map and the repositories
they concern, and a dependency edge names both its ends by kind and qualified id — so an
edge may cross projects, cross the task and project levels, and cross sources.
docs/metadata.md says which keys are reserved and where each source
keeps them.
Writing a source in another language
A source that is not a Rust crate speaks a line-oriented JSON protocol over stdio:
docs/plugin-protocol.md specifies it completely — the
framing, the capability handshake, one method per trait method, and the error envelope.
Configure one with the subprocess plugin, which names the program to run and hands it
its own settings verbatim:
sources:
notes:
plugin: subprocess
config:
command: /usr/local/bin/my-source
args:
secrets: # forwarded in the handshake; nothing else is
settings: # this source's own `config:` block
A source behind that seam is a source like any other: it declares its own capabilities,
so a plan says pushed down for what it applies itself, and the engine compensates for
the rest exactly as it does in process.
onetaskgraph-source is the reference implementation of that plugin side, and the test
host this repository drives its own journeys against: it hosts any built-in plugin over the
same protocol, so every journey runs a second time over a real pipe to a real second
process, and so you can read a working peer beside the specification.
It is not part of the command-line interface. Nothing onetaskgraph does needs it at
run time, and nothing may depend on finding it beside an installed CLI or resolve it off a
search path — a downstream test chain that did picked up an unrelated stale build and
treated a reference host as a runtime dependency. Read it, or build it, from a checkout:
Licence
MIT. See LICENSE.