The footer reached the model on 0 of 278 prompts.

The hook is session_nudges, wired to UserPromptSubmit. It emits a footer instruction and a few optional sections. The hook timeout was 5s, and live runs took 5.4-8s. Claude Code cancelled the hook on every prompt, so the model never saw a word of it.

Reading the right record

The hook_timing module has a transcripts subcommand that reads the harness transcripts and counts success versus cancelled per hook command. It takes about 30s to run. It is the ground truth, and the per-run timing rows the hook writes are the cheap standing measure beside it.

Where the time went

Live runs took 5.4-8s, and under cProfile the same payload took 27s:

  • nudge_resolver.compose_panel took 24.6s, and rank_tasks inside it took 22.8s.
  • session_attach.find_task_dir was called 98 times for 15.3s. Each call ran AREAS.rglob(task_id) across the entire notes tree, with no cache.
  • footer.find_task_path was called 70 times for 7.3s, the same pattern, memoized per id only.
  • footer.compose itself, which does the git and vault subprocess calls, took about 1.4-2.1s. That part was fine.

The expensive piece was an optional ranking panel looking up task directories one id at a time by walking the whole tree.

The other victims were not the same bug

The transcript table showed other hooks cancelled too. session_hook prompt and units_capture prompt were profiled standalone.

The rglob pattern was not there. session_hook prompt ran in 0.61s wall (0.15s profiled). units_capture prompt ran in 1.98s wall (0.67s profiled), and most of that was units.resolve_manifest globbing at about 0.25s and a 0.14s import of nudge_resolver. Their multi-second transcript durations came from machine load and from the hooks being cancelled alongside the slow one. Both hooks were left unchanged. memory_recall_hook and hub_pulse also show timeouts in the baseline, and we did not investigate them.

The fix, in two parts

First, lib/task_index.py: one os.walk per process that builds {id -> dir} and {id -> .md}, shared by session_attach.find_task_dir, footer.find_task_path, and footer.find_task_dir. It is in-process only, with a 30s TTL. If a hit’s path has vanished, it triggers one rescan, and session_attach clears it after auto-slotting a folder. The test compares the index against the old rglob on a tmp tree, including ids outside the epics, a file where a dir is expected, a missing id, TTL expiry, and the rescan.

After, same payload under cProfile: 4.69s total, find_task_dir down to 50 calls and 0.81s (0.77s of that is the single index build), rank_tasks at 0.92s.

Second, a budget. Another session had just folded the five UserPromptSubmit hooks into one prompt_dispatch process with a 20s timeout. A second settings entry for the footer would have undone that fold, so session_nudges now protects its own footer from inside:

  • The footer and access mode are built first and never skipped.
  • The ranking panel runs in a worker thread with a join budget, PANEL_BUDGET_S, 2.5s. Over budget it is dropped and recorded skipped:over_budget.
  • The four cheap optional sections (checkpoint, handoff, waiting, stepback) are skipped once the run is past LATE_AFTER_S (5s) and recorded skipped:late.
  • Every run appends a row to .runs/hook-timing/YYYY-MM-DD.jsonl.

What the numbers do and do not prove

Wall time of the hook, three runs each, same payload, with the box at about 100% CPU from other sessions’ test gates during both measurements:

median
before37.1s
after4.41s
after (second batch)4.84s
through prompt_dispatch (all five hooks)4.58s

Those absolute numbers are inflated by load. Unloaded, the earlier live runs were 5.4-8s, and the after-fix floor is about 3-4s, dominated by the footer.compose git calls (about 1.4s) and Python start and imports (0.4s).

The live report shortly after the fix, n=51 rows and mixed with other sessions’ load, gave p50 3.8s, p95 8.9s, max 10.8s, with 19 runs over 4s, the panel dropped 2x, and other sections skipped 7x. The machine was never quiet, so that needs re-measuring.

Whether the footer arrives on real prompts is not yet known. Transcripts for prompts after the fix did not exist yet. Success is session_nudges cancelled near 0 in the transcripts run, and the over_4s count small in report. If skipped_sections shows the panel dropped often, the remaining cost is recent_sessions plus rank_tasks, each about 0.9s unloaded.

Takeaway

The hook was cancelled on every prompt, and the profile pointed at uncached tree walks in the ranking panel. The fix added a shared task index and a time budget, and the hook got faster under the profile. Delivery is unproven until the transcripts run shows session_nudges cancelled near 0 on prompts after the fix.