Write a hook
Scaffold a new hook conforming to memo-flow bundle conventions. The skill interrogates you before writing anything; you can't produce an inconsistent hook by accident.
Process
1. Interrogation
Ask the author each question in order. Do not skip any. Do not write files until all answers are collected.
When presenting options via AskUserQuestion, keep the two fields straight: label is the short chip text the user clicks — 1–5 words. description is the 1–2 sentence explanation of what picking the option means. Long prose in label renders as an unreadable chip; it belongs in description.
Hook name (kebab-case, no .sh suffix, e.g. context-monitor):
- Must be unique among hooks in
skills/engineering/memo-hooks/hooks/
Trigger event — pick one:
PreToolUse— fires before any tool call; can block (exit 2)PostToolUse— fires after any tool call; advisory only (exit 0)Stop— fires when the agent is about to stop; can block (exit 1)UserPromptSubmit— fires on each user turn; can block (exit 2 surfaces message)PreCompact— fires before context compaction; can block (exit 1)
Tool/pattern matcher (if PreToolUse or PostToolUse):
- Memo-flow convention: settings.json matcher is always
""(empty / fires on every tool call). Any tool-name or path-pattern filtering happens inside the hook script (typically againsttool_input.tool_name/tool_input.file_path). - Collect the desired filter here as a script-level constraint, not a settings.json value. The scaffold will encode it inside the script body.
- Example: filter to
Writecalls wherefile_pathmatcheshandoff-*.md.
Exit-code contract — pick one:
advisory— always exits 0; emits warnings to stderr onlyblocking— exits 2 (or 1 for Stop/PreCompact) to halt the triggering action; stderr shown to user
Disabled-mode semantics — what does the hook do when "enabled": false in config?
- Standard answer (used for all existing hooks): exit 0 immediately, no output, no side-effects
- Confirm or describe a custom disabled behavior
Performance budget — how long may the hook take per fire?
fast(< 50 ms): read-only, no network, no subprocessmoderate(< 500 ms): small disk I/O, one python3 callslow(> 500 ms): heavy I/O or external subprocess — note this; slow hooks degrade UX
State needs — does the hook read or write state files?
- None: pure read of stdin + config
- Read-only: reads an existing state file
- Read-write: reads and atomically updates a state file (must use flock or temp-and-rename)
Default config — collect the hook-specific config keys (besides enabled), their types, and default values:
- Example:
threshold: number = 130000,mode: string = "auto"
2. Consistency check
Before scaffolding, verify:
- If exit-code contract is
blocking, the script must emit a message before exiting non-zero - If state is
read-write, the script must use a lock file (flock) or atomic temp-and-rename - If a
tool/pattern matcheris provided, confirm the trigger isPreToolUseorPostToolUse - The hook name does not already exist in
skills/engineering/memo-hooks/hooks/
Report any inconsistency and ask the author to clarify before proceeding.
3. Scaffold
Produce all four outputs. Write them in order. Do not omit any.
3a. Hook script
Path: skills/engineering/memo-hooks/hooks/<name>.sh
Follow this template exactly (replace <NAME>, <EVENT>, etc.):
#!/usr/bin/env bash
# <name>.sh — <EVENT> hook: <one-line description>.
#
# <Behavior summary — what it does and when>
#
# Config location: $MEMO_FLOW_CONFIG (env) or ./.claude/memo-flow/config.json (cwd)
# Config key: "<name>"
# Fail-open: missing or unparseable config → treat as enabled with defaults.
# Disabled hook: exits 0 immediately with no output.
set -euo pipefail
# ── find config ───────────────────────────────────────────────────────────────
CONFIG_FILE="${MEMO_FLOW_CONFIG:-./.claude/memo-flow/config.json}"
# ── read config (fail-open) ───────────────────────────────────────────────────
read_config() {
python3 - "$CONFIG_FILE" <<'PYEOF'
import json, os, sys
config_file = sys.argv[1]
defaults = {
"enabled": True,
# ... hook-specific defaults ...
}
if not os.path.exists(config_file):
print(json.dumps(defaults))
sys.exit(0)
try:
data = json.load(open(config_file))
hook_cfg = data.get("<name>", {})
if not isinstance(hook_cfg, dict):
print(json.dumps(defaults))
sys.exit(0)
merged = dict(defaults)
merged.update(hook_cfg)
print(json.dumps(merged))
except Exception:
print(json.dumps(defaults))
PYEOF
}
config_json=$(read_config)
enabled=$(python3 -c "import json,sys; print(json.loads(sys.argv[1]).get('enabled', True))" "$config_json")
# disabled → exit 0 immediately (no latency cost)
if [ "$enabled" = "False" ]; then
exit 0
fi
# ── read event from stdin ─────────────────────────────────────────────────────
event=$(cat)
# ... hook-specific logic ...
3b. config defaults — two files, deliberately different
Memo-flow carries two copies of the per-hook config defaults. They share keys but disagree on enabled. Missing either leaves fresh installs broken.
File 1: memo-hooks/modules/hook-config.sh — _DEFAULTS block
Used by the hook script's fail-open path when config.json is missing or unparseable. Convention: enabled: true (don't break if config disappears).
"<name>": {
"enabled": true,
"<key1>": <default1>,
"<key2>": <default2>
}
File 2: memo-hooks/install.sh — the inline fresh-config heredoc (install.sh:476-488)
Used when install.sh first writes .claude/memo-flow/config.json. Convention: enabled: false (opt-in on fresh install, user enables per hook via /memo-hooks).
"<name>": {
"enabled": false,
"<key1>": <default1>,
"<key2>": <default2>
}
The two
enabledvalues are intentionally inverted. Same keys/values otherwise. If you copy-paste between files, double-check you've flippedenabled.
Known drift: existing hooks already disagree on non-
enabledkeys (e.g.context-monitor.modeis"auto"in_DEFAULTSbut"notify"ininstall.sh). That's an upstream bug — for new hooks, keep all non-enabledkeys identical between the two files.
3c. settings.json template entry + install.sh wiring
The entry that install.sh will register. Show the exact JSON:
{
"id": "memo-flow:<name>",
"command": ".claude/memo-flow/hooks/<name>.sh",
"type": "command"
}
Important:
"type": "command", not"stdin". Claude Code silently ignorestype: "stdin"entries;install.sheven ships a repair function (_repair_broken_settings_entries) that rewritesstdin → commandfor memo-flow entries on every run. Always emitcommand.
Registered under the hook's trigger event key in .claude/settings.json, with an empty matcher (memo-flow convention — see step 1).
The install.sh wiring lives in two locations — they cannot be bundled together because the manifest call references ${settings_rel}, which is only defined between the two blocks.
Location A — after the last "$SETTINGS_SH" insert call (≈ install.sh:518):
<name>_cmd=".claude/memo-flow/hooks/<name>.sh"
<name>_hook="{\"id\":\"memo-flow:<name>\",\"command\":\"${<name>_cmd}\",\"type\":\"command\"}"
"$SETTINGS_SH" insert "$SETTINGS_JSON" "<EVENT>" "" "$<name>_hook"
Location B — after the last existing settings_entry manifest append (≈ install.sh:529), where ${settings_rel} and ${SCOPE} are in scope:
manifest_append_if_absent "$MANIFEST" \
"{\"id\":\"memo-flow:settings-<name>\",\"kind\":\"settings_entry\",\"target\":\"${settings_rel}\",\"hook_id\":\"memo-flow:<name>\",\"scope\":\"${SCOPE}\",\"customized\":false}"
Note the settings-mutator.sh insert signature: <file> <event> <matcher> <hook-json>. The matcher is the third positional arg and is always "" for memo-flow hooks (script-internal filtering, per step 1).
3d. README row (memo-hooks SKILL.md, Branch A2 opt-in prompt)
Add a row to the Branch A2 sub-question 1 list in skills/engineering/memo-hooks/SKILL.md. That's the bulleted list of hooks shown during fresh install's per-hook opt-in multiSelect. As of writing, the list lives near the line beginning - **\context-monitor`**`.
Use the existing format — kebab-case name in backticks, em-dash, prose description, optional default callout:
- **`<name>`** — <one-line user-facing description of what the hook does and when it fires>. <Optional: default behavior or threshold note>.
Example (existing): - **context-monitor** — Watches your session's token count on every UserPromptSubmit. When you're nearing the context limit, it injects a warning so you can run /handoff before reasoning degrades. Default threshold: 130 000 tokens.
Do not invent a "What gets installed" section — none exists. The opt-in list is the canonical surface where new hooks become discoverable to consumers.
4. Confirm before writing
Show the author a summary of all four outputs. Ask: "Write these files? [y/N]". Only proceed on explicit confirmation.
5. Write files
Write every output. Do not leave any "manual" steps — an unregistered hook is a dead hook.
- Write the hook script at
skills/engineering/memo-hooks/hooks/<name>.shandchmod +xit. - Update
memo-hooks/modules/hook-config.sh_DEFAULTS(File 1 in step 3b). - Update
memo-hooks/install.shinline fresh-config heredoc (File 2 in step 3b). - Update
memo-hooks/install.shwith both wiring blocks from step 3c — Location A (≈ line 518: hook-var assignment +"$SETTINGS_SH" insert) and Location B (≈ line 529:manifest_append_if_absentfor the settings entry). Do not collapse them;${settings_rel}is only defined between the two. - Update
memo-hooks/SKILL.mdwith the README row from step 3d.
6. Done
Confirm all five outputs from step 5 were written. Remind the author:
- Add an integration test under
tests/for the new hook - Run
bash bin/run-tests.shto verify end-to-end - Sanity-check by running
bash skills/engineering/memo-hooks/install.sh --check-only --project-dir <test-project>against a scratch project to confirm the new hook lands correctly
Opt-in flow: once the hook is registered via
install.sh, it automatically participates in the Branch A2multiSelectprompt the next time a user runs/memo-hookson a fresh project. Adding a description row tomemo-hooks/SKILL.mdBranch A2 is required so the entry is populated and meaningful — an undescribed hook appears in the list but gives users no basis for choosing it.
Notes
- Consumer-local hooks (landing path in the consumer project rather than the bundle) are designed-in but not exposed in v1 — this skill always targets the bundle path.
- The
idfield in settings.json entries is tolerated by Claude Code (empirically confirmed) and used by the marker-fence module for idempotent insert/remove. - Hook script performance:
python3subprocess startup costs ~30–80 ms. Keep the hot path (disabled check) before any subprocess call.