Skip to content

feat(session-handoffs): opt-in hooks for one current note and a no-note nudge - #14

Merged
lhoupert merged 1 commit into
feat/session-handoffsfrom
feat/session-handoffs-guard
Oct 2, 2026
Merged

lhoupert merged 1 commit into
feat/session-handoffsfrom
feat/session-handoffs-guard

Conversation

@lhoupert

@lhoupert lhoupert commented Sep 30, 2026 •

Copy link
Copy Markdown

Adds two opt-in Claude Code hooks to session-handoffs. Each is off unless its environment variable is set:

  • SESSION_HANDOFFS_GUARD=1: when a session is asked for a handoff or writes a new note, it is told which of its notes and snapshots are still current, and which memory lines name them. If the new note supersedes one that still has no banner, Claude gets one more turn to add it.
  • SESSION_HANDOFFS_NUDGE=1: for sessions that never write notes. The first time a session has made 30 tool calls over 2 hours without one, the user sees one line; it costs no model tokens.

Stacked on #13. Tested with 54 unit tests, a replay of a real duplicate-note incident, and timings on a 21 MB transcript (about 20–40 ms per event).

For reviewers: tried in a live Claude Code 2.1.285 session loaded with --plugin-dir. The prompt, Write and Stop hooks each gave the expected context, and the Stop feedback came once, with no loop. The nudge, which needs 2 hours, was checked only in tests. It reads the same undocumented transcript format as #13 and stays silent (exit 0, no output) on anything unexpected.

Author attestation

  • I am a human, these are my changes, and I have reviewed and understood every change and can explain why each is correct.

AI-assisted: Claude Code wrote the hook, tests and docs, after checking the hook contract against the Claude Code docs and real transcripts. It went through a /code-review max and two rounds of independent verification. Every rule has a test that fails without it.

🤖 Generated with Claude Code

…te nudge

- scripts/handoff_guard.py runs on UserPromptSubmit, PostToolUse (Write)
  and Stop. It only reads this session's transcript, the notes and the
  memory files, and never writes a file.
- SESSION_HANDOFFS_GUARD=1: a prompt asking for a handoff (any spelling;
  not a path, a file name, pasted text or a /command) and a Write that
  creates a note get this session's other current notes, snapshots of
  it included, and the memory lines that name them, as context. At the
  end of that turn, a note named on a Supersedes or Replaces line of the
  new note that still has no banner comes back once as Stop hook
  feedback. Snapshots of the session are left to /session-handoffs,
  which banners them once the session's own note covers them.
- SESSION_HANDOFFS_NUDGE=1: for sessions that don't write notes. The
  first time a session has made 30 tool calls over 2 hours without a
  note, the user sees one systemMessage. Nothing is stored: it fires at
  the Stop where that holds and didn't at the previous Stop.
- A turn is everything since the previous Stop's stop_hook_summary entry,
  so a prompt, peer message or task notification queued mid-turn doesn't
  split it.
- Silent in subagents, plan and dontAsk modes, and `claude -p`/SDK runs
  the session registry doesn't list as interactive. Any error, SystemExit
  included, means exit 0 and no output; each hooks.json command ends in
  `|| true` (exit 2 on UserPromptSubmit erases the prompt) and runs
  `python3 -B`, so nothing is cached.
- Context is written as facts, not instructions; titles are shown only
  for notes this session wrote and that nobody changed since.
- About 20-40 ms per event on a 21 MB transcript: a turn end reads back
  only to the previous Stop.
- hooks/hooks.json wires it for plugin users, off unless a variable is
  set; SKILL.md gains "Optional: hooks" with a pinned-copy install for
  settings.json. Plugin version 1.0.0 -> 1.1.0, so installs pick it up.
- Tests: one per rule and per silent case, each failing with its rule
  removed, plus two that run the real script and parse its stdout as
  JSON.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T8PczU1B2Y9DCWAV36fnH3
@claude

claude Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Claude finished @lhoupert's task in 18s —— View job


❌ Changes requested — see findings below.

Blocking

  • skills/session-handoffs/scripts/handoff_guard.py:455 (on_stop): the Stop output is {"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": …}}. As far as I know, Claude Code's Stop hook doesn't accept hookSpecificOutput/additionalContext. It is only defined for events like UserPromptSubmit, PostToolUse and SessionStart. The way to give Claude one more turn from a Stop hook is {"decision": "block", "reason": "<text>"}.
    • As written, the "one more turn to add the banner" feature is probably a silent no-op.
    • Nothing in the tests or docs covers decision/block, so the tests can't catch this. The PR also says the hook has not been tried in a live session.
    • The existing stop_hook_active early return at line 496 only makes sense with block, which suggests that was the intent.
    • Suggested fix:
      s = listing(head, left, t, config)
      return {"decision": "block", "reason": s} if s else None
      Then update the Stop tests to assert this shape. Please confirm in a scratch session before merging, since the transcript and hook contract are undocumented.

Non-blocking notes

  • handoff_guard.py:537: except BaseException also swallows KeyboardInterrupt and SystemExit. That is deliberate and documented, so fine.
  • hooks.json: the || true after python3 is redundant. main() already never exits non-zero, apart from interpreter failure. Harmless.

Simplify (ponytail)

  • handoff_guard.py (542 lines) and test_handoff_guard.py (1135 lines): a lot of regex heuristics (NOT_ASKED, SAYS, NEGATED, CONTINUED, REMARK) parse free text for "Supersedes". A fixed frontmatter/first-line convention such as Supersedes: <path> would let you delete most of them. Keep the current approach if typed-text tolerance matters more.
  • NOTES_LINE plus the REMARK fallbacks in snapshots(): read one path from a single documented form, and delete the three-way retry loop.
  • before(), last_turn() and lineage() each re-scan the transcript. lineage() could return the first-timestamp and call count as well, to avoid the separate pass.

I didn't run the tests.


💰 Estimated review cost: $0.15 · 0m17s · 6 turns

@dannybauman
dannybauman self-requested a review October 1, 2026 20:37

@dannybauman dannybauman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lhoupert I don't think the Claude bot's blocking issue is real. it says the Stop hook can't hand Claude that extra turn the way you wrote it, but the Claude Code hooks docs say it can: https://code.claude.com/docs/en/hooks#stop-decision-control

that lines up with what you saw when you tried it live, and the tests pass for me too

@lhoupert
lhoupert merged commit ad165e8 into feat/session-handoffs Oct 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants