Skip to main content

Crate onetaskgraph_core

Crate onetaskgraph_core 

Source
Expand description

The onetaskgraph engine.

It drives the sources a configuration names and reports what it had to do to answer. Everything a plugin author needs lives in onetaskgraph-plugin-api instead, and no plugin crate may depend on this one: deny.toml permits this crate exactly one wrapper, the binary, failing the required deny job, and scripts/check-plugin-isolation.sh reads the real cargo metadata graph inside just check.

§The invariant that shapes this crate

No work data may be stored, cached, indexed or mirrored outside a plugin. The engine compensates for a missing capability transiently: it holds at most one source page plus the caller’s page and writes nothing down. Three mechanisms enforce that rather than asking for it: deny.toml refuses every embedded store, index and cache crate, so reaching for one fails the required deny job; a sandboxed journey plants sentinels, drives every verb, and fails if one reaches any file written during the run; and a re-ask test fails if one query asked twice reaches the source once. The latter two land with the verbs they drive.

The repository README is included below so its worked Rust example is compiled and executed as a doctest whenever this crate’s documentation tests run.

§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 --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

onetaskgraph sources list

onetaskgraph task list [--source S]... [--label L]... [--not-label L]...
                       [--status S]... [--project P | --no-project]
                       [--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 project list / show / deps          # the same flags, minus the project filter
onetaskgraph project copy <ID> --to <SOURCE> [--no-tasks] [--match-by KEY] [--recreate]
                                                 [--dry-run]

onetaskgraph document list / show                # the same flags, minus --status
onetaskgraph document copy <ID>... --to <SOURCE> [--match-by KEY] [--recreate] [--dry-run]

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

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.

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.

FlagWhat it is for
--dry-runEvery read, no write, and the action each item would have got.
--recreateAn 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 KEYDelete 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-tasksCopy a project on its own. By default project copy copies the project and every task in it, matching each task independently.

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:

{"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.

§Exit codes

CodeMeaning
0Success — every source asked, every source answered.
1The command failed while running: an id that names nothing, a configuration it will not run on, a source name nothing configures.
2The invocation itself was wrong — an unknown flag, a value out of range.
4The query ran and at least one source did not answer. The others’ results still stand and the failure is named on standard error.

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

§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

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.

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
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:

VariableSets
ONETASKGRAPH_PAGE_SIZE=100top-level page_size
ONETASKGRAPH_DEFAULT_SOURCES=work,notestop-level default_sources
ONETASKGRAPH_SOURCES__WORK__CONFIG__ROOT=/tmp/tasksthe root of the source named work
ONETASKGRAPH_SOURCES__GH_MAIN__PLUGIN=github-projectsthe 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 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.

§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: [--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 ships beside the main binary and is the reference implementation of the plugin side — it hosts any built-in plugin over the same protocol, so you can read a working peer beside the specification.

§Licence

MIT. See LICENSE.

Re-exports§

pub use config::Config;
pub use config::ConfigError;
pub use config::Loaded;
pub use config::OutputFormat;
pub use config::SourceConfig;
pub use subprocess::MAX_LINE;
pub use subprocess::Program;
pub use subprocess::RequestDeadline;
pub use subprocess::SubprocessConfig;
pub use subprocess::SubprocessPlugin;
pub use subprocess::SubprocessSource;
pub use subprocess::serve;
pub use subprocess::serve_plugin;

Modules§

config
The configuration document, the three layers over it, and what they resolve to.
subprocess
Sources that are other processes, on both sides of the pipe.

Structs§

CopyItems
The items one copy names: at least one, because a copy naming none is not a copy.
CopyOutcome
What happened to one item.
CopyReport
What a copy did, one entry per item.
CopyRequest
A request to copy work into one configured destination.
CredentialName
The name of an environment variable a credential arrives under.
DependencyRequest
A request for a page of one item’s dependency edges.
DocumentFilters
The filters a document list carries.
DocumentRequest
A request for a page of documents.
Engine
The sources a configuration resolved to, and the queries they answer.
Environment
A snapshot of the environment a process was started with.
Filters
The filters every list verb shares.
GlobalId
One item, qualified by the source it came from.
LabelRequest
A request for a page of labels.
LeftBehind
The items a failed copy left at the destination — one of them at least, always.
PageToken
The engine’s own resume token: one plugin cursor per source stream, opaque to the caller exactly as a plugin’s cursor is opaque to the engine.
Paging
Which page of a result set the caller wants.
ProjectRequest
A request for a page of projects.
Qualified
One item, under the qualified id the engine addresses it by.
QualifiedEdge
One dependency edge with both ends qualified.
QualifiedEndpoint
One typed, qualified endpoint in an engine dependency response.
QueryPlan
What the engine did, per source.
QueryResponse
One page of engine output, with the plan that produced it.
ResolvedCredential
One credential name the file supplied, and where its value comes from.
ResolvedSource
One configured source, built and ready to answer.
SearchRequest
A request for a page of search hits.
Secrets
Where a plugin’s named credential is looked up.
SecretsReport
What the credentials file supplied, without supplying it.
SourceFailure
One source’s failure, kept beside the results the other sources returned.
SourceListing
What one configured source is, as sources list reports it.
SourcePlan
What one source was asked for, and what happened to each predicate.
TaskRequest
A request for a page of tasks.
UnavailableSource
One configured source that could not be built at all.

Enums§

ConfiguredSource
One configured source, in exactly one of the two states a configured source has.
CopyAction
The four things a copy can do to one item, and the id each of them is about.
CopyScope
What the ids a copy names are, and what travels with them.
CredentialLayer
Which layer answered for one credential name.
EngineError
What the engine refuses before it asks any source.
MatchBy
The caller-named escape for a correspondence neither origin rule can find.
PluginKind
One of the plugin kinds this build has.
Predicate
One thing a query can ask of a source.
ProjectSelector
Which project a task must belong to, as a command line names it.
SearchHit
One hit of a search that may cross entities.
SearchKind
Which entities a search covers.
SourceState
Whether a configured source is answering.

Constants§

SCHEMA_BUNDLE_VERSION
The bundle’s own version, bumped whenever a root is added, removed or renamed.

Functions§

plugin_for
The plugin registered for kind, or None when nothing answers to that name.
plugin_kinds
The kind names in registry, for help text and error messages.
registry
Every plugin kind this build knows, in a stable order.
resolve
Build every configured source, in name order.
resolve_available
Build every configured source, keeping the ones that refused beside the ones that built.
schema_bundle
Every contract root, keyed by name, plus each registered plugin’s config schema.
validate_sources
Check every configured source without building any of them.