onetaskgraph-core 0.2.51

The onetaskgraph engine: the plugin registry, global-id qualification, and the plan every response carries.
Documentation

onetaskgraph

A terminal showing five task rows — three from a folder of plans and two from the folder the team's tickets live in, interleaved in configured-name order, each row a qualified id, a status category and a title

One interface over the ticketing systems your work actually lives in.

Every image in this README is a real capture of this CLI's output, produced by running the binary against a small fixture and refused by CI the moment its bytes stop matching what the binary prints — so the pictures cannot drift away from the tool.

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 --explain shows 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.

    copy is 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 drives copy and 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, linear and github-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

Each source says what it can do, and sources list is where you read that back: the predicates it applies itself, how far it walks dependencies, and the largest page it will serve.

A terminal showing two sources, one in-memory and one local-md, each with the predicates it applies natively, the directions it walks task and project dependencies, and its maximum page size

onetaskgraph sources list
onetaskgraph sources fields <SOURCE> [--apply] [--json]
# Without --apply this only reports what a GitHub Projects board lacks of the fields its
# source's configuration names: the Status options status_mapping resolves to, and the
# Priority field and its options when priority_mapping is set. --apply adds the missing
# options and creates a missing Priority field, sending every existing option back with its
# id, then verifies every pre-existing option and every item's values of both fields and
# prints recovery data if GitHub drifted.
onetaskgraph sources status-options <SOURCE> [--apply] [--json]
# The Status-only form of `sources fields`, which supersedes it; kept as it was.

onetaskgraph task list [--source S]... [--label L]... [--not-label L]...
                       [--status S]... [--priority none|urgent|high|medium|low]...
                       [--project P | --no-project] [--commented-since RFC3339]
                       [--search TEXT] [--in title|content|both]
                       [--limit N] [--page TOKEN] [--explain] [--allow-partial] [--json]
onetaskgraph task show <ID>
onetaskgraph task deps <ID> [--direction depends-on|depended-on-by]
onetaskgraph task copy <ID>... --to <SOURCE> [--match-by KEY] [--recreate] [--dry-run]
onetaskgraph task comment add    <ID> [--body-file PATH] [--author NAME]
onetaskgraph task comment list   <ID>
onetaskgraph task comment edit   <ID> <COMMENT-ID> [--body-file PATH]
onetaskgraph task comment delete <ID> <COMMENT-ID>
onetaskgraph task status set <ID> draft|backlog|todo|queued|in-progress|done|cancelled|unknown
onetaskgraph task priority set <ID> none|urgent|high|medium|low
onetaskgraph task content set <ID> --file PATH
onetaskgraph task metadata set <ID> <KEY> <VALUE>
onetaskgraph task update <ID> [--title TITLE] [--body-file PATH]
                         [--status CATEGORY [--status-name NAME]] [--priority PRIORITY]
                         [--metadata KEY=JSON]... [--remove-metadata KEY]...
                         [--delivers ID... | --no-delivers] [--depends-on ID... | --no-depends-on]
onetaskgraph task create <SOURCE> --project P --title TITLE
                         [--template FILE [--search-path DIR]... | --template-loader FILE
                          | --body-file PATH]      # none of the three: the body on stdin
                         [--answers FILE] [--var NAME=VALUE]... [--status CATEGORY]
                         [--label L]... [--repository R]... [--depends-on ID]...
                         [--delivers ID]... [--metadata KEY=JSON]...
onetaskgraph task render <ID> [--template FILE | --template-loader FILE] [--search-path DIR]...
                         [--answers FILE] [--var NAME=VALUE]... [--unset NAME]... [--dry-run]
onetaskgraph task answers <ID>

onetaskgraph project list / show / deps          # the same flags, minus the project filter
onetaskgraph project copy <ID> --to <SOURCE> [--no-tasks | --member TASK-ID...]
                                                 [--match-by KEY] [--recreate] [--dry-run]
onetaskgraph project metadata set <ID> <KEY> <VALUE>

onetaskgraph document list / show                # the same flags, minus --status
onetaskgraph document copy <ID>... --to <SOURCE> [--match-by KEY] [--recreate] [--dry-run]
onetaskgraph document metadata set <ID> <KEY> <VALUE>
onetaskgraph document create <SOURCE> --project P --title TITLE [--id DOC]
                             ...                 # the body, label, repository and metadata
                                                 # flags of `task create`
onetaskgraph document render <ID> ...            # the flags of `task render`
onetaskgraph document answers <ID>

onetaskgraph label list [--source S]...
onetaskgraph search <TEXT> [--in ...] [--kind task|project|both]

onetaskgraph template variables <FILE> [--search-path DIR]... | --template-loader FILE
onetaskgraph template render <FILE> [--search-path DIR]... | --template-loader FILE
                             [--answers FILE] [--var NAME=VALUE]...

onetaskgraph config show                         # every setting and the layer it came from
onetaskgraph schema                              # the JSON Schema bundle both SDKs use

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.

task list --commented-since <INSTANT> keeps the tasks one of whose comments was created, or last edited, at or after the instant — an RFC 3339 time with its offset, such as 2026-09-28T12:00:00Z; one without an offset is refused, naming the flag. A task with no comments never matches, and a comment deleted before the query is not a match. It combines with every other filter, and the answer is exactly their intersection. A folder of Markdown, an in-memory source and a GitHub Projects board apply it themselves. A board asks GitHub's issue search, scoped by the board alone, for the issues updated since the instant, then reads those candidates' comments and no others' — exact for new and for edited comments in every repository the board's items live in, because GitHub moves an issue's updatedAt when one of its comments is added or edited, which the board's credentialed journey re-takes on every run. That search is an index that lags a write by a second or two, so when you ask again from the time you last asked, overlap the two instants by more than that. A source that does not apply it itself — Linear, today — is narrowed by the engine, which reads that source's comments task by task for every task the other filters kept: correct, and as costly as that sounds on a large workspace.

A terminal showing one task from task show: an aligned block of id, title, status, project, labels and a path location, then the task's body, then its one comment with that comment's id, author and created and updated times

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.

On GitHub Projects, terminal writes keep both GitHub representations aligned: done selects the mapped Done option and closes the issue as completed, while cancelled selects the mapped Cancelled option and closes it as not planned. Those are the shipped option names. An open-category write reopens a closed issue before selecting its mapped option; a draft item has no issue state to reopen. If any mapped option is absent, the write is refused by that option's name before either representation changes — sources fields (and its Status-only form, sources status-options) counts a terminal category's mapped option as configured, so it names a missing Done or Cancelled and --apply adds it. Reads keep GitHub's issue decision authoritative for closed issues—completed reads done and not planned reads cancelled, regardless of the displayed option—while the Status option decides an open issue's category.

A task's priority is one of none, urgent, high, medium and low, and every task carries one — none when none is set, and for every task of a source that cannot hold one. task list --priority keeps the tasks at any of the priorities named, and task priority set writes that one field and answers with the priority as the source reads it back; none clears it. A copy carries a task's priority like its status. A folder of Markdown holds it as a priority: key, Linear as its own issue priority, and a GitHub Projects board as the option of a single-select Priority field that the source's priority_mapping names — Urgent, High, Medium and Low unless it says otherwise. A board source configured without priority_mapping holds no priority, and a copy or a task priority set that would write one to it is refused by name before the board is asked anything. sources fields <SOURCE> --apply creates the board's Priority field, or adds the options it lacks, without disturbing an option or an item's value that is already there.

A task's content is replaced on its own with task content set <ID> --file PATH: the file's bytes become the task's body, and status, priority, metadata, labels, repositories and dependencies stay exactly as they are — on a GitHub board, whose metadata lives in the issue body beside the content, that block is kept as it was. There is no compare-and-set: what the file holds replaces whatever the task held.

One metadata key of a task, a project or a document is set on its own with task, project or document metadata set <ID> <KEY> <VALUE>, which adds the key or replaces what it holds and changes nothing else about the record — every other key, and every other field, stays exactly as it was, and a key already holding the value is not written at all. KEY is your own dotted <namespace>.<name>: two or more non-empty segments, never in the onetaskgraph. namespace this product keeps in step itself. VALUE is exactly one JSON value, parsed as JSON and never as YAML, so a bare yes or 2026-01-01 is refused rather than given a type — quote a string as '"text"'. An unqualified id, a key or a value of those shapes is refused before any source is asked. The answer — MetadataSet under --json — carries the id, the key, the value as the source reads it back after the write, and the record's location. Metadata is not status, so no task it delivers is re-evaluated. A folder of Markdown edits the one entry of its metadata: block and no other byte, and refuses by name a block it cannot edit that narrowly; a GitHub Projects board sends one update of the issue body that changes only its metadata slot; the in-memory source holds the value for the life of its process; and Linear, which has nowhere to put one key on its own, refuses with the linear plugin cannot write a task's metadata on its own. A source with no write side, a record the source does not hold, and a stdio plugin whose handshake does not declare the write are each refused by name.

Several fields of one task are updated together with task update <ID>, which writes every field it names and nothing else: --title, the content from --body-file, --status (and --status-name, the status's own word where the source keeps one), --priority, any number of --metadata KEY=JSON to set and --remove-metadata KEY to remove, and --delivers or --depends-on lists that replace the task's own, their --no-… forms replacing them with none. A field that already holds the value named is not written, and an update in which nothing differs writes nothing at all; the answer — TaskUpdated under --json — says which fields were written, carries the task as its source reads it back, and under spent what the source's own meter says the call cost. Labels, repositories and the project a task is filed under are not among the fields: an existing item is never moved. On a GitHub Projects board that is at most one read of the issue, one updateIssue carrying the title, the body — visible content and metadata block together — and any state change, one field write each for Status and Priority, and only the blockedBy edges that differ; Linear sends one issueUpdate of what differs, and a folder of Markdown replaces the file once, every byte it was not asked to change kept. Naming a status or a delivers list keeps every delivered task in step exactly as task status set does, and exits 4 when one could not be; naming neither re-evaluates nothing. A key both set and removed is refused before anything is written, and so is an update naming no field.

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, task status set or task update — 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. Here one --label query reaches two sources of differing capability — a folder of Markdown, and a source that declares it cannot filter by label at all:

A terminal showing two task rows above a plan block: the source that declares it cannot filter by label is listed with applied locally: label, and the folder of Markdown with pushed down: label, each with the number of pages it served

The folder filtered the rows itself; the other source returned the wider set and the engine narrowed it. One query, two plans, and the same correct answer either way — which is what the capability declaration above buys you and why it is worth reading. --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.

--dry-run is how you read that table's first row before trusting it: every source is read, nothing is written, and each item is reported with the action it would have got.

A terminal showing a dry-run copy of two tasks into another source: the first names its counterpart there and reads updated, the second has none and reads created, followed by the line counting rewritten, unresolved and ambiguous references

Every field a copy read is written — title, content, status, labels, project, repositories, metadata and the edges — except url, location, key, created_at and updated_at, which are the destination's own: the short handle a backend shows people is issued by that backend, so the one the source wore is not the one the destination does. 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:

{"items": [{"source": "notes:ENG-142", "action": "updated", "destination": "work:ENG-142"}]}

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.

Task templates

A task template is a minijinja document whose variables are declared in YAML front matter, so the shape of a task is a file you own rather than prose several tools restate:

---
onetaskgraph_template: 1          # required when front matter is present
description: <string>             # optional
variables:                        # optional; name -> declaration
  <name>:                         # ^[a-z][a-z0-9_]*$
    description: <string>         # required, non-empty: what a prompt shows
    type: string                  # string | text | integer | boolean | list | object; default string
    items: string                 # list only: string | object; default string
    required: true                # default: true without `default`, false with one
    default: <value of `type`>    # optional; `required: true` beside a default is refused
---
# {{ title }}
{% for criterion in criteria %}
- {{ criterion }}
{% endfor %}

A file without front matter declares nothing; front matter with an unknown key is refused by that key. string is one line and text any number. The body is minijinja, whole — filters, loops, conditionals, macros, a variable used many times or not at all. {% extends %}, {% include %} and {% import %} find the files they name in the --search-path directories, in order, and never in the working directory unless you name it. The declared set is every chain file's front matter together: a redeclaration may change description, default and required — the file nearer the rendered one wins — but never type or items, which is refused naming both files. A template named by an expression — {% include kind ~ ".md" %} — is part of the chain too, for the answers that name it, and joins it at the tag that names it, exactly as a file a literal names would: its front matter joins the declared set at its own distance from the rendered file, and its bytes join the digest where rendering first reads it. Rendering is strict — a name that is neither declared nor set fails, naming it and its file — with no auto-escaping, trailing newlines kept, and trim_blocks and lstrip_blocks on. An optional variable with no answer and no default is none.

template variables lists the declared set and the template's digest: sha256: over every chain file's name and bytes, which moves whenever any file of the chain does. Each variable template render needs takes the first of:

  1. --var NAME=VALUE — literal text for a string or text variable, YAML for any other;
  2. the answers file, --answers FILE (- for standard input): a YAML mapping of name to value;
  3. a prompt, when the interactive setting is on — one variable at a time, showing its description, type and default, asking again when a value is not of its type;
  4. its declared default.

An answer to no declared variable, an answer of the wrong type, and — when not interactive — every required variable left unanswered, listed in one refusal, each exit 2. So does an interactive run with something left to ask whose standard input is not a terminal: it never waits for an answer nobody can type. Automation passes --no-interactive, which both SDKs always do.

Creating and regenerating from a template

task create and document create make an item in one source, its body rendered from a template — --template FILE, or --template-loader FILE for a template a caller states (below) — with its answers taken exactly as template render takes them, or given as it is with --body-file PATH or on standard input. task create prints the new item's qualified id, and under --json the item exactly as task show --json prints it; document create answers as document show does, and with --id DOC naming a document the source holds it replaces that document rather than adding a second. A key of the reserved onetaskgraph. namespace given with --metadata, and a source that cannot be written, are refused by name before anything is written.

An item rendered from a template records where it came from under the reserved metadata key onetaskgraph.template: the template's reference — a template file's absolute path, or a loader document's reference verbatim — the chain digest it rendered with, and the SHA-256 of its content (body_digest) and of its resolved answers as canonical JSON (answers_digest). An item made from a plain body records none. From that entry alone a hand edit (the content's hash is not body_digest) and a changed template (the chain's digest is not digest) are both visible. It proves nothing about who wrote it: a check reading only these hashes trusts them, so provenance forged by hand passes it. The entry is four strings, whatever the template's reference — nothing caps its length but what the destination caps a whole item at — and a copy carries it like any other metadata.

The answers themselves are kept in one place only: beside the item in a local-md folder's own file, which is where an item is authored — never in its content or its metadata, so they cost a hosted item nothing, and nothing of a task is written twice into a GitHub issue. task answers and document answers print them as YAML (--json: one JSON object), and refuse, naming the item, when none are stored — which is every item of a source that keeps none. A copy carries content and metadata alone, never the answers, at either end.

task render and document render regenerate an item in place, and write its content, its provenance and its stored answers in one write and nothing else — its id, title, status, labels, project, repositories, dependencies and every other metadata key stay as they were. The answers start from the stored ones when they hash to the recorded answers_digest; otherwise every required variable has to be answered again, and a render that leaves one unanswered is refused as supply every required answer, naming each and why the stored answers were not used (exit 2). --var, --answers and --unset NAME — which drops an answer so its default applies — are laid over that base. The template is the one given; else the recorded reference, re-read when it is a readable file — its extends, include and import resolved over --search-path again, because the provenance records none; else the render is refused naming that reference, because a reference is never turned into a location. --dry-run renders and writes nothing; --json prints {id, digest, body_digest, changed, body}, and a render that would change nothing reports changed: false and writes nothing.

A caller that layers templates of its own states the result with a template loader document — --template-loader FILE, - for standard input, but never together with --answers - — and this product renders exactly that, never resolving a caller's layers nor running a caller's command:

{"reference": "<string>", "entry": "<name>", "search_path": ["<absolute dir>"],
 "templates": [{"name": "<name>", "source": "<text>"}], "digest": "sha256:<hex>"}

reference (required, non-empty) is what the item records; entry (required) is loaded over the search_path directories, then the inline templates; digest, when present, must be the digest that chain computes, or the render is refused naming both. Every other key is ignored. template variables and template render take one in place of their <FILE>. docs/local-md.md describes the answers block.

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, or answers a template refuses.
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, task status set or task update — 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:

onetaskgraph task list --limit 20
# ... 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:

cargo install onetaskgraph            # from crates.io
uv tool install onetaskgraph-cli      # from PyPI, no Rust toolchain needed
npm install -g onetaskgraph-cli       # from npm, no Rust toolchain needed

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:

cargo add onetaskgraph-core onetaskgraph-plugin-api serde_json
cargo add tokio --features macros,rt-multi-thread
uv add onetaskgraph-sdk               # Python
bun add @onetaskgraph/sdk             # TypeScript

The engine links its local sources — in-memory, local-md and subprocess — in every build. The two that reach a network are features no default enables, so an application that wants only a local store does not compile an HTTP and TLS stack it never runs: add --features github-projects,linear (or either one) to the first line for GitHub Projects or Linear. A configuration naming one a build left out is refused with the feature that enables it.

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 onetaskgraph_core::{
    Config, CopyItems, CopyRequest, CopyScope, Engine, Environment, GlobalId, Secrets,
};
use onetaskgraph_plugin_api::SourceName;
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = Config::from_document(json!({
        "sources": {
            "drafts": {
                "plugin": "in-memory",
                "config": {"tasks": [{
                    "id": "T-1",
                    "title": "Ship the guide",
                    "content": "Publish the Markdown workflow.",
                    "status": {"category": "todo", "name": "Todo"},
                    "labels": [],
                    "metadata": {},
                    "repositories": []
                }]}
            },
            "work": {"plugin": "in-memory", "config": {}}
        }
    }))?;
    let secrets = Secrets::load(Environment::default())?;
    let engine = Engine::build(&config, &secrets);

    let report = engine.copy(&CopyRequest {
        items: CopyItems::new(vec!["drafts:T-1".parse::<GlobalId>()?])
            .expect("a copy names at least one item"),
        scope: CopyScope::Tasks,
        destination: SourceName::new("work")?,
        match_by: None,
        recreate: false,
        dry_run: false,
    }).await?;

    let outcome = &report.items[0];
    assert_eq!(outcome.source.to_string(), "drafts:T-1");
    assert_eq!(outcome.destination().unwrap().to_string(), "work:T-1");
    assert_eq!(outcome.action.name(), "created");
    println!("{} -> {} ({})", outcome.source,
        outcome.destination().expect("the copy created a destination"),
        outcome.action.name());

    let copied = engine.task(outcome.destination().unwrap()).await?;
    assert_eq!(copied.items[0].item.title, "Ship the guide");
    println!("{}", copied.items[0].item.title);
    Ok(())
}

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.

A failure reaches a linking caller as a typed value rather than as a document to parse: a write that landed while a task it delivers could not be kept in step reports that entry as DeliveryOutcome::Failed, whose Failure reads each member of the failure document — class() among them — through the read-only getters its own API documentation lists.

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: [work, notes]   # omitted means every configured source
page_size: 50
output: text                     # text | json
interactive: true                # prompt for what a command was not given
sources:
  work:
    plugin: linear
    config: { api_key_env: LINEAR_API_KEY, team: ENG }
  notes:
    plugin: local-md
    config: { root: ~/notes/tasks }

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_INTERACTIVE=false top-level interactive
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 --set sources.work.config.root=/tmp/tasks
onetaskgraph config show --page-size 100 --default-sources work,notes --json
onetaskgraph config show --no-interactive       # or --interactive; they conflict

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.

A terminal showing every effective setting in three columns — the dotted path, the value, and the layer it came from: one row naming the environment variable that set it, one naming the command-line flag, one reading default, and the rest naming the configuration document, with the secrets file underneath

That third column is the whole point: default_sources above came from an environment variable, page_size from a flag that outranks the document's own value, and every sources.* entry from the document, named by path.

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 task list

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.

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. It holds across the subprocess seam too, so a local-md root relative to its document names the same directory in process and behind the seam: the engine rewrites nothing in a settings: block, and the child learns which document's directory to measure its declared fields from through the handshake. docs/plugin-protocol.md §3.8 is the one statement of that member, including when it is absent and what a plugin written before it does.

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.

A terminal showing two dependency rows from task deps: the same task blocking another task of its own source, and blocking a task of a different source, each end named by kind and qualified id

The far end of the second row is in another source. It is reported by qualified id and kind and never followed: opening it is a command of your own.

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: [--serve]
      secrets: [LINEAR_API_KEY]   # forwarded in the handshake; nothing else is
      settings: { root: ~/notes } # 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:

cargo build -p onetaskgraph --bin onetaskgraph-source

Licence

MIT. See LICENSE.