feat(hooks): add SessionEnd progress logger and local progress tracker (#87)
* feat(hooks): add SessionEnd progress logger and local progress tracker Adds a SessionEnd hook that prompts for modules studied at session end and appends a record to ~/.claude-howto-progress.json — outside the repo so progress survives git pull without being overwritten. Also adds local-progress/index.html: a self-contained visual tracker with checkboxes for all 10 modules, per-module notes, an overall progress bar, and Export/Import to sync with a local JSON backup file. Key patterns demonstrated: - SessionEnd vs Stop (fires once on exit, not after every response) - /dev/tty for interactive input in hooks (stdin carries the JSON payload) - $CLAUDE_PROJECT_DIR for portable paths (never hardcode /Users/...) - Guard clause to prevent global hook running in unrelated projects Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(hooks): address PR review — bash 3.2 compat, JSON escaping, textarea XSS - Replace pipeline+while with IFS for-loop (bash 3.2 compatible) - Pass NOTES as Python arg to avoid broken JSON on quotes/backslashes - Set textarea.value instead of innerHTML to prevent XSS from imported JSON Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
67f4871e68
commit
47b97d1037
@@ -965,6 +965,141 @@ python3 09-advanced-features/setup-auto-mode-permissions.py
|
||||
- `DROP TABLE`, `kubectl delete`, `terraform destroy`
|
||||
- `npm publish`, `curl | bash`, production deploys
|
||||
|
||||
### Example 8: Learning Progress Logger (SessionEnd)
|
||||
|
||||
Log which modules you studied at the end of each Claude Code session. Progress is stored
|
||||
in `~/.claude-howto-progress.json` — outside the repo, so it survives `git pull` without
|
||||
being overwritten.
|
||||
|
||||
**Why `SessionEnd` and not `Stop`?**
|
||||
`Stop` fires after *every* Claude response. `SessionEnd` fires once when the session
|
||||
terminates — exactly what you want for an end-of-session diary entry.
|
||||
|
||||
**Why `/dev/tty` for input?**
|
||||
Hook scripts receive the hook JSON payload via `stdin`, so interactive `read` must use
|
||||
`/dev/tty` directly to reach the terminal.
|
||||
|
||||
**File:** `06-hooks/session-end.sh`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# SessionEnd hook: prompts for modules worked on, then appends a session record
|
||||
# to ~/.claude-howto-progress.json for persistent learning progress tracking.
|
||||
|
||||
PROGRESS_FILE="$HOME/.claude-howto-progress.json"
|
||||
|
||||
# Guard: only run inside this repo
|
||||
if [[ "$CLAUDE_PROJECT_DIR" != *"claude-howto"* ]] && [[ "$PWD" != *"claude-howto"* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$PROGRESS_FILE" ]; then
|
||||
echo '{"sessions":[]}' > "$PROGRESS_FILE"
|
||||
fi
|
||||
|
||||
DATE=$(date +"%Y-%m-%d")
|
||||
TIME=$(date +"%H:%M")
|
||||
|
||||
echo ""
|
||||
echo " Which modules did you work on? (e.g. 06,07 or press Enter to skip)"
|
||||
echo " 01=Slash 02=Memory 03=Skills 04=Subagents 05=MCP"
|
||||
echo " 06=Hooks 07=Plugins 08=Checkpoints 09=Advanced 10=CLI"
|
||||
printf " > "
|
||||
read -r INPUT </dev/tty
|
||||
|
||||
if [ -z "$INPUT" ] || [ "$INPUT" = "skip" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MODULES_JSON=$(echo "$INPUT" | tr ',' '\n' | tr -d ' ' | while read -r m; do
|
||||
case "$m" in
|
||||
01) echo '"01-slash-commands"' ;;
|
||||
02) echo '"02-memory"' ;;
|
||||
03) echo '"03-skills"' ;;
|
||||
04) echo '"04-subagents"' ;;
|
||||
05) echo '"05-mcp"' ;;
|
||||
06) echo '"06-hooks"' ;;
|
||||
07) echo '"07-plugins"' ;;
|
||||
08) echo '"08-checkpoints"' ;;
|
||||
09) echo '"09-advanced-features"' ;;
|
||||
10) echo '"10-cli"' ;;
|
||||
*) echo "\"$m\"" ;;
|
||||
esac
|
||||
done | paste -sd ',' -)
|
||||
|
||||
printf " Notes? (optional, press Enter to skip): "
|
||||
read -r NOTES </dev/tty
|
||||
|
||||
SESSION="{\"date\":\"$DATE\",\"time\":\"$TIME\",\"modules\":[${MODULES_JSON}],\"notes\":\"${NOTES}\"}"
|
||||
|
||||
python3 - "$PROGRESS_FILE" "$SESSION" <<'PYEOF'
|
||||
import sys, json
|
||||
path, new_session = sys.argv[1], json.loads(sys.argv[2])
|
||||
with open(path, 'r') as f:
|
||||
data = json.load(f)
|
||||
data.setdefault('sessions', []).append(new_session)
|
||||
with open(path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
PYEOF
|
||||
|
||||
echo " Saved to $PROGRESS_FILE"
|
||||
```
|
||||
|
||||
**Configuration** (in `.claude/settings.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-end.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output — `~/.claude-howto-progress.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"date": "2026-04-18",
|
||||
"time": "14:32",
|
||||
"modules": ["06-hooks", "07-plugins"],
|
||||
"notes": "Installed first hook, tried pre-commit example"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Key patterns demonstrated:**
|
||||
|
||||
| Pattern | Why it matters |
|
||||
|---------|----------------|
|
||||
| `SessionEnd` event | Fires once on exit — not after every response like `Stop` |
|
||||
| `read -r INPUT </dev/tty` | Hooks own `stdin` (JSON payload); use `/dev/tty` for user input |
|
||||
| `$CLAUDE_PROJECT_DIR` | Portable path — never hardcode `/Users/yourname/...` |
|
||||
| Guard clause at top | Prevents the hook running in unrelated projects if installed globally |
|
||||
| Store outside the repo | `~/` path survives `git pull` without overwriting your data |
|
||||
|
||||
**Companion: visual progress tracker**
|
||||
|
||||
For a full checkbox-based UI covering all 10 modules, open the included tracker in your browser:
|
||||
|
||||
```bash
|
||||
open local-progress/index.html
|
||||
```
|
||||
|
||||
Progress is stored in browser `localStorage` (never written to disk inside the repo).
|
||||
Use the **Export** button to save a snapshot as JSON, and **Import** to restore it.
|
||||
|
||||
## Plugin Hooks
|
||||
|
||||
Plugins can include hooks in their `hooks/hooks.json` file:
|
||||
|
||||
94
06-hooks/session-end.sh
Executable file
94
06-hooks/session-end.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# SessionEnd hook: prompts for modules worked on, then appends a session record
|
||||
# to ~/.claude-howto-progress.json for persistent learning progress tracking.
|
||||
#
|
||||
# Fires once when the Claude Code session terminates — not after every response.
|
||||
# Uses /dev/tty for interactive input since stdin carries the hook's JSON payload.
|
||||
#
|
||||
# Install: add to .claude/settings.json under the "SessionEnd" event (see below).
|
||||
|
||||
PROGRESS_FILE="$HOME/.claude-howto-progress.json"
|
||||
|
||||
# Guard: only run inside this repo
|
||||
if [[ "$CLAUDE_PROJECT_DIR" != *"claude-howto"* ]] && [[ "$PWD" != *"claude-howto"* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create progress file if it doesn't exist
|
||||
if [ ! -f "$PROGRESS_FILE" ]; then
|
||||
echo '{"sessions":[]}' > "$PROGRESS_FILE"
|
||||
fi
|
||||
|
||||
DATE=$(date +"%Y-%m-%d")
|
||||
TIME=$(date +"%H:%M")
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Claude Code — Learning Session End"
|
||||
echo " $DATE $TIME"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo " Which modules did you work on? (e.g. 06,07 or press Enter to skip)"
|
||||
echo " 01=Slash 02=Memory 03=Skills 04=Subagents 05=MCP"
|
||||
echo " 06=Hooks 07=Plugins 08=Checkpoints 09=Advanced 10=CLI"
|
||||
echo ""
|
||||
printf " > "
|
||||
read -r INPUT </dev/tty
|
||||
|
||||
if [ -z "$INPUT" ] || [ "$INPUT" = "skip" ]; then
|
||||
echo " Skipped — no session logged."
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Map short numbers to module names (for loop avoids pipeline+while, which bash 3.2 can't parse)
|
||||
IFS=',' read -ra PARTS <<< "$INPUT"
|
||||
MODULES_JSON=""
|
||||
for m in "${PARTS[@]}"; do
|
||||
m="${m// /}" # strip spaces
|
||||
case "$m" in
|
||||
01) label='"01-slash-commands"' ;;
|
||||
02) label='"02-memory"' ;;
|
||||
03) label='"03-skills"' ;;
|
||||
04) label='"04-subagents"' ;;
|
||||
05) label='"05-mcp"' ;;
|
||||
06) label='"06-hooks"' ;;
|
||||
07) label='"07-plugins"' ;;
|
||||
08) label='"08-checkpoints"' ;;
|
||||
09) label='"09-advanced-features"' ;;
|
||||
10) label='"10-cli"' ;;
|
||||
*) label="\"$m\"" ;;
|
||||
esac
|
||||
MODULES_JSON="${MODULES_JSON:+$MODULES_JSON,}$label"
|
||||
done
|
||||
|
||||
printf " Notes? (optional, press Enter to skip): "
|
||||
read -r NOTES </dev/tty
|
||||
|
||||
# Pass NOTES as a separate argument so Python handles JSON escaping —
|
||||
# avoids broken JSON when notes contain quotes or backslashes.
|
||||
python3 - "$PROGRESS_FILE" "$DATE" "$TIME" "$MODULES_JSON" "$NOTES" <<'PYEOF'
|
||||
import sys, json
|
||||
|
||||
path, date, time_str, modules_raw, notes = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
|
||||
|
||||
new_session = {
|
||||
"date": date,
|
||||
"time": time_str,
|
||||
"modules": json.loads(f"[{modules_raw}]") if modules_raw else [],
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
with open(path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
data.setdefault('sessions', []).append(new_session)
|
||||
|
||||
with open(path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
PYEOF
|
||||
|
||||
echo ""
|
||||
echo " Saved to $PROGRESS_FILE"
|
||||
[ -n "$NOTES" ] && echo " Notes: $NOTES"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user