feat(hooks): Stop hook closes dangling timeline entries — fail-open

The preamble writes event:'started' to the project timeline at every skill
start, but the matching 'completed' write lives in prose at the END of the
skill workflow — unenforceable. An interrupted session, a context blowout,
or an agent that simply stops leaked started > completed forever, and the
leak was unrepairable after the fact (observed live in #2553).

New hosts/claude/hooks/timeline-stop-hook (+ .ts, question-log-hook shim
pattern): on Claude Code's Stop event it appends event:'completed' with
outcome 'unknown' and source 'stop-hook' for every 'started' entry in the
project timeline that has no matching completion. setup registers it via
gstack-settings-hook add-event (Stop was already an accepted event) under
its own source tag, idempotently; --no-team and gstack-uninstall remove it.

FAIL-OPEN contract (F5), pinned by tests: ALWAYS exits 0 — corrupt
timeline (bad lines skipped individually, valid ones still repaired),
missing timeline, garbage/empty stdin, bun missing from PATH (the shim
'|| true's), and an over-cap timeline (10MB skip) all repair nothing and
block nothing; errors land in ~/.gstack/hook-errors.log best-effort. The
write path is append-only with a ~2s internal budget, and a second Stop is
a no-op (already-closed entries never re-close). Correlation is
project-scoped by design — the preamble's session id is shell-local, so a
concurrent same-project session's entry may close early as a traceable
source:'stop-hook' row rather than a silent leak; the header documents the
trade-off.

Fixes #2553

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 09:49:32 -07:00
co-authored by Claude Fable 5
parent 9c0de5fed1
commit 4d0e7b7c2a
5 changed files with 396 additions and 1 deletions
+23 -1
View File
@@ -1997,9 +1997,31 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
fi
fi
# Also tear down plan-tune hooks on --no-team (matches the existing pattern).
# ─── Timeline Stop hook (#2553) ──────────────────────────────────────────────
# The preamble writes event:"started" to the project timeline at every skill
# start; the completion write lives in end-of-workflow prose and is
# unenforceable — interrupted sessions leaked started > completed forever.
# Register a Stop-event hook that closes dangling entries. FAIL-OPEN contract
# (F5): the hook always exits 0 and repairs best-effort — it can never block
# a session. Idempotent via the (event, source) dedup in gstack-settings-hook;
# removed by --no-team and gstack-uninstall.
TIMELINE_STOP_HOOK="$SOURCE_GSTACK_DIR/hosts/claude/hooks/timeline-stop-hook"
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -x "$TIMELINE_STOP_HOOK" ]; then
if ! "$SETTINGS_HOOK" list-sources 2>/dev/null | grep -q "gstack-timeline-stop"; then
if "$SETTINGS_HOOK" add-event \
--event Stop \
--command "$TIMELINE_STOP_HOOK" \
--source gstack-timeline-stop \
--timeout 5 >/dev/null 2>&1; then
log " registered Stop hook: session timeline entries now close even when a skill is interrupted (backup: settings.json.bak.<ts>; remove: $SETTINGS_HOOK remove-source --source gstack-timeline-stop)"
fi
fi
fi
# Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern).
if [ "$NO_TEAM_MODE" -eq 1 ] && [ -x "$SETTINGS_HOOK" ]; then
"$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null || true
"$SETTINGS_HOOK" remove-source --source gstack-timeline-stop 2>/dev/null || true
fi
# ─── Redact pre-push guard consent (#1946) ───────────────────────────────────