Skip to content

chant workspace graph

chant workspace graph [dir] [--at <rev>] [-o <file>] [--member <name>] [--kind <kind file>] [--env <name>] [--no-cache] [--dry-run]
chant workspace graph [dir] --live --env <name> [--overlay] [--traffic <level>] [-o <file>] [--member <name>] [--kind <kind file>]
chant workspace graph [dir] --composites [--at <rev>] [-o <file>] [--member <name>] [--env <name>]
chant workspace graph [dir] --intent <path[:start-end]|path#symbol> [--at <rev>] [--kind <kind file>...] [--follow-squash] [--json]
chant workspace graph [dir] --intent --record <id> [--at <rev>] [--kind <kind file>...] [--follow-squash] [--json]

chant workspace graph reads every member of kind chant through that member’s own chant graph --format ir and composes the results into one document (#2537, ws-009, ws-018, ws-022). The toolchain each member gets works as described for chant workspace build. Example groups have no place in the graph.

A member of a kind from a pinned package is read too when the package’s kinds file says how, in the kind’s graph block (#2874). The terraform and choudoufu kinds of the terraform lexicon have one. chant writes a reader project for each such member in a temporary directory outside the workspace, with a chant.config.json that declares the lexicon, and runs the member’s chant graph there under the member’s toolchain. --env isn’t passed to it, since the reader project declares no environments. The project is removed when the read ends. A member of a package kind without a graph block is listed with kind-not-run, and build, lint and audit skip every package kind.

The document is part of the workspace read contract, with its own schema and reason codes.

chant graph is unchanged. At a workspace root it still prints the single-project IR of the root project, and this command is the only place members are composed.

Every node id becomes <member>/<id>. Member names can’t contain /, and :: keeps its meaning for stacks inside a member, as in app/web::Bucket. Edge ends and $ref values in attributes are rewritten the same way, as are compositeInstance, runtimeOwner and the nodes of exports and imports. Each node and edge also gains a member field.

groups.byMember maps each member to its node ids. A lexicon or a composite type means the same thing in every member, so byLexicon and byComposite keep their keys and merge the ids across members. The keys of byStack, byContainer and byWave name things inside one member, and they are prefixed with the member too.

The links section holds the member links, followed by the record links when --kind names a record kind. The records section lists the records of that kind, and it is empty without --kind.

A member of kind workspace holds its own declaration. The outer workspace reads it read-only, through the nested workspace’s own command (#2551, ws-007, ws-071). chant runs chant workspace graph in the nested root, which hands itself to the nested workspace’s own chant when it has one, as at any root. The outer read passes on --at, --env, --live, --overlay, --traffic and --no-cache, and nothing that writes. The answer must be a graph document of a contract this chant reads.

The nested document is folded in under the outer member’s name. A node delivery/appService of the nested workspace reference-workspace becomes reference-workspace/delivery/appService, so ids read outer/inner/id. Every other reference to a node gets the same prefix, as described under Composition. Each such node keeps member as the outer member and names the nested member in nested, and its sourceLoc.file is relative to the nested root. The outer member’s entry is composed. Its nested object holds the nested declaration’s name and contract version, with the nested workspace’s own member list and links. A nested workspace that can’t be read leaves the member failed, with command-failed, output-unreadable or ir-version-unsupported.

The chant repository declares reference-workspace/ this way, so chant workspace graph --member reference-workspace at the chant root shows the reference workspace’s delivery member under reference-workspace/delivery/.

The outer workspace never writes inside a nested one. build, lint and audit skip it with kind-not-run, and chant workspace upgrade refuses a patch that reaches into it.

With --live, each member runs chant graph --live under its own toolchain, so its graph is the account as it stands rather than its source (#2875). --overlay and --traffic <level> are passed along too, and a member’s chant then writes the drift overlay and the behaviour prediction onto its nodes. Composition keeps every attribute a member printed and rewrites only $ref values, so the _drift, _overlay and _behaviour attributes arrive as the member’s chant wrote them. A member’s meta, where the prediction’s whole-estate figures sit, stays on its entry.

The flags mean what they mean to chant graph. --live needs --env, and --overlay and --traffic only act on a live read, so this command refuses either without --live rather than ignore it. --live with --at fails with live-at-revision, because a live read is of the account now and --at reads the source at a revision. --namespace is not passed, since it scopes one member’s read. Run that member’s own chant graph for it.

Each member entry carries live, which is true when the member ran with --live, and then readAt, when its read finished, as an ISO time. A member that did not run has live: false and no readAt. A live read is never served from the member cache or stored in it, so a live member has cached: false and stamp: null.

A member whose account can’t be reached is not told apart from any other member: chant graph --live reports an unreachable account as warnings on stderr and a graph with fewer nodes, and exits 0, so there is no signal for a code of its own. Its entry is composed, and with --overlay the declared entities its chant could not observe carry _unobserved with the reason.

A member’s IR with no version field comes from a chant older than that field. It is read as version 1 and upgraded in place, and the member’s entry has irVersion: null. An IR with a version newer than this chant reads is left out with the reason code ir-version-unsupported.

Most reads of a workspace find most members unchanged, and starting a member’s chant costs close to a second before it does any work (#2481). So each member’s source read is kept on disk and served while nothing it depends on has changed (#2876, ws-059). A stored read is served when all of these match:

Part of the keyWhat it covers
the member’s stampthe member’s files and the install around it, as the stamp rule says
the toolchainthe real path of the member’s bin/chant and its package version, plus its source files when it is a checkout rather than an install
the command linethe member’s chant graph arguments, --env included
the kind’s readerfor a member of a package kind read through a reader project, the kind’s graph block and the installed version of the package supplying it
the environmentevery environment variable except those a shell changes on its own, such as PWD, SHLVL, TERM* and npm_*

A read that observes an account (--live, --overlay or --traffic on the member’s command line) is never stored, and neither is a read that failed. A read whose stamp moved while the member ran is not stored either. Some file systems keep mtimes to the second, so a working-tree read of a file younger than two seconds is left out too.

With --at, the commit id stands in for the files, because a commit’s tree never changes. A read at the same commit is served however the working tree has moved since.

A read never changes the workspace it reads, so the cache lives outside it. The base directory is $CHANT_CACHE_DIR when that is set, then $XDG_CACHE_HOME/chant, then ~/.cache/chant. Under it, each workspace gets a directory in workspace-graph/ named for the real path of its root. Each workspace holds at most 512 entries and 128 MiB, dropping the least recently used first. Entries are written whole, to a temporary name and renamed, so concurrent reads never see half of one. Deleting the cache is always safe. --no-cache reads every member and leaves the cache untouched.

The document follows the schema https://intentius.io/chant/schemas/workspace/graph/v1/graph.schema.json, shipped in @intentius/chant at src/workspace/graph.schema.json. Readers ignore fields they don’t know. Fields are only added within a contract version, and the code lists are closed.

{
"$schema": "https://intentius.io/chant/schemas/workspace/graph/v1/graph.schema.json",
"contract": 1,
"chant": "0.81.0",
"at": null,
"version": 1,
"workspace": { "name": "acme", "root": "." },
"members": [
{ "name": "api", "dir": "services/api", "kind": "chant", "status": "composed", "reason": null, "chant": "0.80.0", "irVersion": 1, "live": false },
{ "name": "legacy", "dir": "legacy", "kind": "chant", "status": "composed", "reason": null, "chant": null, "irVersion": null },
{ "name": "docs", "dir": "docs", "kind": "other", "status": "skipped", "reason": { "code": "kind-not-run", "message": "..." }, "chant": null, "irVersion": null }
],
"nodes": [{ "id": "api/apiNs", "kind": "Namespace", "lexicon": "k8s", "attrs": {}, "member": "api" }],
"edges": [],
"groups": {
"byMember": { "api": ["api/apiNs"], "legacy": ["legacy/Queue"] },
"byLexicon": { "k8s": ["api/apiNs"] }
},
"exports": [],
"imports": [],
"links": [
{
"consumer": "legacy", "producer": "api", "output": "QueueUrl", "kind": "output",
"origin": "inferred:joinKey", "label": "folded", "input": "queueUrl",
"resolves": "source", "status": "resolved", "reason": null,
"from": "legacy/queueUrl", "to": "api/Queue"
}
],
"collectors": [],
"records": []
}

contract is the read-contract version and chant the version that composed the graph. at is the full commit id with --at, and null for the working tree. workspace.root is the workspace root relative to the git root. version is the version of the composed graph format. A member’s chant is the version its toolchain reported, or null when the member was read by a chant older than member-run. The meta and pipeline of a member’s IR, when it has them, stay on the member’s entry, except for the collector topology, which has its own section. A composed member also carries cached, which says whether the member cache answered it. Its stamp is the member’s stamp, or null when none could be taken.

When the declaration can’t be read, the command prints { "$schema", "contract", "chant", "error": { "code", "message", "location" } } instead, with one of the declaration’s error codes or an --at code, not-a-git-repository or revision-unknown. --live with --at fails with live-at-revision.

links lists the member links (#2539, ws-008). A declared link comes from the consumer’s links in the declaration and is matched exactly against the producer’s exports. A join nobody declared is inferred from the members’ IRs. Each of a member’s imports is matched to the other members’ exports with the core joinKey(), which ignores case and punctuation. The row is labelled exact when the names are equal and folded when they differ only in case or punctuation.

A declared link replaces the inferred edge it covers, so the same join never appears twice. When an import matches outputs of two producers, the row has status: "ambiguous" and a candidates list instead of a producer, and no edge is drawn. A member of kind other, or of a kind from a package, is a link target through the outputs its entry or kind lists, even though it has no IR.

FieldValue
consumerThe member that reads the output
producer, outputThe member and output it reads. An ambiguous row has candidates instead, each with producer, output, label and to
kindThe link kind, output or telemetry
origindeclared, or inferred:joinKey
labelexact or folded. A declared link is always exact
inputThe consumer’s import an inferred join feeds, or null for a declared link
from, toThe composed ids of the import and export nodes, when the IRs name them
resolvessource
statusresolved, missing, unresolved, invalid or ambiguous
protocol, targetA telemetry link only: the OTLP protocol it states, and whether output named a pipeline or an exporter of the producer’s collector
reasonWhy the row isn’t resolved, or null

chant workspace check resolves the same links from source without running any member, and fails on a link whose output is missing.

collectors lists where each member’s telemetry goes (#2559). Each member whose project declares OpenTelemetry Collector config through the otel lexicon has one entry, in declaration order. The list is empty when no member does.

chant core reads no collector config. The otel lexicon’s graphMeta hook answers the topology of the project’s collector entities, the member’s chant graph --format ir carries it as meta.collector, and composition lifts it into this section. Nothing is added for a member whose toolchain has no otel lexicon or whose project declares no collector.

FieldValue
memberThe member whose source declares the collector
pipelinesEach pipeline under service.pipelines, with its id (traces, traces/backend), its signal, and its receivers, processors and exporters as collector ids
componentsEvery receiver, processor, exporter, connector and extension, with its id, kind, type, the endpoints its config states, the wire protocols it speaks (empty when unknown) and the pipelines that use it
exportersThe exporters again, each with its endpoints, its pipelines and the signals that reach it

An exporter’s endpoints are the addresses its config names, so a reader can say where a member’s traces go without reading the collector YAML.

A telemetry link in links targets an entry of this list: the pipeline or exporter its output names.

The --kind <kind file> option reads the records of that kind at the revision the declaration is read at. It reads them the way chant workspace records does. The records are listed in records, each with supersededBy and remediatedBy (#2774): the id of the closed record that replaces it, and the ids of records that remediate it without changing its state. Each current record, one that no other record supersedes, adds rows to links (#2549):

  • An asset row for each file the record pins by hash in its evidence, with the pin’s state: pinned, drifted, missing or stale.
  • A constrains row for each member:<name> or path:<path> the record governs, resolved when the member is declared or the path exists and missing otherwise.
{
"kind": "asset", "origin": "declared", "resolves": "source",
"recordKind": "decision", "record": "ref-002",
"recordPath": "reference-workspace/decisions/ref-002-where-the-screen-design-lives.md",
"target": "design/screens/home.json", "member": "design",
"status": "pinned", "reason": null,
"sha256": "074e55f5...", "actual": "074e55f5..."
}

A record link has record and target where a member link has consumer and producer. The read contract lists its fields and describes how a reader walks from a file to the artifacts behind it through these rows. A kind that can’t be read leaves records empty, prints its error code on stderr, and exits 1.

CodeThe member
kind-not-runhas a kind the workspace commands don’t run, such as other
dir-missing, unknown-kind, kind-probe-failedcan’t be read, as chant workspace ls reports
command-failedprinted no graph, because its chant graph exited with a failure, or, for a member read through its kind’s graph block, because the lexicon that reads it isn’t installed where the workspace resolves its pin
output-unreadableprinted something that is not a graph IR
ir-version-unsupportedprinted an IR newer than this chant reads

With --composites, the command prints a different document: each composite instance the members declare, joined to the components that can deploy it (#2662). A reader such as hud builds its deployment choices from these rows and offers no component the document doesn’t list. chant makes no choice itself.

Each chant member runs chant graph --format ir and chant graph --components --format ir under its own toolchain, at the same revision with --at. The instances come from the composed graph’s compositeInstance and compositeParent fields, and the components from each component’s contract. A component matches an instance in one of two ways:

  • Its contract’s composites lists one of the instance’s composite kinds. This is compared exactly.
  • Its contract lists no composites, and its name joins the instance’s kind or the instance’s own name with the core joinKey(). So loom-backend matches a LoomBackend instance, labelled folded. This is the naming convention the component contract documents for a component that says nothing.

A component can match an instance in another member. Each match says how it crosses members in via. The value is member when both are in one member, and link when the component’s member reads the instance’s member through a resolved member link. Otherwise it is unlinked, and the reader decides what that means. The document is JSON whether or not --json is given.

{
"$schema": "https://intentius.io/chant/schemas/workspace/composites/v1/composites.schema.json",
"contract": 1,
"chant": "0.84.0",
"at": null,
"workspace": { "name": "acme", "root": "." },
"members": [
{ "name": "app", "dir": "app", "kind": "chant", "status": "read", "reason": null, "chant": "0.84.0", "runtimeReasons": [], "environmentReasons": [] },
{ "name": "delivery", "dir": "delivery", "kind": "chant", "status": "read", "reason": null, "chant": "0.84.0", "runtimeReasons": [], "environmentReasons": [] }
],
"composites": [
{
"id": "app/backend", "member": "app", "instance": "backend",
"kinds": ["LoomBackend"], "lexicons": ["aws"], "nodes": ["app/backendRepo", "app/backendService"],
"components": [
{ "component": "delivery/loom-backend", "by": "name", "against": "kind", "value": "LoomBackend", "label": "folded", "via": "link" }
]
},
{ "id": "app/cache", "member": "app", "instance": "cache", "kinds": ["CacheCluster"], "lexicons": ["aws"], "nodes": ["app/cacheCluster"], "components": [] }
],
"components": [
{
"id": "delivery/loom-backend", "name": "loom-backend", "member": "delivery", "archetype": "service", "composites": null,
"file": "delivery/src/loom-backend.component.ts",
"runtimes": [
{ "name": "local", "lexicon": null, "default": true, "command": "chant run --components loom-backend" },
{ "name": "fleet", "lexicon": "fleet", "default": false, "command": "chant run --components loom-backend --on fleet" }
],
"environments": [
{ "name": "local", "default": true, "source": "builtin", "site": null, "command": "chant run --components loom-backend" },
{ "name": "staging", "default": false, "source": "config", "site": null, "command": "chant run --components loom-backend --env staging" },
{ "name": "prod", "default": false, "source": "config", "site": { "runtime": "fleet", "url": "https://loom.example.com", "domain": null, "lifecycle": "origin" }, "command": "chant run --components loom-backend --on fleet --env prod" },
{ "name": "pr-42", "default": false, "source": "ledger", "site": null, "command": "chant run --components loom-backend --env pr-42" }
]
}
],
"reasons": [],
"summary": { "composites": 2, "withComponent": 1, "withoutComponent": 1, "components": 1 }
}

An instance’s kinds has one entry for a plain composite. A composite built from nested composites also lists the inner kinds, because its nodes carry them. components lists every component the members read declare, including ones that match no instance. A component’s archetype is null when the member’s chant is older than the field.

A member is read when both of its graphs were read. When its component graph fails, the member is failed with command-failed or output-unreadable. Its instances are still listed, and the command exits 1. When the list is empty, or no instance has a component, reasons says why:

CodeMeaning
composites-no-chant-memberNo member of kind chant was read, so nothing declares a composite instance or a component
composites-none-declaredThe members read declare no composite instance
composites-no-componentThe members read declare no component, so no composite instance has one

Each component lists in runtimes where its deploy can run (#2674). The first entry is the built-in local runtime. After it comes each lexicon in the member’s chant.config.ts whose opRuntime hosts component runs, in config order. That is the list chant run --components <name> --on <runtime> accepts in the member’s directory. A lexicon whose runtime hosts only Op runs isn’t listed, because chant run --components refuses it. default marks the runtime chant run uses without --on, which is local. command is the exact line to run in the member’s directory, with --on for any runtime but local. A reader offers only these runtimes and never guesses one.

The reading chant loads each member’s chant.config.ts and the lexicons it lists, from the working tree or from the tree --at exported. When either can’t be read, the member’s runtimeReasons says so. Its components still list local, and the exit code stays the same.

CodeMeaning
runtimes-config-unreadableThe member’s chant.config.ts couldn’t be read, so only local is listed
runtimes-lexicon-unreadableA lexicon the config lists couldn’t be loaded, so it isn’t listed

Each component also lists in environments where it may be deployed (#2695). The first entry is local, the environment chant run --components deploys to without --env, and the only one with default: true. chant.config.ts has no default environment of its own. After local come the names the member’s config declares in environments, in config order, and then each environment that has a release ledger for the member on chant/lifecycle, sorted. source says where each name came from: config, ledger, or builtin for local when neither names it. A name both places know is config. A pattern such as pr-* isn’t an environment by itself, so it isn’t listed, but it lets the ledger’s pr-42 through. Each command deploys the component to that environment on the member’s default runtime, so it carries --env unless the environment is the default. To deploy on another runtime, add that runtime’s --on from runtimes.

A component may also declare environments of its own, each with where a release goes there (#3153). An environment it declares carries site: { runtime, url, domain, lifecycle }, each null when not declared, and site is null for the rest. When the site’s runtime is one the member hosts, the command runs there with --on, as for prod above. A name only the component declares is added after the others with source: "component" when chant run --env takes it. When the config’s environments don’t cover it, it is left out and environmentReasons holds environments-component-undeclared. The config is read in the same load as the runtimes. The ledger comes from the local chant/lifecycle branch, as for workspace status. It is read there even with --at and is never fetched. chant run --env refuses a name the config’s environments doesn’t cover when it declares any. So a ledger environment the config no longer covers isn’t listed, and the member’s environmentReasons names it.

CodeMeaning
runtimes-config-unreadableThe member’s chant.config.ts couldn’t be read, so no environment comes from it; local and the ledger’s are listed
environments-none-declaredThe config declares no environments, so --env takes any name and only local and the ledger’s are listed
environments-ledger-undeclaredThe ledger has releases in an environment the config doesn’t cover, so it isn’t listed
environments-ledger-unreadableThe chant/lifecycle branch exists and the member’s ledger directories couldn’t be listed
environments-component-undeclaredA component declares an environment the config doesn’t cover, so it isn’t listed

The reference workspace’s delivery member declares the app with the docker lexicon’s DockerWebService composite and deploys it with the app component, whose contract lists that kind in composites. Its document has one row, delivery/app, with that component matched by: "composites" and via: "member" and no reason. The component’s only runtime is local. The delivery config declares no environments, so the only environment is local and environmentReasons holds environments-none-declared.

With --intent <region>, the command prints a different document: the intent graph over one region of the workspace (#2651). A person looking at a piece of code asks how it got this way and who decided it should be this way. The document lays out what the workspace records about both and leaves the judgment to the person. No member runs, and git is read through a local git process, so the command needs no network unless --follow-squash has to fetch a pull request ref.

The region is a path from dir (or the current directory): a file, a directory, path:line or path:start-end. A graph node id such as delivery/appService works too, when no path by that name exists. The region is then the file and line in the node’s sourceLoc.

path#symbol names a declaration instead of lines, such as lobby/server.mjs#createApp (#3034). Line numbers drift as a file is edited, and a symbol does not. chant resolves the symbol to its current lines in the tree read and follows them through history like a line range. The lines run from the declaration’s doc comment to its last line. Symbols resolve in TypeScript and JavaScript files, and in any language a lexicon of the file’s member resolves: the walk loads the lexicons the member’s chant.config names and asks each plugin’s symbolResolvers() (#3313). Core’s resolver keeps .ts and .js. A dotted path such as Server.listen names a member, and a bare name that is not top-level finds the one member it names. The region node’s symbol says what the name resolved to. A file no resolver reads is refused with intent-symbol-unsupported, and the message says when a member’s lexicon could not be loaded. A line range still works there. A name the file does not declare is intent-symbol-unknown, and its message lists the top-level declarations. A name that matches two members is intent-symbol-ambiguous, and its message lists the qualified names to choose from.

The walk gathers three sources before it relates any of them:

  1. The commits that touched the region. A line range is followed with git log -L, a file with git log --follow, and a directory with git log -- <dir>. Each commit carries its author and date and its trailers. It also has its provenance level, and the pull request number when its subject ends with one.
  2. The decisions whose constrains cover the region, read through each record kind: each --kind file, or without --kind, every kind the declaration names. A path: entry covers the region when it is the region or a directory above it, and member:<name> covers the region’s member. An issue entry covers it when one of the region’s commits names that issue in the origin repository, and a contract id covers it when a plugin joined that contract to one of the commits. Decisions in the supersession chain of a covering decision come along.
  3. The artifacts those decisions pin. Artifacts relate to code only through decisions, so a file is in the graph because a decision in the graph pins it.

Each gap is a finding node with a closed code. For example, a commit made while no decision constrained the region by path is intent-commit-undecided, and a region constrained only through its member is intent-constraint-coarse.

A decision’s window opens at the commit that decided it. That is the commit that last moved its record into an approved state (for the decision kind, decided, ratified or superseded) and kept it there, and for a record added decided, the commit that added it. A record not approved in the history read has no such commit, and its window opens at the commit that added it. The decision node names the deciding commit in decidedIn, as { sha, date, subject }, or null. The window closes where the window of the record superseding it opens. A commit made inside the window is not taken to carry the decision out (#2656). It has a within edge to the decision and the state decided-by-window, unless it is the decision’s own work, which is decided. A commit is a decision’s own work when a commit join gives it a unit, and either the unit or its contract names the decision in decisions, or the decision constrains that contract. It is also the decision’s own work when its own trailers name the decision, or a work item that implements it. The read contract lists every node kind, edge kind and code.

Pass a work kind too, such as --kind work/work.kind.mjs, and the walk adds the work items whose constrains cover the region (#2683). Each is a work node with its state, ready and blockedBy. An implements edge runs to each decision it carries out, and a needs edge to each item it waits on. Every change to the region made between the item’s first commit and the one that marked it done or dropped (or the revision read, while it is open) has a within edge to the item with state: "worked", and that change keeps the state the decisions give it. Each finding gets addressed and addressedBy, with an addressed-by edge to each work item closing it, and three findings are added: intent-decision-unimplemented, intent-work-blocked and intent-work-open-decided-code. Work items explains them.

A review session can lead to a work item. When a session kind is read and one of its records names the item in a comment’s follow_ups (or its own follow_ups) as <kind>:<id>, the work node lists that session under reviews, with the numbers of those comments, counted from 1 (#3154). A builder’s commit carries the item through its Chant-Record: work:<id> trailer, so the change joins back to the review that asked for it. hud writes the follow_ups when a review’s changes are kept.

Without --json the walk prints as text with one line per node. The region comes first and the findings last, in the order the walk runs. Under each decision come the commits made inside its window, and when any of them is not the decision’s own work, the question the person has to answer about it:

decision dec-001 decided: The notes list is sorted newest first; constrains path:app/notes.mjs (path), ...
within 9c1e04aa sort the notes alphabetically; unit U-0002, in dec-001's window and not its work
decided 3f2b7d10 add the notes list; unit U-0001, dec-001's own work
ask is this drift, a superseding decision nobody wrote down, or the decision being wrong?

The walk over the reference workspace has no such lines, because its decisions constrain app only as a member:

$ chant workspace graph --intent app/src/server.mjs:19 --kind decisions/decision.kind.mjs
region app/src/server.mjs:19 (file, member app) in the working tree
decision ref-001 decided: How the app is deployed; constrains member:app (member), ...
decision ref-002 decided: Where the screen design lives; constrains member:app (member), ...
artifact design/screens/home.json pinned; pinned by ref-002 at 074e55f5 (pinned); now 074e55f5
commit 72173388 2026-09-23 lex00: feat(workspace): the reference workspace as chant's integration fixture (#2543) (#2595), lines 13; unattested
finding intent-commit-undecided: 72173388 changed the region when no decision constrained app/src/server.mjs by path
finding intent-decision-provisional: the decisions constraining app/src/server.mjs are decided, and none is in a closed state
finding intent-constraint-coarse: app/src/server.mjs is constrained only through its member, app
...

Without --kind, the walk reads every record kind the declaration names, in the declaration’s order: the workspace’s own first, then each member’s (#2680). The declaration is the one the walk reads, at the revision under --at, and each kind file is loaded from the working tree, as a --kind file is. --kind overrides the declaration, and a workspace with neither reads no decisions, as before. The reference workspace declares its decision kind, its work kind and its design member’s session kind. The sessions constrain nothing, so chant workspace graph --intent app/src/server.mjs:19 without --kind prints the walk below plus a work line for W-001, which constrains member:app.

The document’s why answers the question from a line or a symbol (#3034). For a file region it reads git blame over the region’s current lines, at --at or in the working tree, and gives each span of lines the commit that last wrote it and the agent runs behind that commit. When several runs made one commit and a run’s end recorded the hunks it wrote, the line goes to the run whose hunks hold it. Without hunks every run is listed, and the gap intent-why-run-ambiguous names the lines. Lines not committed yet have no commit and the gap intent-why-uncommitted.

The decisions come most relevant first. A decision carried out by a commit or run that made current lines comes first: through its unit, its Chant-Record or Chant-Lease trailer, or the work item and records of the run that made it. A recorded run’s work item and records count for its commits as those trailers do. Then come the decisions that constrain the region by path, the narrowest first, and after them those that constrain it more coarsely: by contract or issue, then by member. Superseded decisions come last. The runs come by how many current lines each wrote, each with the work item it worked on. When no current decision governs the region or is carried out by what made it, explained is false and the gap intent-why-no-decision says so. hud offers to record a decision there.

A directory region is not blamed: its why lists the decisions the same way and every run in the walk. The text walk prints the answer after the findings:

why explained, lines 2-4
decision s-001 carried, 1 line
decision s-003 path
decision s-002 member
lines 2 3f2b7d10; no run
lines 3 9c1e04aa; run-a (claude-code/claude-opus-5-5) on W-001
lines 4 3f2b7d10; no run

A commit joins its run by its Chant-Run trailer or by the run’s own list of commits. When it has neither, as after a rebase or a cherry-pick that dropped the trailer, it joins every run whose end recorded a commit with the same git patch-id --stable (#3036). That join is by content: its made-by edge has joinedBy: ["patch-id"] and recordedAs, the commit the run recorded, and the span and the run in why carry joinedBy so a reader can show it apart from a trailer join. The run’s recorded hunks don’t narrow a content join, since the rewritten commit’s line numbers may have moved. A trailer join comes first and a record join second. A patch-id join is made only when neither gives a run. When one run’s trailer or record claims a commit whose content matches another run’s recorded commit, the commit keeps its join and the walk reports intent-commit-join-conflict. A squash of several commits has a patch-id of its own, so it never joins this way. The text walk adds joined by content to such a run and span.

The read contract lists the fields.

A squash merge folds a pull request’s commits into one, and with them the trailers that joined each to its run and its work and the signatures that vouched for them. The forge keeps the originals: GitHub and Forgejo both keep a pull request’s head at refs/pull/<n>/head. With --follow-squash the walk follows a squash to them (#3035, ws-092). A commit counts as a squash when its subject ends with (#<n>), it has one parent, and the pull request’s head is not already in its history. A merge commit or a rebase keeps the originals, so there is nothing to follow there. The original commits are the head’s commits that the squash’s parent doesn’t have, oldest first.

The head is read from refs/chant/pull/<n>/head, refs/pull/<n>/head or refs/remotes/origin/pr/<n>, whichever the clone has. The walk fetches the missing ones from origin in one fetch and keeps them under refs/chant/pull/<n>/head, so the next read needs no network. The commit node’s squash names the forge origin points at (github, forgejo or null) and the ref read, and says whether this read fetched it. Its commits list the original commits, each with its trailers, joins and signature. The squash commit carries the records and lease items the originals’ trailers name and the units their plugin joins give, so it can be a decision’s own work. It also joins every run an original joins, with joinedBy: ["squash"] and via naming the originals on the made-by edge. In why, a squash line’s span has via: the original commit that last wrote the line, by git blame at the head. The span then keeps only that commit’s runs. When the file at the head differs from the squash’s, via is empty and every run is listed. A ref that can’t be read or fetched gives the reason squash-unfollowed and a squash with followed: false, and the walk keeps its answer without the originals.

Without the flag nothing is fetched and no squash is followed. That is the default, since a read stays fast and needs no forge (studio-032 b); a reader offers to follow on a commit whose pullRequest is set.

With --intent --record <id>, the walk starts from one decision instead of one region. It reads the history of every path: and member: entry the record’s constrains lists and keeps each commit once. A path: entry is read as a region is, and a member: entry as its directory, less the directories of the members inside it. The id is the record’s, or <kind>/<id>. An id that no decision of a kind read has is intent-record-unknown.

The document keeps only the commits inside the record’s window. Each commit gets one bucket, the first of these that holds:

bucketThe commit
ownis the record’s own work, as the region walk judges it
workedis inside the window of a work item that is not dropped and that implements the record, or covers one of the commit’s files by a path: entry
within-otheris inside the window of another decision whose path: entries cover one of its files
unexplainedmatches none of these

workedBy and alsoWithin list every work item and other decision that matched, whatever the bucket, and files lists the files the commit changed in the record’s region. counts gives the number in each bucket, and outsideWindow the commits that changed the region outside the window. contract and issue entries name no path, so they are listed and not walked. The document follows its own schema.

$ chant workspace graph --intent --record studio-008
record studio-008 decided: The smoke plays the agent itself with no model and asserts outcomes through chant
window from ebe442fa; decided in ebe442fa 2026-09-26: Correct the studio's and the template's records
entry member:smoke
entry path:smoke
entry path:docs/smoke-claims.md
within-other 3536c978 2026-09-27 Smoke the agents' Claude Code: ...; within studio-010; docs/smoke-claims.md, smoke/smoke.mjs
...
27 commits in the window: 0 own, 0 worked, 27 within another record's window, 0 unexplained; 88 outside it

A commit can point at the lease, the agent run and the records behind it with chant’s own trailers (#3149, ws-075). chant never makes the commit: whoever does adds the lines, and the walk reads them back with no plugin.

TrailerValueWhat the walk does with it
Chant-Agentan agent session the declaration namesreports it; write scope judges the commit as that session
Chant-Leasea work lease’s fencing tokenfinds the work item in the lease histories on the local chant/lifecycle branch, and links the commit to it
Chant-Runan agent run’s idlinks the commit to a run node with the run’s model, principal and cost from the run ledger (#3033). A run whose end lists the commit is linked the same way
Chant-Record<kind>:<id>, such as work:W-001 or decision:ws-075; repeatablelinks the commit to the record when a kind read has it
Chant-Applied-By, Chant-Applied-At, Chant-Applied-Commitwho applied a leased branch, when, and the tip that was appliedreports them on the commit that applied it

Each commit node has joins with what these say. A record or a lease’s work item that a kind read has gets a carries edge from the commit, and the commit counts as the own work of the decision it names, or of each decision the work item implements. In the one-record walk, that commit’s bucket is own. A Chant-Record whose kind isn’t read stays in joins.records with node: null.

A factory commit, and the commit that applies its branch, look like this:

W-012: build C-003 (claude-code)
Chant-Agent: factory
Chant-Lease: 6f1d0c2a-3b4e-4f6a-9d1e-0a2b3c4d5e6f
Chant-Run: 20261003T061522Z-3f9a1c2e
Chant-Record: work:W-012
Chant-Record: contract:C-003
Chant-Record: evidence:9c0f3e...
W-012: apply chant/work/W-012
Chant-Record: work:W-012
Chant-Lease: 6f1d0c2a-3b4e-4f6a-9d1e-0a2b3c4d5e6f
Chant-Applied-By: alice
Chant-Applied-At: 2026-10-03T06:20:00Z
Chant-Applied-Commit: 4b8e1f0c9a7d6e5f4c3b2a1908f7e6d5c4b3a291

Units of work, contracts and evidence come from a plugin, because core has no model of them. A kind file given with --kind supplies them through its commitJoins export. The file may also export a recordKind, or only commitJoins.

Each kind’s commitJoins runs for every commit, in the order the kinds are listed in kinds. When two kinds both join a commit, both joins apply. Each join adds its own unit, contract and evidence nodes and edges, so the commit gets a produced-by edge to each kind’s unit. A node id that two joins both return is one node, and it keeps the data and plugin of the kind listed first. The record ids that both joins name are pooled when the walk decides whether a commit is a decision’s own work. Each kind’s findings stay in its own plugin:<name>: namespace.

The export is either a function or data. A function gets each commit that touched the region (sha, subject, body, author, date and trailers) and a context whose read(path) returns a file from the workspace root in the tree read, and whose list(dir) returns the entries directly inside a directory there: paths from the workspace root, sorted, with a directory’s ending in /. Either returns undefined for a path that isn’t a file or a directory in that tree. With list a plugin can find the record that names a commit, so the commit needs no trailer (#2663). It returns any of unit, contract and evidence, each an object with a string id, and authorship, the trailer keys on the commit that claim who wrote it. A unit or contract may list decisions, the ids of the decision records it carries out, which makes the commit those decisions’ own work.

export function commitJoins(commit, context) {
const id = commit.trailers["Unit"]?.[0];
if (!id) return undefined;
const unit = JSON.parse(context.read(`units/${id}.json`));
return { unit: { id, role: unit.role }, contract: { id: unit.contract } };
}

The data form needs no code. trailers names the trailer that holds each id, and records the file to read for each, with {id} for the id. Every value of the evidence trailer is one piece of evidence. A commit has one unit and one contract, so their trailers give their first value. authorship lists the trailers that claim who wrote a commit.

export const commitJoins = {
trailers: { unit: "Unit", contract: "Contract" },
records: { unit: "units/{id}.json" },
authorship: ["Made-By"],
};

The function may also return findings, each with a code, a message and refs, the things it is about. This is how a plugin reports what it knows and core doesn’t, such as a contract whose criteria changed in a commit that names no decision. The code must be plugin:<name>:<code>, where <name> is the kind’s name: its record kind’s name, or the file’s name without .kind.mjs. A kind file can name its findings itself with a second export, export const commitJoinsName = "chud", so joins kept beside a record kind called decision report plugin:chud:<code> (#2663). The name is its own export because the data form’s object holds only joins, and a function’s name is already the function’s own name. The graph carries each as a finding node about the commit, and resolves the refs that name nodes in the graph to their ids.

export function commitJoins(commit, context) {
// ...
return {
unit: { id, contract: unit.contract },
contract: { id: unit.contract },
findings: criteriaChanged ? [{ code: "plugin:units:criteria-changed", message: `${unit.contract} changed its criteria with no decision`, refs: [unit.contract] }] : [],
};
}

Apart from chant’s own trailers, core reads trailers as git parses them and gives no key a meaning of its own. A commit that carries an authorship trailer and is not attested is the finding intent-trailer-unverified. A join that throws leaves that commit without its unit, adds the reason intent-plugin-failed, and makes the command exit 1 with the document still printed. A finding code outside the kind’s namespace counts as a throw.

OptionEffect
dirWhere the walk up to the declaration starts.
--at <rev>Read the declaration at a commit, and each member’s graph from its source at that commit. The tree is exported to a temporary directory and each member runs there under the toolchain installed now. See what --at reads.
-o, --output <file>Write the document to a file.
--member <name>Compose only these members. Repeatable, and a comma list works too.
--kind <kind file>Read the records of this record kind and add their record links. The path is resolved against the current directory. With --intent it is repeatable, and each file is a record kind, a plugin with commit joins, or both. Without it, --intent reads every kind the declaration names.
--compositesPrint the composite instances and their components instead of the composed graph. Takes neither --kind nor --intent.
--intent <region>Print the intent graph over path, path:line, path:start-end or path#symbol instead of the composed graph.
--record <id>With --intent and no region, print one record’s walk over every entry its constrains lists.
--follow-squashWith --intent, follow each squash merge to its pull request’s original commits, fetching a pull request ref the clone lacks from origin.
--jsonWith --intent, print the document as JSON instead of the text walk.
--env <name>Passed to each member’s chant graph.
--no-cacheRead every member, leaving the member cache unread and unwritten.
--liveRead each member’s graph from the account with chant graph --live. Needs --env, and can’t be used with --at. See the live read.
--overlayWith --live, passed to each member: its graph carries the drift overlay against its source.
--traffic <level>With --live, passed to each member verbatim: its predicting lexicon writes the behaviour prediction at that traffic level.
--dry-runPrint the plan and read nothing.
CodeMeaning
0Every chant member was composed.
1A member failed or couldn’t be read, the declaration couldn’t be read, or the --kind records couldn’t be read. The document is still printed, with the failed members listed. With --intent: the declaration, a kind or the region couldn’t be read, no decision has the --record id, or a plugin’s commit join failed. With --composites: a member’s graph or component graph couldn’t be read, or the declaration couldn’t be read.