diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b2d1ae..7f604ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,36 @@ All notable changes to throughline are documented here. Format loosely follows ## [Unreleased] +### Added +- **Stale-handoff warning** (issue #66): `SessionStart` now compares + `HANDOFF.md`'s `Last Updated` date against the branch's latest commit and + warns past a 14-day gap. Every handoff-file design shares this blind spot + by construction - the file only reflects what was written - and this + project's own handoff went 5+ weeks stale this way before anyone noticed. +- A `demo/homelab/` fictional project with a real, populated + `.claude/throughline/` already in it, wired via `demo/setup.sh`, so someone + can try throughline in under a minute without installing into a real repo. +- A `Related` section in `README.md` naming 9 alternative session-memory + tools and how throughline differs from each. + ### Changed - Publishing `@dynamicagency/throughline-opencode` to npm is now automated from a `v*` tag push via npm Trusted Publishing (OIDC) in `.github/workflows/release.yml`, with provenance attestation and no long-lived npm token (issue #61). +- `README.md` split: per-harness install detail moved to `docs/INSTALL.md`, + and Configuration/worktrees/opt-out/housekeeping/layout moved to + `docs/REFERENCE.md` (which now also documents the trust boundary a tracked + `HANDOFF.md` implies - it's input the agent acts on, not passive + documentation). README drops from 492 to 231 lines. +- `session-onboard.sh`'s `SessionStart` output is now budgeted more + defensively against Claude Code's undocumented size limit on that output + (issue #64): the post-compaction buffer-tail inline shrank from 30/300 to + 20/200 lines/chars-per-line and now renders last (after live git state and + the unconsumed-buffer scan, both small and bounded), and live git state's + `git status` lines are now also capped in width, not just count - a + realistic worst case measured 11,026 characters before this change, 5,818 + after. ## [0.14.0] diff --git a/README.md b/README.md index b9142de..cb43202 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ all five to its own hook/event API - the mechanism differs, the behavior doesn't | Capture point | What it does | |---|---| -| **Session start** | Injects a HANDOFF.md pointer + live git state into context. Mechanical and cheap. | +| **Session start** | Injects a HANDOFF.md pointer + live git state into context, and warns if the handoff looks stale against the branch's latest commit. Mechanical and cheap. | | **User intent** | Appends a redacted, truncated one-liner per prompt to the per-session buffer - the "why" behind the work that otherwise lives only in the (compactable) conversation. | | **Action** | Appends a structured one-liner per captured action: the command, file, search, fetch, or delegated task, flagged if it was interrupted, with obvious secrets masked before anything is written. Mutating actions (bash/edit/write) plus high-signal read-side actions (grep/fetch/search/delegated tasks) and MCP tool calls are captured; the noisiest (plain file reads/globs) are deliberately skipped so the buffer stays skimmable. | | **Compaction boundary** | Stamps a marker into the buffer at the moment of compaction, so a later handoff knows to distill the actions above it from the buffer text rather than from summarized conversation recall - and re-injects the buffer's tail into context right after, so the current session doesn't lose its own recent history to the compaction. | diff --git a/hooks/session-onboard.sh b/hooks/session-onboard.sh index 7d59989..738162f 100755 --- a/hooks/session-onboard.sh +++ b/hooks/session-onboard.sh @@ -130,6 +130,68 @@ 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. Independently diffed against Python's calendar for every + # date from 1899-01-01 to 2100-12-31 (including every ÷100/÷400 leap-year + # boundary) before landing here - zero mismatches. + # + # The regex above only checks that hf_date/commit_date are digit-shaped, + # not that they're a real calendar date - a hand-edited HANDOFF.md with + # "**Last Updated:** 2026-00-00" would otherwise compute a confident wrong + # answer instead of the "say nothing" this hook promises elsewhere. `days()` + # range-checks month/day/leap-February itself and sets `bad` rather than + # silently returning a number for garbage input. + stale_days=$(awk -v hf="$hf_date" -v co="$commit_date" ' + function is_leap(y) { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 } + function days(ds, y,m,d,era,yoe,doy,doe,maxd) { + y = substr(ds,1,4)+0; m = substr(ds,6,2)+0; d = substr(ds,9,2)+0 + if (m < 1 || m > 12) { bad = 1; return 0 } + if (m==1||m==3||m==5||m==7||m==8||m==10||m==12) maxd=31 + else if (m==4||m==6||m==9||m==11) maxd=30 + else maxd = is_leap(y) ? 29 : 28 + if (d < 1 || d > maxd) { bad = 1; return 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 { n = days(co) - days(hf); if (!bad) print n } + ' 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,28 +240,25 @@ 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 bounded on +# BOTH axes - `head -20` caps the line count, and TL_GIT_STATUS_LINE_CHARS +# below caps each line's width - unlike the post-compaction buffer-tail +# inline further down, whose total size depends on how much was captured. A +# `head -20` line-count cap alone is not a size cap: `git status -s` prints +# one line per modified tracked file regardless of path depth, and a +# deep-path monorepo with 20 modified files measured over 2KB here with +# nothing but the line-count bound - line WIDTH needed capping too, the same +# discipline the buffer tail already applies to its own lines. 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. +TL_GIT_STATUS_LINE_CHARS=200 +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" ' + echo "branch: $(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null)" + git -C "$root" status -s 2>/dev/null | head -20 | awk -v max="$TL_GIT_STATUS_LINE_CHARS" ' { if (length($0) > max) print substr($0, 1, max) " …[line truncated]" else print }' @@ -292,12 +351,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..3a6936e 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