Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "throughline",
"description": "Continuous, state-aware session memory for Claude Code. Captures what you did and what is (commands, file changes, decisions, and live git/PR state), then hands it off with judgment at session wrap-up. Readable, committable artifacts; binds to Claude's native memory.",
"version": "0.5.2",
"version": "0.6.0",
"author": {
"name": "Dynamic Agency",
"email": "support@dynamicagency.com"
Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,50 @@
All notable changes to throughline are documented here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/); this project uses semantic versioning.

## [0.6.0]

Issue #9 from the v0.4.0 audit (docs/AUDIT-v0.4.0.md, P2): inline post-compaction
recovery content instead of a pointer. Feature bump - onboard's compact-path
output now carries actual buffer content, not just a filename. An independent
code-review pass on this release caught two gaps in the initial
implementation's bounded-output claim, both closed before merge. Verified by
the full 154-assertion suite (11 new) plus shellcheck under both Homebrew and
an `apt-get install shellcheck` Ubuntu container.

### Added
- **Inlined post-compaction recovery** (`session-onboard.sh`): on
`source=compact`, the last 30 lines of the current session's action
buffer - including any trailing `compaction-boundary` marker, which lands
there naturally since no captured action can land between PreCompact
stamping it and this SessionStart firing - are now printed directly into
the SessionStart block, fenced in a code block. Previously this path only
printed a pointer ("your buffer survived at `<path>`, read it"), which cost
the model a tool call it might not make, at exactly the point where
post-compaction recall is weakest. The pointer to the full file is kept
alongside the inlined tail for sessions that ran longer than the window.
`startup`/`resume`/`clear` sources are unaffected - only `compact` inlines
buffer content.

### Fixed (found during review, before merge)
- **The bounded-output claim was false for two unclamped fields**: a Bash
record's `description` and an Edit/Write/NotebookEdit record's `file_path`
are never length-clamped in `session-capture.sh` (only `command` and the
other free-text fields are), so an unusually long one of either would still
inline verbatim despite the 30-line cap. Rather than clamp every
capture-side field (a change to `session-capture.sh`'s existing, separate
design, out of scope here), the inline-tail block now caps each line to 300
characters itself, via an `awk` pass - the bound this feature's own claim
depends on is now enforced at the point the claim is made, not assumed from
upstream.
- **A stamped `trigger`/`reason` value could break the new markdown fence**:
`tl_clean_ctrl` (shared by `session-flush.sh`/`session-precompact.sh` to
sanitize those fields before stamping) only stripped control characters, not
backticks - a run of 3+ backticks in one of those fields could prematurely
close the ``` fence this release introduces. Not reachable today (both
fields are fixed enum strings from the harness), but cheap to close at the
source: `tl_clean_ctrl` now strips backticks too, matching what the jq-side
`clean` def already does for capture-side fields.

## [0.5.2]

Hot-path perf batch plus issue #15, which turned out to have a second,
Expand Down
25 changes: 16 additions & 9 deletions hooks/_lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,23 @@ tl_split_sid_line() {
_tl_split_line=${1#*"$_tl_tab"}
}

# Replace control characters (including newlines) with a space. Shared by
# session-flush.sh and session-precompact.sh so their reason/trigger fields
# can't break the `<!-- ... -->` marker they're embedded in. (The jq `clean` def in
# tl_jq_redact_defs below duplicates this rule rather than calling it: the
# capture-side hooks apply control-char
# AND backtick stripping together, per-field, inside one jq pipeline that also
# does redaction — pulling just the control-char half out to a separate shell
# call there would mean an extra process per field for no real safety gain.)
# Replace control characters (including newlines) AND backticks with a space.
# Shared by session-flush.sh and session-precompact.sh so their reason/trigger
# fields can't break either delimiter they get embedded in: the `<!-- ... -->`
# HTML comment (control chars), and - since session-onboard.sh's post-compact
# inline-tail feature (issue #9) started wrapping raw buffer content in a
# markdown ``` fence - a run of 3+ backticks in a stamped line could also
# prematurely close that fence. Both trigger/reason are fixed enum strings
# from the harness today (auto/manual; clear/logout/prompt_input_exit/other),
# so this is currently unreachable, but it is cheap to close at the source
# rather than assume every future caller and every future delimiter stays
# safe. (The jq `clean` def in tl_jq_redact_defs below duplicates this rule
# rather than calling it: the capture-side hooks apply control-char and
# backtick stripping together, per-field, inside one jq pipeline that also
# does redaction — pulling just this half out to a separate shell call there
# would mean an extra process per field for no real safety gain.)
tl_clean_ctrl() {
printf '%s' "$1" | tr '[:cntrl:]' ' '
printf '%s' "$1" | tr '[:cntrl:]`' ' '
}

# Single source for the on-disk timestamp format, used by every record line and
Expand Down
27 changes: 24 additions & 3 deletions hooks/session-onboard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,32 @@ case "$data" in
;;
esac

# Post-compaction recovery: the conversation was just summarized, but this
# session's buffer is intact on disk. Point Claude at it explicitly.
# Post-compaction recovery (issue #9): the conversation was just summarized,
# but this session's buffer is intact on disk. Inline its TAIL directly into
# this SessionStart block instead of only pointing at the file - a bare
# pointer costs the model a tool call it may not make, right where
# post-compaction recall is weakest. Bounded to the last N lines, EACH also
# capped at TL_COMPACT_TAIL_LINE_CHARS characters (via the awk pass below) -
# not just line count. A record's Bash `description` and Edit/Write/
# NotebookEdit `file_path` fields are never length-clamped in
# session-capture.sh (only `command` and the other free-text fields are), so
# without this hook's OWN cap an unusually long one of those would still
# inline verbatim; capping here, at the point this block's own bounded-size
# claim is made, holds regardless of what any capture-side branch does or
# later stops doing. The full-file pointer is kept for anything older than
# the tail.
TL_COMPACT_TAIL_LINES=30
TL_COMPACT_TAIL_LINE_CHARS=300
if [ "$src" = "compact" ] && [ -n "$sid" ] && [ -f "$bufdir/session-$sid.md" ]; then
buf="$bufdir/session-$sid.md"
echo
echo "🧷 Context was just compacted. This session's action buffer survived at \`${bufdir#"$root"/}/session-$sid.md\` - read it to recover what you did before the compaction. The raw actions persist even though the conversation summary dropped detail."
echo "🧷 Context was just compacted. The last $TL_COMPACT_TAIL_LINES line(s) of this session's action buffer are inlined below to recover what you did before the compaction, without an extra read - the raw actions persist even though the conversation summary dropped detail. Full history (if the session ran longer than this tail) is at \`${bufdir#"$root"/}/session-$sid.md\`."
echo '```'
tail -n "$TL_COMPACT_TAIL_LINES" "$buf" 2>/dev/null | awk -v max="$TL_COMPACT_TAIL_LINE_CHARS" '
{ if (length($0) > max) print substr($0, 1, max) " …[line truncated]"
else print
}'
echo '```'
fi

# Surface unconsumed buffers from OTHER sessions. Exclude the current session's
Expand Down
45 changes: 44 additions & 1 deletion tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,13 @@ eq "tl_safe_sid rejects '.'" "$(tl_safe_sid '.')" ""
eq "tl_safe_sid rejects '..'" "$(tl_safe_sid '..')" ""
eq "tl_safe_sid rejects empty" "$(tl_safe_sid '')" ""

# 5a2. tl_clean_ctrl unit tests: control chars AND backticks both become a
# space (issue #9 review finding: a stamped trigger/reason containing a
# run of backticks could otherwise break the markdown fence the new
# inline-tail feature wraps buffer content in).
eq "tl_clean_ctrl replaces control chars with space" "$(tl_clean_ctrl "$(printf 'a\tb')")" "a b"
eq "tl_clean_ctrl replaces backticks with space" "$(tl_clean_ctrl 'trig```ger')" "trig ger"

# 5c. tl_data_dir honors an absolute THROUGHLINE_DATA_DIR override
ABS_DIR="$WORK/abs-data"
eq "tl_data_dir honors absolute THROUGHLINE_DATA_DIR" \
Expand All @@ -280,10 +287,46 @@ cap '{"session_id":"T","tool_name":"Bash","tool_input":{"description":"x","comma
printf '%s' '{"session_id":"T","trigger":"manual"}' | sh "$H/session-precompact.sh"
has "PreCompact writes a compaction-boundary marker" "$(cat "$BUF/session-T.md")" '<!-- compaction-boundary'

# 7. onboard on source=compact points at the live buffer
# 7. onboard on source=compact inlines the buffer TAIL directly (issue #9),
# not just a pointer - saves the model a tool call exactly where
# post-compaction recall is weakest.
O=$(printf '%s' '{"source":"compact","session_id":"T"}' | sh "$H/session-onboard.sh")
has "onboard(compact) names this session buffer" "$O" 'session-T.md'
has "onboard(compact) explains recovery" "$O" 'compacted'
has "onboard(compact) inlines the captured action" "$O" '**bash**'
has "onboard(compact) inlines the compaction-boundary marker" "$O" '<!-- compaction-boundary'

# 7a. the inline tail is specific to the compact path - startup/resume never
# inline buffer content, only the pointer/warning lines.
O_START=$(printf '%s' '{"source":"startup","session_id":"T"}' | sh "$H/session-onboard.sh")
hasnt "onboard(startup) does not inline the buffer tail" "$O_START" '**bash**'
O_RESUME=$(printf '%s' '{"source":"resume","session_id":"T"}' | sh "$H/session-onboard.sh")
hasnt "onboard(resume) does not inline the buffer tail" "$O_RESUME" '**bash**'

# 7b. the inline tail is BOUNDED to the last N lines - an old line well before
# the compaction seam is not inlined, only recent history near it.
reset_buf
i=1
while [ "$i" -le 40 ]; do
printf -- '- `t` **bash** line%s - `cmd%s`\n' "$i" "$i" >> "$BUF/session-T.md"
i=$((i + 1))
done
O_TAIL=$(printf '%s' '{"source":"compact","session_id":"T"}' | sh "$H/session-onboard.sh")
hasnt "onboard(compact) tail excludes a line beyond the bound" "$O_TAIL" 'cmd10`'
has "onboard(compact) tail includes the first line within the bound" "$O_TAIL" 'cmd11`'
has "onboard(compact) tail includes the most recent line" "$O_TAIL" 'cmd40`'

# 7c. a single OVERSIZED line within the tail window is truncated per-line
# (review finding: session-capture.sh never length-clamps a Bash
# description or an Edit/Write/NotebookEdit file_path, so this hook's own
# per-line cap - not an assumption about capture-side clamping - is what
# actually keeps the inlined block bounded).
reset_buf
LONGDESC=$(awk 'BEGIN{for(i=0;i<3000;i++) printf "x"}')
printf -- '- `t` **bash** %s - `ls`\n' "$LONGDESC" > "$BUF/session-T.md"
O_LONG=$(printf '%s' '{"source":"compact","session_id":"T"}' | sh "$H/session-onboard.sh")
hasnt "onboard(compact) does not inline an oversized field verbatim" "$O_LONG" "$LONGDESC"
has "onboard(compact) marks a truncated oversized line" "$O_LONG" '…[line truncated]'

# 8. flush stamps ended once, anchored
printf '%s' '{"session_id":"T","reason":"clear"}' | sh "$H/session-flush.sh"
Expand Down
Loading