traj: formatted tail and cat return the steps --filter matches - #112
Merged
nickjalbert merged 1 commit intoSep 9, 2026
Merged
Conversation
In formatted mode — the default on a tty — _format_line hands each step to a
jq program that begins with select(matches_filters($filters)) and reads the
six result lines back with a `{ read; read; ... } < <(jq ...)` group.
Whenever jq emits fewer than six lines, a read fails at EOF and under the
file's `set -e` that kills the `while read` subshell driving the stream. The
guard meant to skip such a step, `[[ -n "$entry_type" ]] || return 0`, sits
right after the group and was never reached.
A filtered-out step emits nothing, so `traj tail --filter type=thought` at a
terminal printed nothing and exited 1: the first line of every trajectory is
its header, which no type filter matches. A filter the header does match
turned it into silent truncation instead, the stream stopping at the first
non-matching step with matching ones still to come. A step the preview
cannot render — `traj append` takes JSON on stdin and checks only that it
parses, so .content can be an object — emits one line, and ended the stream
on every formatted path, -r included, since its pre-filter passes the step.
A malformed line did the same to a bare `traj cat`. 2c74855 states the
invariant this breaks and hardened every jq call that reads a trajectory
file except this one, which is handed a single line; 93ad7bb then added
--filter, which made the dead guard matter for every non-matching step.
The -a branch of the same function already does it right: capture jq's
output, return on failure or empty, then split. Do the same here. Capturing
rather than putting `|| true` on the old group matters for the unrenderable
step: `|| true` reads the one line jq did emit and prints a fabricated line
with a blank stamp and id, while the capture lets `|| return 0` see the
failure and skip the step. One difference from the process substitution has
to be kept: the capture drops trailing newlines, so a record whose last
field is the empty string arrives a line short; the old read saw that field
as empty, and the read group is allowed to come up short so it still does.
Raw mode filters correctly and is untouched, so every scripted caller in the
repo is unaffected. The TUI runs traj formatted (CLICOLOR_FORCE=1): its
auto-refresh `tail -r -n 50` renders the same bytes for any tree it renders
today and no longer stops short at an unrenderable step, and a `tail
--filter` typed into its /traj pane hits this today.
tests/test_traj_formatted_filter.sh pins: cat and tail with a type filter
exit 0 and print the matching steps; formatted and raw modes select the same
step ids; a filter only the header matches prints one line and exits 0; a
filter nothing matches prints nothing and exits 0; two --filter flags AND
and comma values OR; the -a and raw neighbours are unchanged; a formatted
line still carries stamp, id, type, source and content in that order, so the
six reads stay matched to the six lines jq emits; -r still prefixes the id
with the file hex; a malformed line no longer truncates the stream and leaks
nothing to stderr; a record with an empty trailing field is shown; and a
step jq cannot render is skipped with no fabricated line in its place.
MaxFreedomPollard
force-pushed
the
traj-filter-format
branch
from
September 5, 2026 04:09
6d5dcf0 to
b666593
Compare
Contributor
|
LGTM; thanks!! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #110, same function.
What
In formatted mode — what a terminal gets by default —
traj tailandtraj catend the stream at the first step_format_linecannot turn intosix lines, exit 1, and say nothing. Two ways to hit that:
A filter.
--filter type=thoughtat a terminal prints nothing at all,because the first step of every trajectory is its header and no type filter
matches it. Widen the filter so the header does match and it becomes silent
truncation instead:
A step the preview cannot render.
traj appendtakes JSON on stdin(
thinkers/monolith/prompt.md) and checks only that it parses, so.contentcan be an object. The preview slices it, jq errors after one of its six
lines, and the stream dies there —
-rincluded, because its pre-filterpasses the step. A malformed line does the same to a bare
traj catwith noflags.
-agets past all of these — it renders the object step and skips themalformed line — and
--rawreturns every step; the default formatted view isthe one that dies, and
-rdies with it on the unrenderable step.tail -f --filter …is the sharpest form: the follower dies at the firstnon-matching step and stays dead, with no diagnostic.
Why
_format_linehands each step to a jq program that begins withselect(matches_filters($filters))and reads the six result lines back with a{ read; read; … } < <(jq …)group. Whenever jq emits fewer than six lines —a filtered-out step emits none, an unrenderable one emits one — a
readfailsat EOF, and under the file's
set -euo pipefailthat kills thewhile readsubshell driving the stream. The guard meant to skip such a step,
[[ -n "$entry_type" ]] || return 0, is right after the group and is neverreached.
2c74855 states the invariant this breaks — "a single corrupt line skips
gracefully instead of aborting and dropping all subsequent entries" — and
hardened every jq call that reads a trajectory file except this one, which is
handed a single line and cannot use
fromjson?. 93ad7bb then added--filter,which made the dead guard matter for every non-matching step rather than only
for corrupt lines.
The
-abranch of the same function already does this right: capture jq'soutput,
|| return 0, guard empty, then split. This makes the default branchdo the same.
The fix
The jq program is unchanged. Two details worth a sentence each:
|| trueon the old group. A one-token|| trueafter the process substitution also stops the deaths and passesevery filter case — but on an unrenderable step it reads the one line jq
did emit and prints a fabricated
----/--/-- --:--:-- [thought]line witha blank id. Capturing first lets
|| return 0see jq's exit status and skipthe step. The test pins the difference.
|| trueon the new group.$(…)drops trailing newlines, so arecord whose last field is the empty string comes back a line short. The
process substitution kept that newline and read the field as empty; this
keeps doing so instead of failing on the sixth read.
_format_lineis reached only in formatted mode. Every scriptedtrajcallin the repo passes
--rawand is untouched. One automated caller does runformatted: the TUI sets
CLICOLOR_FORCE=1on everytrajit spawns(
tui/headlong/src/main.rs:341) and auto-refreshes withtail -r -n 50. Forany tree that view renders today its output is byte-identical; where it
used to die on an unrenderable step and show a truncated tree, it now shows
the rest. A
tail --filter …typed into its/trajpane hits this bug today.Output for every step that rendered before is unchanged; two small
differences, both matching the
-abranch: a step whose preview jq errors isnow skipped rather than ending the stream, and on bash ≥ 4.4 a NUL byte in a
step's content now draws bash's "ignored null byte" warning on stderr, as it
already does under
-a.Tests
tests/test_traj_formatted_filter.shpins, in formatted mode:catandtailwith a type filter exit 0 and print the matching steps; formatted andraw modes select the same step ids (raw is the oracle); a filter only the
header matches prints one line and exits 0 — the first non-match no longer
ends the stream; a filter nothing matches prints nothing and exits 0; two
--filterflags AND and comma values OR; the-aand raw neighbours areunchanged; a formatted line still carries stamp, id,
[type, source]andcontent in that order, so the six reads stay matched to the six lines jq
emits;
-rstill prefixes the id with the file hex; a malformed line nolonger truncates the stream and leaks nothing to stderr; a record with an
empty trailing field is shown; and a step jq cannot render is skipped with no
fabricated line in its place.
On
main: 9 passed, 17 failed — the nine are the neighbour and renderingcontrols. With the fix: 26 passed, 0 failed. The one-token alternative above:
25 passed, 1 failed, on the fabricated-line check. Mutation-tested against
fourteen deliberate breakages of
bin/traj— reverting to the processsubstitution, dropping the capture's
|| return 0, inverting the empty guard,dropping the read group's
|| true, ignoring the filter, printing only thefirst match, hardcoding the type, reordering or swapping jq fields, dropping a
read, breaking the timestamp branch, leaking jq's stderr, corrupting the
content column — thirteen are caught. The fourteenth, deleting
[[ -n "$_fields" ]] || return 0, is not observable because the read grouptolerates a short record and the
entry_typeguard then returns; the linestays because it mirrors the
-abranch and says what is meant.Checked locally:
shellcheck -S warningclean over the CI file set, the newtest clean at
-S infotoo. Parses and runs under Apple's bash 3.2 and a pureBSD userland as well as GNU coreutils; stable across timezones and locales.
cloc bin/ thinkers/+1 code line.tests/run-all.shgreen apart from twofailures that are pre-existing on
mainhere and green in CI:test_persona_chat_exit.sh(pinsPATHto/usr/bin:/bin, which has nojqon a Homebrew Mac) and
test_workspace_runtime.sh(_runtime_linestats.git/HEAD, which does not exist when the checkout is a git worktree, as mineis).