Reflect: the report over the agent¶
echolot analyze stands between the trace and the agent. echolot reflect
stands between the agent's session and the person maintaining the tool. Same
reason in both places: a session transcript is hundreds of kilobytes to tens
of megabytes, and reading it — by a human or by a model — is slow, expensive
and gives a different answer every time. So the transcript is compressed
deterministically into a table of facts and a short list of signals, and the
decision "what to change" is made over those.
cd ~/StudioProjects/my-app # the project the agent worked in
echolot reflect --list # candidate sessions
echolot reflect --last # the newest one that used echolot
echolot reflect --session 3f2a # one session, by the first characters of its id
echolot reflect --since 2h # every session in the last 2h / 30m / 3d
echolot reflect --all # every one, plus a summary
Output: .echolot/reflect/<session>.md for a human, .json for the agent —
/echolot-reflect reads the json and turns signals into a list of proposed
changes. When a run covers more than one session (--all, or --since that
finds several) it also writes summary.md and summary.json beside them: a
row per session and how often each signal fired, which is where a signal
that fires in most sessions shows up as a design item rather than a note.
Where things are looked for, when they are not where the defaults say:
| flag | default | what it names |
|---|---|---|
--project ROOT |
. |
the application project the agent worked in — its run log, its config, its source tree |
--transcripts DIR |
~/.claude/projects/<slug> |
where Claude Code's transcripts are; naming it leaves Codex's sessions out |
-c, --local |
echolot.yml in the project, local.yml beside it |
the config the protocol checks read |
-o DIR |
.echolot/reflect |
where the reports go; relative to the project |
--from-log |
off | ignore any transcript and read the run log alone — see below |
Two sources¶
The tool's own record. Every command appends one line to
.echolot/log/runs.jsonl: what was asked (argv), where, when, exit code,
duration, a hash of the config, a traceback if there was one, and a few facts
the command chose to note — how many detectors fired, whether the anchor
matched. This is the only source that does not depend on which agent was
typing: transcripts differ between agents and lose the exit code the moment a
call is wrapped in 2>&1 | tail; this file does not. ECHOLOT_NO_RECORD=1
switches it off.
The agent's transcript. Claude Code keeps every session under
~/.claude/projects/<slug>/, the slug being the working directory with /
turned into -; subagents get their own file under
<session>/subagents/. It has what the tool cannot see: the prompts, the
questions to the human and the answers, every tool call with its input and
whether it failed, tokens, the prompt handed to perf-hunter and what came back.
The format is undocumented; the reader treats every field as optional and
puts anything surprising into the report's "Reader notes" rather than into an
exception.
Codex's sessions. Codex keeps each thread under
~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<thread id>.jsonl, under
CODEX_HOME when that is set, with the working directory in its first line.
A subagent is a thread of its own, whose first line names its parent, so a
hunt's loop is found from the main thread and read with it. Commands come
with their exit codes and output, edits as the patches Codex applied, and
tokens per response. Two things are not there to read. A subagent's file
opens with a copy of its parent's history, and only what follows is the
subagent's own. And the message the main thread hands a subagent is
encrypted on disk, so the check on what the brief said (agent_prompt_gaps)
is listed as not checked, with that reason, instead of finding a brief with
nothing in it. A subagent is the loop when it ran echolot guide loop, which
the hunt skill tells it to read first (#193).
reflect runs from the application project because that is where all of
them live. Claude Code's sessions and Codex's are read side by side, newest
first, and --list says which agent each came from. Only sessions that used
echolot for real work are candidates — one that merely reflected does not
count, or the newest session would always be the one doing the reflecting. A
reflection is reflect, the guide reflect its skill runs first, and
/echolot reflect or /echolot-reflect under any prefix the host adds.
A subagent is a hunt when it is the loop: of type perf-hunter, or one that
ran echolot guide loop, which both readers can see. An Explore or
general-purpose helper is none, and gets none of the hunt's signals: reading
sources is its job.
What is in the report¶
Signals first, then the facts they rest on. Each section exists because of a decision it lets you make:
| section | what it holds | the decision it serves |
|---|---|---|
| Signals | protocol breaks (warn), workarounds and friction (info) — each with its rows and a hint |
what to change, in what order |
| Protocol checks passed | the checks that came back ok, one line each |
which rules held, so a short Signals section is read as clean and not as unchecked |
| Not checked | the checks that could not run (skip): the source does not carry what they read, the session was building the tool, or the check itself broke |
which silences are no verdict |
| Entry | prompts, slash commands, skills loaded, time to real work | is the way into the tool obvious |
| Timeline | milestones: first call of each subcommand, questions, config written, agent launched, first temporary slice | where the time went |
| echolot calls | every invocation: subcommand, args, exit, duration, output size, config used, help lookups | which commands the agent fumbles |
| Questions to the human | each AskUserQuestion: options, recommended, chosen, time to answer |
are the questions needed, are the defaults right |
| What the main context concluded | its last message, quoted — conclusion in the json |
what the session came to; for one that read an ANR report or a report and ran no hunt, the whole result |
| Subagent | rounds against loop.max_rounds, re-records, tools, tokens, what fed its window, whether the prompt named the traces / the regression / the change, whether the conclusion has all eight fields, and what it returned |
is the loop behaving |
| Temporary instrumentation | per file, prefix added vs removed; was there a grep after the last edit; what the source tree holds now | did cleanup happen |
| Cost | wall time, tokens for main and subagents separately, tools by type, largest tool outputs, longest silences | what eats the window |
| Recorder | the runs.jsonl lines inside the session's window |
exit codes the transcript lost |
Not in the report: thinking text (counted, not quoted), full tool outputs
(sizes and error heads only), full prompts (truncated). It lives in
.echolot/, which is in .gitignore.
Signals are detectors¶
The same idea as echolot/sql/detectors/: one signal is one small function
in echolot/reflect/signals.py over the normalised session; it returns a row
set or nothing. Add a function and append it to SIGNALS — and then decide
two things that list does not say. Whether it can run on echolot's own calls
alone: FROM_CALLS_ALONE, the checks a session without a transcript gets.
And whether it still means something in a session that was building the
tool: MEANS_SOMETHING_WHILE_BUILDING. Both are allowlists, so a new signal
is held back from both until somebody adds it — listed under Not checked
rather than reporting clean over evidence it never had. A hint on a signal
is one line about what it usually means for the tool — a pointer for whoever
reads the report, never a verdict.
The ones that ship, by what they watch:
- entry —
entry_fumbling: several slash commands, or the human interrupting, before real work started;agent_prompt_gaps: the prompt handed to the subagent left out the traces, the regression or the change - protocol — doctor before analyze; the trace never opened directly; the
loop stayed in the subagent; the loop's own guides left to the subagent;
rounds within the limit; every inserted tracing
call carries the prefix; edits inside
instrumentation.allowed; the temporary markers are gone (see below); the conclusion has its eight fields; analysis ran on the project's config and not one the agent wrote; thresholds edited only aftercalibrate; the traces analysed before a re-record were copied out first (amvinside the build tree does not count — gradle cleans it); values a person confirmed still hold - workarounds —
report.jsoncut up by hand;--help/explainmid-work; gradle / adb / perfetto driven directly instead ofcollect;agent_reported_bugs: the agent saying in its own words, in English or Russian, that something in echolot was wrong — quoted rather than classified, since a sentence that names the defect is worth more than any rule - failures — echolot calls that failed, tracebacks apart from clean exits, shell failures apart from the tool's own; retries, with what moved between the two attempts; tool errors outside echolot, by kind
- cost — silences of two minutes or more, each with what it was read off the call before it (a subagent running, the human answering, a build); tool outputs over 8k characters; the subagent's window fed by reading sources by hand rather than by the report (per activity: echolot, report reading, source reading, instrumentation edits, builds — calls and characters, with the reads made before the first marker was placed counted separately)
Cleanup is read off the tree. Whether a temporary marker is left is
answered by reading the checkout when the report is made — under
instrumentation.allowed, or all of it when the config names no roots — and
that outranks the transcript: what the agent typed,
edited or restored with git is intent, what the sources hold now is the fact.
A git checkout that restored a file leaves no edit to balance, and a grep
wrapped in echo labels once had the prefix read off its own label. The
transcript's reading — additions against removals, a grep after the last edit
— is what is left when the tree cannot be read.
The harness is not echolot. A call the agent's harness refused at its
permission screen, that the person declined, or that ran long enough to be
moved to the background did not fail — the command never ran, or is still
running. Those are left out of echolot_failures and counted as friction
under kinds of their own in env_friction.
What it cannot see, honestly¶
instrumentation.allowed governs temporary instrumentation. A fix the human
asked for in the main context is a different matter, so only subagent edits
and prefixed edits are held against the list.
Rounds are counted from the transcript: an analyze that follows a re-record
opens a new round, and the commands of one shell line count in the order they
run, so echolot collect … && echolot analyze … is a round of its own. A
launch, adb shell am start, records nothing and is not a re-record. If a
project records traces some other way, the pattern in RE_RE_RECORD needs
that way, or the count is low.
The exit code in a transcript is the Bash tool's, not echolot's: a cd into
a path with spaces fails before the tool runs. Where the recorder has a line
for the call, it wins; where it has none, the failure is marked as the
shell's. Several echolot invocations in one Bash line share the line's exit
code, duration and output; the report says so (2 calls in one line, ≤27 s)
rather than crediting each with the whole. When zsh reports a glob that
matched nothing, the invocation carrying that glob is marked as skipped by the
shell — it never ran, whatever the tool's exit code says.
Token counts come from the transcript's usage fields, taken as the maximum per API response — the rows of one response are written as it streams and the last row carries the final numbers.
Without a transcript¶
Claude Code and Codex have a reader each. Every other client — and a run
from a plain shell, or from CI — gets the report built from
.echolot/log/runs.jsonl alone:
echolot reflect --last # falls back on its own, and says it did
echolot reflect --last --from-log # ignore any transcript and use the log
--last falls back when the project has no transcript, and also when it has
some and none of them used echolot: an old Claude Code session that edited
the README, while the hunt ran from Cursor.
The recorder is the floor because it does not depend on who was driving. Every
command writes a line from every caller, and it keeps the exit code a
transcript loses the moment a call is wrapped in 2>&1 | tail. What it holds
is which echolot commands ran, when, for how long, with what exit code, and
the facts each attached.
So the checks that read echolot's own calls run unchanged — doctor_first,
confirmed_changed, echolot_failures, retries, help_lookups,
long_gaps, the FROM_CALLS_ALONE set. The rest have nothing to read.
confirmed_changed is there by design rather than by luck. An investigation
records every value in echolot.yml a person confirmed when it opens, and
each analyze inside it logs the ones that no longer hold, with both values.
The first time it mattered was a Codex session, where there was no transcript
to read: the hunt rewrote the end of the scenario a person had confirmed, and
every report after that measured a window nobody had agreed to (#196).
And that is the part worth getting right. A check that finds no evidence
returns "clean": trace_opened_directly with nothing to look at reports "the
trace was never opened directly" — a green tick over the one rule the whole
design rests on, from a source that could never have seen it either way. So a
reader declares what its source can show, in Session.carries, and a check
needing more is listed under Not checked with its silence named as no
verdict rather than a clean one.
A sitting stands in for a session. The log is a stream with no session id in
it, so runs are cut into sittings wherever the tool was left alone for more
than half an hour — the same notion hunt uses to decide whether it is looking
at the same sitting before asking a question. The gap is measured from the end
of the last run, so collect -n 5 on a real device does not split one sitting
in two.
A session that was building the tool¶
Every protocol signal is a rule for a hunt: do not open the trace yourself, analyse against the project's config, capture through the tool. A session spent writing detectors breaks all three by definition, and the report then leads with warnings about a hunt that never happened.
Two conditions decide it, both cheap. The project is a checkout of echolot
itself, which its own pyproject.toml says, and the session ran neither
collect nor hunt — those are what using it looks like, so somebody
dogfooding a real hunt from inside the source tree still gets the ordinary
report.
The protocol checks are then held back the same way a missing source holds
them back: listed under Not checked, with the reason, because silence
there is no verdict rather than a clean one. What still runs is everything
about friction — a failing call, a retry, a help lookup, a tool error, a long
silence, an output that ate the window: the MEANS_SOMETHING_WHILE_BUILDING
set. A tool that misbehaves under its own author is exactly as broken as one
that misbehaves under a user.
This was found by pointing reflect at the session that wrote it: three
findings came back, all true, none about anything anyone did wrong.
Another agent's transcript¶
Everything above the reader — facts, signals, report — works on the normalised
session in echolot/reflect/model.py: turns, tool calls, questions, usage,
subagents. Another client means another reader producing that shape, declaring
what it carries, and nothing else changes. Codex's reader is the second one:
codex.py, beside claude_code.py. It reads questions to the human,
subagents and tokens as Claude Code's does. What degrades is what Codex does
not record in the clear: the brief the main thread hands a subagent is stored
encrypted, so agent_prompt_gaps is listed as not checked, and a question
carries no recommended option and no record of which one the person chose.