From 40ece58270d5e69702fb1b8c9007357a8788dd0d Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Tue, 1 Sep 2026 11:07:02 -0500 Subject: [PATCH 1/2] fix(#66): warn when HANDOFF.md is older than the branch's latest commit Every handoff-file design shares one blind spot by construction: the file only reflects what was written, so it can look current while work has moved on without it. This project's own HANDOFF.md went 5+ weeks stale this way (nested-workspace gap). session-onboard.sh already reads HANDOFF.md's "Last Updated" line and already shells out to git for live state - comparing the two turns the silent failure into a warning. Compares HANDOFF.md's Last-Updated date against the branch's latest commit date via a portable civil-calendar day-count in awk (no `date -d`/`date -j`, which differ between GNU and BSD date and would need separate handling for Windows Git Bash per #67). Warns past a 14-day gap; silent when the gap is smaller, absent, or negative (an in-flight, uncommitted-state handoff ahead of the last commit is normal). Skipped on a `compact` re-fire, matching the gitignore nudge's own reasoning. fix(#64): budget session-onboard.sh's compact-restart output A realistic (not pathological) compact-restart scenario - a 30-line near-max-width buffer tail plus a normal HANDOFF.md and ~20 untracked files - measured at 11,026 characters, over the undocumented ~10,000-character cap a competing plugin (adrrr/persistent-handoff) claims Claude Code enforces on SessionStart output. Claude Code's own docs specify no limit at all, so the exact cutoff is unverified, but an unbounded worst case is worth budgeting against regardless. Two changes: TL_COMPACT_TAIL_LINES/TL_COMPACT_TAIL_LINE_CHARS shrunk from 30/300 to 20/200, and the buffer-tail inline (the one block with no fallback pointer... wait, it has one - "full history is at " - unlike live git state, which has none) now renders LAST in the script instead of before the unconsumed-buffer scan and live git state. If truncation happens at all, it now eats into the block that already tells you where to find the rest, not into small bounded orientation content with no such fallback. Same worst-case fixture measured at 5,818 characters after both changes - roughly half - with live git state confirmed intact and preceding the tail inline. Adds 5 new assertions to tests/run.sh (167 -> 171 passing) and updates one existing assertion for the new tail bound (30 -> 20 lines). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UXL2ffVDsAp4P7387Nr18G --- hooks/session-onboard.sh | 124 ++++++++++++++++++++++++++++++--------- tests/run.sh | 50 +++++++++++++++- 2 files changed, 145 insertions(+), 29 deletions(-) diff --git a/hooks/session-onboard.sh b/hooks/session-onboard.sh index 7d59989..73cf1f6 100755 --- a/hooks/session-onboard.sh +++ b/hooks/session-onboard.sh @@ -130,6 +130,54 @@ else echo "No HANDOFF.md yet for this project. One will be written at the next handoff." fi +# Stale-handoff detection (issue #66): HANDOFF.md's own "Last Updated" date +# compared against the latest commit on this branch. A handoff-file design +# has one built-in blind spot: the file only reflects what was written, so +# it can look current - still present, still readable - while work has +# quietly moved past it. This project's own handoff went 5+ weeks stale this +# way before anyone noticed (nested-workspace gap: a parent-directory +# session's work on this repo never reached this repo's own HANDOFF.md). +# Live commit history is the one signal available here to catch that +# silently, so use it - skipped on `compact` (same reasoning as the +# gitignore nudge above: informational, not worth repeating mid-session) and +# whenever either date is unavailable or unparseable, in which case this +# says nothing rather than guessing. +TL_STALE_HANDOFF_DAYS=14 +if [ "$src" != "compact" ] && [ "$in_worktree" = "1" ] && [ -f "$hf" ]; then + hf_date=$(grep -m1 -i "last updated" "$hf" 2>/dev/null | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1) + commit_date=$(git -C "$root" log -1 --format=%cd --date=short 2>/dev/null) + if [ -n "$hf_date" ] && [ -n "$commit_date" ]; then + # Plain-integer civil-calendar day count (Howard Hinnant's days_from_civil), + # not `date -d`/`date -j`: those flags differ between GNU and BSD date, and + # this needs to run unmodified on macOS, Linux, and (per issue #67) Windows + # Git Bash alike. Verified against a same-month gap, a year boundary, and a + # leap-year February before landing here. + stale_days=$(awk -v hf="$hf_date" -v co="$commit_date" ' + function days(ds, y,m,d,era,yoe,doy,doe) { + y = substr(ds,1,4)+0; m = substr(ds,6,2)+0; d = substr(ds,9,2)+0 + if (m <= 2) y -= 1 + era = int((y >= 0 ? y : y-399) / 400) + yoe = y - era*400 + doy = int((153*(m + (m>2?-3:9)) + 2)/5) + d - 1 + doe = yoe*365 + int(yoe/4) - int(yoe/100) + doy + return era*146097 + doe - 719468 + } + BEGIN { print days(co) - days(hf) } + ' 2>/dev/null) + # Strip a leading '-' before the digit-only check so a HANDOFF.md newer + # than the latest commit (a negative gap - describing in-flight, + # uncommitted state, which is normal) isn't blanked out as "unparseable" + # the way genuinely non-numeric awk output would be. + _tl_check=${stale_days#-} + case "$_tl_check" in + ''|*[!0-9]*) stale_days="" ;; + esac + if [ -n "$stale_days" ] && [ "$stale_days" -ge "$TL_STALE_HANDOFF_DAYS" ]; then + echo "⚠️ HANDOFF.md was last updated $hf_date, $stale_days day(s) before the most recent commit ($commit_date) - it may be stale." + fi + fi +fi + # Nudge toward gitignoring bufdir/ - the one subdir that must ALWAYS stay # untracked regardless of policy, since it can hold raw, only best-effort- # redacted command/path text. Checks bufdir/ specifically rather than $data/ @@ -178,31 +226,20 @@ case "$data" in ;; esac -# 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" +# Live git state, deliberately placed early (issue #64): it's small and +# tightly bounded (one branch line + `head -20` status lines), unlike the +# post-compaction buffer-tail inline below, whose size depends on how much +# was captured and is the thing most likely to push this block past whatever +# undocumented size limit Claude Code applies to SessionStart output. If +# truncation happens, it should cut into the buffer tail - which already +# carries its own "full history is at " fallback pointer - not into +# this, which has none. +if [ "$in_worktree" = "1" ]; then echo - 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#"$droot"/}/session-$sid.md\`." + echo "### Live git state" 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 "branch: $(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null)" + git -C "$root" status -s 2>/dev/null | head -20 echo '```' fi @@ -292,12 +329,45 @@ if [ -d "$bufdir" ]; then fi fi -if [ "$in_worktree" = "1" ]; then +# 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. +# +# Placed LAST in this script (issue #64), not where it used to sit: a +# realistic worst case (a 30-line, near-max-width tail plus a normal header +# and a handful of untracked files) measured at 11KB, over an undocumented +# ~10,000-character cap a competing plugin claims Claude Code enforces on +# SessionStart output (unverified against Claude Code's own docs, which +# specify no limit at all - but an unbounded worst case is worth budgeting +# against regardless of the exact cutoff). 30/300 shrunk to 20/200 to bring +# the worst case for this block alone down to roughly 4-5KB, and it now +# renders after every other block in this script, all of which are small and +# bounded - so if truncation does happen, it eats into the one block that +# already tells you where to find the rest (`session-$sid.md`), not into +# live git state or the HANDOFF pointer, which have no such fallback. +TL_COMPACT_TAIL_LINES=20 +TL_COMPACT_TAIL_LINE_CHARS=200 +if [ "$src" = "compact" ] && [ -n "$sid" ] && [ -f "$bufdir/session-$sid.md" ]; then + buf="$bufdir/session-$sid.md" echo - echo "### Live git state" + 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#"$droot"/}/session-$sid.md\`." echo '```' - echo "branch: $(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null)" - git -C "$root" status -s 2>/dev/null | head -20 + 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 + exit 0 diff --git a/tests/run.sh b/tests/run.sh index 0f10057..1adb7b0 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -58,6 +58,10 @@ ok() { PASS=$((PASS + 1)); printf ' ok %s\n' "$1"; } bad() { FAIL=$((FAIL + 1)); printf ' FAIL %s\n' "$1"; } has() { case "$2" in *"$3"*) ok "$1" ;; *) bad "$1 (missing: $3)"; printf ' got: %s\n' "$2" ;; esac; } hasnt() { case "$2" in *"$3"*) bad "$1 (unexpected: $3)"; printf ' got: %s\n' "$2" ;; *) ok "$1" ;; esac; } +# before