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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions hooks/session-onboard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,106 @@
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"
# 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
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:-<unset>}"
echo " THROUGHLINE_WORKTREE_SHARED=${THROUGHLINE_WORKTREE_SHARED:-<unset>}"
echo " THROUGHLINE_DISABLE=${THROUGHLINE_DISABLE:-<unset>}"
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.
Expand Down
71 changes: 71 additions & 0 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,77 @@ 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)
# 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"

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"

# 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
Expand Down
Loading