From 73e95e8d0695f8f3a99ed1ada70eaa363f04e867 Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Tue, 1 Sep 2026 14:10:48 -0500 Subject: [PATCH 1/2] fix(#71): add session-onboard.sh --doctor diagnostic throughline resolves its data location through three env vars, a .throughlineignore marker checked at two roots, sticky git-worktree migration logic, and a soft jq dependency that degrades capture silently when missing. Diagnosing "why isn't capture firing" meant reading _lib.sh and reasoning through the precedence by hand. --doctor prints the resolved root/data root (and whether worktree- sharing applies), activation state and reason, jq availability, live/archived buffer counts, and the three env vars' values. It is read-only - checked before the THROUGHLINE_DISABLE kill switch (that's exactly the state someone runs --doctor to discover), and never calls tl_active() so it never bootstraps a data directory as a side effect. Counts buffers via a plain glob-and-count loop rather than find | wc: this is a diagnostic meant to work in the degraded environments it exists to investigate, and neither find nor wc is a POSIX-sh-guaranteed dependency the way plain globbing and arithmetic are - caught by testing the --doctor jq-missing fixture, which stubs a minimal PATH. Closes #71 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019VuUKvqH3q511w8xVd4VFY --- CHANGELOG.md | 7 ++++ docs/REFERENCE.md | 23 ++++++++++++ hooks/session-onboard.sh | 80 ++++++++++++++++++++++++++++++++++++++++ tests/run.sh | 25 +++++++++++++ 4 files changed, 135 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5235171..36c30a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to throughline are documented here. Format loosely follows ## [Unreleased] +### Added +- `session-onboard.sh --doctor` (issue #71): a read-only diagnostic that + prints the resolved data root, activation state and reason, `jq` + availability, live/archived buffer counts, and the three env vars' + current values - so diagnosing "why isn't capture firing" no longer means + reading `_lib.sh` and reasoning through the precedence rules by hand. + ## [0.15.0] ### Added diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 7d40f14..30beed3 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -151,6 +151,29 @@ the offer entirely when the files aren't actually committable in your layout. > opens on purpose. Keep tracking to repos whose writers you'd trust to shape > agent behavior. +### Troubleshooting + +throughline resolves its data location through three env vars, a +`.throughlineignore` marker checked at two roots, sticky git-worktree +migration logic, and a soft `jq` dependency that degrades capture silently +when missing - reasoning through all of that by hand to answer "why isn't +capture firing" gets old fast. `session-onboard.sh --doctor` prints the +resolved state directly instead: + +```sh +sh hooks/session-onboard.sh --doctor +# or, once installed as a plugin: +sh "$CLAUDE_PLUGIN_ROOT/hooks/session-onboard.sh" --doctor +``` + +It reports the resolved root and data root (and whether they differ, i.e. +worktree-sharing applies), the activation state and why (`disabled` / +`ignored` / `active` / `would-bootstrap`), whether `jq` is on `PATH`, live vs. +archived buffer counts, and the three env vars' current values. It is +read-only: unlike a real hook run, it never bootstraps a data directory as a +side effect, so it's safe to run out of curiosity on a project that has never +activated throughline. + ## Housekeeping Everything throughline writes grows without automatic bound: there is no diff --git a/hooks/session-onboard.sh b/hooks/session-onboard.sh index a599b72..f03539b 100755 --- a/hooks/session-onboard.sh +++ b/hooks/session-onboard.sh @@ -14,6 +14,86 @@ DIR=$(unset CDPATH; cd -- "$(dirname -- "$0")" && pwd) . "${CLAUDE_PLUGIN_ROOT:-$DIR/..}/hooks/_lib.sh" 2>/dev/null || . "$DIR/_lib.sh" +# --doctor (issue #71): a read-only diagnostic report for "why isn't capture +# firing" / "where does my data live", so answering that no longer requires +# reading _lib.sh and reasoning through the precedence rules by hand. Checked +# before the THROUGHLINE_DISABLE kill switch below (not after): the disable +# being set is exactly the state someone runs --doctor to discover, so the +# kill switch must not swallow this path the way it swallows normal +# SessionStart output. Reads no stdin, writes nothing (in particular, never +# calls tl_active() - that function's job is to bootstrap the data dir as a +# side effect, which a diagnostic must not do), and exits before any of the +# normal SessionStart flow below runs. +if [ "${1:-}" = "--doctor" ]; then + root=$(tl_root) + tl_resolve_data_root + droot="$_tl_data_root" + data=$(tl_data_dir) + + # Same canonicalize-then-compare the worktree-sharing notice below uses + # (issue #42) - $root is deliberately non-canonical (tl_root()'s doc + # comment), so comparing it to $droot raw would false-positive "shared" on + # every macOS project via the /tmp -> /private/tmp symlink alone. + if [ "$droot" = "$(_tl_canonicalize_path "$root")" ]; then + shared="no" + else + shared="yes (this is a linked worktree; data is shared with the main working tree)" + fi + + if tl_disabled; then + state="disabled" + reason="THROUGHLINE_DISABLE=${THROUGHLINE_DISABLE}" + elif [ -f "$droot/.throughlineignore" ] || [ -f "$root/.throughlineignore" ]; then + state="ignored" + reason=".throughlineignore present" + elif tl_data_exists; then + state="active" + reason="data directory already exists" + else + state="would-bootstrap" + reason="no data directory yet - the next hook run will create one" + fi + + if tl_have_jq; then + jq_state="present" + else + jq_state="MISSING - capture cannot parse hook payloads and will not run" + fi + + # Plain glob-and-count rather than find | wc: this is a diagnostic meant to + # work in exactly the degraded environments someone reaches for --doctor to + # investigate, and neither find nor wc is a POSIX-sh-guaranteed dependency + # the way plain globbing and arithmetic are. + live=0 + if [ -d "$data/buffer" ]; then + for _tl_f in "$data/buffer"/session-*.md; do + [ -f "$_tl_f" ] && live=$((live + 1)) + done + fi + archived=0 + if [ -d "$data/buffer/archive" ]; then + for _tl_f in "$data/buffer/archive"/session-*.md; do + [ -f "$_tl_f" ] && archived=$((archived + 1)) + done + fi + + echo "throughline doctor" + echo "===================" + echo "root: $root" + echo "data root: $droot" + echo "worktree-shared: $shared" + echo "data dir: $data" + echo "activation: $state ($reason)" + echo "jq: $jq_state" + echo "buffers: $live live, $archived archived" + echo + echo "env vars:" + echo " THROUGHLINE_DATA_DIR=${THROUGHLINE_DATA_DIR:-}" + echo " THROUGHLINE_WORKTREE_SHARED=${THROUGHLINE_WORKTREE_SHARED:-}" + echo " THROUGHLINE_DISABLE=${THROUGHLINE_DISABLE:-}" + exit 0 +fi + # Machine-wide kill switch: fully silent, even about existing data. The # per-project .throughlineignore keeps orienting toward existing content; # the global disable does not - "off" must mean off. diff --git a/tests/run.sh b/tests/run.sh index 35f2ef6..d3bf202 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -949,6 +949,31 @@ PLUGIN_VER=$(jq -r '.version' "$ROOT/.claude-plugin/plugin.json") O17=$(printf '%s' '{"source":"startup","session_id":"T"}' | sh "$H/session-onboard.sh") has "onboard header carries the plugin version" "$O17" "throughline v$PLUGIN_VER" +# 12o. --doctor (issue #71): a read-only diagnostic report. Checked BEFORE the +# THROUGHLINE_DISABLE kill switch (the disabled state is exactly what +# someone runs --doctor to discover), reads no stdin, and must never +# bootstrap a data dir as a side effect - tl_active() does that, and +# --doctor must not call it. +DOC1=$(THROUGHLINE_DISABLE=1 sh "$H/session-onboard.sh" --doctor) +has "doctor reports under THROUGHLINE_DISABLE (not swallowed by the kill switch)" "$DOC1" "disabled" + +DOC2=$(CLAUDE_PROJECT_DIR="$WT_IGN_LINK" sh "$H/session-onboard.sh" --doctor) +has "doctor reports ignored when .throughlineignore is present" "$DOC2" "ignored" + +DOC3=$(CLAUDE_PROJECT_DIR="$WT_LINK" sh "$H/session-onboard.sh" --doctor) +has "doctor reports the shared data root from inside a linked worktree" "$DOC3" "$WT_MAIN" +has "doctor labels a linked worktree as worktree-shared" "$DOC3" "linked worktree" + +DOC4=$(PATH="$STUB" sh "$H/session-onboard.sh" --doctor) +has "doctor reports missing jq" "$DOC4" "MISSING" + +FRESH_DOC="$WORK/fresh-doctor" +mkdir -p "$FRESH_DOC" +fixture_repo "$FRESH_DOC" +DOC5=$(CLAUDE_PROJECT_DIR="$FRESH_DOC" sh "$H/session-onboard.sh" --doctor) +has "doctor reports would-bootstrap on a never-activated project" "$DOC5" "would-bootstrap" +absent "doctor does not create a data dir as a side effect" "$FRESH_DOC/.claude" + # 13. capture breadcrumbs a swallowed write failure and onboard surfaces it. # Chmod the SESSION FILE itself read-only (not the directory): appending to # an existing file is gated by the file's own write bit, independent of the From 03a8e655204cf5496cf00f6d80c2620f181b0ff8 Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Tue, 1 Sep 2026 14:17:17 -0500 Subject: [PATCH 2/2] fix: review findings on PR #77 (--doctor) - The new worktree-sharing assertion matched the full absolute WT_MAIN path, which is red on windows-latest: $WT_MAIN is in MSYS form while the doctor's own "data root:" line resolves via `git worktree list` in native C:/... form for the identical directory. Match a path suffix instead, the convention every other worktree assertion in this file already follows. - --doctor collapsed tl_active()'s bootstrap-failed outcome into would-bootstrap and asserted the next hook run "will" create the data dir - exactly wrong in the one scenario ("why isn't capture firing") this diagnostic exists to explain. Walk up to the nearest existing ancestor and probe writability; report "LIKELY TO FAIL" when it isn't writable. - Add dedicated assertions for the "active" state and for an exact seeded buffer count - both branches of the state machine had zero regression coverage beyond incidentally passing through them. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019VuUKvqH3q511w8xVd4VFY --- hooks/session-onboard.sh | 20 ++++++++++++++++ tests/run.sh | 50 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/hooks/session-onboard.sh b/hooks/session-onboard.sh index f03539b..1d5e6f6 100755 --- a/hooks/session-onboard.sh +++ b/hooks/session-onboard.sh @@ -52,6 +52,26 @@ if [ "${1:-}" = "--doctor" ]; then else state="would-bootstrap" reason="no data directory yet - the next hook run will create one" + # Walk up to the nearest existing ancestor and probe its writability - + # without this, --doctor could not distinguish an ordinary not-yet- + # activated project from the one state a real hook run treats as + # bootstrap-failed (permissions, disk full; see the warning below at + # $_tl_active_reason = "bootstrap-failed"), in exactly the scenario + # ("why isn't capture firing") this diagnostic exists to explain. Pure + # parameter expansion, not dirname, for the same no-extra-deps reason as + # the buffer-count loop below - safe to loop unbounded since $data is + # always absolute (derived from tl_root(), which is always $PWD or + # $CLAUDE_PROJECT_DIR), so stripping one path segment at a time always + # terminates at "" (treated as "/") in a bounded number of steps. + _tl_p="$data" + while [ ! -d "$_tl_p" ] && [ -n "$_tl_p" ]; do + _tl_p="${_tl_p%/*}" + done + [ -z "$_tl_p" ] && _tl_p="/" + if [ ! -w "$_tl_p" ]; then + state="would-bootstrap (LIKELY TO FAIL)" + reason="\`$_tl_p\` is not writable - bootstrap will fail" + fi fi if tl_have_jq; then diff --git a/tests/run.sh b/tests/run.sh index d3bf202..735faeb 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -961,8 +961,22 @@ DOC2=$(CLAUDE_PROJECT_DIR="$WT_IGN_LINK" sh "$H/session-onboard.sh" --doctor) has "doctor reports ignored when .throughlineignore is present" "$DOC2" "ignored" DOC3=$(CLAUDE_PROJECT_DIR="$WT_LINK" sh "$H/session-onboard.sh" --doctor) -has "doctor reports the shared data root from inside a linked worktree" "$DOC3" "$WT_MAIN" -has "doctor labels a linked worktree as worktree-shared" "$DOC3" "linked worktree" +# Match the WT_MAIN path as a SUFFIX, not the full absolute path: on Windows +# CI, $WT_MAIN (derived from $WORK, in MSYS form, e.g. /tmp/...) and the +# doctor's own "data root:" line (resolved via `git worktree list`, which +# reports the native C:/... form) describe the identical directory but never +# match as a full-string substring - every OTHER worktree assertion in this +# file already sidesteps this by matching a path suffix (see lines 374/381/ +# 386); this one broke that convention and was red on windows-latest until +# fixed to match it. +has "doctor reports the shared data root from inside a linked worktree" "$DOC3" "wt-main/.claude/throughline" +has "doctor labels a linked worktree as worktree-shared" "$DOC3" "linked worktree" +# WT_MAIN already has a HANDOFF.md/data dir from earlier onboard calls (5e4/ +# 5e above) by this point in the suite, so DOC3 - which reports on the SHARED +# main root's state, not the linked worktree's own - exercises the "active" +# branch of the state machine. Pinned explicitly: it is the most common +# real-world case and had zero dedicated assertions. +has "doctor reports active state when data already exists" "$DOC3" "activation: active" DOC4=$(PATH="$STUB" sh "$H/session-onboard.sh" --doctor) has "doctor reports missing jq" "$DOC4" "MISSING" @@ -974,6 +988,38 @@ DOC5=$(CLAUDE_PROJECT_DIR="$FRESH_DOC" sh "$H/session-onboard.sh" --doctor) has "doctor reports would-bootstrap on a never-activated project" "$DOC5" "would-bootstrap" absent "doctor does not create a data dir as a side effect" "$FRESH_DOC/.claude" +# 12o-2. buffer counts: seed a KNOWN number of live and archived buffer files +# and assert the exact reported count, not just that the words "live"/ +# "archived" appear - the counting loop itself (a glob, not find | wc; +# see the comment in session-onboard.sh) had zero regression coverage +# beyond incidentally landing on 0/0 in every other doctor fixture. +FRESH_DOC_BUF="$WORK/fresh-doctor-bufs" +mkdir -p "$FRESH_DOC_BUF/.claude/throughline/buffer/archive" +fixture_repo "$FRESH_DOC_BUF" +: > "$FRESH_DOC_BUF/.claude/throughline/buffer/session-a.md" +: > "$FRESH_DOC_BUF/.claude/throughline/buffer/session-b.md" +: > "$FRESH_DOC_BUF/.claude/throughline/buffer/archive/session-c.md" +DOC6=$(CLAUDE_PROJECT_DIR="$FRESH_DOC_BUF" sh "$H/session-onboard.sh" --doctor) +has "doctor reports the exact seeded buffer counts" "$DOC6" "2 live, 1 archived" + +# 12o-3. would-bootstrap distinguishes the LIKELY TO FAIL case (project root +# not writable - what a real hook run treats as bootstrap-failed) from +# an ordinary not-yet-activated project. Same NTFS-chmod-emulation +# caveat as the other permission-based tests in this file (12e). +if is_windows; then + skip_win "doctor reports would-bootstrap LIKELY TO FAIL on an unwritable root" +elif [ "$(id -u)" != "0" ]; then + FRESH_DOC_RO="$WORK/fresh-doctor-ro" + mkdir -p "$FRESH_DOC_RO" + fixture_repo "$FRESH_DOC_RO" + chmod 555 "$FRESH_DOC_RO" 2>/dev/null + DOC7=$(CLAUDE_PROJECT_DIR="$FRESH_DOC_RO" sh "$H/session-onboard.sh" --doctor) + chmod 755 "$FRESH_DOC_RO" 2>/dev/null + has "doctor reports would-bootstrap LIKELY TO FAIL on an unwritable root" "$DOC7" "LIKELY TO FAIL" +else + ok "doctor LIKELY TO FAIL test skipped (running as root)" +fi + # 13. capture breadcrumbs a swallowed write failure and onboard surfaces it. # Chmod the SESSION FILE itself read-only (not the directory): appending to # an existing file is gated by the file's own write bit, independent of the