Conversation
Four alternatives (guided-analyst plugin, findings model with specialist agents, closed-loop fix-and-measure, continuous JVM performance SRE) for turning the existing MCP server, shells, and heap analyses into a set of Claude Code skills and agents. Includes an evidence-backed inventory of the current surface and the gaps each tier closes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
Implements alternatives A and B from doc/plans/performance-engineer-in-a-box.md. Alternative A - plugins/jafar-perf, a Claude Code plugin carrying the methodology the tools do not: nine skills (triage, cpu, latency, gc, memory-leak, heap-diff, compare, jfrpath, report) and seven agents (a perf-lead coordinator plus five specialists with narrow tool allowlists). It bundles .mcp.json, so installing it registers the MCP server too. Alternative B - the server-side groundwork: - Unified Finding model. jfr_diagnose, jfr_use, jfr_tsa, jfr_compare, pprof_use, otlp_use and hdump_report now emit findings in one shape with a stable id, so results from several tools merge and de-duplicate instead of being reconciled from prose. Heuristic findings say so. - jfr_diagnose runs the USE and TSA analyses it previously only recommended, merges their findings, and reports capabilityGaps separately - what the recording cannot answer is not a negative answer. depth=quick opts out. - jfr_compare: baseline versus candidate, normalised for duration and sampling rate, with a comparability block and a noise floor. - MCP prompts and resources, so the methodology reaches clients that do not install the plugin. Three bugs surfaced while verifying against real artifacts: - Heap-to-JFR correlation was unreachable over MCP: hdump_query got a bare SessionResolver, so the documented cross-type join always threw. The server now supplies an McpCrossSessionContext. - That join then still produced only nulls. AllocationAggregator read objectClass.name as a plain string, but the parser wraps string constants, so every real recording aggregated to nothing. The existing tests all fed a flattened shape the parser never emits. - JfrPath rejected the duration suffixes (10ms, 1s) that jfr_help and the MCP tutorial have always documented. Added, with no minute suffix, since m already means mebibytes. Verified end to end against recordings and a heap dump captured from a synthetic workload: jfr_compare attributes an engineered profile shift (Workload.alpha 54.13% -> 93.35% of samples), and the correlation resolves byte[] to 3494 allocation samples with topAllocSite Workload.main. Test counts against baseline HEAD: shell-core 212 -> 221, jfr-mcp 213 -> 235, all new tests passing. The 5 failures in each module are pre-existing and identical on baseline - they need binary fixtures that get_resources.sh cannot download in this environment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
Companion to the performance-engineer-in-a-box plan, covering the opposite direction: the model inside jfr-shell/hdump-shell/pprof-shell/otlp-shell rather than in an external MCP client. Four sections carry the weight: - Authentication. com.anthropic:anthropic-java resolves credentials itself, so AnthropicOkHttpClient.fromEnv() serves both an API key and an OAuth profile with no code of ours. Verified by inspecting the artifact from Maven Central - it ships CredentialResolver, ProfileConfig and the core.auth token providers. Separates the two things 'keyless' can mean: an OAuth profile, which works today and bills as API usage, versus a Claude subscription, which is Claude Code's entitlement and is reached honestly only by delegating to a local Claude Code install. - The cost-defining decision: the model composes queries and reads results, never raw events. The query engine is already the right reducer. - Recording content is untrusted input. Thread names, exception messages and heap string values are attacker-controllable when the recording comes from a third party, so results need data delimiters and a read-only tool surface. - Egress control, reusing the existing scrubber in tools/ for redaction, plus a dry-run that prints what would be sent and sends nothing. Three alternatives from an ask command through an agentic loop whose real deliverable is a replayable .jfrs transcript, to a delegate backend for subscription users. Recommendation is A now with the backend seam designed for B, and llm status / llm dry-run in the first release regardless of tier. Includes the docs, tutorials and blog plan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
Implements alternative A from doc/plans/llm-in-the-shell.md. jfr> ask which threads used the most CPU? # Groups execution samples by thread name and ranks the ten busiest. events/jdk.ExecutionSample | groupBy(sampledThread/javaName) | top(10, by=count) Commands: ask, explain, llm status, llm dry-run, llm cost. Four decisions shape this: - The model composes queries; it never sees raw events. The query engine is already the right reducer, so a 900 MB recording costs the same as a 2 MB one and the recording never leaves the machine. - Both auth modes are the SDK's job. AnthropicOkHttpClient.fromEnv() resolves an API key or the keyless OAuth profile from 'ant auth login', so Jafar adds no auth code - only diagnostics, because the SDK does not fail fast when credentials are missing. llm status catches the three traps: a stale key shadowing a profile, an empty-but-set key, and both credentials at once. - Recording content is untrusted input. Thread names and heap string values are attacker-controllable when the recording came from a third party, so they are fenced in explicit data markers, the system prompt declares them data, and the tool surface is read-only. - The query is always printed before it runs, so a wrong guess is visible and the user learns JfrPath rather than being insulated from it. Egress is controlled: result rows are redacted by field name (reusing the scrubber's model), truncated with the truncation declared, and llm dry-run prints the exact bytes a real call would send without sending them. The feature is optional at every level. The SPI is in shell-core with no new dependencies; the Anthropic SDK is only in the new llm-core module, taken as runtimeOnly and discovered via ServiceLoader. Dropping that one line removes the SDK entirely and the commands degrade to a clear message - air-gapped use is a supported configuration. Seams left for the agentic mode, documented in doc/plans/llm-in-the-shell-handoff.md: LlmBackend takes a tool-using method alongside complete(); LlmService gains analyze() next to ask() and explain(), reusing redaction and usage accounting; LlmCommands.Host is already the shape of the tool surface; and the .jfrs recorder is where the transcript goes. The handoff also names what a delegate backend for subscription users needs. Verified: 27 unit tests against a fake backend covering redaction, config, reply parsing, prompt construction, prefix stability, data fencing and every degraded path; and end to end in a built shell against a real recording - llm status, llm dry-run, ask without credentials, and both credential traps each produced the intended local diagnostic. The live API path is not tested; no credentials were available and spending someone's money from a test is not acceptable. Handoff section 6 lists exactly what that leaves unverified. Not wired into the unified jafar-shell: it has its own command chain and no variable store, so llm.* settings would not resolve there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
The previous commit wired ask only into jfr-shell's CommandDispatcher, and said the unified shell was left out because it has no set/vars. That was the wrong trade: jfr-shell parses everything as JFR, so opening a heap dump there fails outright, and jafar-shell is the only entry point that opens all four formats. The HdumpPath and samples-grammar references were therefore implemented, tested, and unreachable - and the tutorial showed a cross-format example that could not work. jafar-shell now has ask, explain and llm, with a host adapter over its own session manager and module evaluators. The module of the current session picks the language, so ask on a heap dump gets HdumpPath. Verified by opening a real heap dump and checking llm dry-run emits the HdumpPath reference with its roots and operators. The settings caveat is smaller than the one I used to justify skipping it: that shell has no set command, so llm.* resolves from its global VariableStore (which nothing populates yet) and then from JAFAR_LLM_* environment variables. The adapter already reads the store, so set works the day it is added. Also made the prompt wording format-neutral - it used JFR-specific phrasing that is now shown to heap-dump and profile sessions too. Docs corrected: the tutorial's cross-format example now names jafar-shell and its prompt, LlmSetup states which shell has what, and the handoff's "deliberately not done" entry is now about the missing set command rather than the missing wiring. Test state, measured against HEAD~1 with --rerun-tasks and no fixtures staged: identical failing sets, 126 in both, +12 tests from the new suite, zero new failures. Those 126 are jfr-shell tests that assert on the content of the binary recordings get_resources.sh downloads, which this environment cannot fetch; substituting a different recording makes them worse, not better, so they are left absent. shell-core 253 and jfr-mcp 274 pass with no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
Splits the single Anthropic module in two and adds an OpenAI-compatible one, so
'ask' is not locked to one vendor. It also fixes three defects that only showed
up once the whole path was driven end to end against a real server.
Providers
llm-anthropic Anthropic Java SDK, unchanged behaviour (renamed from llm-core)
llm-openai OpenAI chat-completions over the JDK HttpClient, no provider SDK
llm-openai ships two ids, 'openai' and 'ollama', which are the same code with
different Profile defaults: endpoint, model, and whether a key is required. Any
other server speaking that protocol - vLLM, LM Studio, Groq, Together,
OpenRouter, Ollama Cloud - is reachable by setting llm.base-url, with no new
code. Adding a named id is a new Profile, not new transport.
Consequences of not privileging a provider:
- LlmConfig no longer carries a model default. Each backend supplies its own
defaultModel(), because a model id set for one provider is meaningless to
the next.
- No Authorization header is sent when there is no key. An empty bearer breaks
several local servers.
- A loopback llm.base-url is probed with GET /models, so 'llm status' says
reachable or cannot reach instead of hanging at request time. A remote
endpoint is not probed: that would cost a round trip per status call.
- Cached tokens are split out of prompt_tokens, so usage adds up the same way
across backends.
Validate before running
A small local model writes invalid queries often enough that the feature would
be unusable without this: the candidate query is parsed with the same parser
that would execute it, and on rejection the parser's own error goes back with a
request to correct it, bounded by llm.max-retries (default 1, capped at 3). 'ask'
prints the correction count next to the token usage rather than hiding the round
trip. This is what makes a local model a real option, and a local model is what
makes 'ask' usable on a recording that must not leave the machine.
Three defects fixed
- 'ask' reached nothing in the interactive jfr-shell. CommandDispatcher runs
JfrPath two ways - through a JfrSelector when one is supplied, directly
through JfrPathEvaluator when one is not - and io.jafar.shell.Shell builds it
the second way. The host adapter knew only the first, so every 'ask' ended in
"No query evaluator available for this session" while the fake-host unit
tests stayed green. LlmHostAdapterTest now covers both paths; it fails
against the old adapter.
- 'ask' in jafar-shell passed the raw query string where a parsed query was
expected. The adapter now parses first, and JfrQueryEvaluator also accepts a
raw string - which its own interface documents and the Hdump, pprof and OTLP
evaluators already did.
- Token usage was not reported when the generated query then failed to run.
The request was paid for either way.
Also: LlmCommands.helpText() was unreachable and contained an unformatted %s.
It is now wired to 'help ask' / 'help explain' / 'help llm', and says what is
actually true.
Verification
- llm-openai is tested against a real com.sun.net.httpserver.HttpServer on
loopback, not a mocked client, because what is most likely wrong there is on
the wire: JSON shape, headers, usage accounting, error-to-remedy mapping.
- The full path was driven in both built shells against a real recording and a
stub OpenAI-compatible server scripted to answer first with a query the
parser rejects and then with a valid one. Both produced
"events/jdk.ExecutionSample | count()" -> 1142, matching the same query typed
by hand, with "1 correction(s)" in the usage line.
- :jfr-shell:test with --rerun-tasks: 732 tests, 126 failures, an identical
failing set to HEAD (126 of 729). Those failures are missing downloaded
fixtures in this environment, not code.
- No hosted provider has been called from this repository. The handoff document
section 6 states exactly what that leaves unverified.
Docs updated for the split and the provider choice: AGENTS.md, CHANGELOG.md,
doc/cli/LlmSetup.md (provider table, Ollama setup, wrong-query loop, new
settings), doc/cli/LlmPrivacy.md (local models change the central claim), and
doc/plans/llm-in-the-shell-handoff.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
ask in the shells
Combined JUnit Test Report
HTML Test ReportsRun artifacts: https://github.com/btraceio/jafar/actions/runs/34811598504
|
CI failed one test on this branch - McpOtlpTransportTest.otlpSummaryReturnsSampleInfo,
1 of 274 - and the report gave almost nothing to work with:
org.opentest4j.AssertionFailedError at McpOtlpTransportTest.java:101
Line 101 is assertSuccess, whose three assertions carried no payload, so the
message could not distinguish a per-call timeout from a transport-level error
from a tool that ran and returned an error naming its own cause. The failure
does not reproduce: 13 full runs of the suite here, 274 tests each, zero
failures, and the OTLP class alone passes too.
Two changes, neither of which weakens an assertion:
- assertSuccess now includes the response in every message. A timeout names the
timeout and the property that raises it; a JSON-RPC error prints the response;
a tool error prints the tool's own text. Verified by removing the setup call
and watching the message become:
result.isError must be false, but the tool reported:
{"error":"No otlp profile open. Use otlp_open first.","success":false}
which is exactly the sentence that was missing from the CI log.
- The 12 setup 'otlp_open' and 'pprof_open' calls that were not asserted now
are. An open that fails currently surfaces two lines later as a failure of the
call under test, which is how this investigation started at the wrong place.
The hdump and JFR transport tests already did this.
This does not claim to fix the underlying flake, and nothing is skipped,
disabled or quarantined. It makes the next occurrence self-diagnosing. Two
candidate mechanisms remain open, both order- and timing-dependent and neither
touching the code under test: the suite shares one session file at a fixed
tmpdir path (jfr-mcp/build.gradle:63), and callTool has a 15s per-call timeout
while CI runs several test JVMs in parallel.
Ruled out already: the OtlpTools change on this branch touches only otlp_use,
not handleOtlpSummary; a handshake timeout fails differently, at
McpTransportHarness:111 and across all 7 tests in the class rather than one;
japicmp is SKIPPED; and jfr-mcp pins toolchain 25, so the JDK 21 job label is
not a JDK difference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
The jafar-perf install steps existed only in plugins/jafar-perf/README.md and jfr-mcp/README.md - never in the root README, which is the file someone arriving at the repository actually reads. It documented the MCP server install in detail and did not mention that a plugin exists. Adds a Claude Code Plugin section immediately before MCP Server, and notes in the MCP section that plugin users have already registered the server, so the two paths do not read as two separate things to install. Every figure in it was checked against the tree: 9 skills, 7 agents, marketplace name btraceio, plugin jafar-perf, and plugins/jafar-perf/.mcp.json registering the jafar server as `jbang jfr-mcp@btraceio --stdio`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
/plugin marketplace add clones the marketplace's repository. Carrying the marketplace here meant installing 160 KB of Markdown cost a clone of ~18 MB, 9.6 MB of which is binary JFR test recordings a plugin user has no use for. The plugin moves to btraceio/jafar-perf, which is that 160 KB and nothing else. Install changes by one word - the marketplace keeps the name 'btraceio', so 'jafar-perf@btraceio' is unchanged and only the argument to 'marketplace add' moves: /plugin marketplace add btraceio/jafar-perf /plugin install jafar-perf@btraceio Removed here: .claude-plugin/marketplace.json and plugins/jafar-perf/. Repointed: README.md, jfr-mcp/README.md, doc/cli/AskTutorial.md, doc/mcp/WhenToUseWhich.md, CHANGELOG.md. The split has one real cost, so AGENTS.md now states it as the thing to remember rather than as a note: the skills name MCP tools and parameters explicitly, they are not covered by this repository's tests, and a tool rename here silently breaks a skill there. The design documents under doc/plans/ are left as written - they record what was proposed at the time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
ask in the shellsask in the shells
The previous commit documented btraceio/jafar-perf, which could not be created - the GitHub App has no Administration:write on the organisation. The plugin is published from jbachorik/jafar-perf-box instead, so every link and install line here follows it: README.md, jfr-mcp/README.md, AGENTS.md, CHANGELOG.md, doc/cli/AskTutorial.md, doc/mcp/WhenToUseWhich.md. The marketplace keeps the name 'btraceio', so the install lines read /plugin marketplace add jbachorik/jafar-perf-box /plugin install jafar-perf@btraceio which do not match on purpose. '@btraceio' resolves against the marketplace name, not the repository, so holding it fixed means moving the plugin into the organisation later changes one argument and does not break the plugin id for anyone who already installed it. Both READMEs say so, because otherwise it reads as a typo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
The plugin repository has been moved into the organisation, so every link and install line follows it: README.md, jfr-mcp/README.md, AGENTS.md, CHANGELOG.md, doc/cli/AskTutorial.md, doc/mcp/WhenToUseWhich.md. Also drops the paragraph in README.md explaining why the two install lines did not match. They match now - the marketplace is named btraceio and the repository is btraceio/jafar-perf-box - so the explanation described something that is no longer true. /plugin marketplace add btraceio/jafar-perf-box /plugin install jafar-perf@btraceio The plugin content is published: btraceio/jafar-perf-box commit 0212227 carries the nine skills, seven agents, .mcp.json, and the tool-drift check, on top of the repository's existing Apache-2.0 LICENSE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
The repository moved to the organisation at some point, but seventeen links
across nine files still named github.com/jbachorik/jafar. They resolve today
only because GitHub redirects a renamed owner, and that redirect is not a
guarantee - it breaks the moment someone creates a repository at the old path.
Swept: .github/ISSUE_TEMPLATE/config.yml and question.yml, CHANGELOG.md's
version-compare link references, CONTRIBUTING.md, LIMITATIONS.md,
PERFORMANCE.md's clone command, RELEASE_NOTES_v0.1.0.md, SECURITY.md's advisory
link, and demo/doc/DEMO_README.md.
Four occurrences of the name are deliberately left alone, because they are not
repository links:
- the security contact address in SECURITY.md and CONTRIBUTING.md, which is a
personal mailbox and still correct;
- the Maven POM developer id in build.gradle and jafar-gradle-plugin/
build.gradle, which identifies the developer rather than the repository and
appears in already-published artifact metadata.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
serverInfo.version was the literal "0.10.0" and nobody ever updated it, so every
release from 0.10.0 onwards has introduced itself to clients as 0.10.0. The
published 0.26.2 still does - verified by running the jar from Maven Central:
{"name": "jafar-mcp", "version": "0.10.0"}
That matters more than it looks. A client that wants to know whether a tool or a
response field is available has exactly one version to ask for, and it has been
wrong for sixteen releases. It came up while deciding how the jafar-perf plugin
should pin the server it talks to: the handshake could not answer the question,
so the plugin's drift check has to compare tool lists instead.
The shadow jar now carries Implementation-Version, and the server reads it back
through Package.getImplementationVersion(). A manifest written by the build
cannot fall out of step with the release the way a literal can. Outside a jar -
tests, an IDE - there is no manifest and the answer is "unknown", which is
honest, rather than a number that might be wrong.
Verified end to end: the built 0.27.0-SNAPSHOT jar now answers
{"name": "jafar-mcp", "version": "0.27.0-SNAPSHOT"}
and :jfr-mcp:test is 274 tests, 0 failures. No test depended on the old literal;
the transport tests supply their own client-side serverInfo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
The commands worked but were undiscoverable. Typing 'as<Tab>' offered nothing,
'llm <Tab>' offered nothing, and 'set llm.<Tab>' offered nothing - so the only
way to learn that llm.max-retries exists, or how it is spelled, was to read the
documentation. A shell command that does not complete is a command most users
never find.
Both shells:
- ask, explain and llm complete as commands. jfr-shell's CommandCompleter and
jafar-shell's GLOBAL_COMMANDS had every other command and not these three.
- 'llm <Tab>' offers status, dry-run and cost with descriptions. The question
after 'llm dry-run' is free text and is deliberately left uncompleted;
offering command names mid-sentence is noise.
jfr-shell also:
- 'set <Tab>' in the name position offers the twelve llm.* settings, each with
a one-line description. A settable name can be any variable, so there is
nothing to enumerate in general - but these are a closed, documented set and
the ones nobody can guess.
- 'help <Tab>' lists ask, explain and llm among its subjects.
- 'help ask' now ends with worked examples, matching what 'help events' and
the other subjects already did. It previously stopped at the settings list.
ShellCompleterLlmTest covers all of it, and one of its tests reads
LlmConfig.java and fails if a setting that class actually reads is not offered.
A setting that completes but is never read is worse than one that does not
complete: it looks supported and silently does nothing. Verified the test is not
vacuous by removing llm.max-retries from the list and watching it fail.
:jfr-shell:test with --rerun-tasks: 739 tests, 126 failures - the same
environmental failing set as before, seven new tests, zero new failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
There was no way to persist a credential except an environment variable, which
is the wrong place for a long-lived secret: every process the shell starts
inherits it, it turns up in crash dumps and CI logs, and exporting it inline
writes it to shell history. A file only its owner can read has none of those
properties and survives opening a new terminal.
LlmSettingsFile reads ~/.config/jafar/llm.properties, or $JAFAR_LLM_CONFIG, or
$XDG_CONFIG_HOME/jafar/llm.properties. Keys are the same names 'set' uses, so a
file and a set command are interchangeable.
Resolution is now: a set command, then an environment variable, then the file,
then the default. Environment above file is deliberate - CI overrides without
editing anything - but it means a stale variable silently shadows the file, so
'llm status' now names the file, warns when others can read it, and says which
layer each setting actually came from:
Settings file
-------------
/home/you/.config/jafar/llm.properties
llm.api-key from the settings file
llm.backend from JAFAR_LLM_BACKEND (overrides the settings file)
With no file it prints what to create and where, because "no settings file" is
the answer to the question someone asks when their file is not being read.
Verified in the built shell in all three states: file only, file shadowed by an
environment variable, and no file. The permission warning fires on a 644 file
and is silent on 600.
LlmSettingsFileTest covers parsing, blank and commented values, the permission
warning, an explicit path that does not exist, and the precedence chain. Java
cannot set environment variables in-process, so LlmConfig and LlmSettingsFile
each grew a package-private seam for supplying a file directly; the environment
layer itself is exercised by the end-to-end runs above rather than by a unit
test.
Also documented what was missing from the setup page: the Anthropic CLI needs
installing before 'ant auth login' can work - brew install anthropics/tap/ant on
macOS, or go install - and a note that 'ant' collides with Apache Ant, which has
owned that name for twenty years and will answer instead if it is earlier on the
PATH.
:shell-core:test 274 tests and :jfr-shell:test 739 tests, with the same
environmental failing sets as before and zero new failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
`ask` was a verb and `llm dry-run` was a subcommand of a different noun, so the two spellings of the same operation did not look related. Nothing told you that `llm dry-run <q>` was the no-send form of `ask <q>`, and `explain` had no dry-run form at all — the one place where recording data enters the prompt was the one place you could not inspect first. Dry-run is now a flag on the verb: `ask --dry-run <question>` and `explain --dry-run`. The flag is recognised anywhere in the argument, because someone who types it at the end means it, and treating it as part of the question would send the very request they were trying not to send. `llm` keeps `status` and `cost`. `llm dry-run` still works as an undocumented alias and points at the new form, so anyone who learned it from an early draft is not left with a broken command. Fixes a bug this exposed: neither shell recorded the result of a hand-typed query, so `explain` only ever worked after `ask`, despite saying it explains "the most recent result". Both dispatchers now remember the last row-shaped result and prime the handler with it — lazily, so recording a result still does not load a backend. Verified against a recording made for the purpose (JDK 25, 10s, profile settings), since the fixtures in this tree are stripped and do not parse: jfr> show events/jdk.ExecutionSample | count() jfr> explain --dry-run Nothing was sent. ... characters : 1148 Result (1 rows): count 136 Completion and help in both shells follow the new shape. Tests: `LlmCommandsTest` 18/18 (6 new for the flag), `ShellCompleterLlmTest` 7/7, `:jafar-shell:test` green. `:jfr-shell:test --rerun-tasks` reproduces the recorded 126-failure baseline exactly — zero new failures; the `:shell-core:test` failures are the same missing-fixture ones (`NoSuchFileException: ../parser-core/src/test/resources/test-jfr.jfr`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
The settings file was added so a long-lived key need not live in an
environment variable. It worked for the OpenAI-compatible backends, which
read `config.apiKey()`, and did nothing at all for Anthropic, which asked
`AnthropicOkHttpClient.fromEnv()` and inspected only the environment. So a
key sitting in ~/.config/jafar/llm.properties produced:
anthropic Anthropic API (anthropic-java) NOT READY
No credentials found: no ANTHROPIC_API_KEY, ...
with the key right there in the file the same command had just listed.
Now:
anthropic Anthropic API (anthropic-java) READY
llm.api-key (settings file)
A configured key takes precedence over ANTHROPIC_API_KEY: it was chosen
deliberately for this tool, while the environment variable may be left
over from something else in the same terminal. The client is rebuilt when
the key changes, so `set llm.api-key` mid-session takes effect instead of
reusing a client built from the old one. The key itself is never printed —
only where it came from, which is the part that is hard to guess.
Also fixes the `ant` install instructions, which sent someone into two
dead ends. The Homebrew tap owner is `anthropics`, plural: `anthropic/tap`
fails with "Repository not found" on github.com/anthropic/homebrew-tap.
And plain `brew install ant` is Apache Ant, the Java build tool, which
installs cleanly and then has no idea what `auth login` means. Both are
now called out where the command is, not further down. Go 1.25+, per the
CLI's own docs, not 1.22. There is no macOS release tarball — every
darwin_* and Darwin_* name under v1.32.0 404s while linux_amd64 is 200 —
so on macOS it is Homebrew or `go install`, which is worth saying on an
Intel Mac where Homebrew now warns the platform is unsupported.
The README now leads with the settings file, since it needs no CLI and no
environment variable, and says plainly that `ant` is optional.
Tests: 6 new in llm-anthropic (its first test source set) covering the
configured key, the reported source, that the key is never printed, and
that a blank key is not treated as a credential. `:llm-anthropic:test`
and `:llm-openai:test` green; `:shell-core:test` unchanged at the same 5
missing-fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
Every document, the help text, `llm status` and the tab completion tell
people to run `set llm.backend = ollama`. The shell answered:
Invalid variable name: llm.backend
`set` validates names against [a-zA-Z_][a-zA-Z0-9_]*, which cannot allow
a dot in general: in an expression `${a.b}` means field b of variable a,
so a variable literally named `llm.backend` would be unreachable and
ambiguous. Settings are not query variables — they are read back by name
through the config lookup and never substituted into an expression — so
they are now admitted by name, from one list in shell-core that the `set`
validation, the completer and the docs all share.
Allowing the name exposed two more failures underneath it, each of which
looked like success:
- A bare word went down the expression path and was read as a query, so
`set llm.backend = ollama` answered "Invalid query: Unknown root:
ollama".
- A bare integer was coerced to a double, so `set llm.max-rows = 20`
printed "Set llm.max-rows = 20.0", which LlmConfig then failed to parse
as an int and silently replaced with the default. `llm status` went on
reporting 50.
A setting's value is now stored as literal text. `${...}` substitution
still applies and surrounding quotes are stripped, so a URL needs no
quoting: `set llm.base-url = http://localhost:11434/v1`. An `llm.`-prefixed
name that is not a setting is reported as a typo with the real names
listed, instead of becoming a variable nothing will ever read.
Verified in the built jar, which is the only place this could have been
caught — all four settings now reach `llm status`:
jfr> set llm.backend = ollama
jfr> set llm.max-rows = 20
backend : ollama
max rows : 20
`unset llm.backend` already worked (it does not validate) and still does.
Tests: 7 new in SetLlmSettingTest, asserting the value as LlmConfig reads
it back rather than what the command printed, since printing something
was never the problem. 6 of the 7 fail against the previous dispatcher;
the seventh is the guard that ordinary variables are unaffected, and
passes both before and after. `:jfr-shell:test --rerun-tasks`: 752 tests,
126 failures, an identical set to the recorded baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
…cost AGENTS.md was 544 lines and had become a manual: one section ran to 241 lines, the build commands sat between the release process and the coding style, and nothing said which part to read for the change you were making. An entry point that has to be read end to end is not an entry point. It is now 141 lines — the map, the module list, the quick start, and the rules that apply to every change — with the detail moved verbatim into doc/agents/: Build, Architecture, Shells, Mcp, Llm, Release. doc/README.md registers the area. Content was moved, not rewritten; a line-by-line check of the old file against the new set accounts for every line. The new material is doc/agents/Verification.md, and it is the reason for the rest. This session produced a run of bugs that unit tests could not have caught, and they rhymed: - `set llm.backend = ollama` was documented in five places, in the help text, in `llm status`'s own advice, and in tab completion — and had never been run. The shell rejected the name outright. Completion was offering names the shell would refuse. - `ask` reached nothing in the interactive shell while every fake-host test was green, because the dispatcher has two query paths and the adapter knew one. - `explain` only ever worked after `ask`, in both shells, because neither recorded a hand-typed query's result. - `llm.api-key` was honoured by two backends of three; the settings file the README recommends did nothing for the third. - `set llm.max-rows = 20` stored 20.0, which failed to parse as an int and silently became the default, while the shell printed a confirmation. - The MCP handshake reported version 0.10.0 for sixteen releases. So the file is eight rules, each with the case file that produced it: type it into the built artifact; enumerate every path; a fallback that hides a misconfiguration is a bug; documentation is code, run it; prove the test fails without the fix; compare failure sets by name, not count; one source of truth for any list two places must agree on; say what you did not verify. The three runnable snippets in it were run: the R1 shell invocation, the R6 baseline diff (126 failures, identical set), and a syntax check of the R5 stash sequence. Also fixed a pre-existing broken link in doc/README.md (`unTypedAPITutorial.md`; the file is `UntypedAPITutorial.md`). Two line-number references to AGENTS.md in doc/plans/ are now stale. They are left as written, consistent with how the other design documents on this branch record what was true at the time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
The AGENTS.md split left `AGENTS.md:364-372` and `AGENTS.md:121-132` in
doc/plans/performance-engineer-in-a-box.md pointing at content that had
moved, and at line numbers that no longer mean anything. They now link to
the sections themselves — doc/agents/Mcp.md#mcp-server-jfr-mcp and
doc/agents/Build.md#go-parser-commands — which a move cannot invalidate
the way a line number can.
The surrounding sentences are claims about what was true when the document
was written ("the only accurate list"), so each keeps its original wording
and notes that the content was in AGENTS.md at the time, rather than being
silently rewritten to describe today.
Both links and both anchors were resolved against the target files; no
other line-number references to AGENTS.md remain in the repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
Reported from a real run against a local Ollama forwarding to the cloud:
> ask which method is using most CPU
No query could be extracted from the model's reply. Nothing was run.
[llm: 1357 in, 2048 out]
2048 out is exactly llm.max-tokens. The reply was not malformed, it was
truncated: the model was still reasoning when it hit the ceiling. The
shell billed the full budget and reported nothing useful about why.
Two faults, one visible and one underneath it.
The visible one: both backends read `finish_reason` into
LlmResponse.stopReason and *nothing consumed it*. The reason for the
failure was in hand and thrown away, leaving a message that misdescribed
what happened. `ask` now explains itself — truncation names the ceiling,
an empty reply says so and notes that some models put their output in a
separate reasoning field, and anything else prints what the model actually
said so it is not a guessing game.
The underlying one: 2048 was sized for the answer. A query and one line of
rationale really is small — the number was not careless, it was reasoning
about the wrong thing, because a reasoning model spends that same budget
before it writes anything.
Raising the default to 8192 was the first fix and the wrong one: it makes
every runaway four times more expensive to cap a case that only some
models have. Instead the ceiling now discovers what it is talking to. When
a reply stops on `length` without producing a query, LlmService raises to
MAX_TOKENS_WHEN_THINKING (16384), says so, and asks again; the discovery
is remembered per model for the session. The trigger is the reply's own
stop reason — a list of reasoning model names would be stale within a
month and says nothing about a local model someone renamed. A ceiling the
user set is never lowered.
Fixing that exposed a third: LlmCommands built a new LlmService per
command, so the discovery was thrown away and every ask paid for the
truncated attempt again. Verified against a stub, by logging the
max_tokens of each request:
before: 2048, 16384, 16384, 2048, 16384, 16384
after: 2048, 16384, 16384, 16384, 16384
and a model that answers immediately stays on 2048 throughout, so nothing
pays for a capability it does not use.
Tests: 5 new in shell-core, driving a backend that truncates until given
room. 3 of the 5 fail with the escalation disabled; the other two are the
guards that an ordinary model and a user-set ceiling are left alone, and
correctly pass either way. `:shell-core:test` 279 tests with the same 5
missing-fixture failures as before; `:jfr-shell:test --rerun-tasks` 752
tests, 126 failures, name-for-name identical to the baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
`ask` sent the model a bare list of type names, which left it choosing an event type by whether the name happened to contain a word from the question. A recording already documents itself: JFR annotates event classes with @Label and @description, so the answer was sitting in the file the whole time. The model now sees jdk.ExecutionSample — Java Execution Sample Snapshot of a thread executing Java code. Threads that are not executing Java code, including those waiting or executing native code, are not included. which not only identifies the right type for a CPU question but states what it does not cover — often the difference between a right answer and a plausible one. Measured on a 10s recording with 181 event types: 177 carry a label, 96 a description, and the inventory grows from ~990 to ~3,900 tokens. It goes in the cached system prefix rather than the user message, because it is fixed for a recording: the first question pays for it and every question after reads it from cache. That only holds if the text is byte-identical between calls, so renderInventory sorts, and a test asserts two different input orders render the same string. Verified on the built jar: two consecutive `ask --dry-run` calls produce prefixes identical to the byte, 21,310 of them. Event counts are deliberately not included, despite being the obvious thing to add. JFRSession seeds eventTypeCounts to 0 from metadata (:72, :109) and only increments while a query's handlers run (:156-159), so before a query every count is zero — sending them would tell the model every type is empty. Real counts mean scanning the recording, which would make `ask` cost grow with file size, the one property this design exists to protect. The count field stays on TypeEntry, unset, rather than being faked. Security: the inventory is recording-derived and stays inside the RECORDING_DATA fence even though it now sits in the system prompt — a custom event type is named and documented by whoever produced the recording. LlmServiceTest's fencing test moved with it and got stricter: it now locates the hostile payload, asserts a fence encloses it, and additionally asserts it does not leak into the question turn. Falls back to names alone when metadata cannot be read, so a backend without annotation support degrades to today's behaviour rather than breaking `ask`. Parsed once per recording and cached. Tests: 7 new in TypeInventoryTest. `:shell-core:test` 286 tests, 5 failures, the same missing-fixture set; `:jfr-shell:test --rerun-tasks` 752 tests, 126 failures, name-for-name identical to the baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
JFR is self-describing, so a field name cannot be inferred from a type
name: an event's fields are whatever that recording declares, they differ
between JDK versions, and for a custom event they are unknowable in
advance. Sending type names and descriptions told the model which type to
use and left it guessing how to address anything inside — which is how a
plausible `stackTrace/frames[0]/method/name` reaches the parser and costs
a correction round trip, or worse parses and answers a different question.
Sending every type's fields up front is the obvious fix and the wrong
shape. Measured on a 10s recording: 181 event types, 994 fields, ~9,800
tokens — nearly all of it about types the question never touches, and
unbounded on a recording full of custom events.
So the model asks. It may answer `FIELDS: <types>` instead of `QUERY:`,
and is sent those types' fields together with the types those fields lead
to, one level deep:
jdk.ExecutionSample — Java Execution Sample
fields: sampledThread: java.lang.Thread, stackTrace: ...
java.lang.Thread
fields: group: ..., javaName: java.lang.String, ...
jdk.types.StackTrace
fields: frames: jdk.types.StackFrame[], truncated: boolean
That one level is the point: knowing sampledThread is a java.lang.Thread
is only useful alongside that type's own fields, which is where javaName
comes from. Array dimension is rendered too, so `frames[0]` is read rather
than assumed.
Verified end to end against a stub playing the model's side, driving the
built jar over a real recording:
round 1: system=21,327 chars (cached), conversation=42 chars
round 2: system=21,327 chars (same, so cached), conversation=1,188
events/jdk.ExecutionSample | groupBy(sampledThread/javaName) | top(5, by=count)
| 136 | main |
1,188 characters against the ~24,000 a full field dump would have cost,
with the cached prefix untouched between rounds.
Bounded on both axes: at most 8 types per request, at most one round. A
model that asks again after being answered is looping rather than
learning, and the unmet request is reported instead of spending the user's
tokens on another lap.
One bug worth recording: the class metadata carries `fields` as a list of
rendered display strings and `fieldsByName` as the structured map. Reading
`fields` and testing each element for a Map yields an empty list and no
error at all — the first run produced labels and descriptions with every
field list silently empty. Caught by looking at the bytes the stub
received rather than at whether the command succeeded.
Field order comes from a HashMap, so it is sorted; two runs of the same
question now produce an identical payload, verified.
Tests: 7 new in FieldRequestTest covering the exchange, the dictionary,
the unchanged prefix, the cap, the loop guard, and that a direct answer
still costs one round trip. `:shell-core:test` 293 tests, the same 5
missing-fixture failures; `:jfr-shell:test --rerun-tasks` 752 tests, 126
failures, name-for-name identical to the baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
Reported from a real recording: asked about CPU, the model reached for jdk.ExecutionSample — which had no events — and ignored datadog.ExecutionSample, which had several thousand. The inventory was every type the *metadata declares*. JFRSession's scanMetadata reads the first chunk's metadata and aborts, so getAvailableTypes returns everything the JVM registered whether or not it ever emitted anything. A recording made with an agent that ships its own sampler therefore lists an empty jdk.ExecutionSample beside a vendor type carrying the actual samples, and a model given only names picks the one it recognises. It was choosing correctly from a list that was wrong. I had argued counts were unaffordable because they mean scanning the recording, and that ask must not scale with file size. That reasoning was wrong in a way worth naming: **ask answers with a query, and running that query streams every event anyway**. The scan was already being paid, one line later, for the same recording. So events are counted once via JfrPathEvaluator.countAllEventTypes, and the inventory now separates types that hold data — with counts — from types merely declared, which collapse into a single line the model is told not to query. On a 30s recording that is 73 types with events against 108 without, and the prompt got *smaller*: 22,797 chars to 16,494. The counts outlive the session. EventCountCache stores them under $XDG_CACHE_HOME/jafar/event-counts, keyed on the recording's path, size and modification time — not beside the recording, which is often a directory that is read-only, shared, or simply not the shell's to litter. A file replaced in place misses rather than answering from a stale count, and a corrupt entry discards the whole file rather than being partly believed: a wrong count is worse than no count, because the model acts on it. Measured on a 1.8 MB recording, 1.27s to 0.88s, and the saving grows with the file. `llm.count-events = false` skips the pass. One distinction carries weight in the code: a type absent from a *successful* count holds 0 events, while -1 means no count was taken. Only 0 moves a type to the do-not-query list; -1 renders nothing, so the model infers nothing from silence. Getting this wrong the first time made the empty list come out empty. The prompt also now says a type's package says nothing about its relevance, since the failure was partly familiarity bias toward jdk.*. Tests: 5 in EventCountCacheTest (round trip, invalidation on change, absent and corrupt files, empty counts never written) and 3 more in TypeInventoryTest for the split, the anti-bias line and singular "1 event". `:shell-core:test` 296 tests, `:jfr-shell:test --rerun-tasks` 757 tests, 126 failures name-for-name identical to the baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
`ask` turns a question into one query. That answers "how many execution
samples are there"; almost no real performance question has that shape.
The useful ones need a look, a narrowing, and a conclusion drawn from what
came back — and nothing in the feature could do that, because `ask` cannot
see its own result.
`analyze <question>` can. The model answers with QUERY:, FIELDS: or
ANSWER:; the shell runs the query, feeds the rows back redacted and
truncated, and repeats until it concludes or runs out of budget. Verified
end to end against a stub playing a real investigation, driving the built
jar over a recording made for the purpose:
> events/jdk.ExecutionSample | groupBy(sampledThread/javaName) | top(3, by=count)
3 rows
> events/jdk.ObjectAllocationSample | groupBy(objectClass/name) | top(3, by=count)
3 rows
Execution samples concentrate on the main thread, and allocation
samples are dominated by byte[]. ...
The model cited "main", which only appears in its reply if the rows
actually reached it — the thing that separates a loop from a slower `ask`.
**Text protocol, not native tool calling.** This departs from the handoff
document's §3.1, which expected `completeWithTools` on `LlmBackend`. Tool
use exists on the hosted providers and not on a small local model served
through an OpenAI-compatible endpoint, so building on it would have made
the investigation loop a hosted-only feature and split the backend SPI in
two. The `FIELDS:` exchange had already shown a text protocol carrying a
multi-round conversation through every backend unchanged.
**The transcript is the point.** A conclusion produced by a model is not
reproducible; the queries it ran are. Each run writes them to a .jfrs
script, and that script re-runs: executing the generated one reproduces
the exact numbers the answer was drawn from — 2482 samples on `main`, 8519
byte[] allocations. Handoff §3.4 argues this converts the loop's weakest
property into a verifiable artifact, and it is right.
**Bounded on two axes**, because an unbounded loop against a paid API
loses money quietly: `llm.max-steps` (6) caps the moves and
`llm.max-total-tokens` (200000) caps the spend, checked before each
request. The remaining step count goes in every turn, so the model wraps
up rather than being cut off mid-thought.
Also moves `Finding` from `jfr-mcp` to `shell-core`
(`io.jafar.shell.core.findings`) — handoff §3.5, mechanical, no MCP
dependencies — so a shell investigation and an MCP one share one shape and
can merge. This is also the first step of extracting the analysis
heuristics, which is the other half of the request and is still to come.
Tests: 9 in AnalyzeLoopTest — that results reach the model, that rows are
redacted on the way out (this path sends far more recording data than
`ask`, so it matters more here), that a rejected or throwing query is fed
back rather than ending the run, that both caps bite, and that an
unusable reply is nudged rather than treated as an answer.
`:shell-core:test` 305 tests, `:jfr-mcp:test` 235, both at their existing
5 missing-fixture failures; `:jfr-shell:test --rerun-tasks` 757 tests with
125 failures against a 126 baseline — one *fewer*, and not by my doing:
`MutationBasedCompletionTests.pipelineCandidatesWhenPresentAreReasonable`
is a `@Property(tries = 300)` randomised test that happened to draw a
passing seed. No new failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
Both seen in a real run against a recording whose samples come from
datadog.ExecutionSample.
The model wrote:
QUERY: FIELDS: jdk.types.StackFrame, jdk.types.Symbol
and the shell ran "FIELDS: jdk.types.StackFrame, jdk.types.Symbol" as a
query, which failed with "Unknown root: FIELDS: [at 7]". The model had
confused the two directives; taking the line at face value turned a
recoverable slip into a spent step that taught it nothing. A directive
nested inside a QUERY: line is now read as that directive.
The second is a real gap in the query language rather than a parsing slip.
The model reached for
groupBy(stackTrace/frames[0]/method/name, agg=count)
which is a parse error: a path inside a function argument cannot be
indexed. The legal form, groupBy(stackTrace/frames/method/name), parses —
and answers a different question, counting every frame on every stack
rather than the leaf. On a 30s recording that is 7371 "invoke" and 4942
"main" against a true leaf ranking. So the natural expression is
unsupported and the supported one is wrong, which is a reliable way to
make a model look stupid.
Until indexing inside function arguments is supported, the language
reference now says so and names stackprofile() as the way to rank hot
methods. That is what the model reached for next unaided, and it returned
73 rows.
Tests: 3 in AnalyzeLoopTest for the directive recovery, including that an
ordinary query is untouched and that a truncated "QUERY: QUERY:" does not
recurse. shell-core 308 tests and jfr-mcp 235, both at their existing 5
missing-fixture failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
The analyses live in jfr-mcp shaped as handleJfrX(...) -> CallToolResult, with the computation woven into the response building. That is why the shell's `analyze` cannot use any of it: USE, TSA and the diagnosis heuristics are reachable only by speaking MCP. This begins moving them to shell-core, where jfr-mcp becomes the adapter rather than the owner. Groundwork, and one analysis moved: - AnalysisTarget carries the three things the analyses actually use — the session, its path, its number — so they no longer depend on jfr-mcp's SessionRegistry. sessionId is an int because that is what both session managers use and what the MCP output has always carried; making it a String would have quietly changed the JSON. - Progress replaces the direct calls to the MCP server's sendProgress, so a long analysis can report itself to a shell, a test, or nothing. - summary() moves to JfrAnalyses in shell-core along with the helpers only it used. handleJfrSummary is now six lines that call it and wrap the result. **The safety net came first for the rest, because there wasn't one.** jfr_use, jfr_tsa and jfr_diagnose are exercised only by McpJfrTransportTest — which cannot run without the binary recordings get_resources.sh downloads, and is one of this environment's five standing failures — and by McpEndToEndTest, a separate task. So in this environment nothing executable covered nineteen hundred lines of the most intricate code in the repository, and moving it would have been a guess dressed up as a refactor. JfrAnalysesCharacterizationTest pins the contract of summary, use, tsa, diagnose, hotmethods and exceptions against a synthetic recording built by SimpleJfrFileBuilder, so no fixture download is needed. It asserts the keys a caller binds to rather than the numbers, which depend on the recording: `findings` stays an array, `sessionId` stays numeric, and diagnose keeps `capabilityGaps` — the load-bearing one, since a caller that loses it starts reporting absence as evidence. The net is proven rather than assumed: renaming capabilityGaps to capability_gaps in the current code fails exactly diagnoseKeepsItsShapeAndItsGaps and nothing else. Also confirms the covering tests for what moved: summaryProvidesRecording- Overview in HandlerLogicTest runs here and passes against the extracted implementation, as do the hotmethods and exception tests. jfr-mcp 241 tests and shell-core 308, both at their existing 5 missing-fixture failures. use, tsa and diagnose have not moved yet; they are next, and now have something watching. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
JfrAnalysisTools goes from 3368 lines to 1217; JfrAnalyses in shell-core holds the 2467 that actually compute something. The five handlers are now six lines each — resolve the session, forward progress, translate an exception into the error text the tool has always returned — and the heuristics are reachable from the shell for the first time. Moved: exceptions, hotmethods, use, tsa, diagnose, their thirty-odd private helpers, and the nested ExceptionAnalysis, ThreadStateMetrics, MonitorCorrelation, QueueCorrelation and BLOCKING_STATES. JfrFindings goes to shell-core.findings beside the Finding it produces. Member boundaries were found by brace-matching rather than by eye, because a range that is one line wrong compiles and means something else. **diagnose no longer serialises to JSON and parses it back.** It composed the other tools by calling handleJfrX, reading the text out of the CallToolResult and running it through MAPPER.readValue — five times per diagnosis. Those are direct calls now. The error semantics are preserved: each sub-analysis was skipped when it returned an error, and is skipped when it throws. Behaviour was held fixed deliberately, because the MCP tests are the only thing watching. Two places where that nearly slipped: - AnalysisTarget.sessionId is an int. SessionInfo.id() is an int and the summary result has always carried a number; a String would have changed the JSON without changing a test that runs here. - **The evaluator is injected, and I dropped it.** JfrAnalyses first built its own `new JfrPathEvaluator()`, which looked equivalent and was not: ConsumeEdgeCasesTest constructs the server with an evaluator that yields nothing, and an analysis holding its own real one ignored the double and read the recording instead. That surfaced as jfrExceptionsWithZeroEventsReturnsEmptyResponse failing with "Event type 'jdk.JavaExceptionThrow' not found". Confirmed it was mine by stashing and re-running, not assumed. JfrQuerySource restores the injection. That second one is the whole argument for having built the net first: it is a change nothing about the diff would have shown, and the only reason it was caught is that a test exercised a substituted dependency. The helpers that stayed behind are no longer duplicated — stage one had copied extractFrames, extractMethodName and unwrapValue and left the originals in place. JfrAnalysisTools now forwards to the one copy, and keeps thin delegators for detectExecutionEventType, extractFrames, extractMethodName and isNativeMethod because JfrCompareTools and JafarMcpServer already reached them through it. jfr-mcp 241 tests, shell-core 308, jfr-shell 757 with 126 failures name-for-name identical to the baseline — all three at their standing missing-fixture failures and nothing new. Still to do: point the analyze loop at these, which is the reason for the move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
The extraction's payoff. `ANALYSIS: <name>` joins QUERY:, FIELDS: and
ANSWER: in the loop's protocol, and reaches JfrAnalyses in shell-core —
the same code the MCP server exposes as jfr_diagnose and the rest, since
there is now one copy. The model gets the thresholds, the USE and TSA
passes and the capability gaps instead of trying to rebuild that judgement
out of queries, which it cannot do: a query has no opinion about what
609 collections at 20.2 ms mean.
Driven end to end against a stub over a real recording:
* diagnose
done
> events/jdk.ObjectAllocationSample | groupBy(objectClass/name) | top(3, by=count)
3 rows
The diagnosis flagged high GC pressure and the allocation breakdown
is dominated by byte[]. Look at the allocation call sites.
Both halves are load-bearing there: the diagnosis text is what the model
read the GC pressure from, and "byte[]" is only citable because the query
rows reached it.
Two egress bugs surfaced while checking what the model actually received,
rather than that the command worked.
**A finding's own description was being redacted.** `description` is in
the default list because an event row can carry application data under it;
in a Finding it is Jafar's explanation of what it found. The model was
getting the numbers with the reasoning replaced by <redacted> — the less
useful half of each finding. Redactor.forAnalysis leaves that one key
alone on the analysis path only.
**The parser's string wrapper was being redacted wholesale, everywhere.**
A string constant arrives as {string=[B} rather than [B, and `string` is
in the default redact list, so every wrapped constant sent to the model
was replaced: class names, symbols, group-by keys. The model saw
count key
8519 {string=<redacted>}
for data that was never sensitive, and the redaction looked like it was
working. This predates the loop and affected `explain` too. The wrapper is
unwrapped before the decision is taken, so the decision is made on the
real field name; a wrapped value under a genuinely redacted field is still
redacted, and a multi-field map is left alone. Same shape as the
AllocationAggregator bug this PR already documented: right key, wrong
structure, no complaint.
Sub-analyses are not embedded (includeAnalysis=false) — the loop can ask
for `use` or `tsa` itself, and a diagnosis carrying both would spend most
of a step's character budget on data the model did not request.
llm.max-analysis-chars caps what one result may occupy.
The drift test caught me adding that setting to LlmConfig and not to
LlmSettings, which is what it is for.
Tests: 6 more in AnalyzeLoopTest (the verb parses, the analysis runs, its
findings reach the model, a description survives, an invented name is
answered with the real ones rather than costing a step, and a host with no
analyses says so) and 3 in RedactorTest for the unwrap, including that it
is not an escape hatch. shell-core 317, jfr-mcp 241, jfr-shell 757 with
126 failures name-for-name identical to the baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
…tself
Two rules and one document, all paid for during this branch.
**R9 — inspect the payload, not the exit status.** A command that succeeds
has not told you it did the right thing. Driving `analyze` against a stub
and reading what the stub received showed
count key
8519 {string=<redacted>}
Class names were being redacted because the parser's wrapper has an inner
key named `string`. The command succeeded, the rows arrived, the redaction
ran — only the payload showed it was wrong. The same read caught a
Finding's own description being redacted, and earlier the field-metadata
feature "working" while every field list was silently empty. When a model
is the consumer this is the only way: it will produce a fluent answer from
redacted data and you cannot tell from the answer.
**R10 — before a refactor, establish the net, then prove it fails.** Find
what covers the code *in this environment*, not what exists in the repo.
jfr_use, jfr_tsa and jfr_diagnose were covered only by a test that cannot
run here and one in a separate task, so moving nineteen hundred lines on a
green `:jfr-mcp:test` would have been a guess. It also records the two
things that change behaviour while being invisible in a diff: a type that
crosses a boundary (int vs String sessionId), and an injected dependency
replaced by a constructed one.
**doc/agents/DataShapes.md** collects the bug class that has now bitten
four times: code reads a structure by assuming what is inside it, the
assumption is wrong, and nothing complains. A wrapped string constant read
as a String (AllocationAggregator, and again in redaction); a display list
read as a data list (`fields` vs `fieldsByName`); a declared type taken for
a present one (which is how `ask` offered an empty jdk.ExecutionSample);
and a count field that is all zeros until something scans. The fifth will
look like the first four.
Existing rules gained the case files this session produced: R2 the seam
that vanished in the extraction, R3 the `finish_reason` that was captured
and never read.
**Self-bootstrapping.** AGENTS.md now carries the obligation as a standing
rule — leave this better than you found it, in the same change, because a
lesson kept in a commit message is lost — and Verification.md says what
earns a rule: a bug that cost more than one attempt, or a cost you were
confidently wrong about. One-line fixes you spotted immediately are not
lessons. Every rule needs a case file with the real error text and the real
numbers; if you cannot write one you have not understood the bug well
enough to generalise from it. Case files stay after the bug is fixed, as
evidence, but get corrected when they stop being true — a stale case file
is worse than none because it is quotable.
The section also covers the map, the links and the docs: a new area gets a
row in both maps, section links beat line numbers (AGENTS.md:364-372 was
dead within a day), behaviour and its documentation change together, and
doc/plans stays as written because a design document records what was
proposed.
The link check is included rather than asserted, and run: 23 misses, none
under doc/agents/ — three deliberate placeholders, four footnote refs that
are not links, sixteen older pages pointing at renamed files. Left alone
rather than swept into an unrelated change. My first version of that
snippet counted its own regex as links, which is the R9 point in
miniature, so it skips fenced code now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
…e aggregate Two failures from a real 'analyze gc behaviour in detail' run, both reproduced against a recording before touching anything. groupBy on a field the type does not have returned zero rows and no complaint: events/jdk.GarbageCollection | groupBy(gcType, agg=count, value=duration) (no rows) jdk.GarbageCollection has no gcType (cause, duration, eventThread, gcId, longestPause, name, startTime, sumOfPauses), but an empty result reads exactly like "this recording has no such events", so the reader moves on rather than fixing the name. It now counts the events the key was offered and, when none of them yielded a key, names the key, the count and the fields the type does have. A group-by over a type with no events at all is still an empty result: the check only fires where the answer would have been empty anyway, so nothing that returns rows today can start failing. groupBy names its aggregate column after the function, so the natural follow-up was rejected: ... | groupBy(name/name, agg=sum, value=sumOfPauses) | sortBy(value, asc=false) Error: sortBy: field 'value' not found. Available: [sum, key] groupBy's own sortBy= argument already spells that column 'value', so the pipeline stage is the same thought written the other way round. Both sortBy and top now read it, and only when there is no real column of that name on rows shaped the way groupBy shapes them. top had the same gap and failed silently instead, which is worse: an unresolved path yields null for every row, compareValues(null, null) is 0, and the sort keeps the input order — the first n rows presented as the top n. Both events/jdk.JavaMonitorEnter | groupBy(monitorClass, agg=sum, value=duration) | top(10, by=value) events/jdk.ObjectAllocationSample | groupBy(objectClass/name, agg=sum, value=weight) | top(20, by=value) are examples in LanguageReference, so every model was shown the pattern. The new test pins the case: before the fix it returns the group with sum=15 ahead of the one with sum=100. Also: the interactive shell's 'help' never listed ask, analyze, explain or llm, and 'help analyze' did not route to their help text. Both fixed. Nine tests; five fail without the change. Full suite unchanged at 173 pre-existing failures, same set by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
…lm.confirm 'ask' and 'analyze' answered the same questions and 'analyze' answered them better, so the differences that remained were all in ask's favour by accident rather than design. Three of them are now gone. analyze printed "3 rows" and nothing else. The rows are the evidence for the conclusion printed underneath, they are already in memory, and they are the same rows the model was given — so they are now rendered under each step, capped at llm.max-rows with a line saying so when the result was longer. The loop's Step record carries only a count, so the command layer parks the rows in its own QueryRunner and renders them from the step callback; that ordering is what puts the table under the '> query' line rather than above it. explain after analyze had nothing to describe. It turned out ask was in the same position: the shell keeps one "last result", written only by queries typed directly, while ask recorded its result on the LlmCommands instance that explain then overwrote from that older memory. So 'show ... ; ask ... ; explain' described the 'show', presented as the query just run. Host.rememberResult gives both commands the shell's memory, and analyze hands back the last result the investigation looked at. llm.confirm was ignored by analyze. The setting promises a query is shown before it runs, and a loop picks each query from the previous result, so there is no query to show in advance and no honest way to both honour it and investigate. analyze now refuses, before the backend is resolved, and points at ask and --dry-run. Nothing is sent. Also: the interactive shell's own 'help' listed none of ask, analyze, explain or llm. Seven tests against a scripted backend, driven through a new package-private pinService seam — without it the command layer can only be exercised on the paths that stop before a backend is reached. Five fail without the change; the other two are the regression guards (an empty result renders no table, --dry-run still works under llm.confirm). Verified end to end against a recording and a stub server, not only in the harness. Full suite unchanged at 173 pre-existing failures, same set by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
…?' is short 'ask' named the command that could not answer most questions. It turned a question into one query; the investigation that reads each result and concludes was called 'analyze', which is not what anyone types when they have a question. So the names now follow what a user is doing: 'ask' is the investigation, '?' is short for it, and 'as-query' is the one-shot form that expresses the question as a single query. 'analyze' and 'investigate' remain as word aliases. '?' is taken before the line is split into words, so '?why is this slow' and 'ask why is this slow' are one command rather than two spellings of which only the second works. Nothing legal is shadowed: every query root is a bare word (JfrPathParser.java:37-40), and no command used '?' before. '#' was the other candidate and is not usable. It is the comment character in .jfrs scripts (ScriptRunner.java:106) and the shebang and description marker in Shell.java, so '# what allocates the most?' would work when typed and be silently skipped in a script or a recorded session. The asymmetry is the problem, not the collision. The methods keep their old names — LlmCommands.analyze implements 'ask', LlmCommands.asQuery implements 'as-query' — because they are named after what they do rather than after what a user is doing. CommandDispatcher's switch is the mapping, and doc/agents/Llm.md says so. The unified jafar-shell had only 'ask' and no investigation at all, and its Host never implemented rememberResult, so it carried the same stale-'explain' bug. Both fixed there too. Seven dispatch tests: '?' bare, '?' with no space, each word alias, a query that must not be shadowed, and help routing for all three names. Docs, CHANGELOG, tab completion and both shells' help updated in the same commit. Full suite unchanged at 173 pre-existing failures, same set by name; verified in the built jar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
The completer case file and the changelog both said "twelve settings". There are sixteen: llm.count-events, llm.max-steps, llm.max-total-tokens and llm.max-analysis-chars arrived with the investigation loop. A number in prose that nothing checks is a number that goes stale, so both now say what they mean without counting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx
ask in the shellsask — an investigation inside the shells
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turns Jafar's existing surface — the MCP server, four shells, four query languages, the heap
analyses — into something that carries methodology, not just capability. Two directions, and
they meet in the middle:
which analysis to run and can merge results from several tools. The Claude Code plugin that
carries that methodology is published separately — see §1.
ask— a model inside the shells themselves, which investigates a question overseveral queries, prints every one of them with the rows it returned, and concludes. Multi-provider,
including a fully local one.
Then the two meet: the analyses the MCP server exposes as
jfr_diagnose,jfr_useandjfr_tsamoved into
shell-core, so the in-shell investigation calls the same code rather than trying torebuild that judgement out of queries.
While this PR is open the branch is a series of self-contained commits, so it can be reviewed a
piece at a time; the two design documents come first deliberately, so the implementation can be read
against a stated plan. The sections below follow that order.
1.
jafar-perf— the methodology layer (separate repository)A Claude Code plugin carrying what the tools do not: nine skills (
triage,cpu,latency,gc,memory-leak,heap-diff,compare,jfrpath,report) and seven agents — aperf-leadcoordinator plus five specialists with narrow tool allowlists. It bundles.mcp.json,so installing it registers the MCP server too.
It is not in this repository.
/plugin marketplace addclones the marketplace's repository, soshipping it here would have cost a ~18 MB clone — 9.6 MB of it binary JFR test recordings — to
deliver 160 KB of Markdown. It is published from
btraceio/jafar-perf-box, which is that 160 KB.
The split has one real cost, and it is now enforced rather than merely documented: the skills name
MCP tools and parameters explicitly, and nothing in this repository's tests covers them, so a tool
rename here would silently break a skill there.
scripts/check_tool_references.pyin the pluginrepository starts the published server, asks it for
tools/list, and fails if any tool named in askill or agent is missing — running weekly, because the drift originates here and a push trigger
on that repository would never fire at the moment it matters.
AGENTS.mdstates the obligation too.2. MCP groundwork so results compose
Findingmodel.jfr_diagnose,jfr_use,jfr_tsa,jfr_compare,pprof_use,otlp_useandhdump_reportnow emit one shape with a stable id, so results from severaltools merge and de-duplicate instead of being reconciled from prose. Heuristic findings say so.
jfr_diagnoseruns the analyses it used to only recommend — USE and TSA — merges theirfindings, and reports
capabilityGapsseparately: what the recording cannot answer is not anegative answer.
depth=quickopts out.jfr_compare, new: baseline versus candidate, normalised for duration and sampling rate,with an explicit comparability block and a noise floor.
3.
ask— an investigation inside the shellsask <question>—?for short,analyzeandinvestigateas word aliases — runs a query, readsthe result, decides what to look at next, and concludes.
as-query <question>is the one-shot form,which expresses the question as a single query, prints it, and runs it. Also
explain [--dry-run],llm status,llm cost. Wired into bothjfr-shelland the unifiedjafar-shell— the latteris the only entry point that opens all four formats, so a question about a heap dump gets HdumpPath
and one about a profile gets the samples grammar.
Five decisions shape it:
reducer, so a 900 MB recording costs the same as a 2 MB one and the recording never leaves the
machine.
the model was given, capped at
llm.max-rows— so a wrong guess is visible, the numbers behindthe conclusion are checkable, and the user learns the query language rather than being insulated
from it.
.jfrsscript: the conclusion came from a model and is not reproducible, but the evidence is a file that
can be opened, re-run, and disagreed with. That converts the loop's weakest property into a
verifiable one.
attacker-controllable when the recording came from a third party, so they are fenced in explicit
data markers, the system prompt declares them data, and the tool surface is read-only.
shell-corewith no new dependencies; provider codelives in modules taken as
runtimeOnlyand discovered viaServiceLoader. Drop those lines andno provider dependency is present at all — air-gapped use is a supported configuration.
The loop speaks a text protocol, not a provider's tool-calling API. A reply is a
QUERY:,FIELDS:,ANALYSIS:orANSWER:line. This is a deliberate departure from the handoff document's§3.1, which expected a
completeWithToolsmethod onLlmBackend: tool use exists on the hostedproviders and not on a small local model behind an OpenAI-compatible endpoint, so building on it
would have made the loop hosted-only and split the SPI in two. Bounded on two axes because an
unbounded loop against a paid API loses money quietly —
llm.max-steps(6) andllm.max-total-tokens(200000) — and the remaining step count is in every turn, so the modelconcludes rather than being truncated.
llm.confirmturnsaskoff rather than modifying it. The setting promises a query is shownbefore it runs; a loop picks each query from the previous result, so there is no query to show in
advance and no honest way to both honour it and investigate. It refuses before the backend is
resolved, so nothing is sent.
Egress is controlled: result rows are redacted by field name (reusing the scrubber's model),
truncated with the truncation declared, and
--dry-runprints the exact bytes a real call wouldsend without sending them. On
askthat is the opening request only, since later steps depend onwhat earlier ones return.
4. The analyses moved out of the MCP server
jfr_diagnoseand the rest lived inJfrAnalysisTools, written againstjfr-mcp's ownSessionRegistry.SessionInfoand calling the server'ssendProgressdirectly — which is why theywere reachable only over MCP.
diagnose,use,tsa,summary,hotmethodsandexceptionsarenow
io.jafar.shell.core.analysis.JfrAnalysesinshell-core(2,467 lines moved;JfrAnalysisToolswent from 3,371 lines to 1,217, its handlers now thin delegations that preservethe exact error strings). Three small types carry what they used to take from the server:
AnalysisTarget,Progress, andJfrQuerySource.So
ANALYSIS: diagnosein the shell andjfr_diagnoseover MCP reach the same conclusions ratherthan similar ones — one copy, with the thresholds, the USE and TSA passes, and the
capabilityGaps.A move this size is only safe with a net, so the characterization tests came first, in their own
commit: they pin the current output of every analysis before anything moved. They earned it — the
extraction dropped the query-engine injection (
new JfrPathEvaluator()in place of the caller's),which
ConsumeEdgeCasesTestrelies on, and the net caught it.JfrQuerySourceexists to make thatinjection explicit rather than incidental.
5. What the model is told about the recording
The model was given a bare list of type names, which left it choosing an event type by whether the
name happened to share a word with the question, and inventing field paths from there. A recording
documents itself, so that guessing is unnecessary:
Labels and descriptions, from the recording's own metadata (
@Label("CPU Load"),@Description("Information about the recent CPU usage of the JVM process")). Measured on a 10 srecording: 181 event types, 177 with a label, 96 with a description. This lives in the cached
prompt prefix, being fixed per recording — which only pays off if the text is byte-stable, so
the rendering sorts, and two consecutive
--dry-runcalls were checked to produce prefixesidentical to the byte.
Fields on demand. JFR is self-describing: an event's fields are whatever that recording
declares, differ between JDK versions, and for a custom event are unknowable in advance. Sending
all of them costs ~9,800 tokens (181 types, 994 fields), almost all about types the question never
touches. So the model may answer
FIELDS: <types>and is sent those types' fields plus the typesthose fields lead to — one level, which is what makes
sampledThread/javaNamederivable ratherthan invented, and renders array dimension so
frames[0]is too. One extra round trip and ~1,200characters instead of ~24,000. Capped at 8 types and one round; a model that keeps asking is
reported, not looped on.
Event counts, and which types are empty. JFR metadata declares every type the JVM
registered, whether or not it emitted anything — so a recording made by an agent that ships its
own sampler lists an empty
jdk.ExecutionSamplebeside a vendor type holding thousands of events,and a model told only the names picks the one it recognises. That was observed, not theorised.
The shell now counts the events once, lists the types that have data with their counts, and
collapses the rest into one line the model is told not to query.
An earlier revision of this description said counts could not be sent because computing them means
scanning the recording. That was wrong: the query answering the question makes the same pass
anyway. The count is cached under
$XDG_CACHE_HOME/jafar/event-counts(else~/.cache/jafar/),keyed by the file's path, size and modification time, so later sessions reuse it and a replaced
file does not answer from a stale count.
llm.count-events = falseskips it.6. Multi-provider, and a local option
llm.backendanthropicllm-anthropicopenaillm-openaiOPENAI_API_KEYollamallm-openailocalhost:11434llm-openaicarries no provider SDK — OpenAI chat-completions over the JDKHttpClient.openaiandollamaare the same class with differentProfiledefaults, so any other serverspeaking that protocol (vLLM, LM Studio, Groq, Together, OpenRouter, Ollama Cloud) is reachable by
setting
llm.base-url, with no new code.Not privileging a provider had four concrete consequences:
LlmConfiglost its model default (eachbackend supplies its own, because a model id set for one provider is meaningless to the next); no
Authorizationheader is sent when there is no key (an empty bearer breaks several local servers);a loopback base URL is probed with
GET /modelssollm statusreports reachability rather thanhanging at request time; and cached tokens are split out of
prompt_tokensso usage adds up thesame way across backends.
A local model changes the privacy story, not just the cost one. With
ollamanothing leavesthe machine — which is the configuration for a recording that came from a customer. It only works
because of the next item.
7. Validate before running, and adapt to the model
A small local model writes invalid queries often enough that this would be unusable without it. The
candidate query is parsed with the same parser that would execute it; on rejection the parser's
own error goes back with a request to correct it, bounded by
llm.max-retries(default 1, cappedat 3). The shell prints
1 correction(s)next to the token usage rather than hiding the round trip.If the retry does not rescue it, the query and the parser's complaint are shown and nothing runs.
The output ceiling discovers reasoning models rather than listing them.
llm.max-tokensstaysat 2048 — the size of an answer, and the cap on a runaway — but a model that reasons before
answering spends that budget thinking, hits the ceiling mid-thought and returns no query at all,
billing the full amount for nothing. When a reply stops on
lengthwithout a query, the shellraises the ceiling to 16384, says so, retries, and remembers it for that model for the session. The
trigger is the reply's own stop reason; a list of reasoning-model names would be stale within a
month and says nothing about a local model someone renamed. A ceiling set by the user is never
lowered.
8. A credential belongs in a file, not the environment
llm statusused to tell you to export an API key. An environment variable is inherited by everychild process the shell spawns and shows up in
ps eand in crash dumps, so a key now also resolvesfrom a settings file —
$JAFAR_LLM_CONFIG, else$XDG_CONFIG_HOME/jafar/llm.properties, else~/.config/jafar/llm.properties— consulted aftersetand the environment, so nothing that workedbefore changes. A group- or world-readable file is called out with the fix to apply.
llm statusnow reports which layer answered for each setting, because a stale environment variable quietly
shadowing the file looks exactly like the file not being read at all.
The setup docs also stopped assuming
antis on the machine: it is the Anthropic CLI, it is notinstalled on a stock macOS, and the instructions now say where to get it — including that the tap
owner is
anthropics, plural, and that plainbrew install antis Apache Ant, a different toolthat installs cleanly and then has no idea what
auth loginmeans.9.
AGENTS.mdbecame an entry pointIt was 544 lines, one section of which ran to 241, and nothing in it said which part to read for the
change you were making. It is now 159 lines — the map, the module list, the quick start, and the
rules — with the detail moved verbatim into
doc/agents/:Build,Architecture,Shells,Mcp,Llm,Release.Two of the new files are not moved text:
doc/agents/Verification.md— ten rules for knowing a changeworks in this repository, each with the case file that produced it, drawn from the bugs below.
Type it into the built artifact; enumerate every path; a fallback that hides a misconfiguration is
a bug; documentation is code, run it; prove the test fails without the fix; compare failure sets
by name, not count; one source of truth for any list two places must agree on; say what you did
not verify; inspect the payload, not the exit status; when you move code, pin the behaviour first.
doc/agents/DataShapes.md— five bugs in this repository thatshare one shape: code reads a structure by assuming what is inside it, the assumption is wrong,
and nothing complains. A wrapped string constant, a display list mistaken for a data list, a
declared type mistaken for a present one, a count field that is not a count, and a missing column
that sorts silently. It exists because the sixth one will look exactly like these.
The knowledge base also carries a standing rule to maintain itself — "Leave this knowledge base
better than you found it" — with a runnable link checker, so the next change is expected to fold its
own lessons back in rather than leave them in a commit message.
Bugs found while verifying against real artifacts
These were not the task; they surfaced because the work was checked end to end rather than only
unit-tested. Nearly all of them had green tests.
In existing code:
hdump_querywas passed a bareSessionResolver, so the documented cross-type join always threw "Cross-type join requires aCrossSessionContext". The server now supplies an
McpCrossSessionContext.AllocationAggregatorreadobjectClass.nameas aplain string, but the untyped parser wraps string constants — so every real recording
aggregated to nothing. The existing tests all fed a flattened shape the parser never emits.
groupByon a field the event type does not have returned zero rows and no complaint. Anempty result reads exactly like "this recording has no such events", so the reader moves on
instead of fixing the name. Found in a real
ask gc behaviour in detailrun, where the modelgrouped
jdk.GarbageCollectionby agcTypethe type does not have. It now counts the events thekey was offered and names the key, the count, and the fields the type does have. The check only
fires where the result would have been empty anyway.
top(n, by=value)silently returned the wrong rows.groupBynames its outputkeyand theaggregate after the function, so
by=valueresolved tonullon every row;compareValues(null, null)is0, so the sort kept the input order and returned the first n rowsas the top n. Both
top(10, by=value)lines in the model-facing language reference were affected.sortBy(value)had the same gap but failed loudly, which is how it was noticed at all. Both nowread
valueas the aggregate column — only where no real column of that name exists, so arecording's own
valuefield is never shadowed.jfr_helphas always documented.[duration>10ms]was a parse error despite appearing in four places in the help text. Added
ns/us/ms/s, with nominute suffix, since
malready means mebibytes.JfrQueryEvaluator.evaluaterejected a raw query string, though its own interface documents"parsed query object or raw query string" and the Hdump, pprof and OTLP evaluators all accept
both.
serverInfo.versionin the handshake wasa literal
"0.10.0"that was never updated, so sixteen releases — 0.26.2 included — identifiedthemselves as 0.10.0, and anything gating on the version was misled. It is now read from the jar
manifest (
Implementation-Version, which the shadow-jar build stamps), so it cannot drift again.In this PR's own new code, found by driving the built jars rather than the test harness:
jfr-shell.CommandDispatcherrunsJfrPath two ways — via a
JfrSelectorwhen supplied, viaJfrPathEvaluatordirectly when not —and
io.jafar.shell.Shellbuilds it the second way. The host adapter knew only the first, soevery question ended in "No query evaluator available for this session" while every fake-host
unit test stayed green.
LlmHostAdapterTestnow covers both paths; verified it fails againstthe old adapter.
set llm.backend = ollamawas impossible to run. The command appears in five documents, inthe help text, in
llm status's own advice and in tab completion, and had never been typed intothe shell:
setrejects dotted names, because${a.b}means field access in an expression.Allowing the name exposed two more failures underneath, each of which looked like success — a
bare word went down the expression path and was read as a query ("Unknown root: ollama"), and a
bare integer was coerced to a double, so
set llm.max-rows = 20stored20.0, failed to parseas an int, and silently fell back to the default while the shell printed a confirmation.
llm.api-keyreached two backends of three. The settings file the README recommends didnothing for Anthropic, which asked the SDK alone and inspected only the environment — so a key
sitting in the file produced "No credentials found". Found by following this PR's own README
advice on a machine where it should have worked.
finish_reasonwas read by both backends and consumed by nobody. A reply truncatedmid-thought reported only "No query could be extracted from the model's reply", with the token
count that would have explained it printed on the next line.
fieldsas rendereddisplay strings and
fieldsByNameas the structured map; reading the first and testing eachelement for a
Mapyields an empty list silently. Same shape of bug asAllocationAggregatorabove, caught by looking at the bytes the stub received.
delivers a string constant as
{string=[B}, andstringis in the default redact list — meaninga field named string — so class names, symbols and group-by keys reaching the model became
{string=<redacted>}. Pre-existing, and it affectedexplaintoo. The wrapper is now unwrappedbefore the decision, which is taken on the outer field's real name.
JfrPathEvaluatorignored the doubleConsumeEdgeCasesTestsupplies and went to the recordinginstead — precisely the sort of difference a refactor is supposed not to make. Caught by the
characterization net written before the move, and confirmed to be this PR's by stashing.
explaindescribed the wrong result after a question. The shell keeps one "last result",written only by queries typed directly; the LLM commands recorded theirs on a handler object that
explainthen overwrote from that older memory. Soshow ... ; ask ... ; explaindescribed theshow, presented as the query just run.askinjafar-shellpassed the raw string where a parsed query was expected.paid for either way.
LlmCommands.helpText()was unreachable and contained an unformatted%s. Now wired tohelp ask/help as-query/help explain/help llm.to discover them or any of the sixteen
llm.*settings was to read the source. Both completersnow know them, the settings complete with one-line descriptions, and a test reads
LlmConfig.javaso a setting added later that is never offered fails the build. The interactive
jfr-shell's ownhelplisted none of the LLM commands at all until the last commit.On the command names
askoriginally named the one-shot translation, and the investigation was calledanalyze. Thatwas backwards: "ask" is what someone does when they have a question, and a single query answers
almost no real performance question. So
askis the investigation,?is short for it, andas-queryis the one-shot form —analyzeandinvestigateremain as word aliases.?is taken before the line is split into words, so?why is this slowandask why is this sloware one command rather than two spellings of which only the second works. Nothing legal is shadowed:
every query root is a bare word.
#was considered for the one-shot and rejected: it is the comment character in.jfrsscripts andthe shebang and description marker in the script reader, so
# what allocates the most?would workwhen typed and be silently skipped in a script or a recorded session. The asymmetry is the
problem, not the collision.
The method names deliberately do not follow:
LlmCommands.analyzeimplementsaskandLlmCommands.asQueryimplementsas-query, because the methods are named after what they do andthe commands after what a user is doing.
CommandDispatcher's switch is the mapping, anddoc/agents/Llm.mdsays so.Verification
Against real artifacts, generated locally:
jfr_compareattributes an engineered profile shift —Workload.alpha54.13% → 93.35% ofsamples — between two recordings of a synthetic workload.
byte[]to 3494 allocation samples withtopAllocSite=Workload.main.touched, and the fixed behaviour re-checked in the rebuilt shell jar.
The question path, in both built shells, against stub OpenAI-compatible servers scripted to
reproduce each behaviour in turn — an invalid query then a valid one, a reply truncated on
length,a
FIELDS:request, anANALYSIS:request, a multi-step investigation ending inANSWER:. Thefixtures in this tree are stripped and several do not parse, so the recordings used were made for
the purpose (JDK 25, 10 s,
settings=profile):Tests.
llm-openaiis driven against a realcom.sun.net.httpserver.HttpServeron loopbackrather than a mocked client, because what is most likely wrong there is on the wire: JSON shape,
headers, usage accounting, error-to-remedy mapping.
./gradlew test --continue, run on this branch and on a worktree oforigin/mainand comparedname-by-name:
origin/mainparser-coreparser-codegentoolsjfr-mcpshell-corejfr-shell167 failures on both sides, an identical set by name —
common the sorted failure namesreports nothing on either side alone. All 167 fail with
NoSuchFileExceptionon binary recordingsget_resources.shdownloads and this environment cannot fetch; substituting a different recordingmakes them worse, not better, so they are left absent. CI, which does have those recordings, is
green on
a8a386d— the last commit carrying code — acrossTests (JDK 8),Tests (JDK 21)and
Combined Test Report.New tests are checked against the code they fix, per R5:
SetLlmSettingTestfails 6 of 7 againstthe previous dispatcher,
ThinkingModelCeilingTest3 of 5 with the escalation disabled,GroupByColumnsTest5 of 9 without the query-language fix, andAnalyzeCommandTest5 of 7 withoutthe row rendering. In each case the remainder are the guards that unaffected behaviour stays
unaffected, and correctly pass either way.
One CI failure, investigated
An earlier run failed
McpOtlpTransportTest.otlpSummaryReturnsSampleInfo, 1 of 274, reported onlyas
AssertionFailedError at McpOtlpTransportTest.java:101. It did not reproduce in 13 full localruns of that suite, and every CI run since has been green. Ruled out: the
OtlpToolschange heretouches only
otlp_use, nothandleOtlpSummary; a handshake timeout fails differently (atMcpTransportHarness:111, across all 7 tests in the class);japicmpwas SKIPPED; andjfr-mcppins toolchain 25, so the JDK 21 job label is not a JDK difference.
Rather than leave it at "probably flaky", a later commit makes the next occurrence self-diagnosing:
assertSuccessnow includes the response in every message (a timeout names the timeout, a toolerror prints the tool's own text), and the 12 previously unasserted
otlp_open/pprof_opensetupcalls are asserted, so a failed open no longer surfaces two lines later as a failure of the call
under test. Nothing is skipped, disabled or quarantined. Two order- and timing-dependent candidates
remain open, neither touching the code under test: the suite shares one session file at a fixed
tmpdir path (
jfr-mcp/build.gradle:63), andcallToolhas a 15 s per-call timeout while CI runsseveral test JVMs in parallel.
What is not verified
Stated plainly because the docs state it too (
doc/plans/llm-in-the-shell-handoff.md§6, and R8 indoc/agents/Verification.md):this work was written in. Spending someone else's money from a test is not acceptable, so the
Anthropic request shape, real
cache_read_input_tokenson a second call, and each provider's401/403/429/404 message strings are untested here. The author has run the shell against a hosted
model by hand (see the next item), but nothing automated covers it. The cheapest way to close most
of that costs nothing:
ollama serve,set llm.backend = ollama,ask.FIELDS:has never been chosen by a real model here. It is proven as a mechanism — a scriptedstub exercises the exchange end to end and the protocol handling is unit-tested — but whether a
model asks rather than guesses is instruction-following, and a small local model may well ignore
it. The correction loop still catches a bad query, just without the saving.
ANALYSIS:andQUERY:fare better: one real multi-step run against a hosted model opened withANALYSIS: diagnose, followed with queries, and concluded correctly — it is also where the twoquery-language bugs above came from — but that run never asked for fields, which is exactly how
it came to group by a field the type does not have.
QueryProposalis tested against hand-written reply shapes and the stubs' output, not against areal model.
?gc behaviourrun printedrejected: big.jfrfromrunQueryon a query that works. It did not reproduce in six subsequent runs, including theidentical input and a cold event-count cache. No cause is claimed.
tool-driftworkflow has never executed on GitHub. The script it runs isverified locally in both directions — passing on the current skills, and failing with exact
locations when a tool is renamed — but the workflow itself will first run on that repository.
.mcp.jsonstill launches the MCP server through the JBang catalog, which resolvesto the latest published release. Pinning it to an exact version waits on 0.27.0 being released,
since 0.26.2 — still the latest — carries neither
jfr_comparenor the findings shape the skillsrely on.
Docs
doc/plans/performance-engineer-in-a-box.mdanddoc/plans/llm-in-the-shell.md(the two designdocuments, left as written — they record what was proposed at the time, including the plugin living
in-tree and
askbeing a single translation),doc/plans/llm-in-the-shell-handoff.md(what shipped,the seams, and the verification gaps),
doc/agents/**(new),doc/cli/LlmSetup.md,doc/cli/AskTutorial.md,doc/cli/LlmPrivacy.md,doc/cli/JFRPath.md,doc/mcp/WhenToUseWhich.md,plus
AGENTS.md,README.mdandCHANGELOG.md.Follow-up, deliberately not in this PR
jafar-shellstill has nosetcommand (gap G8), sollm.*there comes fromJAFAR_LLM_*environment variables or the settings file.
.mcp.jsonto an exact version, once 0.27.0 is released.🤖 Generated with Claude Code
https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx