Desire Path
Desire Path (dp) captures agent tool calls and turns repeated failures into
patterns you can query. It is for developers maintaining agent instructions,
tool names, and integrations. The CLI stores data in a local SQLite database;
source plugins interpret each agent’s event format, and hooks connect capture
to the agent.
A desire path is a trail worn into grass where people choose to walk. Here, a mistaken tool name is evidence of the interface an agent expected to find. A repeated failure can suggest a better tool name, a missing feature, or an instruction worth fixing.
From failure to a fix
- Ingest a call through a source plugin. Failed calls also become desires.
- Query
dp pathsto find repeated names anddp inspectfor the details. - Use
dp similarto compare a mistaken name with known tools. - Record a mapping with
dp alias, then improve instructions or configure pave if active interception is appropriate.
An alias alone stores a mapping; it does not automatically reroute every call. The first-success example demonstrates capture and query without installing any hooks.
Capabilities and boundaries
| capability | what it does |
|---|---|
| Patterns | Rank repeated failures and inspect their error messages |
| Suggestions | Compare names with known tools using string similarity |
| Aliases | Store intended tool mappings and command correction rules |
| Source plugins | Normalize calls from Claude Code, Codex, Cursor, and Kiro |
| Exports | Write failure or invocation records for offline analysis |
| Signposting | Offer search guidance through optional agent hooks |
Signposting observes weak searches and can offer a stack tool command. Its evaluation contract, limitations, and measurements are in the signposting plan and evaluation chapters. It is separate from basic capture.
Where to go next
- Getting started: install and produce a first result.
- Using agents: inspect and configure hook capture.
- Architecture: storage and plugin design.
- The stack: how the tools fit together.
- Docs map: design notes, historical plans, and supporting files.
Getting started
Install a release, ingest one synthetic failure, and query it before connecting an agent.
Install a release
For Linux x86-64:
mkdir -p /tmp/dp-install && cd /tmp/dp-install
curl -fLO https://github.com/scbrown/desire-path/releases/download/v0.7.0/desire-path_0.7.0_linux_amd64.tar.gz
curl -fLO https://github.com/scbrown/desire-path/releases/download/v0.7.0/checksums.txt
sha256sum --check --ignore-missing checksums.txt
tar -xzf desire-path_0.7.0_linux_amd64.tar.gz dp
mkdir -p "$HOME/.local/bin" && install -m 755 dp "$HOME/.local/bin/dp"
export PATH="$HOME/.local/bin:$PATH"
dp version
Expected: dp 0.7.0 (5ca33ba). If you see something else, check command -v dp.
Release v0.7.0
also contains Linux arm64, macOS amd64/arm64, and Windows amd64/arm64 archives.
Download your matching archive and checksums.txt, verify its SHA-256 before
extracting, then place dp (or dp.exe) on PATH. On macOS use shasum -a 256
to compare the archive digest with its entry; on Windows use Get-FileHash -Algorithm SHA256.
The commands above specifically exercise the Linux amd64 archive.
Build from source
With Go 1.24 or later:
go install github.com/scbrown/desire-path/cmd/dp@v0.7.0
export PATH="$(go env GOPATH)/bin:$PATH"
dp version
A Go module install does not receive the release build’s version linker flags;
its output can say dev. To build the current checkout with Git metadata:
git clone https://github.com/scbrown/desire-path.git
cd desire-path
make install
dp version
First success in three commands
This uses a separate temporary database and requires Python 3:
DP_DEMO=$(mktemp -d)
printf '%s\n' '{"tool_name":"read_file","error":"unknown tool","session_id":"demo"}' | dp --db "$DP_DEMO/desires.db" ingest --source claude-code --json > "$DP_DEMO/record.json"
dp --db "$DP_DEMO/desires.db" paths --json | python3 -c 'import json,sys; p=json.load(sys.stdin)[0]; print(p["pattern"], p["count"])'
Expected stdout:
read_file 1
ingest records the invocation and, because the payload has an error, its failure
pattern. The saved record.json carries the generated ID and timestamp. Run the
three commands again to get another isolated database and the same output.
Connect your agent
Follow Using agents. Once calls accumulate, try
dp paths --top 5, dp inspect read_file, and dp similar read_file.
dp alias read_file Read records an intended mapping; it does not by itself
install interception. See pave for that separate setup.
Troubleshooting
- Wrong version: check
command -v dp. A different binary may appear earlier on PATH. - Metrics diagnostic: without
DESIRE_PATH_METRICS_PUSHGATEWAY, v0.7.0 reports that no metrics were pushed on stderr. Local ingestion still works; check the query result. - No failures yet:
dp pathsneeds failed invocations. Use the synthetic fixture above to distinguish an empty history from a broken setup. - Hook not found: the agent process needs the same PATH that contains
dp.
See configuration for persistent settings and database paths.
Configuration
dp stores configuration in ~/.dp/config.toml. You can view and update settings using the dp config command or by editing the file directly.
Config File Location
By default: ~/.dp/config.toml
Override with the DESIRE_PATH_CONFIG environment variable:
export DESIRE_PATH_CONFIG=/path/to/custom/config.toml
Valid Configuration Keys
| Key | Type | Description | Default |
|---|---|---|---|
db_path | string | Path to the SQLite database file | ~/.dp/desires.db |
default_source | string | Default source tag for recorded desires when --source is not specified | "" (empty) |
known_tools | string | Comma-separated list of known tool names used by dp similar | "" (empty—uses built-in list) |
default_format | string | Default output format: "table" or "json" | "table" |
Usage Examples
View Current Config
# Show all settings
dp config
# Show a specific key
dp config db_path
Set Values
# Change database path
dp config db_path /data/dp/desires.db
# Set default source
dp config default_source claude-code
# Use JSON output by default
dp config default_format json
Configure Known Tools
The known_tools setting controls the list of tool names used by dp similar for similarity matching. If empty, dp uses a built-in default list (currently: Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch).
Set a custom list:
dp config known_tools "Read,Write,Edit,Bash,CustomTool,AnotherTool"
dp splits on commas and trims whitespace, so you can use spaces for readability:
dp config known_tools "Read, Write, Edit, Bash, CustomTool"
Reset to Default
To clear a setting and revert to the default, set it to an empty string:
dp config known_tools ""
Global Flags
Some configuration can be overridden per-command using global flags:
Database Path
# Use a different database for one command
dp --db /tmp/test.db list
# Set via environment variable
export DESIRE_PATH_DB=/tmp/test.db
dp list
Precedence: --db flag > DESIRE_PATH_DB env var > db_path config > default (~/.dp/desires.db)
Output Format
# Force JSON output
dp --json paths
# Force table output (default)
dp paths
The --json flag overrides default_format config for that command.
Advanced: Direct File Editing
~/.dp/config.toml is plain TOML. Example:
db_path = "/data/dp/desires.db"
default_source = "claude-code"
known_tools = ["Read", "Write", "Edit", "Bash", "CustomTool"]
default_format = "json"
If the file doesn’t exist, dp creates it on first write. Invalid TOML causes an error — use dp config for safer editing.
Migration note: Legacy
config.jsonfiles are automatically migrated to TOML on first load.
Database Configuration
Database Path
The SQLite database stores desires, invocations, aliases, and schema metadata. By default it lives at ~/.dp/desires.db.
Change the path permanently:
dp config db_path /new/path/desires.db
Or temporarily:
dp --db /tmp/test.db list
SQLite Options
dp uses pure-Go SQLite (via modernc.org/sqlite) with WAL mode enabled for concurrent reads and writes. There are no user-configurable SQLite options—the database is tuned for dp’s access patterns.
If you need to inspect the database directly:
sqlite3 ~/.dp/desires.db
Tables: desires, invocations, aliases, schema_version.
Source Plugin Configuration
Each source plugin (like claude-code) may have its own configuration needs. Check the integration docs:
For Claude Code specifically, the hook configuration lives in ~/.claude/settings.json, not in dp’s config file. Run dp init --source claude-code to set it up.
Environment Variables
| Variable | Description | Example |
|---|---|---|
DESIRE_PATH_DB | Override database path | export DESIRE_PATH_DB=/tmp/test.db |
DESIRE_PATH_CONFIG | Override config file path | export DESIRE_PATH_CONFIG=/etc/dp/config.toml |
Environment variables take precedence over config file settings but are overridden by command-line flags.
Tips
- Use
--jsonoutput withjqfor scripting:dp paths --json | jq '.[] | select(.count > 10)' - Keep
known_toolsin sync with your AI’s actual tool set for bettersimilarresults - If you’re testing or developing, point
--dbat a temporary database to avoid polluting your real data - The config file is optional—all keys have sensible defaults
For command-specific options, see the Command Reference.
Concepts
The desire_path CLI tracks how AI coding assistants try to interact with your system. Four core concepts form the backbone of dp:
Desires
A desire is a single failed tool call from an AI assistant. When Claude Code tries to call read_file but that tool doesn’t exist, that failure gets recorded as a desire. Each one captures what the AI wanted to do, what went wrong, and the context around it.
Paths
A path emerges when the same tool fails repeatedly. Like a worn trail across a lawn, frequent failures for read_file form a pattern that says “build a sidewalk here.” Paths show you what capabilities to prioritize building.
Aliases
An alias maps a hallucinated tool name to a real one. When Claude keeps calling read_file but your tool is actually named Read, create an alias. This connects desires to reality and helps you understand what the AI is actually trying to accomplish.
Invocations
Invocations track ALL tool calls, not just failures. When enabled, you get the full picture: success rates, usage patterns, session timelines. This turns desire_path from a failure tracker into a comprehensive telemetry system for AI tool usage.
Learn more about invocations →
The flow: AI assistants generate desires (failures). Repeated desires form paths (patterns). Aliases connect desires to real tools. Invocations expand tracking to include successes, giving you the complete story.
Desires
A desire is a single failed AI tool call. It’s the atomic unit of desire_path: one moment where an AI assistant tried to do something and couldn’t.
What Gets Captured
Every desire records:
- tool_name (required): The tool the AI tried to call
- tool_input: The arguments it tried to pass (JSON)
- error: The error message or reason for failure
- source: Which AI system generated this (e.g., “claude-code”)
- session_id: Groups desires from the same conversation
- cwd: The working directory when the call failed
- timestamp: When it happened (auto-generated)
- metadata: Additional context as JSON (optional)
Behind the scenes, each desire also gets a UUID for tracking.
How Desires Get Recorded
Manual Recording
Pipe JSON to dp record:
echo '{
"tool_name": "read_file",
"tool_input": {"path": "/etc/config.yaml"},
"error": "tool not found",
"source": "claude-code"
}' | dp record --source claude-code
Automatic Recording
Set up hooks in your AI tool to automatically capture failures:
# Initialize desire_path for Claude Code
dp init --source claude-code
# Now failures get recorded automatically as you work
When Claude Code tries to call a non-existent tool, the failure flows into desire_path without manual intervention.
Example Scenario
You’re working with Claude Code. It tries to call read_file to read a configuration file. But that tool doesn’t exist in Claude Code’s toolkit (the real tool is Read).
The failure gets recorded as a desire:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"tool_name": "read_file",
"tool_input": {
"path": "/home/user/config.yaml"
},
"error": "tool 'read_file' not found",
"source": "claude-code",
"session_id": "session-abc123",
"cwd": "/home/user/project",
"timestamp": "2026-02-09T10:30:00Z"
}
Why This Matters
Individual desires are data points. But when you see read_file fail 47 times across multiple sessions, that’s a path — a clear signal that the AI expects this capability. That’s when you know to build an adapter, create an alias, or extend your toolset.
Desires are the raw material. Paths are the insight.
Paths
A path is an aggregated pattern of repeated desires. When the same tool name fails multiple times, it forms a well-worn trail — a signal that this capability is genuinely needed.
The Metaphor
Think of a university campus. Planners lay out sidewalks where they think people should walk. But students take shortcuts across the grass. Over time, these shortcuts become visible worn paths. Smart planners pave over the desire paths because they reveal where sidewalks actually belong.
Desire paths in AI tooling work the same way. When Claude Code repeatedly tries to call read_file and fails, that repeated pattern tells you: “Build this. I need this.”
What Paths Show
Each path aggregates desires by tool_name and shows:
- pattern: The tool name that keeps failing (e.g.,
read_file) - count: How many times it’s failed
- first_seen: When this pattern first appeared
- last_seen: Most recent occurrence
- alias_to: If you’ve mapped this to a real tool (optional)
Viewing Paths
Use dp paths to see the patterns:
dp paths
Output:
PATTERN COUNT FIRST SEEN LAST SEEN ALIAS
read_file 47 2026-02-01 09:15:23 2026-02-09 10:30:45 Read
execute_bash 23 2026-02-03 14:22:10 2026-02-09 08:12:33
list_dir 15 2026-02-05 11:05:42 2026-02-08 16:44:21 Glob
write_output 8 2026-02-07 13:30:12 2026-02-09 09:18:55
What To Do With Paths
High Count = High Priority
A tool that fails 47 times is screaming “I should exist.” That’s your top priority for building or aliasing.
Low Count = Wait and Watch
A tool that fails twice might be a one-off mistake or an edge case. Don’t build infrastructure for noise.
Recent Activity = Active Pain Point
If last_seen is today and count is climbing, this is actively blocking work right now.
Paths vs. Desires
- Desires are raw events: individual failures with full context
- Paths are aggregated insights: patterns showing what matters
You don’t fix individual desires. You fix paths. The frequency tells you what to prioritize.
Example Workflow
- Check paths:
dp paths - See
read_filehas failed 47 times - Discover your tool is actually called
Read - Create an alias:
dp alias read_file Read - Now
dp pathsshows the mapping
Paths reveal the problem. Aliases (or building new tools) solve it.
Aliases
An alias maps a hallucinated tool name to a real one. When the AI keeps calling read_file but your actual tool is named Read, an alias bridges that gap.
Why Aliases Exist
AI assistants often hallucinate tool names that feel natural but don’t match your actual API. Claude Code might try:
read_filewhen the real tool isReadexecute_commandwhen it’sBashsearch_fileswhen it’sGlob
These aren’t bugs in the AI — they’re reasonable guesses. But they create friction. Aliases let you connect desires to reality without renaming your tools or retraining models.
Creating an Alias
Basic syntax:
dp alias <hallucinated_name> <real_tool_name>
Example:
dp alias read_file Read
Now when you run dp paths or dp similar, desires for read_file show their connection to Read.
Upsert Behavior
Creating an alias twice updates the target:
dp alias read_file Read # Maps read_file → Read
dp alias read_file ReadFile # Updates to read_file → ReadFile
No error, no duplicate entries. The latest mapping wins.
Listing Aliases
See all current mappings:
dp aliases
Output:
ALIAS MAPS TO
read_file Read
execute_bash Bash
search_files Glob
Deleting an Alias
Remove a mapping when it’s no longer needed:
dp alias --delete read_file
The desires remain in your database, but the mapping is gone. dp paths will show the raw pattern again.
How Aliases Appear in Commands
In dp paths
Without alias:
PATTERN COUNT FIRST SEEN LAST SEEN
read_file 47 2026-02-01 09:15:23 2026-02-09 10:30:45
With alias:
PATTERN COUNT FIRST SEEN LAST SEEN ALIAS
read_file 47 2026-02-01 09:15:23 2026-02-09 10:30:45 Read
In dp similar
Suggestions incorporate alias information to show what the AI is trying to accomplish with tools that actually exist.
When To Use Aliases
Perfect Match, Wrong Name
The AI’s concept matches your tool exactly, just different naming:
read_file→Readexecute_bash→Bash
Subset Mapping
The AI wants something specific that’s part of a broader tool:
search_codebase→Greplist_directory→Glob
Don’t Alias If…
- The concepts don’t actually align (forcing a bad mapping creates confusion)
- You’re planning to build the hallucinated tool anyway (let the path data guide development)
Command Correction Rules
Aliases extend beyond tool names. Command correction rules fix mistakes inside tool parameters — like wrong CLI flags, deprecated command names, or outdated paths.
The Problem
AI assistants don’t just hallucinate tool names. They also misremember CLI flags:
scp -rwhen the correct flag isscp -Rgrep patternwhen you preferrg patterncurl -kwhen you needcurl --cacert cert.pem
These are the same kind of desire path: the AI’s intent is correct, but the specifics are wrong.
Rule Types
| Type | What It Fixes | Example |
|---|---|---|
| Flag | Wrong CLI flag | -r → -R in scp |
| Command | Wrong command name | grep → rg |
| Literal | Wrong string in a command | user@old: → user@new: |
| Regex | Pattern-based replacement | curl -k → curl --cacert cert.pem |
How They Work
Rules are scoped to a specific command within a pipeline. Given a Bash tool call like:
cat file.txt | scp -rP 22 user@host:/path
A flag rule for scp with -r → -R would:
- Parse the pipeline into segments:
cat file.txtandscp -rP 22 user@host:/path - Find the segment where the command is
scp - Locate the
-rflag within-rP(combined flags) - Rewrite to
-RP:cat file.txt | scp -RP 22 user@host:/path
The correction is precise: it only touches the right command and the right flag.
Creating Rules
# Flag correction
dp alias --cmd scp --flag r R --message "scp uses -R for recursive"
# Command substitution
dp alias --cmd grep --replace rg
# Literal replacement
dp alias --cmd scp "user@old:" "user@new:"
Enforcement
Rules are enforced by dp pave --hook, which installs a PreToolUse hook in Claude Code. When a tool call matches a rule, the hook rewrites the parameters before the tool executes. The AI sees a note about the correction in the response.
See dp pave for details on the hook mechanism.
Aliases as Documentation
Your alias list is a rosetta stone between AI expectations and your actual API. It documents the gap between “what AI assistants naturally try to do” and “what your system actually provides.”
That gap is valuable data. Don’t just fix it — learn from it.
Invocations
Invocations track ALL tool calls, not just failures. When enabled, desire_path becomes a comprehensive telemetry system for AI tool usage.
Signposting sibling mode
Signposting preserves a deterministic search exactly. A PreToolUse sibling may start a detached semantic prefetch, while PostToolUse offers its pointer only when the literal result is empty or too large and a warm result is available inside the production budget. A signpost is a runnable command, not a replacement result set. Following that command is adoption. Hook timeouts and failures are dropped, so the unmodified search remains the worst-case behavior.
V1 covers Bash-mediated grep and rg; native agent Grep/Glob tools are not
intercepted. The evaluation and event contract lives in
docs/plans/009-signposting-eval.md.
The Difference
- Desires: Only failed tool calls
- Invocations: Every tool call (success or failure)
Invocations give you the full picture: success rates, usage patterns, which tools get hammered, which never get touched, and how sessions unfold over time.
Enabling Invocations
Turn on full tracking when initializing:
dp init --source claude-code --track-all
The --track-all flag activates invocation recording. Now every tool call flows into your desire_path database, not just the failures.
What Gets Captured
Each invocation records:
- source: Which AI system made the call (e.g., “claude-code”)
- instance_id: Specific AI session or conversation
- host_id: Machine where the call happened
- tool_name: The tool that was called
- is_error: Boolean — did it succeed or fail?
- error: Error message if
is_erroris true (null otherwise) - cwd: Working directory during the call
- timestamp: When it happened
- metadata: Additional context as JSON (optional)
Bash command and output evidence
New Bash invocations include command_capture, command_shape, and ordered
command_argv0 metadata. Identities omit arguments and paths. For example,
cd project && sd show item || true records cd/sd:show/true; this describes
the submitted program, not which branches executed or which command failed.
Dynamic commands, unsupported execution wrappers, and oversized inputs record
command_capture=unknown. Older rows have no command attribution; they are not
backfilled or counted as successful captures.
Line-anchored Error:, Fatal:, REFUSED, and HTTP 4xx/5xx output signatures
also produce error invocations and desires when the caller masks its exit.
failure_evidence=output-signature distinguishes these from native-error.
An expected negative control can print an error, so signature counts require
interpretation; they are not independently verified subprocess failures.
output_scan=unknown discloses absent, unsupported, or oversized responses.
The original response remains available in the existing metadata.
This SQLite query counts weekly captured invocations containing a tool,
deduplicating repeated mentions within a program. It separates native errors
from output signatures rather than assigning a chain’s failure to cd:
WITH programs AS (
SELECT DISTINCT i.id, strftime('%Y-%W', i.timestamp) AS week,
c.value AS command, i.is_error,
json_extract(i.metadata, '$.failure_evidence') AS evidence
FROM invocations AS i, json_each(i.metadata, '$.command_argv0') AS c
WHERE i.timestamp >= '2026-10-01' AND i.timestamp < '2026-11-01'
AND c.value IN ('bd', 'br', 'sd', 'gh')
)
SELECT week, command, count(*) AS captured_invocations,
sum(is_error = 1 AND evidence = 'native-error') AS native_errors,
sum(is_error = 1 AND evidence = 'output-signature') AS output_signatures,
round(100.0 * sum(is_error) / count(*), 2) AS flagged_percent
FROM programs GROUP BY week, command ORDER BY week, command;
Always report capture coverage over the same date interval too:
SELECT coalesce(json_extract(metadata, '$.command_capture'), 'historical-unknown')
AS capture, count(*) AS invocations
FROM invocations
WHERE tool_name IN ('Bash', 'exec_command', 'functions.exec_command')
AND timestamp >= '2026-10-01' AND timestamp < '2026-11-01'
GROUP BY capture;
Dates are explicit example bounds; choose the intended reporting interval. Use an existing database through a read-only SQLite connection. Masked exits cannot be recovered when the caller suppresses both its status and output; preserve command exits in scripts and include errors in tool responses.
Viewing Invocation Stats
Get aggregated statistics:
dp stats --invocations
This might show:
TOOL NAME TOTAL SUCCESS FAILED SUCCESS RATE
Read 324 320 4 98.8%
Bash 156 142 14 91.0%
Glob 89 89 0 100.0%
Edit 67 63 4 94.0%
read_file 47 0 47 0.0%
Insights immediately visible:
Readworks reliably (98.8% success)Bashhas issues (14 failures worth investigating)read_filefails every time (needs aliasing or building)
Exporting Invocation Data
Pull raw data for deeper analysis:
dp export --type invocations --format json > invocations.json
Now you can:
- Load into analytics tools
- Build custom dashboards
- Track trends over time
- Correlate with other metrics
The Source Plugin System
Invocations use desire_path’s plugin architecture. Each AI tool has its own parser:
dp init --source claude-code --track-all # Claude Code invocations
dp init --source aider --track-all # Aider invocations
dp init --source custom-ai --track-all # Your custom AI tool
The source plugin handles:
- Parsing that AI tool’s specific output format
- Extracting tool call data
- Recording to the desire_path database
Different AI tools structure their telemetry differently. Plugins normalize everything into a common schema.
Use Cases
Success Rate Monitoring
Which tools are fragile? If Bash fails 10% of the time, maybe error handling needs work.
Usage Patterns
Which tools actually get used? You might discover Claude Code calls Read 10x more than anything else — worth optimizing.
Session Timelines
Replay how a conversation unfolded: “First it read the file, then globbed for tests, then ran bash commands.” Debug AI reasoning by seeing the sequence.
Hallucination Detection
Tools with 0% success rate are hallucinations. But you already knew that from desires. What’s new: tools with 50% success rate might have naming collisions or API confusion.
When To Enable Invocations
Don’t Enable If…
- You only care about failures (desires are enough)
- Storage or performance is constrained (invocations generate more data)
- You’re just getting started (start simple, expand later)
Do Enable If…
- You’re building production AI tooling (comprehensive telemetry matters)
- You want to optimize tool implementations (need success data to measure improvements)
- You’re analyzing AI behavior patterns (full session data reveals reasoning flows)
- You’re running experiments (A/B testing tool changes requires success metrics)
The Full Picture
Desires tell you what’s broken. Invocations tell you what’s working, how often, and why.
Together, they turn desire_path from a failure tracker into an AI observability platform. You’re not just fixing problems — you’re understanding how AI assistants interact with your system at every level.
More data. Richer insights. Better tools.
Command Reference
The dp CLI provides commands for recording, analyzing, and fixing tool call failures in AI coding workflows.
Command Categories
Record & Ingest
Commands for capturing tool call data from AI coding tools.
- record - Deprecated compatibility entrypoint; use ingest
- ingest - Ingest tool call data from a source plugin
- init - Set up integration with AI coding tools
Query & Analyze
Commands for exploring recorded desire paths.
- list - List recent desires
- paths - Show aggregated paths ranked by frequency
- inspect - Show detailed view of a specific desire path
- stats - Show summary statistics
- export - Export raw desire or invocation data
Map & Fix
Commands for resolving tool name mismatches.
- similar - Find known tools similar to a tool name
- alias - Create, update, or delete tool name aliases and command correction rules
- aliases - List all configured aliases and rules
- pave - Turn aliases into active tool-call intercepts
Configure
Commands for managing configuration.
- config - Show or modify configuration
All Commands
| Command | Description |
|---|---|
| record | Deprecated compatibility entrypoint; use ingest |
| ingest | Ingest tool call data from a source plugin |
| init | Set up integration with AI coding tools |
| list | List recent desires |
| paths | Show aggregated paths ranked by frequency |
| inspect | Show detailed view of a specific desire path |
| stats | Show summary statistics |
| export | Export raw desire or invocation data |
| similar | Find known tools similar to a tool name |
| alias | Create, update, or delete tool name aliases and correction rules |
| aliases | List all configured aliases and rules |
| pave | Turn aliases into active tool-call intercepts |
| config | Show or modify configuration |
Global Flags
All commands support these global flags:
| Flag | Default | Description |
|---|---|---|
| –db PATH | ~/.dp/desires.db | Path to the SQLite database |
| –json | false | Output results as JSON |
Additional commands
The v0.4.0 CLI also lists sources, version, turns, recoveries, struggling,
env-needs, map, mappings, suggest, serve, eval, and signpost.
legend-refresh builds the gazetteer for the read-time
entity legend, a second stage of signpost.
Use dp <command> --help for their version-specific options.
The docs map routes their design notes and the
evaluation chapters describe signposting experiments.
dp record (deprecated)
record is a compatibility entrypoint. New integrations should use
dp ingest --source <name>, which records invocations and also
stores failed calls as desires. See the first-success fixture
for a complete example.
dp ingest
Ingest tool call data from a source plugin
Usage
dp ingest [flags]
Flags
| Flag | Default | Description |
|---|---|---|
| –source | “” | Source plugin name (required) |
Examples
$ cat payload.json | dp ingest --source claude-code
Ingested 3 desires from claude-code
$ curl https://api.example.com/tool-calls | dp ingest --source custom-plugin
Ingested 12 desires from custom-plugin
$ dp ingest --source nonexistent < data.json
Error: source plugin "nonexistent" not found
Available sources: claude-code, cursor
Details
The ingest command reads raw data from stdin and uses a source plugin to parse it into desire records. Unlike dp record, which expects pre-formatted JSON, dp ingest delegates parsing to a plugin that understands the source’s native format.
The --source flag is required. If omitted, the command will error and list available source plugins.
Source plugins are responsible for:
- Parsing the input format
- Extracting tool names, errors, inputs, and metadata
- Handling batches of tool calls
This command is useful for bulk imports, integrating with custom AI tools, or processing historical data that wasn’t captured in real-time.
Plugins are discovered from the source/ package. To add a new source, implement the Source interface and register it in the plugin registry.
Codex Stop hooks
Configure a native Codex Stop hook with dp init --source codex, or install the
complete bundle with dp hooks install. The fixed command
dp ingest --source codex reads the hook JSON from stdin; turn JSON must never
be appended as an argument. This allows long turns to pass the operating
system’s single-argument limit.
printf '%s' '{"hook_event_name":"Stop","session_id":"example-session","cwd":"/project","turn_id":"turn-1"}' | dp ingest --source codex
This records one agent_turn invocation with the session as instance_id and
turn_id in metadata. Stop requires a nonempty string session ID. Other hook
events are refused. Assistant prose and transcript contents are not recorded by
this adapter. Existing notify payloads with type: agent-turn-complete and
thread-id still parse, but new installations use Stop because notify delivers
its entire payload through argv. Only the exact old dp notify command is retired;
foreign notify commands and other hooks are preserved.
dp init
Install source-specific agent configuration:
dp init --source claude-code
The Claude Code installer merges ~/.claude/settings.json and installs ingestion
for successful and failed tool calls, plus search-guidance and correction hooks.
See Using agents for the complete hook list and
capture-only configuration for the minimal alternative.
Flags
| flag | purpose |
|---|---|
--source | Select a source plugin; see dp sources |
--settings | Override the source-specific settings path |
--signpost-url | Configure and verify a search backend |
--signpost-log | Choose a signposting event log; - disables it |
--signpost-search-mode | Request hybrid, semantic, or keyword search |
--skip-signpost-url-check | Explicitly install before the backend is available |
Use dp init --help to inspect the installed version’s flags. --track-all and
--claude-code are not flags in v0.4.0. Ingestion already captures both outcomes.
dp list
List recent desires
Usage
dp list [flags]
Flags
| Flag | Default | Description |
|---|---|---|
| –since | “” | Duration or timestamp (30m, 24h, 7d, etc.) |
| –source | “” | Filter by source identifier |
| –tool | “” | Filter by tool name |
| –limit | 50 | Maximum number of desires to show |
Examples
$ dp list
TIMESTAMP SOURCE TOOL ERROR
2026-02-09 14:32:15 claude-code read_file unknown tool
2026-02-09 14:31:42 claude-code file_read tool not found
2026-02-09 14:28:33 cursor edit_document invalid parameters
2026-02-09 14:15:09 claude-code grep_search command failed
2026-02-09 13:58:21 claude-code write_file permission denied
5 desires shown (limit: 50)
$ dp list --since 1h --source claude-code
TIMESTAMP SOURCE TOOL ERROR
2026-02-09 14:32:15 claude-code read_file unknown tool
2026-02-09 14:31:42 claude-code file_read tool not found
2026-02-09 14:15:09 claude-code grep_search command failed
3 desires shown (limit: 50)
$ dp list --tool read_file --limit 10
TIMESTAMP SOURCE TOOL ERROR
2026-02-09 14:32:15 claude-code read_file unknown tool
2026-02-08 16:22:44 cursor read_file file not found
2026-02-08 11:05:33 claude-code read_file unknown tool
3 desires shown (limit: 10)
Details
The list command displays recent desire paths in reverse chronological order (newest first). Each row shows when the failure occurred, which AI tool generated it, the attempted tool name, and the error message.
Use --since to filter by time. Accepts durations like “30m”, “2h”, “7d”, or absolute timestamps in RFC3339 format.
Use --source to filter by AI coding tool. This is useful when you’re debugging integration issues with a specific tool.
Use --tool to filter by the attempted tool name. This helps identify recurring failures for a particular tool.
The --limit flag caps the number of results. Default is 50. Set to 0 for unlimited results (not recommended for large datasets).
Combine filters to narrow down results:
$ dp list --since 24h --source claude-code --tool read_file
For programmatic access, use the global --json flag to get structured output.
dp paths
Show aggregated paths ranked by frequency
Usage
dp paths [flags]
Flags
| Flag | Default | Description |
|---|---|---|
| –top | 20 | Number of top paths to show |
| –since | “” | Filter by RFC3339 timestamp |
Examples
$ dp paths
RANK PATTERN COUNT FIRST_SEEN LAST_SEEN ALIAS
1 read_file 142 2026-01-15 09:23:11 2026-02-09 14:32:15 Read
2 file_read 89 2026-01-18 11:05:44 2026-02-09 14:31:42 Read
3 grep_search 67 2026-01-20 08:15:22 2026-02-09 14:15:09 Grep
4 edit_document 45 2026-01-22 13:44:33 2026-02-09 14:28:33 Edit
5 write_file 38 2026-01-25 10:12:09 2026-02-09 13:58:21 Write
6 search_grep 31 2026-01-28 15:33:44 2026-02-08 16:47:22 Grep
7 bash_exec 28 2026-02-01 09:08:15 2026-02-07 18:22:33 Bash
8 run_command 24 2026-02-03 12:55:09 2026-02-06 14:11:55 Bash
9 file_write 19 2026-02-04 08:44:21 2026-02-05 16:33:12 Write
10 glob_find 17 2026-02-05 11:22:44 2026-02-09 09:14:28 Glob
Showing top 10 of 47 unique patterns
$ dp paths --top 5 --since 2026-02-01T00:00:00Z
RANK PATTERN COUNT FIRST_SEEN LAST_SEEN ALIAS
1 read_file 48 2026-02-01 08:15:33 2026-02-09 14:32:15 Read
2 file_read 32 2026-02-01 09:22:11 2026-02-09 14:31:42 Read
3 bash_exec 28 2026-02-01 09:08:15 2026-02-07 18:22:33 Bash
4 run_command 24 2026-02-03 12:55:09 2026-02-06 14:11:55 Bash
5 edit_file 21 2026-02-02 14:33:22 2026-02-08 11:44:09 Edit
Showing top 5 of 29 unique patterns
Details
The paths command aggregates desire records by tool name pattern and ranks them by frequency. This reveals which tool name variations are most commonly attempted by AI coding tools.
The ALIAS column shows if a mapping has been configured using dp alias. When an alias exists, desire_path can automatically find the correct tool name.
Use --top to control how many patterns to display. The default is 20, which typically covers the most actionable patterns.
Use --since to analyze patterns from a specific date forward. This is useful after making changes to your tool configuration to see if new patterns emerge.
This command is essential for:
- Identifying the most frequent tool name mismatches
- Prioritizing which aliases to create
- Understanding how different AI tools name the same capabilities
- Tracking whether integration improvements reduce failure rates
Pattern counts represent unique failure instances, not total attempts. Use dp inspect to drill into a specific pattern.
dp inspect
Show detailed view of a specific desire path
Usage
dp inspect <pattern> [flags]
Flags
| Flag | Default | Description |
|---|---|---|
| –since | “” | Duration or timestamp (30m, 24h, 7d, etc.) |
| –top | 5 | Number of top inputs/errors to show |
Examples
$ dp inspect read_file
Pattern: read_file
Total occurrences: 142
Date range: 2026-01-15 09:23:11 to 2026-02-09 14:32:15
Sources: claude-code (128), cursor (14)
Alias: Read
Activity by day:
2026-02-09 ████████████████████ 24
2026-02-08 ██████████████ 17
2026-02-07 ████████ 9
2026-02-06 ██████████ 12
2026-02-05 ███████████ 13
2026-02-04 ██████ 7
2026-02-03 ████████████ 14
2026-02-02 ███████████ 13
2026-02-01 ████████ 10
(earlier days: 23 total)
Top errors:
unknown tool 89 (62.7%)
tool not found 31 (21.8%)
invalid tool name 15 (10.6%)
tool unavailable 7 (4.9%)
Top inputs:
{"path": "/etc/hosts"} 18
{"file_path": "/home/user/config.json"} 12
{"path": "/var/log/app.log", "offset": 0} 9
{"file_path": "/tmp/data.txt"} 8
{"path": "/home/user/.bashrc"} 7
$ dp inspect grep% --since 7d
Pattern: grep% (SQL LIKE wildcard)
Total occurrences: 45
Date range: 2026-02-02 08:15:22 to 2026-02-09 14:15:09
Sources: claude-code (41), cursor (4)
Matched patterns: grep_search (31), grep_find (9), grep_files (5)
Activity by day:
2026-02-09 ██████████ 8
2026-02-08 ████████ 6
2026-02-07 ██████ 5
2026-02-06 ████████ 7
2026-02-05 ██████ 5
2026-02-04 ████ 4
2026-02-03 ██████ 5
2026-02-02 ██████ 5
Top errors:
command failed 28 (62.2%)
unknown tool 12 (26.7%)
invalid parameters 5 (11.1%)
Details
The inspect command provides a deep dive into a specific desire pattern. Use it to understand:
- How frequently the pattern occurs
- When it first appeared and when it was last seen
- Which AI tools are generating this pattern
- Whether an alias has been configured
- How activity trends over time
- What error messages are associated with it
- What input parameters are commonly attempted
The pattern argument supports SQL LIKE wildcards:
- Use
%to match any sequence of characters:grep%matchesgrep_search,grep_find, etc. - Use
_to match any single character:read_fil_matchesread_file,read_fils, etc.
Without wildcards, the pattern is matched exactly.
The histogram shows daily activity with a simple bar chart. The width of each bar represents relative frequency within the time window.
Use --since to focus on recent activity. This helps identify if a pattern is actively occurring or historical.
Use --top to control how many top errors and inputs are displayed. Default is 5, which usually captures the most common cases.
This command is invaluable for debugging specific integration issues and understanding why a particular tool name is failing.
dp stats
Show summary statistics
Usage
dp stats [flags]
Flags
| Flag | Default | Description |
|---|---|---|
| –invocations | false | Show invocation stats instead of desires |
Examples
$ dp stats
Desire Statistics
Total desires: 1,247
Unique tool patterns: 47
Unique sources: 3
Date range: 2026-01-15 09:23:11 to 2026-02-09 14:32:15
Activity windows:
Last 24 hours: 89 desires
Last 7 days: 412 desires
Last 30 days: 1,247 desires
Top sources:
claude-code: 1,089 (87.3%)
cursor: 142 (11.4%)
copilot: 16 (1.3%)
Top desires:
read_file: 142 (11.4%)
file_read: 89 (7.1%)
grep_search: 67 (5.4%)
edit_document: 45 (3.6%)
write_file: 38 (3.0%)
Top tools (by alias):
Read: 231 (18.5%)
Grep: 98 (7.9%)
Edit: 76 (6.1%)
Write: 57 (4.6%)
Bash: 52 (4.2%)
$ dp stats --invocations
Invocation Statistics
Total invocations: 8,432
Successful: 7,185 (85.2%)
Failed: 1,247 (14.8%)
Unique tools: 23
Unique sources: 3
Date range: 2026-01-15 09:23:11 to 2026-02-09 14:32:15
Activity windows:
Last 24 hours: 645 invocations (89 failures)
Last 7 days: 3,128 invocations (412 failures)
Last 30 days: 8,432 invocations (1,247 failures)
Top sources:
claude-code: 7,344 (87.1%)
cursor: 1,028 (12.2%)
copilot: 60 (0.7%)
Top tools:
Read: 2,847 (33.8%)
Bash: 1,923 (22.8%)
Edit: 1,204 (14.3%)
Write: 891 (10.6%)
Grep: 745 (8.8%)
Failure rates by tool:
Read: 231/2,847 (8.1%)
Grep: 98/745 (13.2%)
Edit: 76/1,204 (6.3%)
Write: 57/891 (6.4%)
Bash: 52/1,923 (2.7%)
Details
The stats command provides a high-level overview of your desire_path data. It’s useful for:
- Understanding the scale of tool call failures
- Identifying which AI tools have the most integration issues
- Tracking improvement over time
- Prioritizing which patterns to fix first
By default, stats shows desire (failure) data. Use --invocations to see statistics about all tool invocations, both successful and failed. Invocation tracking must be enabled with dp init --track-all for this data to be available.
Activity windows show rolling counts for the last 24 hours, 7 days, and 30 days. This helps identify trends: is the failure rate increasing, decreasing, or stable?
Top sources reveal which AI tools are generating the most failures. A high failure rate from one source might indicate a configuration issue or incompatibility.
Top desires show the most frequently attempted tool names. These are your highest-priority candidates for creating aliases.
When invocation tracking is enabled, the failure rate breakdown shows which tools have the highest error rates. This can reveal whether certain tools are more prone to naming mismatches or integration issues.
Run stats periodically to track the health of your AI coding tool integrations.
dp export
Export raw desire or invocation data
Usage
dp export [flags]
Flags
| Flag | Default | Description |
|---|---|---|
| –format | json | Output format: json or csv |
| –since | “” | Filter by RFC3339 timestamp or YYYY-MM-DD |
| –type | desires | Data type to export: desires or invocations |
Examples
$ dp export --format json
{"id":1,"tool_name":"read_file","error":"unknown tool","source":"claude-code","timestamp":"2026-02-09T14:32:15Z","input":"{\"path\":\"/etc/hosts\"}"}
{"id":2,"tool_name":"file_read","error":"tool not found","source":"claude-code","timestamp":"2026-02-09T14:31:42Z","input":"{\"file_path\":\"/home/user/config.json\"}"}
{"id":3,"tool_name":"edit_document","error":"invalid parameters","source":"cursor","timestamp":"2026-02-09T14:28:33Z","input":"{}"}
$ dp export --format csv --since 2026-02-01
id,tool_name,error,source,timestamp,input,metadata
89,read_file,unknown tool,claude-code,2026-02-09T14:32:15Z,"{\"path\":\"/etc/hosts\"}",
90,file_read,tool not found,claude-code,2026-02-09T14:31:42Z,"{\"file_path\":\"/home/user/config.json\"}",
91,edit_document,invalid parameters,cursor,2026-02-09T14:28:33Z,"{}",
$ dp export --format json --since 2026-02-08T00:00:00Z | jq -r '.tool_name' | sort | uniq -c | sort -rn
24 read_file
18 file_read
12 grep_search
9 edit_document
7 write_file
$ dp export --format json --type invocations --since 2026-02-09
{"id":1,"tool_name":"Read","success":true,"source":"claude-code","timestamp":"2026-02-09T14:35:22Z","duration_ms":45,"input":"{\"file_path\":\"/etc/hosts\"}"}
{"id":2,"tool_name":"read_file","success":false,"source":"claude-code","timestamp":"2026-02-09T14:32:15Z","duration_ms":12,"error":"unknown tool"}
{"id":3,"tool_name":"Bash","success":true,"source":"claude-code","timestamp":"2026-02-09T14:30:08Z","duration_ms":234,"input":"{\"command\":\"ls -la\"}"}
Details
The export command outputs raw data for external processing, analysis, or archival. It’s designed for piping to other tools or importing into data analysis platforms.
JSON format outputs JSONL (JSON Lines): one complete JSON object per line. This format is easy to process with jq, jless, or stream into other systems:
dp export --format json | jq 'select(.source == "claude-code")'
CSV format includes headers and is suitable for importing into spreadsheets or databases. Fields containing commas or quotes are properly escaped.
Use --since to export data from a specific date forward. Accepts RFC3339 timestamps (2026-02-09T00:00:00Z) or simple dates (2026-02-09).
Use --type to choose between exporting desire (failure) data or invocation (all tool call) data. Invocation data is only available if you’ve enabled tracking with dp init --track-all.
Common export workflows:
Analyze tool name patterns:
dp export --format json | jq -r '.tool_name' | sort | uniq -c | sort -rn
Find all errors from a specific source:
dp export --format json | jq 'select(.source == "claude-code") | .error' | sort | uniq -c
Export to CSV for spreadsheet analysis:
dp export --format csv > desires.csv
Backup your data:
dp export --format json > backup-$(date +%Y%m%d).jsonl
The export command never modifies the database. It’s read-only and safe to run at any time.
dp similar
Find known tools similar to a tool name
Usage
dp similar <tool-name> [flags]
Flags
| Flag | Default | Description |
|---|---|---|
| –known | “” | Comma-separated list of known tools (overrides defaults) |
| –threshold | 0.5 | Minimum similarity score (0.0 to 1.0) |
| –top | 5 | Maximum number of suggestions to show |
Examples
$ dp similar read_file
Checking alias mappings...
Found alias: read_file -> Read
Suggestions for "read_file":
1. Read (score: 0.82, reason: alias)
Recommended action: Use "Read" instead of "read_file"
$ dp similar file_reader
Checking alias mappings...
No alias found for "file_reader"
Suggestions for "file_reader":
1. Read (score: 0.73)
2. Write (score: 0.58)
3. Edit (score: 0.56)
Recommended action: Consider using "Read" or create an alias with:
dp alias file_reader Read
$ dp similar grepsearch --threshold 0.4
Checking alias mappings...
No alias found for "grepsearch"
Suggestions for "grepsearch":
1. Grep (score: 0.78)
2. WebSearch (score: 0.45)
3. Glob (score: 0.42)
Recommended action: Consider using "Grep" or create an alias with:
dp alias grepsearch Grep
$ dp similar custom_tool --known "CustomRead,CustomWrite,CustomEdit"
Checking alias mappings...
No alias found for "custom_tool"
Suggestions for "custom_tool":
1. CustomEdit (score: 0.54)
2. CustomWrite (score: 0.51)
No strong matches found. Consider checking the tool name.
Details
The similar command helps resolve tool name mismatches by finding the closest matching known tool. It uses two strategies:
- Alias lookup: First checks if an explicit alias has been configured with
dp alias - Similarity matching: Calculates Levenshtein distance to find phonetically or structurally similar tool names
Default known tools (Claude Code conventions):
- Read
- Write
- Edit
- Bash
- Glob
- Grep
- Task
- WebFetch
- WebSearch
- NotebookEdit
Override the known tools list with --known for custom tool environments:
dp similar mytool --known "Tool1,Tool2,Tool3"
The similarity score ranges from 0.0 (completely different) to 1.0 (identical). The --threshold flag filters out weak matches. Default is 0.5, which typically excludes spurious suggestions.
Use --top to limit suggestions. Default is 5, which is usually sufficient to find the right match.
When an alias exists, it’s always shown first with a score of 1.0 and marked as “alias”. This makes aliases the authoritative source for mappings.
Use similar interactively when debugging why a tool call failed:
$ dp list --limit 1
# See a failed tool name
$ dp similar <that-tool-name>
# Get suggestions and create alias if needed
Integrate similar into your workflow by running it after reviewing dp paths to batch-create aliases for the most common patterns.
dp alias
Create, update, or delete tool name aliases and command correction rules.
Usage
dp alias <from> <to>
dp alias --delete <from>
dp alias --cmd <name> --flag <old> <new>
dp alias --cmd <name> --replace <new>
dp alias --cmd <name> <from> <to>
dp alias --tool <tool> --param <param> <from> <to>
dp aliases
Flags
| Flag | Default | Description |
|---|---|---|
| –delete | false | Delete an existing alias or rule |
| –cmd NAME | Command name for CLI corrections (implies tool=Bash, param=command) | |
| –flag OLD,NEW | Flag correction within a command (requires –cmd) | |
| –replace NEW | Substitute the command itself (requires –cmd) | |
| –tool NAME | Tool name for parameter corrections (advanced) | |
| –param NAME | Parameter name to correct (requires –tool) | |
| –regex | false | Treat FROM as a regex pattern (requires –tool/–param) |
| –message TEXT | Custom message shown when correction fires |
Tool Name Aliases
Map a hallucinated tool name to the correct one:
dp alias read_file Read
dp alias search_files Grep
dp alias --delete read_file
Command Flag Corrections
Fix incorrect CLI flags scoped to a specific command:
# scp uses -R (not -r) for recursive
dp alias --cmd scp --flag r R
# With a custom message
dp alias --cmd scp --flag r R --message "scp uses -R for recursive"
# Delete the rule
dp alias --delete --cmd scp --flag r
When dp pave --hook is active, this automatically rewrites scp -r to scp -R in any Bash tool call. Combined flags are handled too: -rP 22 becomes -RP 22.
Command Substitution
Replace one command with another:
# Use ripgrep instead of grep
dp alias --cmd grep --replace rg
# With a message
dp alias --cmd grep --replace rg --message "Use ripgrep instead of grep"
This rewrites grep -rn pattern . to rg -rn pattern . while leaving other commands in a pipeline untouched.
Literal Replacement
Replace a literal string within a specific command’s context:
dp alias --cmd scp "user@old-host:" "user@new-host:" --message "Host migrated"
Advanced: Tool/Param Corrections
For non-Bash tools or arbitrary parameter corrections:
# Correct a path in an MCP tool
dp alias --tool MyMCPTool --param input_path "/old/path" "/new/path"
# Regex replacement
dp alias --tool Bash --param command --regex "curl -k" "curl --cacert cert.pem"
Listing Rules
dp aliases
Output:
FROM TO TYPE COMMAND CREATED
read_file Read alias 2026-02-01 09:15:33
r R flag scp 2026-02-01 09:16:12
grep rg command grep 2026-02-01 09:17:44
Validation
--cmdand--tool/--paramare mutually exclusive--flagrequires--cmd--replacerequires--cmd--flagand--replaceare mutually exclusive--regexrequires--tool/--param--tooland--parammust appear together
Details
The alias command manages both tool name mappings and command correction rules. Tool name aliases define how incorrect tool names should be resolved. Command correction rules define how parameters should be rewritten when a tool is called.
Aliases and rules are upserted: creating one that already exists updates it. This makes it safe to run commands idempotently.
Rules are identified by a composite key: (from, tool, param, command, match_kind). This means you can have multiple rules for the same command targeting different flags.
When you create rules, they take effect immediately if dp pave --hook is installed. The hook checks rules on every tool call and applies corrections transparently.
Common workflow for command corrections:
- Notice the AI keeps using wrong flags (e.g.,
scp -rfails) - Create a rule:
dp alias --cmd scp --flag r R - Install the hook:
dp pave --hook - Future
scp -rcalls are automatically corrected toscp -R
Best practices:
- Add
--messageto explain why the correction exists - Use
--cmdfor CLI corrections (most common case) - Use
--tool/--paramonly for MCP or non-Bash tools - Review rules with
dp aliasesperiodically
dp pave
Turn aliases and correction rules into active tool-call intercepts.
Usage
dp pave --hook
dp pave --agents-md
dp pave --agents-md --append AGENTS.md
Flags
| Flag | Default | Description |
|---|---|---|
| –hook | false | Install a PreToolUse intercept hook in Claude Code |
| –agents-md | false | Generate AGENTS.md / CLAUDE.md rules from alias data |
| –append FILE | Append generated rules to FILE (with –agents-md) | |
| –settings PATH | ~/.claude/settings.json | Path to Claude Code settings file |
Modes
–hook: Real-Time Intercept
Installs a PreToolUse hook into ~/.claude/settings.json that runs dp pave-check on every tool call. The hook has two behaviors:
Phase 1 — Tool Name Blocking: If the AI calls a tool that has a tool-name alias (e.g., read_file when the real tool is Read), the hook blocks the call with exit code 2 and tells Claude to use the correct name.
Phase 2 — Parameter Correction: If the tool name is valid but the parameters contain known mistakes (e.g., scp -r instead of scp -R), the hook rewrites the parameters automatically via updatedInput and allows the call to proceed with corrected values.
dp pave --hook
Output:
PreToolUse intercept hook installed!
Hallucinated tool names matching aliases will now be blocked automatically.
Manage aliases with: dp alias <from> <to>
Running again is safe — it detects the existing hook and reports “already installed.”
–agents-md: Static Rules
Generates markdown rules from your aliases and correction rules. Output has two sections:
Tool Name Corrections — tells the AI which tool names are wrong:
# Tool Name Corrections
The following tool names are INCORRECT. Use the correct names instead:
- Do NOT call `read_file`. Use `Read` instead.
- Do NOT call `search_files`. Use `Grep` instead.
Command Corrections — documents parameter correction rules:
# Command Corrections
## scp
- Flag `-r` should be `-R` (scp uses -R for recursive)
## grep → rg
- Use `rg` instead of `grep`
By default, output goes to stdout. Use --append to write to a file:
dp pave --agents-md --append AGENTS.md
Belt and Suspenders
Use both modes together for maximum coverage:
dp pave --hook --agents-md --append AGENTS.md
--hookis reactive: catches mistakes at call time--agents-mdis preventive: stops mistakes before they happen
How pave-check Works
The dp pave-check command is an internal hook handler. It reads a JSON payload from stdin and performs two phases:
Phase 1: Tool Name Check
Looks up the tool_name in the alias table. If found, blocks the call:
- Exit code 2 + error message on stderr
- Claude Code shows the message and retries with the correct tool name
Phase 2: Parameter Corrections
Queries correction rules for the tool name via GetRulesForTool. For each matching rule:
| MatchKind | What It Does |
|---|---|
flag | Corrects a CLI flag within a specific command (e.g., -r → -R in scp) |
command | Substitutes a command name (e.g., grep → rg) |
literal | Replaces a literal string within a command segment |
regex | Applies a regex replacement across the full parameter value |
If corrections are applied:
- Exit code 0 + JSON on stdout with
updatedInput - Claude Code uses the corrected parameters transparently
If no corrections match:
- Exit code 0 with no output (allow as-is)
Flag-Aware Matching
The flag match kind uses a shell-aware command parser (cmdparse) that:
- Splits commands on
|,&&,||,;to isolate segments - Respects quoted strings (won’t match flags inside quotes)
- Handles combined short flags:
-rP 22→-RP 22 - Scopes corrections to the right command in a pipeline
Example: Given a rule --cmd scp --flag r R:
scp -r file.txt host:/ → scp -R file.txt host:/
scp -rP 22 file host:/ → scp -RP 22 file host:/
cat file | scp -r host:/ → cat file | scp -R host:/
echo "-r" | scp file host:/ → (no change — "-r" is in quotes, not a flag)
Pipe Scoping
Command substitutions only affect the matching segment. Given --cmd grep --replace rg:
cat file | grep pattern | wc -l → cat file | rg pattern | wc -l
Only grep is replaced; cat and wc are untouched.
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Allow (optionally with updatedInput corrections) |
| 2 | Block (tool name alias matched) |
Hook Timeout
The pave-check hook has a 3-second timeout. It typically completes in <50ms. If the database is locked or unreachable, the hook fails open (allows the call).
Troubleshooting
Hook Not Firing
Verify installation:
cat ~/.claude/settings.json | jq '.hooks.PreToolUse'
Check that dp is in your PATH:
which dp
Corrections Not Applying
List your rules to verify they exist:
dp aliases --json
Test manually:
echo '{"tool_name":"Bash","tool_input":{"command":"scp -r file host:/"}}' | dp pave-check
You should see JSON output with updatedInput if a matching rule exists.
Hook Timing Out
The default timeout is 3000ms. If your database is on a slow disk:
- Check database size:
ls -lh ~/.dp/desires.db - Consider running
VACUUMif the database has grown large - Increase the timeout in
~/.claude/settings.jsonif needed
Examples
# Install the hook
dp pave --hook
# Generate rules to stdout
dp pave --agents-md
# Append rules to CLAUDE.md
dp pave --agents-md --append CLAUDE.md
# Both at once
dp pave --hook --agents-md --append AGENTS.md
# JSON output
dp pave --agents-md --json
Read-time entity legend (dp legend-refresh)
The entity legend tells an agent which Quipu entities the text it just read
refers to. After a Read, or a Bash cat/head/tail/sed -n/br show,
the PostToolUse hook can add one line:
Quipu entities here: build-01.example (Host), search-webhook (SystemdService), store-recycle (ambiguous 2)
The agent then knows what the graph covers without having to search for it.
It is off by default. Set DP_LEGEND=1 to enable it.
How it fits the hook
The legend is a second stage of dp signpost, not a separate hook. The two
stages share one per-call context budget (DP_LEGEND_MAX_BYTES, default
600 B):
- The signpost stage runs first. If it emits, it keeps its bytes, because it answers the search the agent actually ran.
- The legend gets whatever remains, which can be nothing.
- If signpost used the whole budget, its output is passed through byte-identical.
The hook path makes no network call. It reads a local gazetteer file that
dp legend-refresh writes on a timer. The stage stays silent and leaves the
hook output exactly as it was in any of these cases:
- the gazetteer is missing, unreadable, or older than
DP_LEGEND_MAX_AGE_MIN(default 1440 minutes) - there is no budget left
- there is no match
- anything else goes wrong
A stale legend could name entities that have since been renamed or retired, and a confident wrong annotation is worse than none.
What gets matched
dp legend-refresh exports rdfs:label values for a fixed list of governed
classes, such as Host, Service, GitRepo, Directive, FailureMode and AlertRule.
Generated and bulk kinds (Observation, Chunk, Commit, CodeSymbol, Section) are
excluded, as are Credential and Formula. Labels are admitted only if they pass
these precision guards:
- at least 4 characters
- a single token must be identifier-shaped: it contains
-,_,.,/or a digit, or is CamelCase - a single token must not be a dictionary word
- a multi-word label must not consist only of dictionary words and stopwords
- a short stoplist is dropped: ubiquitous URL hosts (
github.com,crates.io, …) and names that are also everyday phrases or generic filenames (code-review,deploy.yml). A phrase is added only when a hand-checked sample shows it misleading.
At read time, one Aho-Corasick pass matches every label case-insensitively with these rules:
- matches respect word boundaries
- the longest label wins at any position
- a label followed by
.<ext>is rejected, because it is a filename stem rather than a mention - each label is reported once per read
- a label that names several IRIs is shown as
ambiguous (n) - a bead or file never annotates itself
Each read shows at most 5 entities, and an entity already shown earlier in the session is skipped.
Environment
| Variable | Default | Meaning |
|---|---|---|
DP_LEGEND | off | 1 enables the stage |
DP_LEGEND_GAZETTEER | <user cache>/desire-path/legend-gazetteer.json | gazetteer path |
DP_LEGEND_QUIPU_URL | required | Quipu /query endpoint used by refresh |
DP_LEGEND_NAMESPACE | required | ontology namespace the class names live in |
DP_LEGEND_MAX_BYTES | 600 | budget combined with the signpost stage |
DP_LEGEND_MAX_AGE_MIN | 1440 | refuse an older gazetteer |
DP_LEGEND_LOG | ~/.local/log/dp-legend-events.jsonl | decision log (- disables) |
DP_LEGEND_DICT | /usr/share/dict/words | word list for the guard |
Decision log
Every read the stage sees appends one JSON line with the following fields:
toolandreftext_bytesandraw_hits- the
shownlabels bytesandbudgetlatency_us, which includesload_us, the time to read and compile the gazetteerskipped, the reason for silence
The log is separate from the signpost event log, so Read events do not dilute the signpost evaluation rows. It is also the data source for the value metric: the share of shown entities the agent later queries or cites.
Refreshing
dp legend-refresh # writes the gazetteer atomically, reports each class on stderr
Run it from a timer, for example hourly. If every class query fails, refresh exits non-zero and leaves the existing file in place, so a Quipu outage never replaces a good gazetteer with an empty one.
dp config
Show or modify configuration
Usage
dp config
dp config <key>
dp config <key> <value>
Flags
None
Examples
$ dp config
Configuration (from /home/user/.dp/config.toml):
db_path: /home/user/.dp/desires.db
default_source: claude-code
known_tools: Read,Write,Edit,Bash,Glob,Grep,Task,WebFetch,WebSearch,NotebookEdit
default_format: json
$ dp config default_source
claude-code
$ dp config default_source cursor
Updated configuration: default_source = cursor
$ dp config known_tools "Read,Write,Edit,Bash,CustomTool"
Updated configuration: known_tools = Read,Write,Edit,Bash,CustomTool
$ dp config db_path /home/user/projects/myapp/.dp/desires.db
Updated configuration: db_path = /home/user/projects/myapp/.dp/desires.db
Details
The config command manages desire_path’s persistent configuration. Configuration is stored in ~/.dp/config.toml and applies to all invocations unless overridden by flags.
Valid configuration keys:
db_path
Path to the SQLite database file. Default: ~/.dp/desires.db
Use this to:
- Store project-specific desires in the project directory
- Separate desire data by workspace or client
- Back up or version control desire history
Can be overridden per-command with the global --db flag.
default_source
Default source identifier for commands that accept --source. Default: empty
Use this to:
- Avoid typing
--sourcerepeatedly - Set a consistent source when working primarily with one AI tool
- Simplify commands:
dp recordinstead ofdp record --source claude-code
Can be overridden per-command with the --source flag.
known_tools
Comma-separated list of known tool names for similarity matching. Default: Read,Write,Edit,Bash,Glob,Grep,Task,WebFetch,WebSearch,NotebookEdit
Use this to:
- Customize the tool set for your environment
- Add custom tools to the suggestion engine
- Match your AI coding tool’s specific tool naming conventions
Can be overridden in dp similar with the --known flag.
default_format
Default export format: json or csv. Default: json
Use this to:
- Set a preferred output format for exports
- Simplify commands:
dp exportinstead ofdp export --format csv
Can be overridden in dp export with the --format flag.
Configuration precedence (highest to lowest):
- Command-line flags (e.g.,
--db,--source) - Config file values
- Built-in defaults
To reset a configuration value to its default, delete the key from ~/.dp/config.toml manually or set it to an empty string:
$ dp config default_source ""
Updated configuration: default_source = (unset)
If the config file doesn’t exist, it’s created automatically on first write. The file is TOML formatted for easy manual editing:
db_path = "/home/user/.dp/desires.db"
default_source = "claude-code"
known_tools = ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "Task", "WebFetch", "WebSearch", "NotebookEdit"]
default_format = "json"
Using agents
A source plugin translates an agent event into an invocation: tool name, inputs,
error, session, and working directory. Failed invocations also become desires.
The release’s dp sources command lists the plugins and installer status.
Claude Code
dp init --source claude-code
dp sources
The installer merges ~/.claude/settings.json. In v0.4.0 it adds:
| event | command | purpose |
|---|---|---|
| PostToolUse and PostToolUseFailure | dp ingest --source claude-code | Capture calls and failures |
| PreToolUse, Bash | dp signpost-prefetch | Prepare optional search guidance |
| PostToolUse and PostToolUseFailure, Bash | dp signpost | Offer search guidance |
| PostToolUseFailure | dp pave-correct | Consult correction rules |
Review the settings after installation. These entries use command hooks without
an async flag; do not assume capture has zero latency. For a capture-only
configuration and verification, see Claude Code.
Signposting needs a search backend; see the evaluation plan
and dp init --help for the backend flags. Pave covers
active interception as a separate opt-in.
Other source plugins
dp sources in v0.4.0 lists codex, cursor, and kiro as well as claude-code.
Use the installed binary’s dp init --help and inspect generated configuration
for the chosen source. A parser or installer being present does not establish
that every event is emitted by every agent version. The
Codex design note records its protocol.
For custom event producers, pipe their supported payloads into
dp ingest --source <name>. See writing a source plugin
for the interface and architecture for data flow.
Claude Code
Ensure dp is available on the PATH inherited by the agent process, then run:
dp init --source claude-code
The default installer adds ingestion, signposting, and correction hooks;
Using agents lists them. Inspect ~/.claude/settings.json after
installation. Existing settings are merged rather than replaced.
Capture-only configuration
If you want only capture, merge the following into your existing settings and retain any unrelated hooks. This is a manual alternative to the broader installer:
{
"hooks": {
"PostToolUse": [{"matcher": ".*", "hooks": [{"type": "command", "command": "dp ingest --source claude-code"}]}],
"PostToolUseFailure": [{"matcher": ".*", "hooks": [{"type": "command", "command": "dp ingest --source claude-code"}]}]
}
}
Both events use the same ingest path. Errors become failure patterns; successful
calls remain invocations. record is a deprecated compatibility command.
--track-all is no longer an installation flag: ingestion captures both outcomes.
Verify capture
First run the isolated fixture
to test the binary and storage. Then generate a tool event in the agent and check
dp list and dp paths. A shell fixture proves ingestion, not that the agent
executed the configured hook.
If no events arrive, inspect PATH and the installed settings. Check that the
agent’s user can write ~/.dp/desires.db. The standard installed command hooks
are not marked asynchronous; no latency guarantee is implied here.
Corrections and signposting
Aliases are stored mappings. Pave describes the separate PreToolUse interception setup. Signposting is optional search guidance with its own evaluation contract.
Data handling
Captured inputs and errors can contain project data. Keep the local database and exports within the intended audience. See configuration for database and capture settings.
Writing a Source Plugin
Source plugins let dp integrate with any AI coding assistant. If you’re using a tool that dp doesn’t yet support, you can write a plugin in about 50 lines of Go. This guide shows you how.
Plugin Interface
Every plugin implements source.Source:
package source
type Source interface {
// Name returns the unique identifier for this source (e.g., "my-tool").
Name() string
// Extract parses raw bytes and returns universal fields.
Extract(raw []byte) (*Fields, error)
}
The Extract method receives raw bytes (usually JSON from a hook or log) and returns structured fields:
type Fields struct {
ToolName string // Required: the tool that was invoked
InstanceID string // Optional: session or invocation ID
ToolInput json.RawMessage // Optional: raw JSON input to the tool
CWD string // Optional: working directory
Error string // Optional: error message (for failures)
Extra map[string]json.RawMessage // Source-specific fields
}
Only ToolName is required. Everything else is optional. Source-specific fields (anything not mapped to the universal fields above) go into Extra as raw JSON.
Minimal Plugin Example
Here’s a skeleton plugin for a hypothetical “my-tool”:
package source
import (
"encoding/json"
"fmt"
)
// myTool implements Source for the "my-tool" AI assistant.
type myTool struct{}
// Register the plugin at startup.
func init() {
Register(&myTool{})
}
// Name returns the source identifier.
func (m *myTool) Name() string {
return "my-tool"
}
// Extract parses the my-tool JSON format and returns Fields.
func (m *myTool) Extract(raw []byte) (*Fields, error) {
// Example input format:
// {"name": "read_file", "input": {...}, "err": "not found", "session": "abc123", "dir": "/tmp"}
var payload struct {
Name string `json:"name"`
Input json.RawMessage `json:"input"`
Err string `json:"err"`
Session string `json:"session"`
Dir string `json:"dir"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, fmt.Errorf("my-tool: parsing JSON: %w", err)
}
if payload.Name == "" {
return nil, fmt.Errorf("my-tool: missing required field: name")
}
fields := &Fields{
ToolName: payload.Name,
InstanceID: payload.Session,
ToolInput: payload.Input,
CWD: payload.Dir,
Error: payload.Err,
}
return fields, nil
}
Save this as internal/source/mytool.go.
Field Mapping Guidelines
Map your tool’s output to universal fields using these conventions:
| Universal Field | Purpose | Examples |
|---|---|---|
ToolName | The tool/function/command that was invoked | "Read", "execute_shell", "query_database" |
InstanceID | Session, request, or invocation ID | Session UUID, request trace ID, user ID |
ToolInput | Raw JSON input parameters | Tool arguments as JSON (preserve as-is) |
CWD | Working directory at time of call | "/home/user/project" |
Error | Error message if the call failed | "File not found", "Permission denied" |
Extra | Everything else | Anything specific to your tool |
ToolName (Required)
Must not be empty. This is the key field—dp aggregates desires by tool name. Use the name the AI tried to invoke, even if it doesn’t exist.
InstanceID (Optional)
Ideally a session or request ID that groups related tool calls. Used for:
- Session-level analysis
- Tracing a sequence of calls
- Filtering by session in queries
If your tool doesn’t have sessions, use a user ID, request timestamp, or leave it empty.
ToolInput (Optional)
Preserve the original input as raw JSON. Don’t parse or transform it—just copy the bytes. This allows:
- Inspecting common input patterns
- Debugging why a tool failed
- Replaying tool calls
If input is not JSON, wrap it in a JSON string:
fields.ToolInput = json.RawMessage(`"` + rawInput + `"`)
CWD (Optional)
The working directory when the tool was invoked. Useful for:
- Resolving relative paths
- Understanding context
- Project-level aggregation
If your tool doesn’t provide this, leave it empty.
Error (Optional)
Only set this if the tool call failed. For failures, this field should contain a human-readable error message. For successes, leave it empty.
dp uses Error != "" to determine if a desire should be recorded.
Extra (Optional)
Everything not mapped to the universal fields goes here. Examples:
- Internal IDs (like Claude Code’s
tool_use_id) - Metadata (like
transcript_path,permission_mode) - Timing information (like
duration_ms) - Custom tags or labels
Store as raw JSON:
fields.Extra = map[string]json.RawMessage{
"tool_id": json.RawMessage(`"xyz123"`),
"duration_ms": json.RawMessage(`42`),
}
Full Plugin with Extra Fields
Expanding the example:
func (m *myTool) Extract(raw []byte) (*Fields, error) {
var payload map[string]json.RawMessage
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, fmt.Errorf("my-tool: parsing JSON: %w", err)
}
var toolName string
if v, ok := payload["name"]; ok {
if err := json.Unmarshal(v, &toolName); err != nil {
return nil, fmt.Errorf("my-tool: parsing name: %w", err)
}
}
if toolName == "" {
return nil, fmt.Errorf("my-tool: missing required field: name")
}
fields := &Fields{ToolName: toolName}
// Map optional universal fields
if v, ok := payload["session"]; ok {
json.Unmarshal(v, &fields.InstanceID)
}
if v, ok := payload["input"]; ok {
fields.ToolInput = v
}
if v, ok := payload["dir"]; ok {
json.Unmarshal(v, &fields.CWD)
}
if v, ok := payload["err"]; ok {
json.Unmarshal(v, &fields.Error)
}
// Collect everything else into Extra
knownFields := map[string]bool{
"name": true, "session": true, "input": true, "dir": true, "err": true,
}
extra := make(map[string]json.RawMessage)
for k, v := range payload {
if !knownFields[k] {
extra[k] = v
}
}
if len(extra) > 0 {
fields.Extra = extra
}
return fields, nil
}
This pattern—unmarshal to map[string]json.RawMessage, extract known fields, collect unknowns into Extra—works for most JSON-based tools.
Installer Interface (Optional)
If you want to support dp init --source my-tool, implement source.Installer:
type Installer interface {
Install(opts InstallOpts) error
}
type InstallOpts struct {
SettingsPath string // Override settings file location (empty = use default)
TrackAll bool // Install hooks for all invocations (not just failures)
}
Example:
func (m *myTool) Install(opts InstallOpts) error {
settingsPath := opts.SettingsPath
if settingsPath == "" {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("determine home directory: %w", err)
}
settingsPath = filepath.Join(home, ".mytool", "config.json")
}
// Read existing config
data, err := os.ReadFile(settingsPath)
if os.IsNotExist(err) {
data = []byte("{}")
} else if err != nil {
return fmt.Errorf("read config: %w", err)
}
var config map[string]interface{}
if err := json.Unmarshal(data, &config); err != nil {
return fmt.Errorf("parse config: %w", err)
}
// Add hook configuration
// (Details depend on your tool's hook system)
config["hooks"] = map[string]interface{}{
"on_failure": "dp record --source my-tool",
}
if opts.TrackAll {
config["hooks"].(map[string]interface{})["on_call"] = "dp ingest --source my-tool"
}
// Write config back
newData, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
if err := os.WriteFile(settingsPath, newData, 0o644); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
Make sure the implementation is idempotent—running dp init --source my-tool twice shouldn’t break anything or add duplicate hooks.
Registering the Plugin
In your plugin file’s init() function:
func init() {
Register(&myTool{})
}
Then import the plugin package for side effects in cmd/dp/main.go:
package main
import (
_ "github.com/scbrown/desire-path/internal/source" // registers claude-code
_ "github.com/scbrown/desire-path/internal/source/mytool" // register your plugin
)
If your plugin lives in the same package as claudecode.go (i.e., internal/source/mytool.go), you don’t need a separate import—the package init() runs automatically.
Testing
Write tests in internal/source/mytool_test.go:
package source
import (
"encoding/json"
"testing"
)
func TestMyToolExtract(t *testing.T) {
plugin := &myTool{}
input := `{"name":"read_file","input":{"path":"/tmp/test.txt"},"err":"not found","session":"abc","dir":"/home/user"}`
fields, err := plugin.Extract([]byte(input))
if err != nil {
t.Fatalf("Extract failed: %v", err)
}
if fields.ToolName != "read_file" {
t.Errorf("ToolName = %q, want %q", fields.ToolName, "read_file")
}
if fields.InstanceID != "abc" {
t.Errorf("InstanceID = %q, want %q", fields.InstanceID, "abc")
}
if fields.Error != "not found" {
t.Errorf("Error = %q, want %q", fields.Error, "not found")
}
if fields.CWD != "/home/user" {
t.Errorf("CWD = %q, want %q", fields.CWD, "/home/user")
}
var input map[string]interface{}
if err := json.Unmarshal(fields.ToolInput, &input); err != nil {
t.Fatalf("ToolInput not valid JSON: %v", err)
}
if input["path"] != "/tmp/test.txt" {
t.Errorf("ToolInput.path = %v, want %q", input["path"], "/tmp/test.txt")
}
}
func TestMyToolMissingName(t *testing.T) {
plugin := &myTool{}
input := `{"input":{},"err":"error"}`
_, err := plugin.Extract([]byte(input))
if err == nil {
t.Fatal("expected error for missing name, got nil")
}
}
Run tests:
go test ./internal/source/...
Integration Testing
Test the full pipeline:
# Build dp with your plugin
go install ./cmd/dp
# Test extraction via dp record
echo '{"name":"test_tool","err":"test error","session":"manual"}' | dp record --source my-tool
# Verify it was recorded
dp list --limit 1
Real-World Example: Claude Code Plugin
See internal/source/claudecode.go for a complete, production-quality plugin. Key features:
- Strict JSON validation with helpful error messages
- Mapping Claude-specific fields (
session_id,tool_use_id) to universal + Extra - Idempotent
Install()implementation with hook merging - Comprehensive tests
Use it as a reference when building your own plugin.
Plugin Checklist
Before shipping your plugin:
- Implements
source.Sourceinterface -
Name()returns a unique, kebab-case identifier -
Extract()validatesToolNameis non-empty -
Extract()returns helpful error messages - Maps universal fields correctly
- Stores extra fields in
Extra - Registers via
source.Register()ininit() - Imported in
cmd/dp/main.go - Has tests covering success and error cases
- Documented in
docs/book/src/integrations/<plugin-name>.md - (Optional) Implements
source.Installerfordp initsupport - (Optional)
Install()is idempotent
Distribution
If your plugin is for a public tool, consider submitting it upstream via a pull request to the desire-path repo. If it’s for an internal/proprietary tool, maintain it in a separate module and import it:
import (
_ "github.com/yourorg/desire-path-my-tool-plugin"
)
Plugins don’t need to live in the main dp repository—they just need to call source.Register() at startup.
Next Steps
- Study the Claude Code plugin for a real example
- Read the Architecture to understand dp’s data flow
- Check out the source package godoc for API details
Evaluations
Periodic data-driven evaluations of desirepath’s collected failure data. Each evaluation analyzes the current dataset, identifies actionable patterns, and tracks improvements over time.
| Evaluation | Date | Records | Key Finding |
|---|---|---|---|
| 2026-03-05 Baseline | 2026-03-05 | 13,925 | 82% of failures are Bash CLI misuse; 3 aliases prevent ~250/mo |
| 2026-08-27 Signposting pilot | 2026-08-27 | 70 runs | 47 eligible semantic attempts exceeded the 150 ms budget; zero signposts shown |
| 2026-08-27 Local warm signposting | 2026-08-27 | 70 composite runs | Resident local path delivered signposts at 69 ms successful-delivery p95; zero adoption |
Evaluation: 2026-03-05 Baseline
Analyst: aegis/crew/stryder Date: 2026-03-05 Dataset: 13,925 desires, 13,806 invocations (2026-02-09 to 2026-03-05) Source: 100% Claude Code PostToolUseFailure hooks across Gas Town multi-agent system
Executive Summary
First comprehensive evaluation of desirepath data from a production multi-agent system. 13,925 tool failures recorded across 25 days from ~15 agents. Three key findings: (1) CLI misuse dominates failures at 93%, (2) only 3 aliases exist covering a fraction of correctable errors, (3) MCP infrastructure downtime causes 440 silent failures with no alerting.
Dataset Overview
| Metric | Value |
|---|---|
| Total desires | 13,925 |
| Unique tool names | 29 |
| Date range | 2026-02-09 to 2026-03-05 (25 days) |
| Daily average | 557 failures/day |
| Source | claude-code (13,903), transcript-analysis (22) |
| Aliases configured | 3 |
Failure Distribution by Tool
| Tool | Count | % | Category |
|---|---|---|---|
| Bash | 12,932 | 92.9% | Command execution |
| Read | 502 | 3.6% | File access |
| mcp__homelab__batch_probe | 193 | 1.4% | MCP infrastructure |
| mcp__homelab__prometheus_query | 49 | 0.4% | MCP infrastructure |
| WebFetch | 47 | 0.3% | Network |
| mcp__homelab__container_status | 43 | 0.3% | MCP infrastructure |
| mcp__homelab__service_health | 41 | 0.3% | MCP infrastructure |
| Other MCP tools | 76 | 0.5% | MCP infrastructure |
| Other | 42 | 0.3% | Various |
Analysis by Error Category
1. CLI Misuse (Bash) — 93% of all failures
The 12,932 Bash failures break down into subcategories:
| Subcategory | Count | % of Bash | Actionable? |
|---|---|---|---|
| gt unknown commands | 743 | 5.7% | Yes — document or implement |
| bd unknown flags | 364 | 2.8% | Yes — aliases or flag additions |
| Command not found | 178 | 1.4% | Yes — install or alias |
| Git push rejected | 334 | 2.6% | Partially — workflow issue |
| Not a git repo | 188 | 1.5% | Yes — cwd detection |
| Git unstaged changes | 62 | 0.5% | Partially — workflow issue |
| bd sync required | 112 | 0.9% | Yes — auto-sync or docs |
| Normal dev errors | ~10,951 | 84.6% | No — expected during development |
Key insight: ~15% of Bash failures (1,981) are correctable through aliases, documentation, or tooling improvements. The remaining 85% are normal development friction (test failures, build errors, typos).
2. Top Non-Existent GT Commands
Agents repeatedly try commands that don’t exist:
| Command | Count | What Agent Expected |
|---|---|---|
| gt deacon pending | 175 | Check deacon task queue |
| gt await-signal | 101 | Wait for async event |
| gt mol hook | 43 | Hook a molecule (correct: gt hook) |
| gt health | 41 | System health check |
| gt plugin status | 39 | Check plugin state |
| gt mq integration list | 28 | List MQ integrations |
| gt wisp | 26 | Manage wisps directly |
| gt plugin due | 25 | Check plugin schedule |
| gt sessions | 20 | List active sessions (correct: gt session) |
| gt rig health | 19 | Rig health check |
Recommendation: File desire-path beads for the top 5. Either implement or create aliases with helpful error messages.
3. Top Non-Existent BD Flags
| Flag Attempted | Count | Correct Alternative |
|---|---|---|
| –gated | 64 | (removed feature) |
| –wisp | 35 | (not a filter) |
| –assign | 27 | –assignee (-a) |
| –rig | 23 | (use prefix routing) |
| –comment | 21 | –append-notes |
| –prefix | 14 | (use prefix routing) |
| –mol | 11 | (not a filter) |
| –stdin | 11 | (pipe via heredoc) |
| –owner | 10 | –assignee (-a) |
| –epic | 7 | (not implemented) |
Current alias coverage: Only 3 aliases exist:
--assign→--assignee(bd flag) — covers 27 failures--owner→--assignee(bd flag) — covers 10 failuresbd note X→bd update X --append-notes— covers ~8 failures
Gap: --comment → --append-notes could prevent 21 more failures/month.
--gated appears 64 times but was a removed feature — needs a helpful error message.
4. Read Tool Failures
| Error Type | Count | Root Cause |
|---|---|---|
| EISDIR (read directory) | 162 | Agent used Read instead of ls/Bash |
| File not found | 218 | Agent guessed wrong path |
| File too large | 8 | Exceeded 25K token limit |
Recommendation: Bobbin could inject directory tree output on EISDIR errors (tracked internally). tree command now installed on the eval hosts.
5. MCP Server Downtime
| MCP Tool | Failures | Error |
|---|---|---|
| batch_probe | 193 | no available server |
| prometheus_query | 49 | no available server |
| container_status | 43 | no available server |
| service_health | 41 | no available server |
| list_containers | 21 | no available server |
| container_logs | 20 | no available server |
| Other MCP | 73 | no available server |
Total: 440 MCP failures, all “no available server” — homelab-mcp was down. No alerts fired. Tracked internally: add monitoring.
6. Env-Need Analysis (dp env-needs output)
dp env-needs reports 43 “missing tools” but many are false positives.
The env-need categorizer incorrectly flags shell builtins and installed tools:
| Reported Missing | Actual Status | Issue |
|---|---|---|
| ls, cd, cat, echo | Shell builtins | False positive — these are Bash builtins/coreutils, always available |
| ssh, git, grep | Installed | False positive — exit code != “not found” |
| just | Not installed | True positive — just (justfile runner) not on the eval host |
| dig, nslookup, host | Not installed | True positive — DNS tools missing |
| sqlite3 | Not installed | True positive — was missing, now installed |
| python3 | Installed | False positive — python3 exists, python doesn’t |
Recommendation: env-need categorizer needs refinement. Should check if the command actually produced “command not found” vs other errors. High false positive rate (>50%) reduces trust in the output.
Agent Workspace Analysis
| Workspace | Failures | Primary Issues |
|---|---|---|
| deacon | 3,007 | gt mol squash wrong flags, patrol loop errors |
| aegis/crew/ellie | 1,042 | gt mol squash, read failures |
| deacon/dogs/boot | 982 | Patrol loop startup errors |
| aegis/crew/malcolm | 627 | CLI misuse, path guessing |
| aegis/witness | 603 | Patrol loop errors |
| aegis/crew/goldblum | 603 | Build errors, flag guessing |
| aegis/refinery/rig | 510 | Merge queue errors |
| aegis/crew/ian | 443 | Build/test errors |
| bucket/refinery/rig | 418 | Merge queue errors |
| mayor | 394 | CLI dispatch errors |
Insight: Deacon + dogs account for 29% of all failures. Most are repetitive patrol loop errors (gt mol squash with wrong flags). A single alias or patrol fix would eliminate thousands of failures.
Turn Pattern Analysis
dp turns shows tool call sequences. The dominant pattern is long Bash-only
turns (264, 152, 151 calls). This indicates agents spending many turns retrying
failed Bash commands rather than changing approach.
Recommendation: Consider a “struggling detection” feature — if an agent has >5 consecutive Bash failures on similar commands, surface documentation or suggest an alternative approach.
Alias Effectiveness
Current Aliases (3)
| Alias | Type | Estimated Monthly Prevents |
|---|---|---|
| –assign → –assignee | flag (bd) | ~27 |
| –owner → –assignee | flag (bd) | ~10 |
| bd note → bd update –append-notes | regex | ~8 |
| Total | ~45 |
Recommended New Aliases (5)
| Alias | Type | Estimated Monthly Prevents |
|---|---|---|
| –comment → –append-notes | flag (bd) | ~21 |
| gt sessions → gt session | command | ~20 |
| gt mol hook → gt hook | command | ~43 |
| gt health → gt rig status | command | ~41 |
| –no-digest → (removed, explain) | flag (bd) | ~8 |
| Total | ~133 |
Unrealized Value
With current 3 aliases: ~45 prevents/month (0.3% of failures) With 8 aliases: ~178 prevents/month (1.3% of failures) With doc-mapping for top 50 patterns: ~800 informed/month (5.7% of failures)
dp Feature Utilization Assessment
| dp Feature | Currently Used? | Value | Action Needed |
|---|---|---|---|
dp record | Yes (PostToolUseFailure hook) | High | Working well |
dp ingest | Yes (via record) | High | Working well |
dp stats | Manually by operators | Medium | Could auto-report |
dp paths | Manually by operators | Medium | Good for evaluation |
dp aliases | Yes (3 configured) | High | Add 5 more aliases |
dp pave --hook | Yes (PreToolUse) | High | Working well |
dp pave --agents-md | Not used | Medium | Should generate rules |
dp env-needs | Not used (high false positives) | Low | Needs refinement |
dp turns | Not used | Low | Useful for evaluation only |
dp similar | Not used | Low | Niche use case |
dp suggest | Not implemented yet | High | Would synthesize all signals |
dp serve | Not used | Low | No consumer yet |
Immediate Actions
- Add 5 new aliases —
dp aliasfor –comment, gt sessions, gt mol hook, gt health, –no-digest - Run
dp pave --agents-md— Generate AGENTS.md rules from alias data and append to agent instruction files - Fix env-needs false positives — Check for “command not found” in error text, not just exit code
Future Features Needed
- dp suggest (planned, dp-9 design exists) — Synthesize all data sources into prioritized recommendations
- dp map — Map documentation to failing tool patterns
- Struggling detection — Identify agents retrying same failure pattern
- Recovery tracking — Detect when fixes reduce failures
Methodology Notes
- Data extracted via Python sqlite3 queries against ~/.dp/desires.db
- dp CLI commands (
stats,paths,env-needs,aliases,turns) used for built-in analytics - Error categorization done via substring matching on error text
- Agent identification via cwd field (workspace path → agent name)
- All counts are raw (no dedup by session or time window)
Next Evaluation
Schedule next evaluation for 2026-03-15 (10 days). Track:
- Did the 5 new aliases reduce failures?
- Did MCP monitoring reduce downtime?
- Did bobbin tagging sweep improve injection quality (feedback noise ratio)?
- Total desire count growth rate
- New error patterns emerging
Signposting pilot: production latency budget
Date: 2026-08-27
Models: Codex CLI 0.147.0 and Claude Code 2.1.247
Design: 7 pinned tasks × 2 model families × 5 conditions
Runs: 70/70 completed, one replicate, no failed artifacts
Result
At the production 150 ms semantic timeout, signposting delivered no intervention. The hooks observed 78 Bash literal searches. Forty-seven were eligible for a semantic request (all searches in always-signpost plus null or high-cardinality searches in gated mode), but all 47 timed out and zero signposts were shown.
This pilot therefore supports no adoption, signpost-precision, correctness, or resource-efficiency claim for signposting. The gated and always-on cells were operationally equivalent to their literal-search baseline because the semantic side never reached the model.
A separately labeled 10-second sensitivity probe established that the hook path itself works: a repo-scoped Bobbin request returned three candidates after 2.275 seconds and the Codex model received the signpost. The model did not adopt it on that single probe. This sensitivity result is not included in the 70-run summary.
Reproducibility checks
- All five corpus directories were checked out at the SHA declared in
eval/corpora.jsonbefore execution. - The assignment file contained 70 unique IDs and exactly seven rows for every model-family/condition cell.
- Both agents ran read-only. The runner retained one raw JSONL transcript and one hook-event stream per assignment outside the repository.
- The committed result file has 70 rows; the scorer produced ten summary cells.
- Four replacement-arm rows from an early shim that intercepted shell-startup probes were invalidated and rerun after the shim was narrowed to the explicit evaluation command. Invalidated artifacts were not mixed into these results.
Secondary observations
Prompt instruction produced eight direct Bobbin invocations (six Claude, two Codex). They are not signpost adoption because no signpost was shown. The raw exact-location scorer marked 36 of 70 final answers correct, but this figure is not suitable for a paper claim: post-run audit found acceptable nearby or caller locations that were absent from several single-location fixtures. The fixtures and adjudicator need a blinded acceptance-set pass before correctness comparisons are reported.
Runtime cost also varied substantially by family. Claude averaged roughly 205k–226k recorded tokens per cell-run depending on condition and 4.9–7.1 Bash turns; Codex averaged roughly 73k–111k tokens and 2.3–3.6 Bash turns. With one replicate and seven tasks, these are pilot diagnostics, not general estimates.
Decision
Do not expand to several hundred tasks or draft the planned efficacy preprint from this dataset. First choose and measure a latency remedy that preserves the timeout-and-drop contract—such as a local/warm index or speculative prefetch—then rerun the same pilot. Independently, replace exact-string final-answer scoring with blinded acceptance sets before consuming another full campaign budget.
Artifacts:
eval/results/2026-08-27-pilot-results.jsonleval/results/2026-08-27-pilot-summary.jsoneval/run-campaign.sh
Signposting pilot: local warm delivery
Date: 2026-08-27
Models: Codex CLI 0.147.0 and Claude Code 2.1.247
Budget: 150 ms, unchanged from the production contract
Truth set: seven blinded, precommitted path/line acceptance sets
Result
A resident, repo-local Bobbin index made signposting fast enough to deliver
inside the production budget. In the final affected-cell rerun (seven tasks ×
two families × always-signpost/gated-signpost), all 28 assignments completed
with zero failed artifacts and exactly seven rows in each of four cells.
The hooks observed 55 eligible semantic events. Six produced model-visible signposts, with successful-delivery p95 69 ms. Three of 28 assignments saw at least one signpost. No signpost was adopted. The remaining eligible calls timed out or returned no candidate, so all-eligible p95 remained at the 150 ms drop boundary. The delivery-path gate is satisfied; delivery coverage and efficacy are not.
What changed
PreToolUsecan launch detached, repo-scoped semantic work and publish an atomic cache entry.PostToolUseconsumes a completed cache entry or uses its bounded context for a direct request to an already-warm local Bobbin server.DP_SIGNPOST_PREFETCH=0disables redundant speculation when the resident local server is itself the warm path.- The original literal command is still never wrapped or modified. Cold start, malformed input, server error, and deadline expiry remain silent baseline.
- Correctness is scored from
eval/acceptance-sets.jsonl, not exact strings embedded in model prompts. Missing or duplicate acceptance sets refuse. - Campaign scoring now fails closed; a parse failure cannot increment the completion count without appending a valid row.
Reproducibility and composition
The final 70-row result combines the 28 freshly remeasured affected cells with 42 baseline/prompt/replacement rows from the same pinned-corpus, blinded-set campaign. Those six cells do not install the changed signposting hook. The composite has 70 unique assignment IDs and exactly seven rows in every model-family/condition cell.
The local index covered the five pinned corpora. Bobbin’s installed ONNX loader needed the host’s existing versioned runtime path; after model startup was amortized by a resident HTTP process, a separate 20-query probe measured 17.3 ms p95. The campaign result above is stronger because it includes real hook parsing, model execution, and model-visible context.
Interpretation
This is a delivery result, not an efficacy result. The composite blinded scorer marked 53 of 70 final locations correct, but the intervention produced zero adoptions and zero signpost-correct assignments. No efficacy preprint should be written from these pilots. The negative cold-path result and this local-path delivery result are methods material for later work.
Artifacts:
eval/acceptance-sets.jsonleval/results/2026-08-27-local-warm-results.jsonleval/results/2026-08-27-local-warm-summary.jsoneval/run-campaign.sh
Stage 0: delivery coverage, certified with no model in the loop
Date: 2026-08-29
Provider quota spent: zero — no model runs in this measurement
Harness: eval/replay-delivery.sh over eval/replay-workload.jsonl (185 rows)
Index: eval/build-local-index.sh over the five pinned corpora in eval/corpora.json
Result
Against a resident local index holding all five pinned corpora, signpost delivery is not the bottleneck. Four arms, every eligible event delivered:
| arm | eligible | shown | coverage | timed out | zero candidate | warm hits |
|---|---|---|---|---|---|---|
always-signpost direct | 185 | 185 | 100% | 0 | 0 | 0 |
gated-signpost direct | 35 | 35 | 100% | 0 | 0 | 0 |
always-signpost prefetch 500 ms | 185 | 185 | 100% | 0 | 0 | 185 |
gated-signpost prefetch 500 ms | 35 | 35 | 100% | 0 | 0 | 35 |
The Stage 1 gate was coverage >= 25%. It is met with room, and the campaign’s published 6.5% is explained without appealing to retrieval quality.
Latency on the two direct arms, from the committed events: p50 92 ms (always) and 97 ms (gated), max 134 ms, against the 150 ms PostToolUse contract. The prefetch arms record no lookup latency at all — every event was a warm cache hit, so the PostToolUse hook made no request.
A deliberately cold arm — server restarted, replay begun as soon as it accepted a connection — also delivered 185/185, with the first 20 events at p50 126 ms against 93 ms for the rest and a maximum of 136 ms. Delivery survives a cold start, but the headroom at that maximum is ~14 ms, so this is a result about a settled server on this hardware and not a portable guarantee.
An earlier direct arm delivered 34 of 35 with one event landing exactly on the 150 ms boundary. Coverage on the direct path is therefore ~97-100%, not a flat 100%; the prefetch arms had no such event.
What the earlier campaign was actually measuring
Two independent causes, neither of them retrieval quality:
-
The prefetch head start, not the commit.
9c23bd4only adds theDP_SIGNPOST_PREFETCH=0opt-out; the published run disabled prefetch by environment. Disabling it collapses the effective semantic budget from the 5 s concurrent prefetch to the 150 ms direct contract while leaving that contract’s number unchanged, which is why the drop looked like a code regression. -
Backend corpus coverage, which fixes the “zero-candidate floor” in place. The shared fleet search backend does not index every pinned corpus. Measured with controls in both directions, one query,
limit=3:repo filter count desire-path3 gastown3 beads3 cobra0 ripgrep0 desire_path(deliberate misspelling, negative control)0 Two of the five corpora are absent from that index, and 33 of the 185 workload rows (17.8%) are in them. An event in an unindexed repo returns no candidate no matter how much latency budget it is given, and it is indistinguishable in the logs from a semantic miss. The negative control shows the same query returns 0 for a repo name that does not exist, which is what a name mismatch would also produce.
Against the local index built from the corpora themselves, zero-candidate events are 0 of 185.
The two builds, replayed rather than reasoned about
The campaign’s natural experiment paired two builds 15 minutes apart. Reading
the diff says 9c23bd4 adds only the DP_SIGNPOST_PREFETCH=0 opt-out, so it
cannot itself cause a coverage drop — but that is an inference, and the whole
point of Stage 0 is not to ship one. Both commits were built and replayed
against the same index, same workload, same conditions:
| build | arm | eligible | shown | coverage |
|---|---|---|---|---|
276086a | direct | 35 | 35 | 100% |
276086a | prefetch 500 ms | 35 | 35 | 100% |
9c23bd4 | direct | 35 | 35 | 100% |
9c23bd4 | prefetch 500 ms | 35 | 35 | 100% |
The builds are indistinguishable at equal environment. The published pairing varied the environment, not the code.
The honest limit of a coverage number
Coverage says a signpost reached the model. It says nothing about whether the
signpost was worth reading, and this harness cannot tell you: the backend
returns nearest neighbours, so a deliberately nonsensical query
(zzqq flurbleplex nonexistent tokenoid) also returns 3 results. With no
relevance floor, a “candidate exists” predicate is satisfied almost everywhere,
and 100% coverage is the expected reading rather than a strong one.
So Stage 0 certifies exactly one thing: delivery is no longer the constraint, and a campaign that spends model quota will now actually exercise the intervention. Adoption and correctness remain unmeasured, and the two pilots that did measure them found zero adoptions.
Reproducing
eval/build-local-index.sh --corpus-root <dir of the five pinned checkouts> --index <dir>
ORT_DYLIB_PATH=$HOME/.local/lib/onnxruntime/libonnxruntime.so bobbin serve --http --port 3131 <dir>
eval/replay-delivery.sh --dp ./dp --bobbin-server http://127.0.0.1:3131/search \
--out eval/results --condition gated-signpost
build-local-index.sh carries the ONNX loader recipe that the local-warm pilot
worked around without writing down: ort dlopens libonnxruntime.so by bare
name and panics if the loader cannot find it, and ORT_DYLIB_PATH must name a
real file, not a directory and not a bare soname. The script refuses to run
if it does not, because a missing dylib otherwise indexes nothing and the empty
corpus reads as a genuine coverage measurement of zero.
Artifacts
Event-level JSONL, one row per replayed hook invocation, is committed beside the summaries — the pilots wrote theirs to a temp directory, which is why analysing them needed archaeology:
eval/results/2026-08-29-stage0-events-<arm>.jsonleval/results/2026-08-29-stage0-coverage-<arm>.jsoneval/results/2026-08-29-stage0-summary.json
A defect in the first cut of this harness, worth knowing when reading older arms
eligible originally counted every replayed row. Under gated-signpost most
rows never reach the searcher, so they were scored as eligible-but-undelivered:
the first gated arm read 33/185 = 18% for a run that had delivered 33 of 35,
94%. Eligibility is now derived from the marks a lookup actually leaves — a
shown signpost, a warm cache hit, or a nonzero latency — rather than from the
predicate field, which records the literal-search outcome and is none for
rows that always-signpost searches anyway.
Arm names now carry the condition too. They did not, so a gated run silently overwrote an always-signpost run’s events in the same output directory.
Stage 1 preregistration — signposting at scale, plus a payload arm
Filed: 2026-09-05, before the first assignment ran.
Envelope: 200 assignments (main matrix) + 40 (payload arm), authorised on
aegis-vkirh and scoped on aegis-3npm7w.
Gate cleared: Stage 0 certified delivery coverage at 100% against the 25%
gate (2026-08-29-stage0-delivery-coverage.md).
This document is committed before the campaign so the analysis cannot be chosen after seeing the numbers. Everything below is fixed at filing time.
Why there are two matrices and not one
The two pilots agree on one thing: agents do not take pointers. Across both, zero signposts were adopted, and the second one established that the instrument — not the intervention — had been the problem.
So this campaign asks two different questions, and keeps them apart:
| matrix | arms | question |
|---|---|---|
| main | bare-literal, prompt-semantic, replacement, always-signpost, gated-signpost | does being pointed at the stack change anything? |
| payload | payload-signpost | does being handed the answer change anything? |
They are planned separately and scored separately, and their adoption rates must never be pooled, because adoption is not the same act in the two:
- a pointer arm’s signpost names a command, so adoption = the agent ran it;
- the payload arm has already run it, so adoption = the agent used a location the payload handed over.
Both facts are recorded on every row (ran_stack_command,
used_injected_location) and adoption_kind says which one adopted holds.
dp eval score refuses to aggregate a cell whose rows disagree about the kind.
The metric that IS directly comparable across every arm is correctness, and
it is the one to read first when the two adoption numbers differ.
Fixed design
- Tasks: 20, each with a blinded acceptance set adjudicated and committed
before the campaign (
eval/tasks.jsonl,eval/acceptance-sets.jsonl).eval/check-acceptance.shasserts, at the pinned revisions, that every ground truth exists and falls inside its own accepted range — the defect that made the 2026-08-27 pilot’s correctness numbers unpublishable was an acceptance set that excluded valid nearby locations, so this is now a gate and not a habit. - Families: codex and claude. Two families is a stated generality limit, not a claim about models in general.
- Conditions: the five above; 20 x 2 x 5 = 200. Payload arm: 20 x 2 x 1 = 40.
- Replicates: 1. The blocking is over tasks, not repeats.
- Assignment: deterministic blocked matrix, seed
stage1, shuffled by hash so no arm runs in a favourable block. - Budget: the 150 ms PostToolUse contract, unchanged. The PreToolUse prefetch runs, as it does in production.
- Index: the local resident index over the five pinned corpora
(
eval/build-local-index.sh). Certification runs against this, not against a shared backend that does not hold every corpus.
Endpoints, fixed now
- Primary: adoption rate among model-visible signposts, per family x
condition, with
adoption_kindattached. - Secondary: correctness rate, turns-to-locate, tokens-to-resolution, all per family x condition.
- Payload-arm comparison:
payload-signpostagainstalways-signpost— they apply the same gate and differ only in what is injected. It is not compared againstgated-signpost. - Intent families are recorded per row (
intent_families) and reported, but the campaign is not powered to compare families against each other; that is delivery-harness work, below.
The failure reading, stated in advance
An adoption rate indistinguishable from zero at n >= 60 model-visible signposts is a publishable negative result about signposting as an intervention. It closes the question; it does not motivate a third campaign.
If the payload arm is adopted where the pointer arm is not, the finding is about pointer vs payload, not about signposting being fixed — the payload arm spends stack work on every eligible search whether or not it is wanted, and that cost is the next thing to measure, not this campaign’s result.
No post-hoc arm selection
The arms, the endpoints, the comparator, and the failure reading are the ones in this file. Anything discovered afterwards is reported as exploratory and labelled as such.
What is certified on the delivery harness instead of here
Intent widening (find -name, git log -S|--grep, and symbol promotion) is a
delivery property and is measured with no model in the loop, on the replay
harness, paired and interleaved against the pre-change build. Model quota is not
spent on it. See the Stage 1 delivery note.
Stage 1 delivery: intent widening and the payload arm, with no model in the loop
Date: 2026-09-05
Provider quota spent: zero — no model runs in this measurement
Harness: eval/replay-delivery.sh over eval/replay-workload.jsonl (185 rows)
Index: eval/build-local-index.sh over the five pinned corpora
Delivery is a property of the hook and the index, so widening the intent families and adding a payload arm can both be certified before any assignment is spent. This note is that certification. The adoption question it feeds is the Stage 1 campaign’s, and is reported separately.
The widening does not cost delivery — measured paired and interleaved
The pre-change build (f5d7843) and the widened build were replayed against the
same warm index, alternating, three rounds each:
| round | pre-change | widened |
|---|---|---|
| 1 | 185/185 | 185/185 |
| 2 | 185/185 | 185/185 |
| 3 | 185/185 | 185/185 |
Interleaving is the design, not a flourish. The first replay against a freshly started server scores worse whichever build runs it: an unpaired A-then-B ordering read 177/185 for the widened build and would have published a 4% regression that does not exist. The first two runs of this session did exactly that before the pairing was added.
The structural route is eight times faster than the semantic one
Of 185 recorded literal searches, 33 (18%) are bare identifiers that resolve
and are promoted to the symbol family, which asks /refs instead of /search:
| family | events | shown | p50 | max |
|---|---|---|---|---|
literal-search | 152 | 152 | 79–93 ms | 152 ms |
symbol-search | 33 | 33 | 11 ms | 24 ms |
That is the opposite of what an extra family was expected to cost. Nearly a
fifth of the workload now answers in a tenth of the budget, and the headroom it
frees is real: the events that land on the 150 ms boundary are all
literal-search.
Promotion is decided by resolution, never by the parse. An identifier that resolves nowhere is demoted back to a literal search, and the second round trip is skipped outright when the remaining budget cannot pay for it — the contract outranks the upgrade. Both arms are unit-tested; the demotion path is the one that matters, because a query that merely looks like a symbol is the common case.
The payload arm delivers, inside its cap
Same gate as always-signpost, injecting the result instead of the command:
| round | eligible | shown | coverage |
|---|---|---|---|
| 1 | 185 | 181 | 97.8% |
| 2 | 185 | 185 | 100% |
Round 1 ran while the campaign was saturating this host’s CPU; round 2 did not. Reported rather than dropped, because the production condition this arm will live in is a busy machine, not an idle one.
Payload size, over the 185 delivered payloads of round 2: p50 572 bytes, max 724 against the 800-byte cap, 3 locations each, and zero degraded to a pointer. The degrade path exists for a backend that returns a count with no locations, and on this index it never fired.
One delivered payload, and the pointer the same event would have produced:
Signpost (none): symbol lookup returned 2 line(s). yupana callers 'ErrCircuitOpen' answers it:
claim_test.go:32 circuit := fmt.Errorf("read issue: %w", dolt.ErrCircuitOpen)
claim_test.go:33 if !errors.Is(circuit, ErrCircuitOpen) {
claim_test.go:34 t.Errorf("wrapped circuit error does not match root beads.ErrCircuitOpen")
(3 of 20; run yupana callers 'ErrCircuitOpen' for the rest)
Signpost (none): symbol lookup returned 2 line(s). Try semantic search: yupana callers 'ErrCircuitOpen'
The pointer line above is the build the campaign is running, and it is wrong: yupana does not do semantic search. The wording is fixed after the campaign binary was cut, so the campaign’s pointer arms carry the old text. It is recorded here rather than quietly corrected, because it is a difference between the published arm and the shipped code.
file-find delivers, and measuring it found a parser bug
The find -name family was replayed over eval/replay-workload-families.jsonl
— 15 commands generated by RUNNING them in the pinned corpora, so every
result_cardinality is a real line count and the gating predicate is the real
one. 15 of 15 delivered, p50 79 ms.
The run also caught a defect no unit test had:
find . -path '*/cobra*' -name 'args.go' -> query "cobra"
-path is almost always a directory FILTER on the search and -name is the
thing being looked for, so taking whichever appeared first asked the index about
the repository instead of the file. -name now wins over -path, a bare
-path is still used when there is no -name, and both are pinned by tests.
The row re-extracts as args.go, and the arm still delivers 15/15.
Worth stating plainly: the parser was tested and shipped, and it took replaying real commands through it to find this. A family is a parser plus a predicate plus an eval row, and the eval row is the part that finds what the tests assumed.
What is NOT certified here, and why
history-search is implemented, parsed and unit-tested, but its delivery is
not certified on the local index in this note. The pinned corpora were
cloned --depth 1, so they carry one commit each and the index holds
essentially no commit chunks: a history-family measurement against them would
report zero for a reason that is an artifact of the clone, not a property of the
family. eval/build-family-workload.sh REFUSES to emit history rows from a
shallow corpus for exactly that reason — it names the fetch --unshallow that
would fix it rather than emitting rows that certify a family against an empty
history. That is the same “the instrument cannot see the target” failure this
workstream has paid for twice, and reporting it as a result would be worse than
not measuring it.
The route itself is live. Against a backend that does hold commit chunks, nine
family-shaped queries across three repos returned 3 commit results in 8 of 9,
with git: paths; the ninth is a true miss (a circuit-breaker query against a
repo with no circuit breaker), and a nonexistent repo returns 0 — controls in
both directions.
Full certification needs unshallowed corpora and a re-index, which is deferred until the campaign finishes: indexing is CPU-bound and would contend with the 150 ms hook budget the campaign is being measured against.
Stage 1 results: agents will not take a pointer, will take an answer, and are no more correct either way
Date: 2026-09-05
Preregistered: 2026-09-05-stage1-preregistration.md, committed before the
first assignment ran.
Spent: 240 assignments (200 main matrix + 40 payload arm).
Integrity
| main | payload | |
|---|---|---|
| rows / distinct assignment ids | 200 / 200 | 40 / 40 |
| rows per cell | 20 in each of 10 | 20 in each of 2 |
| permission denials | 0 | 0 |
| scoring failures | 0 | 0 |
An earlier run of the main matrix was discarded and re-run: 38 of its 189 completed rows and all 11 of its failures carried a Bash permission denial, all 49 in one family. That is recorded in full below, because it nearly became the result.
The primary endpoint
Pointer arms: 53 model-visible signposts, ZERO adopted. Both families. 0 of 53 bounds adoption at 5.7% (95%, rule of three).
Payload arm: 26 of 40 adopted — 65%, and 65% in each family independently.
The two adoption metrics are different acts and are not pooled: a pointer is
adopted by RUNNING the command it names, a payload by USING a location it
already fetched. Every row carries adoption_kind, and the scorer refuses to
aggregate a cell whose rows disagree about it. What the two numbers jointly
support is narrow and solid: the same agents that ignore a command will use an
answer.
The endpoint that is directly comparable says nothing changed
| arm | n | adopted | correct | correct in 1 search | turns | tokens |
|---|---|---|---|---|---|---|
| payload-signpost | 40 | 26 | 34 | 6 | 3.83 | 145k |
| always-signpost | 40 | 0 | 33 | 7 | 4.33 | 160k |
| bare-literal (baseline) | 40 | 0 | 33 | 6 | 4.22 | 160k |
One task. Correctness is 34/40 against 33/40 against 33/40. Turns fall by half a step and tokens by ~9%, both well inside noise at n=40.
Across the whole main matrix correctness is flat against each family’s own baseline — claude 0.80 vs 0.80, codex 0.85 vs 0.85 — and so are turns and tokens.
⚠ CORRECTION: the correctness comparison was never informative, and this section overclaimed
Raised by muldoon on aegis-kvtjow and verified here. The adoption result is untouched and is not power-limited — 0 of 53 against 26 of 40 is not marginal.
The correctness comparison is a different matter and I reported it wrongly. Its
baseline is always-signpost at 33/40 = 0.825, so the maximum achievable lift
is 17.5pp. The minimum detectable effect at n=40 per arm, 80% power,
alpha 0.05, is also 17.5pp — the same number. The comparison could only have
reached significance if the payload arm had scored 40/40, perfect. Power to
detect the +2.5pp actually observed: 0.05, indistinguishable from the
false-positive rate.
For 80% power at this baseline it needed, per arm:
+2.5pp 3420 +7.5pp 330 +15pp 65
+5.0pp 800 +10pp 175
So “buys no measurable correctness” should read “the correctness effect is UNTESTED at this n.” Those are different claims and the first is the kind a reader turns into a decision.
The positive control was doing less work than I said
This section originally argued: the same 200 rows separate task difficulty cleanly (strong 0.98, weak 0.79, misleading 0.72), therefore “the instrument detects a large difference in exactly this data. It detects none between conditions.”
That inference does not hold, by the same arithmetic:
strong .98 vs weak .79 19pp at n=50/100 power 0.86 <- real
weak .79 vs misleading .72 7pp at n=100/50 power 0.13 <- not
The gradient establishes that a ~19pp effect is visible at n=50-100. It says nothing about whether a 5-10pp CONDITION effect is visible at n=40 — different comparison, different n, smaller effect. I traded on an equivalence between them that is not there. The control proved the instrument was wired up; it did not make the null between conditions a finding.
Reading, exactly as preregistered
The preregistration fixed this reading in advance, and it is the one that applies:
If the payload arm is adopted where the pointer arm is not, the finding is about pointer vs payload, not about signposting being fixed — the payload arm spends stack work on every eligible search whether or not it is wanted, and that cost is the next thing to measure, not this campaign’s result.
So: signposting-as-a-pointer is a publishable negative result ON ADOPTION — 0 of 53, bounded at 5.7%. That endpoint was adequately powered and the question closes.
Signposting-as-a-payload is adopted, with an unmeasured effect on correctness. It fixes the adoption problem completely; whether that buys anything, this campaign could not say (see the correction above). Before it ships it must justify a stack query on every eligible search delivered whether or not it was wanted — and that case now rests on an unproven benefit rather than a measured absent one. Declining to ship a cost for an unproven return is still the right call; “we measured no return” was not the right reason for it.
Exploratory, and confounded — stated so it is not mistaken for a finding
Within the payload arm, rows where the agent used an injected location were correct 24/26; rows where it did not were correct 10/14. This is not evidence that adoption causes correctness. Agents plausibly adopt precisely when the payload is good, which is when retrieval succeeded, which is when the task was easier. The comparison is post-hoc, the arms are self-selected, and it is reported only because omitting it would be selective.
What nearly became the result instead
--permission-mode dontAsk with --setting-sources '' loads no permission
rules, so Claude denied Bash calls it did not auto-approve. An agent that cannot
run its search still produces a scored row, and that row reads as a failure of
the intervention.
38 of 189 completed runs carried "denied because Claude Code is
11 of 11 that failed to score running in don't ask mode"
49 of 49 were claude. 0 were codex.
Entirely in one family, condition-correlated, and shaped exactly like “signposting does not help” — high turns, high tokens, low correctness. The baselines had been given no settings file at all, since one was written only to carry hooks, so the arms differed in more than the intervention.
Permissions are part of the instrument. Every claude condition now gets a settings file carrying the Bash allow, with hooks added only where the arm calls for them. The contaminated rows were deleted and re-run rather than analysed; the 151 clean rows were kept, because nothing was denied in them.
Decisions taken on this evidence
Both are deliberate choices with reasons, not defaults that happened.
Payload mode is NOT rolled out to the fleet. It is adopted and inert: 65% uptake buying one task out of forty. Rolling it out would pay a stack query on every eligible search, on every pane, forever, for a return this campaign measured as noise. Production stays on the pointer mode already installed — cheap, delivered, and never taken. Recording that as a decision matters: “we left it on pointers” would otherwise read as inertia, when it is a choice made against a number.
Widening the intent families further is dropped. Two families beyond the
original were built and certified for delivery — symbol-search, which is 18% of
recorded searches and answers at p50 11 ms against 79-93 ms, and file-find at
15/15 — and a third, history-search, is implemented but uncertified.
Certifying it would be work in service of a hypothesis this campaign has already answered. Widening changes WHICH searches are eligible for a signpost; it does not change what an agent does with one. The pointer arm was adopted 0 times out of 53 across three families, and the payload arm’s 65% uptake moved correctness by a single task. A fourth family multiplies the denominator of an intervention whose numerator is the problem. That is why it is dropped rather than deferred: deferring implies the evidence might change, and the evidence that would change it is not a family count.
What the family work does leave behind is worth keeping: the structural route is eight times faster than semantic search on the same query, which is a fact about the stack rather than about signposting, and it is measured and recorded.
Limits, stated rather than found later
- The correctness endpoint was underpowered to the point of meaninglessness, and I did not compute that before reporting it. The preregistration sized the campaign for the ADOPTION endpoint (n >= 60 model-visible signposts) and never stated an MDE for correctness. Sizing one endpoint and then reporting another as though it had been tested is the error. A secondary endpoint gets a power statement, or it gets reported as untested.
- n = 53 model-visible signposts, against a preregistered target of 60. The estimate assumed more eligible searches per assignment than agents make. 53 still bounds adoption below 5.7%, and the preregistered minimum was 30, but the shortfall is real.
- Two model families. A stated generality limit, not a claim about models.
- The payload arm’s adoption metric can be satisfied by coincidence — an
agent could reach an injected path by its own search.
resolved_in_one_searchwas recorded as a non-inferential cross-check and shows no difference (6 vs 7), which is itself part of why the correctness reading is “nothing changed”. - One task set, five corpora, one index.
Architecture
dp is a single-binary CLI built in Go with minimal dependencies. It uses a plugin architecture for source integrations, a SQLite database for storage, and Levenshtein-based similarity matching for suggestions. This page explains how the pieces fit together.
Data Flow
graph LR
A[AI Tool Hook] --> B[dp record/ingest]
B --> C[Source.Extract]
C --> D[Fields]
D --> E[ingest.Ingest]
E --> F[Invocation/Desire]
F --> G[SQLite]
G --> H[Queries]
H --> I[CLI Output]
- Hook Trigger: AI tool (e.g., Claude Code) fires a hook on tool call failure (or success, if full tracking is enabled)
- Command Execution: Hook runs
dp recordordp ingestwith--source <name>, passing JSON via stdin - Source Plugin: The named source plugin’s
Extractmethod parses the JSON into universalFields - Normalization:
ingest.IngestconvertsFieldstoInvocationorDesire, generating UUID and timestamp - Storage: Data is written to SQLite database with WAL mode enabled
- Query: CLI commands (
list,paths,inspect, etc.) query the database - Output: Results rendered as table or JSON
Core Components
1. Source Plugin System
Located in internal/source/.
Registry Pattern: Plugins self-register via init() functions. The source package maintains a global registry mapping source names to Source implementations.
type Source interface {
Name() string
Extract(raw []byte) (*Fields, error)
}
Fields Struct: Universal representation of a tool call:
type Fields struct {
ToolName string // Required
InstanceID string // Optional: session/request ID
ToolInput json.RawMessage // Optional: raw input params
CWD string // Optional: working directory
Error string // Optional: error message
Extra map[string]json.RawMessage // Source-specific fields
}
Installer Interface (optional):
type Installer interface {
Install(opts InstallOpts) error
}
Allows dp init --source <name> to automatically configure hooks.
Current Plugins:
claude-code: Parses Claude Code hook JSON, mapssession_id→InstanceID, extractstool_use_id/transcript_pathintoExtra
Adding Plugins: Create a new file internal/source/<name>.go, implement Source, call Register() in init(), import package in cmd/dp/main.go.
See Writing a Plugin for details.
2. Ingest Pipeline
Located in internal/ingest/.
Function: Ingest(ctx, store, raw, sourceName) orchestrates the pipeline:
- Fetch source plugin by name
- Call
Extract(raw)to getFields - Validate
ToolNameis non-empty - Convert
Fields→model.Invocation - Generate UUID and timestamp if missing
- Marshal
ExtraintoMetadataJSON column - Write to database via
store.RecordInvocation()
Error Handling: Returns descriptive errors if source is unknown, extraction fails, or storage fails.
3. Data Model
Located in internal/model/.
Desire: A failed tool call.
type Desire struct {
ID string // UUID
ToolName string // Name of the tool that failed
ToolInput json.RawMessage // Raw input params
Error string // Error message
Source string // Source plugin name (e.g., "claude-code")
SessionID string // Session/instance ID
CWD string // Working directory
Timestamp time.Time // When it happened
Metadata json.RawMessage // Extra fields from plugin
}
Path: Aggregated pattern of repeated desires.
type Path struct {
ID string // Tool name (used as ID)
Pattern string // Tool name
Count int // Occurrences
FirstSeen time.Time // First failure
LastSeen time.Time // Most recent failure
AliasTo string // Alias target (if one exists)
}
Alias: Mapping from hallucinated tool name to real tool name.
type Alias struct {
From string // Hallucinated name
To string // Real tool name
CreatedAt time.Time // When alias was created
}
Invocation: Any tool call (success or failure), used for full tracking.
type Invocation struct {
ID string // UUID
Source string // Source plugin name
InstanceID string // Session/request ID
HostID string // Machine ID (future)
ToolName string // Tool that was invoked
IsError bool // Whether call failed
Error string // Error message (if IsError)
CWD string // Working directory
Timestamp time.Time // When it happened
Metadata json.RawMessage // Extra fields
}
4. Storage Layer
Located in internal/store/.
Interface: Store defines the persistence API. Commands interact with Store, not directly with SQL.
type Store interface {
RecordDesire(ctx, Desire) error
ListDesires(ctx, ListOpts) ([]Desire, error)
GetPaths(ctx, PathOpts) ([]Path, error)
SetAlias(ctx, from, to string) error
GetAliases(ctx) ([]Alias, error)
DeleteAlias(ctx, from string) (bool, error)
Stats(ctx) (Stats, error)
InspectPath(ctx, InspectOpts) (*InspectResult, error)
RecordInvocation(ctx, Invocation) error
ListInvocations(ctx, InvocationOpts) ([]Invocation, error)
InvocationStats(ctx) (InvocationStatsResult, error)
Close() error
}
Implementation: sqliteStore in internal/store/sqlite.go.
Database: Pure-Go SQLite via modernc.org/sqlite (no CGo, cross-compiles easily).
Concurrency: WAL mode enabled for concurrent reads and writes. Writers don’t block readers.
Schema:
CREATE TABLE desires (
id TEXT PRIMARY KEY,
tool_name TEXT NOT NULL,
tool_input TEXT,
error TEXT NOT NULL,
source TEXT,
session_id TEXT,
cwd TEXT,
timestamp TEXT NOT NULL,
metadata TEXT
);
CREATE TABLE invocations (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
instance_id TEXT,
host_id TEXT,
tool_name TEXT NOT NULL,
is_error INTEGER NOT NULL,
error TEXT,
cwd TEXT,
timestamp TEXT NOT NULL,
metadata TEXT
);
CREATE TABLE aliases (
from_name TEXT PRIMARY KEY,
to_name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE schema_version (
version INTEGER NOT NULL
);
Indexes:
desires.tool_name(forGetPaths,InspectPath)desires.timestamp(for time-based filtering)invocations.tool_name,invocations.instance_id,invocations.timestamp
Migrations: Managed in sqlite.go via schema_version table. New migrations bump the version and run idempotently.
5. Analysis Engine
Located in internal/analyze/.
Similarity Matching: Used by dp similar to find known tools similar to a hallucinated name.
Algorithm:
-
Normalize: Convert both strings to lowercase, split camelCase/underscores/hyphens into words
readFile→"read file"Read_File→"read file"read-file→"read file"
-
Levenshtein Distance: Compute edit distance between normalized strings
-
Normalize Score:
score = 1 - (distance / max_length) -
Bonuses:
- Prefix: Add
0.1 * (common_prefix_length / max_length) - Suffix: Add
0.05 * (common_suffix_length / max_length)
- Prefix: Add
-
Filter: Keep suggestions with
score >= threshold(default: 0.5) -
Rank: Sort by score descending, return top N (default: 5)
Example:
Hallucinated: "read_file"
Known tools: ["Read", "Write", "ReadFile", "EditFile"]
Normalized: "read file"
Scores:
- Read: normalize("read") = "read"
distance("read file", "read") = 5
base = 1 - (5/9) = 0.44
prefix = 0.1 * (4/9) = 0.044
total = 0.484 (below threshold, filtered out)
- ReadFile: normalize("ReadFile") = "read file"
distance("read file", "read file") = 0
base = 1.0
total = 1.0 ✓
- EditFile: normalize("EditFile") = "edit file"
distance("read file", "edit file") = 4
base = 1 - (4/9) = 0.56
prefix = 0.1 * (0/9) = 0
suffix = 0.05 * (5/9) = 0.028
total = 0.588 ✓
Suggestions: [("ReadFile", 1.0), ("EditFile", 0.588)]
Customization: Set known_tools in config to override the default list:
dp config known_tools "Read,Write,Edit,Bash,CustomTool"
6. CLI Commands
Located in cmd/dp/ and internal/cli/.
Framework: Cobra (github.com/spf13/cobra) for command parsing, flags, and help.
Structure:
cmd/dp/main.go → entry point, registers commands
internal/cli/*.go → command implementations
Common Pattern:
func runList(cmd *cobra.Command, args []string) error {
db := openDatabase()
defer db.Close()
opts := store.ListOpts{
Since: parseTime(since),
Source: source,
Limit: limit,
}
desires, err := db.ListDesires(cmd.Context(), opts)
if err != nil {
return err
}
if jsonOutput {
return printJSON(desires)
}
return printTable(desires)
}
Global Flags:
--db PATH: Override database path--json: Force JSON output
Output Formats:
- Table: Human-readable, uses
golang.org/x/termfor width detection - JSON: Machine-readable, one JSON array or object
Error Handling: Commands return errors, Cobra prints them to stderr and exits with code 1.
Dependencies
From go.mod:
require (
github.com/google/uuid v1.6.0 // UUID generation
github.com/spf13/cobra v1.10.2 // CLI framework
golang.org/x/term v0.39.0 // Terminal width detection
modernc.org/sqlite v1.44.3 // Pure-Go SQLite
)
Why These?
- uuid: Standard, fast, no deps
- cobra: Best CLI framework in Go, used by kubectl, gh, etc.
- term: Stdlib extension for terminal queries
- sqlite: Pure Go (no CGo), cross-compiles to any platform, fast enough for millions of rows
No Other Deps: No ORM, no logging framework, no config parser beyond encoding/json, no HTTP libraries. dp is self-contained.
Build and Release
Makefile:
install:
go install ./cmd/dp
Releases: Automated via GoReleaser (.goreleaser.yml). Pushes binary artifacts to GitHub Releases for Linux, macOS, Windows.
Binary Size: ~8MB (includes SQLite, CLI framework, compression libraries).
Performance Characteristics
- Desire recording: ~5ms (database write + fsync)
- Path aggregation: ~10ms for 10k desires
- Similarity matching: ~1ms for 100 known tools
- Database size: ~1KB per desire, ~1.5KB per invocation
- Query latency: <1ms for indexed lookups, <10ms for full scans up to 100k rows
Scaling:
- SQLite handles millions of rows fine with proper indexes
- WAL mode keeps reads fast during writes
- If you exceed ~10M records, consider archiving old data or partitioning by date
- For multi-machine aggregation, export to JSON and load into a central database
Security Notes
- Database file permissions:
0600(user-only read/write) - Config file permissions:
0644(world-readable, but no secrets stored) - Hooks run with user’s shell environment—ensure
dpbinary is trusted - No network access, no external API calls, no telemetry
Extension Points
Want to extend dp? Here are the main interfaces:
- Source Plugins: Add support for new AI tools (see Writing a Plugin)
- Store Implementations: Swap SQLite for Postgres, MySQL, etc. (implement
store.Store) - Analysis Algorithms: Replace Levenshtein with ML embeddings, fuzzy matching, etc. (modify
internal/analyze) - Output Formats: Add CSV, Markdown, HTML (modify CLI commands)
- Webhooks: Add
dp servecommand to expose a REST API (new package)
All interfaces are defined in internal/ packages—keep the public API minimal (cmd/dp is the only entry point).
Code Layout
desire-path/
├── cmd/dp/ # CLI entry point
├── internal/
│ ├── analyze/ # Similarity matching
│ ├── cli/ # Command implementations
│ ├── config/ # Config file parsing
│ ├── ingest/ # Data ingestion pipeline
│ ├── model/ # Data types (Desire, Path, Alias, Invocation)
│ ├── source/ # Source plugin system
│ └── store/ # Storage interface + SQLite implementation
├── docs/book/ # This documentation (mdbook)
├── go.mod
├── Makefile
└── README.md
Conventions:
internal/packages are not importable by external code (Go convention)- Interfaces in
internal/store/andinternal/source/allow mocking for tests - No init-time side effects except plugin registration
- Error messages include context:
"source plugin: operation: detail: error"
Testing
Run all tests:
go test ./...
Test Coverage:
- Unit tests for each package (
*_test.gofiles) - Integration tests using in-memory SQLite (
:memory:) - Example-based tests in
sourcepackage for plugin validation
No External Dependencies: Tests don’t need Docker, external databases, or network access. They run in <1s.
Future Architecture Considerations
Potential improvements:
- Multi-database aggregation: Collect desires from multiple machines, merge into a central store
- Streaming ingestion: Replace hook-based recording with a long-running daemon that tails logs
- Machine learning: Train embeddings for better similarity matching
- Distributed tracing: Correlate tool calls across services using OpenTelemetry
- Web UI: Visualize paths, trends, session replays
These would require architectural changes (client/server split, service discovery, etc.), but the current design keeps things simple and fast for single-user CLI workflows.
Next Steps
- Read the source code
- Write a source plugin
- Explore the command reference
- Check out the Claude Code integration
The stack
Caboodle installs these together and proves each one works; every tool also stands alone.
| tool | what it gives your agents |
|---|---|
| caboodle | one wizard that installs the stack and proves it works |
| quipu | a knowledge graph that refuses facts that break its rules |
| camayoc | the starter vocabulary, and how new knowledge earns its way in |
| bobbin | search and context over your repositories, served over MCP |
| yupana | which code calls which: the blast radius before an edit |
| desire-path (you are here) | the tool calls your agents get wrong, so you can fix them |
Desire Path supplies evidence about failed tool calls. Bobbin supplies repository search, and Yupana supplies structural queries. Quipu and Camayoc provide governed knowledge; Caboodle installs and checks the tools. Basic Desire Path capture and queries work locally without any of the other services.
The optional signposting mode can suggest a search or structural command after a weak search. Read its contract before turning it on; presence of a tool is not evidence of improved results.
Docs map
The user guide is built from docs/book. Start with
getting started, agent setup,
commands, or configuration.
Supporting documents
Design notes record intent; the released CLI and current reference describe available behavior.
assets/banner-dark.svg— README banner for dark backgrounds.assets/banner-light.svg— README banner for light backgrounds.design-codex-source-plugin.md— Codex event and installer design; compare with the installed source plugin.plans/001-initial-plan.md— Historical — original architecture and initial implementation plan.plans/002-invocation-tracking.md— Historical — design for tracking successes as well as failures.plans/003-kiro-source-plugin.md— Historical — Kiro source-plugin implementation plan.plans/004-cursor-source-plugin.md— Historical — Cursor source-plugin implementation plan.plans/004-gemini-cli-source-plugin.md— Proposal for Gemini CLI capture; not a claim of released support.plans/006-pave-command-corrections.md— Design for flag-aware command correction.plans/007-recipe-aliases.md— Proposal for multi-step recipe expansion.plans/008-turn-reconstruction.md— Design for reconstructing turns from transcripts.plans/009-new-dp-suggest.md— Design for suggestions from observed failures.plans/009-signposting-eval.md— Signposting contract, event definitions, and evaluation design.plans/backlog.md— Candidate future work; not the released feature list.private-key-checks.md— Contributor guide to the private-key refusal controls.tasks/phase-1-core.md— Historical — Phase 1: Core (Collect + Store) implementation checklist.tasks/phase-2-reporting.md— Historical — Phase 2: Reporting implementation checklist.tasks/phase-3-suggestions.md— Historical — Phase 3: Suggestions & Aliases implementation checklist.tasks/phase-4-polish.md— Historical — Phase 4: Polish implementation checklist.tasks/phase-5-invocations.md— Historical — Phase 5: Invocation Tracking implementation checklist.
Supplementary development book
The root book/ contains supplementary architecture,
operations, and development notes. It is a separate tree and is not the book
published by the Pages workflow. Use the current user guide above for CLI setup.
Theme provenance
The Coal override in docs/book/custom/css/custom.css is vendored byte-for-byte
from Caboodle at b351e6de.
Keep it in sync with the stack standard.
Maintainers
Release and README re-proof is the required checklist whenever updating the README’s pinned release or expected output.
Releasing and re-proving the README
The README pins a release archive, checksum file, and exact version output. Changing the release tag requires a new proof; updating the URL alone is not enough.
After publishing a release:
- Download the new archive and checksum file from the published release into an empty directory. Verify the matching SHA-256 before extracting or executing it.
- Update the tag and asset names in both the README and
Getting started, and update the pinned source-install
version in
.github/workflows/ci.yml. - Extract the README’s install, first-success, and agent-setup shell blocks.
Run them under
env -iwith a fresh temporary HOME and a PATH containing only the release binary and the commands the example requires. Use copies of binaries, never symlinks to a production installation. Agent setup must write only inside that temporary HOME. - Compare
dp versionwith the documented version/commit. Diff the demo’s stdout against the README’s owntextblock. Run every command in the question table after seeding a synthetic failure in the isolated default database. - Record the tag, archive, checksum, platform, exact stdout comparison, and resulting hook configuration in the documentation PR. Other platforms need their own checks; a Linux amd64 run proves only that archive.
- Run
python3 scripts/test-md-links.py,python3 scripts/check-md-links.py,pre-commit run --all-files, andmdbook build docs/book. Require the PR’s source-install, vet, unit, and integration CI steps to finish successfully.
Publish the documentation update only after the new release artifacts exist. A source build does not prove a release archive, and a release archive does not prove the source-install instructions; retain both checks.