Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Ingest a call through a source plugin. Failed calls also become desires.
  2. Query dp paths to find repeated names and dp inspect for the details.
  3. Use dp similar to compare a mistaken name with known tools.
  4. 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

capabilitywhat it does
PatternsRank repeated failures and inspect their error messages
SuggestionsCompare names with known tools using string similarity
AliasesStore intended tool mappings and command correction rules
Source pluginsNormalize calls from Claude Code, Codex, Cursor, and Kiro
ExportsWrite failure or invocation records for offline analysis
SignpostingOffer 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 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 paths needs 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

KeyTypeDescriptionDefault
db_pathstringPath to the SQLite database file~/.dp/desires.db
default_sourcestringDefault source tag for recorded desires when --source is not specified"" (empty)
known_toolsstringComma-separated list of known tool names used by dp similar"" (empty—uses built-in list)
default_formatstringDefault 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.json files 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

VariableDescriptionExample
DESIRE_PATH_DBOverride database pathexport DESIRE_PATH_DB=/tmp/test.db
DESIRE_PATH_CONFIGOverride config file pathexport DESIRE_PATH_CONFIG=/etc/dp/config.toml

Environment variables take precedence over config file settings but are overridden by command-line flags.

Tips

  • Use --json output with jq for scripting: dp paths --json | jq '.[] | select(.count > 10)'
  • Keep known_tools in sync with your AI’s actual tool set for better similar results
  • If you’re testing or developing, point --db at 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.

Learn more about desires →

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.

Learn more about paths →

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.

Learn more about aliases →

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

  1. Check paths: dp paths
  2. See read_file has failed 47 times
  3. Discover your tool is actually called Read
  4. Create an alias: dp alias read_file Read
  5. Now dp paths shows 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_file when the real tool is Read
  • execute_command when it’s Bash
  • search_files when it’s Glob

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 → Read
  • execute_bash → Bash

Subset Mapping

The AI wants something specific that’s part of a broader tool:

  • search_codebase → Grep
  • list_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 -r when the correct flag is scp -R
  • grep pattern when you prefer rg pattern
  • curl -k when you need curl --cacert cert.pem

These are the same kind of desire path: the AI’s intent is correct, but the specifics are wrong.

Rule Types

TypeWhat It FixesExample
FlagWrong CLI flag-r → -R in scp
CommandWrong command namegrep → rg
LiteralWrong string in a commanduser@old: → user@new:
RegexPattern-based replacementcurl -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:

  1. Parse the pipeline into segments: cat file.txt and scp -rP 22 user@host:/path
  2. Find the segment where the command is scp
  3. Locate the -r flag within -rP (combined flags)
  4. 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_error is 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:

  • Read works reliably (98.8% success)
  • Bash has issues (14 failures worth investigating)
  • read_file fails 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

CommandDescription
recordDeprecated compatibility entrypoint; use ingest
ingestIngest tool call data from a source plugin
initSet up integration with AI coding tools
listList recent desires
pathsShow aggregated paths ranked by frequency
inspectShow detailed view of a specific desire path
statsShow summary statistics
exportExport raw desire or invocation data
similarFind known tools similar to a tool name
aliasCreate, update, or delete tool name aliases and correction rules
aliasesList all configured aliases and rules
paveTurn aliases into active tool-call intercepts
configShow or modify configuration

Global Flags

All commands support these global flags:

FlagDefaultDescription
–db PATH~/.dp/desires.dbPath to the SQLite database
–jsonfalseOutput 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

FlagDefaultDescription
–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

flagpurpose
--sourceSelect a source plugin; see dp sources
--settingsOverride the source-specific settings path
--signpost-urlConfigure and verify a search backend
--signpost-logChoose a signposting event log; - disables it
--signpost-search-modeRequest hybrid, semantic, or keyword search
--skip-signpost-url-checkExplicitly 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

FlagDefaultDescription
–since“”Duration or timestamp (30m, 24h, 7d, etc.)
–source“”Filter by source identifier
–tool“”Filter by tool name
–limit50Maximum 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

FlagDefaultDescription
–top20Number 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

FlagDefaultDescription
–since“”Duration or timestamp (30m, 24h, 7d, etc.)
–top5Number 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% matches grep_search, grep_find, etc.
  • Use _ to match any single character: read_fil_ matches read_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

FlagDefaultDescription
–invocationsfalseShow 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

FlagDefaultDescription
–formatjsonOutput format: json or csv
–since“”Filter by RFC3339 timestamp or YYYY-MM-DD
–typedesiresData 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

FlagDefaultDescription
–known“”Comma-separated list of known tools (overrides defaults)
–threshold0.5Minimum similarity score (0.0 to 1.0)
–top5Maximum 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:

  1. Alias lookup: First checks if an explicit alias has been configured with dp alias
  2. 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

FlagDefaultDescription
–deletefalseDelete an existing alias or rule
–cmd NAMECommand name for CLI corrections (implies tool=Bash, param=command)
–flag OLD,NEWFlag correction within a command (requires –cmd)
–replace NEWSubstitute the command itself (requires –cmd)
–tool NAMETool name for parameter corrections (advanced)
–param NAMEParameter name to correct (requires –tool)
–regexfalseTreat FROM as a regex pattern (requires –tool/–param)
–message TEXTCustom 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

  • --cmd and --tool/--param are mutually exclusive
  • --flag requires --cmd
  • --replace requires --cmd
  • --flag and --replace are mutually exclusive
  • --regex requires --tool/--param
  • --tool and --param must 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:

  1. Notice the AI keeps using wrong flags (e.g., scp -r fails)
  2. Create a rule: dp alias --cmd scp --flag r R
  3. Install the hook: dp pave --hook
  4. Future scp -r calls are automatically corrected to scp -R

Best practices:

  • Add --message to explain why the correction exists
  • Use --cmd for CLI corrections (most common case)
  • Use --tool/--param only for MCP or non-Bash tools
  • Review rules with dp aliases periodically

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

FlagDefaultDescription
–hookfalseInstall a PreToolUse intercept hook in Claude Code
–agents-mdfalseGenerate AGENTS.md / CLAUDE.md rules from alias data
–append FILEAppend generated rules to FILE (with –agents-md)
–settings PATH~/.claude/settings.jsonPath 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
  • --hook is reactive: catches mistakes at call time
  • --agents-md is 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:

MatchKindWhat It Does
flagCorrects a CLI flag within a specific command (e.g., -r → -R in scp)
commandSubstitutes a command name (e.g., grep → rg)
literalReplaces a literal string within a command segment
regexApplies 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

CodeMeaning
0Allow (optionally with updatedInput corrections)
2Block (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:

  1. Check database size: ls -lh ~/.dp/desires.db
  2. Consider running VACUUM if the database has grown large
  3. Increase the timeout in ~/.claude/settings.json if 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

VariableDefaultMeaning
DP_LEGENDoff1 enables the stage
DP_LEGEND_GAZETTEER<user cache>/desire-path/legend-gazetteer.jsongazetteer path
DP_LEGEND_QUIPU_URLrequiredQuipu /query endpoint used by refresh
DP_LEGEND_NAMESPACErequiredontology namespace the class names live in
DP_LEGEND_MAX_BYTES600budget combined with the signpost stage
DP_LEGEND_MAX_AGE_MIN1440refuse an older gazetteer
DP_LEGEND_LOG~/.local/log/dp-legend-events.jsonldecision log (- disables)
DP_LEGEND_DICT/usr/share/dict/wordsword list for the guard

Decision log

Every read the stage sees appends one JSON line with the following fields:

  • tool and ref
  • text_bytes and raw_hits
  • the shown labels
  • bytes and budget
  • latency_us, which includes load_us, the time to read and compile the gazetteer
  • skipped, 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 --source repeatedly
  • Set a consistent source when working primarily with one AI tool
  • Simplify commands: dp record instead of dp 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 export instead of dp export --format csv

Can be overridden in dp export with the --format flag.

Configuration precedence (highest to lowest):

  1. Command-line flags (e.g., --db, --source)
  2. Config file values
  3. 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:

eventcommandpurpose
PostToolUse and PostToolUseFailuredp ingest --source claude-codeCapture calls and failures
PreToolUse, Bashdp signpost-prefetchPrepare optional search guidance
PostToolUse and PostToolUseFailure, Bashdp signpostOffer search guidance
PostToolUseFailuredp pave-correctConsult 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 FieldPurposeExamples
ToolNameThe tool/function/command that was invoked"Read", "execute_shell", "query_database"
InstanceIDSession, request, or invocation IDSession UUID, request trace ID, user ID
ToolInputRaw JSON input parametersTool arguments as JSON (preserve as-is)
CWDWorking directory at time of call"/home/user/project"
ErrorError message if the call failed"File not found", "Permission denied"
ExtraEverything elseAnything 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.Source interface
  • Name() returns a unique, kebab-case identifier
  • Extract() validates ToolName is non-empty
  • Extract() returns helpful error messages
  • Maps universal fields correctly
  • Stores extra fields in Extra
  • Registers via source.Register() in init()
  • 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.Installer for dp init support
  • (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

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.

EvaluationDateRecordsKey Finding
2026-03-05 Baseline2026-03-0513,92582% of failures are Bash CLI misuse; 3 aliases prevent ~250/mo
2026-08-27 Signposting pilot2026-08-2770 runs47 eligible semantic attempts exceeded the 150 ms budget; zero signposts shown
2026-08-27 Local warm signposting2026-08-2770 composite runsResident 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

MetricValue
Total desires13,925
Unique tool names29
Date range2026-02-09 to 2026-03-05 (25 days)
Daily average557 failures/day
Sourceclaude-code (13,903), transcript-analysis (22)
Aliases configured3

Failure Distribution by Tool

ToolCount%Category
Bash12,93292.9%Command execution
Read5023.6%File access
mcp__homelab__batch_probe1931.4%MCP infrastructure
mcp__homelab__prometheus_query490.4%MCP infrastructure
WebFetch470.3%Network
mcp__homelab__container_status430.3%MCP infrastructure
mcp__homelab__service_health410.3%MCP infrastructure
Other MCP tools760.5%MCP infrastructure
Other420.3%Various

Analysis by Error Category

1. CLI Misuse (Bash) — 93% of all failures

The 12,932 Bash failures break down into subcategories:

SubcategoryCount% of BashActionable?
gt unknown commands7435.7%Yes — document or implement
bd unknown flags3642.8%Yes — aliases or flag additions
Command not found1781.4%Yes — install or alias
Git push rejected3342.6%Partially — workflow issue
Not a git repo1881.5%Yes — cwd detection
Git unstaged changes620.5%Partially — workflow issue
bd sync required1120.9%Yes — auto-sync or docs
Normal dev errors~10,95184.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:

CommandCountWhat Agent Expected
gt deacon pending175Check deacon task queue
gt await-signal101Wait for async event
gt mol hook43Hook a molecule (correct: gt hook)
gt health41System health check
gt plugin status39Check plugin state
gt mq integration list28List MQ integrations
gt wisp26Manage wisps directly
gt plugin due25Check plugin schedule
gt sessions20List active sessions (correct: gt session)
gt rig health19Rig 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 AttemptedCountCorrect Alternative
–gated64(removed feature)
–wisp35(not a filter)
–assign27–assignee (-a)
–rig23(use prefix routing)
–comment21–append-notes
–prefix14(use prefix routing)
–mol11(not a filter)
–stdin11(pipe via heredoc)
–owner10–assignee (-a)
–epic7(not implemented)

Current alias coverage: Only 3 aliases exist:

  1. --assign → --assignee (bd flag) — covers 27 failures
  2. --owner → --assignee (bd flag) — covers 10 failures
  3. bd 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 TypeCountRoot Cause
EISDIR (read directory)162Agent used Read instead of ls/Bash
File not found218Agent guessed wrong path
File too large8Exceeded 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 ToolFailuresError
batch_probe193no available server
prometheus_query49no available server
container_status43no available server
service_health41no available server
list_containers21no available server
container_logs20no available server
Other MCP73no 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 MissingActual StatusIssue
ls, cd, cat, echoShell builtinsFalse positive — these are Bash builtins/coreutils, always available
ssh, git, grepInstalledFalse positive — exit code != “not found”
justNot installedTrue positive — just (justfile runner) not on the eval host
dig, nslookup, hostNot installedTrue positive — DNS tools missing
sqlite3Not installedTrue positive — was missing, now installed
python3InstalledFalse 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

WorkspaceFailuresPrimary Issues
deacon3,007gt mol squash wrong flags, patrol loop errors
aegis/crew/ellie1,042gt mol squash, read failures
deacon/dogs/boot982Patrol loop startup errors
aegis/crew/malcolm627CLI misuse, path guessing
aegis/witness603Patrol loop errors
aegis/crew/goldblum603Build errors, flag guessing
aegis/refinery/rig510Merge queue errors
aegis/crew/ian443Build/test errors
bucket/refinery/rig418Merge queue errors
mayor394CLI 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)

AliasTypeEstimated Monthly Prevents
–assign → –assigneeflag (bd)~27
–owner → –assigneeflag (bd)~10
bd note → bd update –append-notesregex~8
Total~45
AliasTypeEstimated Monthly Prevents
–comment → –append-notesflag (bd)~21
gt sessions → gt sessioncommand~20
gt mol hook → gt hookcommand~43
gt health → gt rig statuscommand~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 FeatureCurrently Used?ValueAction Needed
dp recordYes (PostToolUseFailure hook)HighWorking well
dp ingestYes (via record)HighWorking well
dp statsManually by operatorsMediumCould auto-report
dp pathsManually by operatorsMediumGood for evaluation
dp aliasesYes (3 configured)HighAdd 5 more aliases
dp pave --hookYes (PreToolUse)HighWorking well
dp pave --agents-mdNot usedMediumShould generate rules
dp env-needsNot used (high false positives)LowNeeds refinement
dp turnsNot usedLowUseful for evaluation only
dp similarNot usedLowNiche use case
dp suggestNot implemented yetHighWould synthesize all signals
dp serveNot usedLowNo consumer yet

Immediate Actions

  1. Add 5 new aliases — dp alias for –comment, gt sessions, gt mol hook, gt health, –no-digest
  2. Run dp pave --agents-md — Generate AGENTS.md rules from alias data and append to agent instruction files
  3. Fix env-needs false positives — Check for “command not found” in error text, not just exit code

Future Features Needed

  1. dp suggest (planned, dp-9 design exists) — Synthesize all data sources into prioritized recommendations
  2. dp map — Map documentation to failing tool patterns
  3. Struggling detection — Identify agents retrying same failure pattern
  4. 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.json before 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.jsonl
  • eval/results/2026-08-27-pilot-summary.json
  • eval/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

  • PreToolUse can launch detached, repo-scoped semantic work and publish an atomic cache entry.
  • PostToolUse consumes a completed cache entry or uses its bounded context for a direct request to an already-warm local Bobbin server.
  • DP_SIGNPOST_PREFETCH=0 disables 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.jsonl
  • eval/results/2026-08-27-local-warm-results.jsonl
  • eval/results/2026-08-27-local-warm-summary.json
  • eval/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:

armeligibleshowncoveragetimed outzero candidatewarm hits
always-signpost direct185185100%000
gated-signpost direct3535100%000
always-signpost prefetch 500 ms185185100%00185
gated-signpost prefetch 500 ms3535100%0035

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:

  1. The prefetch head start, not the commit. 9c23bd4 only adds the DP_SIGNPOST_PREFETCH=0 opt-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.

  2. 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 filtercount
    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:

buildarmeligibleshowncoverage
276086adirect3535100%
276086aprefetch 500 ms3535100%
9c23bd4direct3535100%
9c23bd4prefetch 500 ms3535100%

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>.jsonl
  • eval/results/2026-08-29-stage0-coverage-<arm>.json
  • eval/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:

matrixarmsquestion
mainbare-literal, prompt-semantic, replacement, always-signpost, gated-signpostdoes being pointed at the stack change anything?
payloadpayload-signpostdoes 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.sh asserts, 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_kind attached.
  • Secondary: correctness rate, turns-to-locate, tokens-to-resolution, all per family x condition.
  • Payload-arm comparison: payload-signpost against always-signpost — they apply the same gate and differ only in what is injected. It is not compared against gated-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:

roundpre-changewidened
1185/185185/185
2185/185185/185
3185/185185/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:

familyeventsshownp50max
literal-search15215279–93 ms152 ms
symbol-search333311 ms24 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:

roundeligibleshowncoverage
118518197.8%
2185185100%

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

mainpayload
rows / distinct assignment ids200 / 20040 / 40
rows per cell20 in each of 1020 in each of 2
permission denials00
scoring failures00

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

armnadoptedcorrectcorrect in 1 searchturnstokens
payload-signpost40263463.83145k
always-signpost4003374.33160k
bare-literal (baseline)4003364.22160k

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_search was 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]
  1. Hook Trigger: AI tool (e.g., Claude Code) fires a hook on tool call failure (or success, if full tracking is enabled)
  2. Command Execution: Hook runs dp record or dp ingest with --source <name>, passing JSON via stdin
  3. Source Plugin: The named source plugin’s Extract method parses the JSON into universal Fields
  4. Normalization: ingest.Ingest converts Fields to Invocation or Desire, generating UUID and timestamp
  5. Storage: Data is written to SQLite database with WAL mode enabled
  6. Query: CLI commands (list, paths, inspect, etc.) query the database
  7. 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, maps session_id → InstanceID, extracts tool_use_id/transcript_path into Extra

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:

  1. Fetch source plugin by name
  2. Call Extract(raw) to get Fields
  3. Validate ToolName is non-empty
  4. Convert Fields → model.Invocation
  5. Generate UUID and timestamp if missing
  6. Marshal Extra into Metadata JSON column
  7. 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 (for GetPaths, 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:

  1. Normalize: Convert both strings to lowercase, split camelCase/underscores/hyphens into words

    • readFile → "read file"
    • Read_File → "read file"
    • read-file → "read file"
  2. Levenshtein Distance: Compute edit distance between normalized strings

  3. Normalize Score: score = 1 - (distance / max_length)

  4. Bonuses:

    • Prefix: Add 0.1 * (common_prefix_length / max_length)
    • Suffix: Add 0.05 * (common_suffix_length / max_length)
  5. Filter: Keep suggestions with score >= threshold (default: 0.5)

  6. 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/term for 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 dp binary is trusted
  • No network access, no external API calls, no telemetry

Extension Points

Want to extend dp? Here are the main interfaces:

  1. Source Plugins: Add support for new AI tools (see Writing a Plugin)
  2. Store Implementations: Swap SQLite for Postgres, MySQL, etc. (implement store.Store)
  3. Analysis Algorithms: Replace Levenshtein with ML embeddings, fuzzy matching, etc. (modify internal/analyze)
  4. Output Formats: Add CSV, Markdown, HTML (modify CLI commands)
  5. Webhooks: Add dp serve command 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/ and internal/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.go files)
  • Integration tests using in-memory SQLite (:memory:)
  • Example-based tests in source package 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

The stack

Caboodle installs these together and proves each one works; every tool also stands alone.

toolwhat it gives your agents
caboodleone wizard that installs the stack and proves it works
quipua knowledge graph that refuses facts that break its rules
camayocthe starter vocabulary, and how new knowledge earns its way in
bobbinsearch and context over your repositories, served over MCP
yupanawhich 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.

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:

  1. 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.
  2. 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.
  3. Extract the README’s install, first-success, and agent-setup shell blocks. Run them under env -i with 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.
  4. Compare dp version with the documented version/commit. Diff the demo’s stdout against the README’s own text block. Run every command in the question table after seeding a synthetic failure in the isolated default database.
  5. 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.
  6. Run python3 scripts/test-md-links.py, python3 scripts/check-md-links.py, pre-commit run --all-files, and mdbook 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.