RFD 085: Query Explain
- Status: Discussion
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-05-12
Summary
Add an --explain flag to jp query that prints the rendered system prompt, the resolved tools, the resolved attachments, and the new user query that would be sent to the LLM, then exits without calling the provider. Output is structured and JSON-first via --format json.
Motivation
JP composes the next request to the LLM from many sources: workspace config, conversation deltas, CLI flags, tool directives, attachments, and the user's query. By the time a turn is sent, the user often can't easily tell what the assistant is seeing. Common questions:
- Which tools is the assistant getting this turn, and with what parameter schemas?
- What does the fully rendered system prompt look like after persona, instructions, and overrides have merged?
- Which attachments are attached, and where are they coming from?
- Is the query template rendering the way I think?
Today, answering these requires reading provider logs, instrumenting the code, or guessing. --explain makes the assembled request inspectable in one command. Because the output is structured, it composes with jq and scripts.
Design
User experience
A single boolean flag, no companion flags:
jp q -c architect --explain "my query"
jp q -c architect --explain --format json "my query"Behavior:
- Assembles the full request the same way
jp qwould — including MCP server startup, tool schema resolution, and attachment fetching — then exits before calling the provider. --explainimplies--no-persist. No conversation events are persisted, no title is generated in the background, and locking is delegated toNullLockBackend.- Echoes the rendered system prompt and its sections, the enabled tools (name, description, JSON Schema), the attachment list (source + title), and the new user request.
- Not a dry-run. Request assembly performs its usual side effects: MCP server startup, MCP tool/resource calls, HTTP fetches, file reads, and command-attachment execution. These are real, and exactly what the executed path performs. Only the conversation state and the LLM provider call are skipped. Resolution failures abort the preview, same as a normal query.
The flag composes naturally with every existing jp q flag (-m, -r, --tool, -a, --cfg, -%, etc.). The mental model is "set up the query you'd run, then flip a flag to see what it would be."
Output schema (v1)
{
"schema_version": 1,
"system": {
"prompt": "...",
"sections": [
:
"title": "...",
"tag": "...",
"content": "..."
}
]
},
"tools": [
{
"name": "fs_grep_files",
"description": "Search through the project's files.",
"parameters": {
"/* JSON Schema */": ""
},
"source": "local"
}
],
"attachments": [
{
"source": "file:///path/to/file.md",
"title": "file.md"
}
],
"request": {
"content": "my query",
"schema": null
}
}title on an attachment is a best-effort display label: Attachment.description if set, otherwise derived from the URL's last path segment, otherwise the raw source.
description on a tool is ToolDocs::schema_description() — the same string providers send in the tool schema (the tool's summary if set, otherwise its description). The preview shows what the assistant sees, not a richer internal docs field.
source on a tool follows ToolSource's serialization: builtin, local, or mcp for the common cases; dotted forms (local.<tool>, mcp.<server>, mcp.<server>.<tool>) when the source overrides the name in config.
Text rendering walks the same struct: a header per section, the system prompt rendered as markdown, tools listed with their descriptions and parameter schemas as fenced JSON blocks, attachments as a labeled list, and the user query in a quoted block. The text renderer reuses existing components where possible: SectionConfig::render() for system sections, ToolDefinition::to_parameters_schema() for schemas, the markdown renderer in jp_md, and the format-aware printer.
Architecture
--explain implies --no-persist. The implication is wired in startup, not inside Query::run — by the time Query::run is reached, load_workspace has already chosen the persist and lock backends. The mechanism is a Commands::implies_no_persist() -> bool method (default false) consulted by run_inner before load_workspace; Query returns self.explain. This matches the existing Commands::conversation_load_request() pattern for declarative per-command information needed at startup.
The effective persist value (cli.globals.persist && !cli.command.implies_no_persist()) is computed once and written back to cli.globals.persist before Ctx::new runs. From that point on, both load_workspace and every downstream consumer of ctx.term.args.persist (notably the title-generation branch in Query::run) see the same value, so persist-gated logic doesn't need separate !self.explain guards. The longer-term overlap between the persist flag and the NullPersistBackend type is a known redundancy worth consolidating in a separate refactor; 085 does not depend on that consolidation.
The branch itself short-circuits in Query::run after chat_request stamping but before the editor echo block — the block that renders the user's freshly edited request via TurnView::render_user_request. Skipping the echo prevents it from emitting non-JSON text to stdout under --format json, and is sound because the preview already contains the request body. By that point in the flow, everything the preview needs is already available locally:
Vec<ToolDefinition>fromtool_definitions(...), including fully resolved MCP tool schemas. MCP servers are started byconfigure_active_mcp_serversearlier inQuery::run, independently of persistence.Vec<Attachment>resolved by the attachment handlers.- The final
ChatRequestafter stdin / query / template / editor / schema / author processing. - The system prompt (
cfg.assistant.system_prompt) and rendered sections (build_sections(&cfg.assistant, !tools.is_empty()), the same callbuild_threadmakes). - The per-tool config (
cfg.conversation.tools), which lets the preview recover each tool'ssource—tool_definitionserases that field by the time it returnsVec<ToolDefinition>.
The explain branch reads these values, builds a QueryPreview, prints it via the format-aware printer, and returns. On success, it also removes the editor query file (QUERY_MESSAGE.md) if one was created by the editor path — matching the cleanup the executed path performs after handle_turn. Both paths use the same tool_definitions, attachment resolution, and build_sections helpers with the same upstream inputs. The executed path rebuilds the thread inside run_turn_loop once the new request is added to the stream; the explain path renders its preview from the same inputs. The preview omits conversation history, which is the only place the two snapshots differ.
As an incidental cleanup, the pre-turn build_thread(...) call currently in Query::run is removed. Today it produces a Thread only consumed for its attachments field — handle_turn only reads &thread.attachments, and run_turn_loop rebuilds the thread itself. After this RFD, attachments flows directly into handle_turn, and the explain branch constructs its preview inputs locally.
pub(crate) async fn run(self, ctx: &mut Ctx, handle: Option<ConversationHandle>) -> Output {
// ... existing setup: lock, MCP, build_conversation, tools, attachments,
// sanitize, chat_request stamping ...
if self.explain {
let preview = QueryPreview::from_parts(
&cfg, &tools, &attachments, &chat_request,
);
let result = print_preview(&ctx.printer, &preview);
if let Some(path) = query_file
&& result.is_ok()
{
fs::remove_file(path)?;
}
return result;
}
// ... editor echo (renders the user's freshly edited request) ...
let turn_result = self.handle_turn(/* ..., &attachments, ... */).await;
// ...
}QueryPreview derives Serialize. The shape is versioned via schema_version so future changes can evolve without breaking existing consumers.
JSON output passes the preview to print_json. The current helper accepts &serde_json::Value; this RFD widens it to accept T: Serialize. Text output is a single render function over the struct.
Drawbacks
- Adds a flag to a command that already has many.
jp q --helpgrows. Mitigated by clear short-help text. - Maintenance contract on the JSON shape. Every future change to what gets sent must consider whether to surface the change in the preview and how to evolve the schema. This is the cost of making the request inspectable; the benefit is debuggability for users and AI agents alike.
Alternatives
jp query show subcommand. Ruled out by the existing CLI shape. jp q's trailing positional query: Option<Vec<String>> consumes any non-flag tokens as the query body, so jp q show ... parses show as the query text, not as a subcommand. Reshaping jp q to free up subcommand space is a much larger change than this feature warrants.
jp prompt print (or jp request print) as a peer command. Cleanly separates concerns, but the user must rewrite their jp q invocation in a different form to inspect it. The whole point of the feature is "I have this command, what would it actually do?" — making the user re-type it elsewhere is friction with no architectural payoff. Discoverability is also worse: a user inside jp q --help looking for inspection finds a flag, not a sibling command.
Multiple scope flags (--show-tools, --show-system, ...). Adds CLI surface area for filtering that JSON consumers can do trivially with jq. If filtering proves useful, add it later as a value on the existing flag (--explain=tools,system) without breaking the boolean form.
Showing history contents. Considered and rejected. jp c print already renders conversation history with rich style and turn-selection flags; duplicating that surface would drag scope-modifier flags (--last N, --turn N) onto jp q for a job that is already done well elsewhere. Use jp c print directly when prior turns matter.
Extracting a build_query function as the single source of truth. Earlier drafts proposed pulling the request-building logic out of Query::run into a pure function called by both the executed and explained paths to prevent drift. Once history contents were dropped from the preview, the only work left to share was the system prompt + tools + attachments + new request — all of which Query::run already produces in locals before handle_turn. Short-circuiting there reuses the same locals directly, so the extra function buys nothing.
Wire-format preview. Each provider serializes the request differently (Anthropic blocks, OpenAI messages, Gemini parts). --explain operates on JP's abstract representation, which is stable and provider-agnostic. Wire-level preview is a separate concern with a different audience and can be added later as e.g. --wire-explain without conflict.
Non-Goals
- History contents.
jp c printalready shows conversation history with rich style flags. Use it directly when prior turns matter. - Attachment content. The preview lists attachments by source and title. To inspect content, use
jp a print <url>. - Wire-format preview. Out of scope — see Alternatives.
Risks and Open Questions
- JSON schema stability. Once shipped, downstream scripts will depend on the field names (Hyrum's Law). The
schema_versionfield is the safety valve. Document the schema in the user-facing docs alongside--explainso the contract is explicit from day one. - System prompt rendering parity. Parity is structural: both paths use the same
cfg.assistant.system_promptvalue and the samebuild_sections(&cfg.assistant, !tools.is_empty())call. The executed path rebuilds the thread insiderun_turn_looponce the new request is added to the stream; the explain path renders its preview from the same upstream inputs. The preview omits history, which is the only place the two snapshots differ. --explainis not side-effect-free. Attachment and tool resolution run exactly as on the executed path, including command-attachment execution, HTTP fetches, and MCP server startup. Neither path is expected to write to caches or workspace state during resolution; any handler that does is a separate bug, not unique to this feature.- Session activation under
--no-persist. Today,--no-persist(and therefore--explain) still writes the terminal session's active-conversation mapping —load_workspaceswaps the persist and lock backends to null variants, but not the session backend. Users runningjp q --explain --id Xwill observe their session's active conversation change to X. This is a pre-existing gap in--no-persist, tracked separately; This RFD inherits the behaviour rather than fixing it inline.
Implementation Plan
- Widen
print_jsonto acceptT: Serialize. One-line trait bound change plus call-site updates. Independent of this feature and reviewable on its own. - Add
Commands::implies_no_persist(). A method onCommands(defaultfalse) consulted byrun_innerto compute the effective persist value, which is then written back tocli.globals.persistbeforeCtx::newsoctx.term.args.persistmirrors it.load_workspaceand every downstream consumer of the flag see the same value. Matches the existingCommands::conversation_load_request()pattern. Independent of this feature. - Add the
--explainflag onQueryand the short-circuit branch.Query::implies_no_persist()returnsself.explain, so persistence is forced off before workspace setup. The branch sits inQuery::runafterchat_requeststamping but before the editor echo block, and removes the editor query file on success (matching the executed path's post-handle_turncleanup). As an incidental cleanup, the pre-turnbuild_thread(...)call (only used today to passthread.attachmentsintohandle_turn) is removed;attachmentsflows directly intohandle_turn. - Define
QueryPreviewand supporting types. DeriveSerialize. Version viaschema_version. Build it from&cfg,Vec<ToolDefinition>,Vec<Attachment>, andChatRequest—&cfgcarries the system prompt input and the per-toolsourcelookup. - Add the text and JSON renderers. Text path reuses
SectionConfig::render(),jp_md,ToolDefinition::to_parameters_schema, and the format-aware printer. JSON path passesQueryPreviewto the widenedprint_json. - Tests. Snapshot tests for both text and JSON output across fixture configurations.
- Documentation. A feature page under
docs/features/describing the flag and the schema (withschema_version: 1called out).
Phases 1 and 2 are independent and can land first. Phases 3–6 depend on them. Phase 7 follows merge.