Skip to content

Performance-engineer-in-a-box: a findings model, MCP groundwork, and ask — an investigation inside the shells - #114

Open
jbachorik wants to merge 34 commits into
mainfrom
claude/performance-engineer-box-anlai7
Open

jbachorik wants to merge 34 commits into
mainfrom
claude/performance-engineer-box-anlai7

Conversation

@jbachorik

@jbachorik jbachorik commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Outward: a findings model and MCP groundwork, so an external agent driving the server knows
    which analysis to run and can merge results from several tools. The Claude Code plugin that
    carries that methodology is published separately — see §1.
  • Inward: ask — a model inside the shells themselves, which investigates a question over
    several 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_use and jfr_tsa
moved into shell-core, so the in-shell investigation calls the same code rather than trying to
rebuild 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 — a
perf-lead coordinator plus five specialists with narrow tool allowlists. It bundles .mcp.json,
so installing it registers the MCP server too.

/plugin marketplace add btraceio/jafar-perf-box
/plugin install jafar-perf@btraceio

It is not in this repository. /plugin marketplace add clones the marketplace's repository, so
shipping 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.py in the plugin
repository starts the published server, asks it for tools/list, and fails if any tool named in a
skill 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.md states the obligation too.

2. MCP groundwork so results compose

  • A unified Finding model. jfr_diagnose, jfr_use, jfr_tsa, jfr_compare, pprof_use,
    otlp_use and hdump_report now emit 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 analyses it used to only recommend — USE and TSA — merges their
    findings, and reports capabilityGaps separately: what the recording cannot answer is not a
    negative answer.
    depth=quick opts out.
  • jfr_compare, new: baseline versus candidate, normalised for duration and sampling rate,
    with an explicit comparability block and a noise floor.
  • MCP prompts and resources, so the methodology reaches clients that do not install the plugin.

3. ask — an investigation inside the shells

jfr> ask why is this workload slow
* diagnose
  done

> events/jdk.ObjectAllocationSample | groupBy(objectClass/name) | top(3, by=count)
  3 rows
| count | key               |
+-------+-------------------+
| 5109  | byte[]            |
| 812   | java.lang.String  |
| 344   | java.util.HashMap |

The diagnosis flagged high GC pressure (609 collections, 20.2 ms average pause) and the
allocation breakdown is dominated by byte[]. Look at the allocation call sites.

Transcript: ~/.jafar/investigations/ask-20260913-202249.jfrs

ask <question>? for short, analyze and investigate as word aliases — runs a query, reads
the 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 both jfr-shell and the unified jafar-shell — the latter
is 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:

  • 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.
  • Every query is printed before it runs, with the rows it returned underneath — the same rows
    the model was given, capped at llm.max-rows — so a wrong guess is visible, the numbers behind
    the conclusion are checkable, and the user learns the query language rather than being insulated
    from it.
  • The investigation leaves a re-runnable artifact. Each run writes its queries to a .jfrs
    script: 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.
  • 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.
  • Optional at every level. The SPI is in shell-core with no new dependencies; provider code
    lives in modules taken as runtimeOnly and discovered via ServiceLoader. Drop those lines and
    no 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: or ANSWER: line. This is a deliberate departure from the handoff document's
§3.1, which expected a completeWithTools method on LlmBackend: tool use exists on the hosted
providers 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) and
llm.max-total-tokens (200000) — and the remaining step count is in every turn, so the model
concludes rather than being truncated.

llm.confirm turns ask off rather than modifying it. The setting promises a query is shown
before 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-run prints the exact bytes a real call would
send without sending them. On ask that is the opening request only, since later steps depend on
what earlier ones return.

4. The analyses moved out of the MCP server

jfr_diagnose and the rest lived in JfrAnalysisTools, written against jfr-mcp's own
SessionRegistry.SessionInfo and calling the server's sendProgress directly — which is why they
were reachable only over MCP. diagnose, use, tsa, summary, hotmethods and exceptions are
now io.jafar.shell.core.analysis.JfrAnalyses in shell-core (2,467 lines moved;
JfrAnalysisTools went from 3,371 lines to 1,217, its handlers now thin delegations that preserve
the exact error strings). Three small types carry what they used to take from the server:
AnalysisTarget, Progress, and JfrQuerySource.

So ANALYSIS: diagnose in the shell and jfr_diagnose over MCP reach the same conclusions rather
than 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 ConsumeEdgeCasesTest relies on, and the net caught it. JfrQuerySource exists to make that
injection 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 s
    recording: 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-run calls were checked to produce prefixes
    identical 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 types
    those fields lead to — one level, which is what makes sampledThread/javaName derivable rather
    than invented, and renders array dimension so frames[0] is too. One extra round trip and ~1,200
    characters 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.ExecutionSample beside 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 = false skips it.

6. Multi-provider, and a local option

llm.backend Module Talks to Credential
anthropic llm-anthropic api.anthropic.com API key or keyless OAuth profile
openai llm-openai api.openai.com OPENAI_API_KEY
ollama llm-openai localhost:11434 none

llm-openai carries no provider SDK — OpenAI chat-completions over the JDK HttpClient.
openai and ollama are the same class with different Profile defaults, so 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.

Not privileging a provider had four concrete consequences: LlmConfig lost its model default (each
backend supplies its own, 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 base URL is probed with GET /models so llm status reports reachability rather than
hanging at request time; and cached tokens are split out of prompt_tokens so usage adds up the
same way across backends.

A local model changes the privacy story, not just the cost one. With ollama nothing leaves
the 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, capped
at 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-tokens stays
at 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 length without a query, the shell
raises 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 status used to tell you to export an API key. An environment variable is inherited by every
child process the shell spawns and shows up in ps e and in crash dumps, so a key now also resolves
from a settings file — $JAFAR_LLM_CONFIG, else $XDG_CONFIG_HOME/jafar/llm.properties, else
~/.config/jafar/llm.properties — consulted after set and the environment, so nothing that worked
before changes. A group- or world-readable file is called out with the fix to apply. llm status
now 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 ant is on the machine: it is the Anthropic CLI, it is not
installed on a stock macOS, and the instructions now say where to get it — including that the tap
owner is anthropics, plural, and that plain brew install ant is Apache Ant, a different tool
that installs cleanly and then has no idea what auth login means.

9. AGENTS.md became an entry point

It 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 change
    works 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 that
    share 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:

  • Heap-to-JFR correlation was unreachable over MCP. hdump_query was passed a bare
    SessionResolver, so the documented cross-type join always threw "Cross-type join requires a
    CrossSessionContext"
    . The server now supplies an McpCrossSessionContext.
  • That join then still produced only nulls. AllocationAggregator read objectClass.name as a
    plain 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.
  • groupBy on a field the event type does not have returned zero rows and no complaint. An
    empty 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 detail run, where the model
    grouped jdk.GarbageCollection by a gcType the type does not have. It now counts the events the
    key 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. groupBy names its output key and the
    aggregate after the function, so by=value resolved to null on every row;
    compareValues(null, null) is 0, so the sort kept the input order and returned the first n rows
    as 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 now
    read value as the aggregate column — only where no real column of that name exists, so a
    recording's own value field is never shadowed.
  • JfrPath rejected the duration suffixes jfr_help has always documented. [duration>10ms]
    was a parse error despite appearing in four places in the help text. Added ns/us/ms/s, with no
    minute suffix, since m already means mebibytes.
  • JfrQueryEvaluator.evaluate rejected 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.
  • The MCP server told every client the wrong version. serverInfo.version in the handshake was
    a literal "0.10.0" that was never updated, so sixteen releases — 0.26.2 included — identified
    themselves 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:

  • The LLM commands reached nothing in the interactive jfr-shell. CommandDispatcher runs
    JfrPath two ways — via a JfrSelector when supplied, via JfrPathEvaluator directly when not —
    and io.jafar.shell.Shell builds it the second way. The host adapter knew only the first, so
    every question ended in "No query evaluator available for this session" while every fake-host
    unit test stayed green
    . LlmHostAdapterTest now covers both paths; verified it fails against
    the old adapter.
  • set llm.backend = ollama was impossible to run. The command appears in five documents, in
    the help text, in llm status's own advice and in tab completion, and had never been typed into
    the shell: set rejects 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 = 20 stored 20.0, failed to parse
    as an int, and silently fell back to the default while the shell printed a confirmation.
  • llm.api-key reached two backends of three. The settings file the README recommends did
    nothing 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_reason was read by both backends and consumed by nobody. A reply truncated
    mid-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.
  • The field metadata came back empty, with no error. The class map carries fields as rendered
    display strings and fieldsByName as the structured map; reading the first and testing each
    element for a Map yields an empty list silently. Same shape of bug as AllocationAggregator
    above, caught by looking at the bytes the stub received.
  • Redaction replaced every wrapped string constant, and looked like it was working. The parser
    delivers a string constant as {string=[B}, and string is in the default redact list — meaning
    a field named string — so class names, symbols and group-by keys reaching the model became
    {string=<redacted>}. Pre-existing, and it affected explain too. The wrapper is now unwrapped
    before the decision, which is taken on the outer field's real name.
  • The extraction dropped a dependency injection. An analysis that quietly built its own
    JfrPathEvaluator ignored the double ConsumeEdgeCasesTest supplies and went to the recording
    instead — 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.
  • explain described 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
    explain then overwrote from that older memory. So show ... ; ask ... ; explain described the
    show, presented as the query just run.
  • ask in jafar-shell passed the raw string where a parsed query was expected.
  • Token usage was not reported when the generated query then failed to run — the request was
    paid for either way.
  • LlmCommands.helpText() was unreachable and contained an unformatted %s. Now wired to
    help ask / help as-query / help explain / help llm.
  • The new commands had neither tab-completion nor a help entry in either shell, so the only way
    to discover them or any of the sixteen llm.* settings was to read the source. Both completers
    now know them, the settings complete with one-line descriptions, and a test reads LlmConfig.java
    so a setting added later that is never offered fails the build. The interactive jfr-shell's own
    help listed none of the LLM commands at all until the last commit.

On the command names

ask originally named the one-shot translation, and the investigation was called analyze. That
was backwards: "ask" is what someone does when they have a question, and a single query answers
almost no real performance question. So ask is the investigation, ? is short for it, and
as-query is the one-shot form — 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.

# was considered for the one-shot and rejected: it is the comment character in .jfrs scripts and
the shebang and description marker in the script reader, 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 method names deliberately do not follow: LlmCommands.analyze implements ask and
LlmCommands.asQuery implements as-query, because the methods are named after what they do and
the commands after what a user is doing. CommandDispatcher's switch is the mapping, and
doc/agents/Llm.md says so.

Verification

Against real artifacts, generated locally:

  • jfr_compare attributes an engineered profile shift — Workload.alpha 54.13% → 93.35% of
    samples — between two recordings of a synthetic workload.
  • The heap-to-JFR correlation resolves byte[] to 3494 allocation samples with
    topAllocSite=Workload.main.
  • The two query-language fixes listed under Bugs were reproduced on a recording before being
    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, an ANALYSIS: request, a multi-step investigation ending in ANSWER:. The
fixtures 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):

round 1: system=21,327 chars (cached), conversation=42 chars
round 2: system=21,327 chars (identical, so a cache read), conversation=1,188 chars
events/jdk.ExecutionSample | groupBy(sampledThread/javaName) | top(5, by=count)
| 136   | main |

Tests. llm-openai is driven against a real com.sun.net.httpserver.HttpServer on loopback
rather 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 of origin/main and compared
name-by-name:

module origin/main this branch
parser-core 155 tests, 2 failed 155 tests, 2 failed
parser-codegen 98 tests, 24 failed 98 tests, 24 failed
tools 36 tests, 5 failed 36 tests, 5 failed
jfr-mcp 213 tests, 5 failed 241 tests, 5 failed
shell-core 212 tests, 5 failed 326 tests, 5 failed
jfr-shell 717 tests, 126 failed 771 tests, 126 failed
total 1431 tests, 167 failed 1627 tests, 167 failed

167 failures on both sides, an identical set by namecomm on the sorted failure names
reports nothing on either side alone. All 167 fail with NoSuchFileException on binary recordings
get_resources.sh downloads and this environment cannot fetch; substituting a different recording
makes them worse, not better, so they are left absent. CI, which does have those recordings, is
green on a8a386d — the last commit carrying code — across Tests (JDK 8), Tests (JDK 21)
and Combined Test Report.

New tests are checked against the code they fix, per R5: SetLlmSettingTest fails 6 of 7 against
the previous dispatcher, ThinkingModelCeilingTest 3 of 5 with the escalation disabled,
GroupByColumnsTest 5 of 9 without the query-language fix, and AnalyzeCommandTest 5 of 7 without
the 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 only
as AssertionFailedError at McpOtlpTransportTest.java:101. It did not reproduce in 13 full local
runs of that suite, and every CI run since has been green. Ruled out: the OtlpTools change here
touches only otlp_use, not handleOtlpSummary; a handshake timeout fails differently (at
McpTransportHarness:111, across all 7 tests in the class); japicmp was SKIPPED; and jfr-mcp
pins 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:
assertSuccess now includes the response in every message (a timeout names the timeout, a tool
error prints the tool's own text), and the 12 previously unasserted otlp_open / pprof_open setup
calls 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), and callTool has a 15 s per-call timeout while CI runs
several 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 in
doc/agents/Verification.md):

  • No hosted provider has ever been called from this repository's tests, or from the environment
    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_tokens on a second call, and each provider's
    401/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 scripted
    stub 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: and
    QUERY: fare better: one real multi-step run against a hosted model opened with
    ANALYSIS: diagnose, followed with queries, and concluded correctly — it is also where the two
    query-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.
  • QueryProposal is tested against hand-written reply shapes and the stubs' output, not against a
    real model.
  • One unexplained failure. A single ?gc behaviour run printed rejected: big.jfr from
    runQuery on a query that works. It did not reproduce in six subsequent runs, including the
    identical input and a cold event-count cache. No cause is claimed.
  • The plugin repository's tool-drift workflow has never executed on GitHub. The script it runs is
    verified 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.
  • The plugin's .mcp.json still launches the MCP server through the JBang catalog, which resolves
    to 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_compare nor the findings shape the skills
    rely on.

Docs

doc/plans/performance-engineer-in-a-box.md and doc/plans/llm-in-the-shell.md (the two design
documents, left as written — they record what was proposed at the time, including the plugin living
in-tree and ask being 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.md and CHANGELOG.md.

Follow-up, deliberately not in this PR

  • jafar-shell still has no set command (gap G8), so llm.* there comes from JAFAR_LLM_*
    environment variables or the settings file.
  • Pinning the plugin's .mcp.json to an exact version, once 0.27.0 is released.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y7GJQkDcorE3bC1gaCmmSx

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
@jbachorik jbachorik changed the title Add performance-engineer-in-a-box ideation document Performance-engineer-in-a-box: the jafar-perf plugin, a findings model, and ask in the shells Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Combined JUnit Test Report

  • Total: 2169
  • Passed: 2153
  • Failures: 0
  • Errors: 0
  • Skipped: 16

HTML Test Reports

Run 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
@jbachorik jbachorik changed the title Performance-engineer-in-a-box: the jafar-perf plugin, a findings model, and ask in the shells Performance-engineer-in-a-box: a findings model, MCP groundwork, and ask in the shells Sep 8, 2026
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
@jbachorik jbachorik changed the title Performance-engineer-in-a-box: a findings model, MCP groundwork, and ask in the shells Performance-engineer-in-a-box: a findings model, MCP groundwork, and ask — an investigation inside the shells Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants