A [grep:] hint proves a string exists; it cannot prove the string is code. [grep: fn rotate_token in src/auth.rs] passes just as happily on a comment, a doc example, or a stale copy as on a real definition. Structural hints close that gap: they verify facts about named code symbols — a definition exists, a symbol is referenced inside another, a function is actually called — by static analysis of the working tree, not text matching.
Three pieces make it work, and this page covers all of them:
- The three structural hint types —
[symbol:],[references:],[callers:](grammar on the Hints reference). - Lumen — the persistent, repository-scoped code index at
.zurdo/lumen/that hint resolution queries. - Vela — an optional background watcher that keeps the index fresh between runs. Never required for correctness.
The subsystem is experimental and opt-in: the grammar is stable, but everything sits behind two config gates and is off by default.
Turning it on
[lumen]
enabled = true # build and maintain the index
[experimental]
structural_hints = true # allow [symbol:]/[references:]/[callers:] in PRDs
The gates compose strictly: structural_hints = true while lumen.enabled = false is a config-load error, and a structural hint in a PRD while the gate is off is a validation error naming the disabled gate — zurdo validate, zurdo analyze, run, and --resume all enforce it identically. The full [lumen] key table is on the Configuration page.
What each hint proves
- [ ] limiter type is defined [symbol: struct RateLimiter in src/middleware/rate_limit.rs]
- [ ] app wires the limiter [callers: method RateLimiter::layer in src/middleware/rate_limit.rs within function build_router in src/app.rs]
- [ ] config reaches the limiter [references: struct AppConfig in src/config.rs within struct RateLimiter in src/middleware/rate_limit.rs]
| Hint | Passes iff |
|---|---|
[symbol:] |
A definition of that kind and qualified name exists in exactly that file. Imports and use sites don’t count. |
[references:] |
The target resolves uniquely, the within context resolves uniquely, and an occurrence inside the context deterministically binds to the target through lexical scope and static imports. A same-named symbol from another module doesn’t count. |
[callers:] |
Everything [references:] requires, plus the bound occurrence sits in the callee position of a call expression. Passing the function as a value doesn’t count. |
The binding rule is the teeth: [callers:] on a cross-file call passes only when the call site’s file actually imports the target it names — a copy-paste of the same function name from elsewhere fails as unbound. Failures are typed (symbol_unresolved / binding_unresolved) and carry a closed-taxonomy diagnostic (not-found, wrong kind naming the kind actually found, ambiguous with every candidate listed, capability-rejected) into the next iteration’s prompt. Passing verdicts persist the resolved identity and source span in prd.json and the report’s structural_verdicts array.
Design rule: false negatives over false greens. Anything the resolver cannot bind deterministically — dynamic dispatch, trait objects, macro-generated names, a method call whose receiver type would need inference — fails rather than guesses. A structural hint can be annoyingly strict; it can never be quietly wrong in your favor.
The Lumen index
Lumen extracts structural records from source files (via tree-sitter) into .zurdo/lumen/ — repository-scoped, not per-PRD. One index serves every PRD in the repo, and zurdo run --reset never touches it; only zurdo lumen clear deletes it.
How a run uses it:
flowchart TD
RUN["zurdo run"] --> Q{"PRD contains a<br/>structural hint?"}
Q -->|no| SKIP["Index never touched —<br/>not built, not read"]
Q -->|yes| REPAIR["Pre-flight: repair the index<br/>(reparse what changed)"]
REPAIR -->|non-ready| FAIL["Pre-flight error naming the cause<br/>+ remedy: zurdo lumen rebuild<br/>(before any token is spent)"]
REPAIR -->|ready| VERIFY["Each verification pass:<br/>re-repair, then resolve every<br/>structural hint against the<br/>current working tree"]
VERIFY --> V{"Refresh failed<br/>mid-run?"}
V -->|yes| CLOSED["Structural hints fail closed<br/>against the absent index"]
V -->|no| VERDICT["Typed verdicts +<br/>structural_verdicts in the report"]
The load-bearing properties:
- Lazy. A PRD with no structural hints never constructs or reads any Lumen state, even with
[lumen] enabled = true. - Verifies the current tree. The index is re-repaired before each verification pass, so a task can satisfy its own structural criteria in the same run — code the agent just wrote resolves immediately. (Within a single pass the view is pinned once, so one pass never mixes index generations.)
- Generation-based and crash-safe. Every publish is atomic; old generations are garbage-collected after
gc_grace_minutes(default 10), always keeping at least the current generation and its predecessor. A torn write, stale lock, or SIGKILL mid-rebuild recovers on the next invocation. - Fails closed. No pinned index at evaluation time means the structural hint fails — never passes by default.
- Evidence-integrated. A structural hint’s target files (and the
withincontext’s file) count as evidence paths, so the frozen-overlap lint and evidence-modified warnings cover them like any grep hint.
The zurdo lumen CLI
| Command | What it does |
|---|---|
zurdo lumen status |
Record counts, languages indexed, generations present, last publication time — plus the Vela daemon’s state (vela: not running when absent). Requires [lumen] enabled = true. |
zurdo lumen rebuild |
Rebuild from scratch: take the repository-wide write lock, extract records from the whole repo, publish a new generation atomically, run GC. The remedy for a non-ready index. |
zurdo lumen clear |
Delete .zurdo/lumen/ entirely. Confirms on a TTY; --yes for non-interactive use. |
What resolves, per language
Lumen indexes Rust, Python, Go, TypeScript, and JavaScript (including TSX/JSX). Each adapter maps its constructs onto the closed kind set (function, method, type, class, struct, enum, interface, trait, module, constant, variable); anything unmapped is not indexed and fails “not found” rather than approximating. Every row below is enforced in both directions by the source repo’s capability-matrix test suite.
| Language | Indexed definitions | Cross-file binding via | Deliberately not resolved |
|---|---|---|---|
| Rust | free fn, impl methods, struct, enum, trait, type aliases, mod, const, static |
use trees, nested module paths, pub use re-export chains |
macro_rules! / proc macros; method-call callees (receiver.method() needs type inference); same-named symbols with no import path |
| Python | module-level and class def, class, PEP 695 type aliases, module/class assignments (as variable) |
relative imports (from .m import x) |
the constant kind (assignments always normalize to variable); bare import statements |
| Go | package func, receiver methods, struct, interface, type aliases, const, var, package clause |
package imports resolved through the go.mod module path |
init blocks; same-named symbols in another package with no import |
| TS / JS | function declarations, arrow/function-expression bindings, class + methods, interface, type, enum, variables |
relative imports (./, ../), tsconfig.json paths aliases |
import/export re-exports; same-named symbols with no import path |
Naming rules that trip people up: qualified names use :: in every language (Config::load, even in Python and Go); trait in a hint unifies with interface; type means a type alias only (Go’s type Foo struct is a struct). The file-level module symbol is the file stem — except mod.rs, __init__.py, and index.ts/index.tsx, which take the parent directory’s name, and Go, where it is the package clause identifier.
The Vela watcher
Repair-on-demand means a first structural query after a big rebase can pay a noticeable reparse cost at pre-flight. Vela is the optional cure: a background daemon that watches the source tree and incrementally publishes fresh Lumen generations as files change, so runs find a warm index.
Two properties keep it safe to ignore:
- Never required for correctness. Structural queries always run the in-process repair path and return the same verdict whether the daemon is running or not — Vela only moves the reparse work off the critical path.
- Fail-inert. A failed watcher refresh leaves the prior generation current and usable; an auto-start spawn failure logs a debug line and never blocks the operation that triggered it.
One daemon per repository, arbitrated by a PID lock under .zurdo/vela/ (created owner-only, 0700), with a control socket speaking a closed operation set — status, shutdown, version negotiation, nothing else. The daemon exits on its own after idle_minutes (default 30) without filesystem events or client connections; debounce_ms (default 200) coalesces bursty file events. Daemon output lands in .zurdo/vela/vela.log.
| Command | What it does |
|---|---|
zurdo vela start |
Spawn a detached daemon, wait for socket readiness, exit 0. Already running → reports it and exits 0; an older-version daemon is replaced gracefully; stale PID metadata from a dead process is detected and cleaned up. |
zurdo vela stop |
Ask the daemon to shut down and wait. Not running still exits 0. |
zurdo vela status |
PID, version, repository root, and current Lumen generation. Exits nonzero when not running. |
zurdo vela serve |
Run the watcher in the foreground (what start daemonizes). A second serve in the same repo fails fast. |
With [vela] enabled = true, zurdo also auto-starts a detached daemon after each in-process structural repair — fire-and-forget, so the first structural run of the day warms the index for every run after it. Config keys are on the Configuration page; zurdo lumen status reports the watcher’s PID and publication freshness alongside the index summary.
Authoring with structural hints
Where they earn their keep, relative to the core hints:
- Wiring criteria. “The new middleware is actually installed” is exactly
[callers:]— a grep for the registration line passes on commented-out code; the call binding doesn’t. - Existence with teeth.
[symbol: struct RateLimiter in …]over[grep: struct RateLimiter in …]when you care that it’s a real definition in the right file, not a mention. - Cost ladder. Structural hints are pricier to author than grep (exact kinds, exact files,
::-qualified names) but cheaper and more precise than spinning up[shell:]test infrastructure to prove a relationship — the bundledzurdo-prd-authorskill slots them between the two and checks the config gates before authoring one.
Two caveats to author around: a hint on a construct the adapter doesn’t map (a Rust macro, a Go init block) can never pass — check the table above first; and ambiguity is a failure, so point hints at uniquely-named symbols or qualify them until they resolve uniquely.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Validation error naming experimental.structural_hints |
The PRD uses a structural hint while the gate is off. | Enable both [lumen] enabled and [experimental] structural_hints. |
Config-load error mentioning lumen.enabled |
structural_hints = true with lumen.enabled = false. |
Enable [lumen] too — the gate can’t stand alone. |
| Pre-flight fails with a non-ready index | A working-tree file Lumen needed could not be (re)parsed. | zurdo lumen rebuild, then re-run. The failure is deliberate — before tokens, never a silent criterion failure. |
[symbol:] fails “wrong kind” |
The diagnostic names the kind actually found. | Fix the kind in the hint (struct vs type is the usual culprit). |
[callers:] fails binding_unresolved though the call is there |
The call site doesn’t statically import the target, or the callee needs receiver-type inference. | Bind through a static import, or fall back to [grep:]/[shell:] for that relationship. |
| Structural hints feel slow at pre-flight after big changes | Cold repair is reparsing everything that moved. | Run the Vela watcher so the index stays warm. |
Next: Diagnosis & lessons