Skip to content
Development
Hook

Hooks

What superpowers-v runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
superpowers-v
384 skills8 agents15 commands7 hooks
Install
> /plugin marketplace add procoders/superpowers-v
> /plugin install superpowers-v@procoders

Ships with superpowers-v. Installing the plugin gets these hooks.

What fires, and when

Stop

  • "${CLAUDE_PLUGIN_ROOT}/hooks/epic-goal-stop.sh" || true

PreToolUse

  • MatchesSkill"${CLAUDE_PLUGIN_ROOT}/hooks/brainstorm-trigger0-nudge.sh"
  • MatchesWrite|Edit|MultiEdit|NotebookEdit|Bash"${CLAUDE_PLUGIN_ROOT}/hooks/lane-guard.sh"

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • "${CLAUDE_PLUGIN_ROOT}/hooks/triage-prompt-nudge.sh" || true

PostCompact

  • "${CLAUDE_PLUGIN_ROOT}/hooks/postcompact-resume.sh" || true

SessionStart

Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.

  • Matchesstartup|clear|compact"${CLAUDE_PLUGIN_ROOT}/hooks/session-banner.sh""${CLAUDE_PLUGIN_ROOT}/hooks/memory-refresh.sh"

PostToolUse

  • MatchesWrite"${CLAUDE_PLUGIN_ROOT}/hooks/plan-saved-nudge.sh""${CLAUDE_PLUGIN_ROOT}/hooks/memory-refresh.sh"

PreCompact

  • "${CLAUDE_PLUGIN_ROOT}/hooks/precompact-snapshot.sh" || true
Read hooks/hooks.json

Where it lives

  • hooks/brainstorm-trigger0-nudge.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Compound V — PreToolUse(Skill) hook: Trigger-0 AND Trigger-1 backstop
    # Fires on two Superpowers skill invocations and injects a one-line idempotent
    # reminder for the Compound V trigger that belongs at that transition:
    #   * superpowers:brainstorming  -> Trigger 0 (run the gates in phase-0-recon.md)
    #   * superpowers:writing-plans  -> Trigger 1 (run the three pre-flights FIRST)
    # Trigger 1 is nudged HERE, not when the spec file is written, because
    # brainstorming puts a user-review gate between the two: its state machine goes
    # "User reviews spec?" -> "Invoke writing-plans skill" [approved]
    # (superpowers/6.2.0/skills/brainstorming/SKILL.md:55-57), the User Review Gate
    # says "Wait for the user's response ... Only proceed once the user approves"
    # (:122-127), and "The ONLY skill you invoke after brainstorming is
    # writing-plans" (:61). So the invocation of writing-plans — not the Write that
    # saves the spec — is the moment the spec is approved, and the pre-flights must
    # run on an APPROVED spec.
    # Reminder only, never enforcement: it emits additionalContext exclusively —
    # no permissionDecision, no blocking exit code — and is silent (exit 0) for
    # every other tool, skill, or malformed input.
    #
    # PROBE VERDICT (2026-07-11, installed Claude Code 2.1.197): PreToolUse — PROVEN.
    # Evidence, strongest first:
    #   1. LIVE PROBE: nested `claude -p --settings` session with a PreToolUse(Bash)
    #      hook emitting {"hookSpecificOutput":{"hookEventName":"PreToolUse",
    #      "additionalContext":"PROBE_TOKEN_XYZ123 ..."}} — the model received the
    #      injected context (as a PreToolUse-hook system-reminder next to the tool
    #      result) and repeated PROBE_TOKEN_XYZ123 verbatim. Exit 0, empty stderr.
    #   2. Installed-binary strings (~/.local/share/claude/versions/2.1.197): the
    #      hook-output handler's `case "PreToolUse"` branch assigns
    #      `u.additionalContext = e.hookSpecificOutput.additionalContext`.
    #      (The binary's schema HELP text omits additionalContext for PreToolUse —
    #      help-string staleness; the runtime handler and the live probe win.)
    #   3. Official docs (code.claude.com/docs/en/hooks, fetched 2026-07-11):
    #      PreToolUse listed among events supporting hookSpecificOutput.
    #      additionalContext ("next to the tool result").
    #
    # Hook input format (Claude Code spec): JSON on stdin with tool_name and
    # tool_input; the Skill tool's input carries the skill name in tool_input.skill.
    # Output format: JSON on stdout with hookSpecificOutput.additionalContext.
    
    set -euo pipefail
    if [ "${CV_HEADLESS_CLASSIFY:-}" = "1" ]; then exit 0; fi  # finding 131: never fire inside the headless classifier
    
    # No jq → we cannot parse or emit safely; stay silent rather than ever block.
    command -v jq >/dev/null 2>&1 || exit 0
    
    # Read full hook event from stdin
    input="$(cat)"
    
    # Extract tool name and skill name defensively. Falls back to empty if missing
    # or if stdin is not valid JSON.
    tool_name=$(echo "$input" | jq -r '.tool_name // empty' 2>/dev/null || echo "")
    skill_name=$(echo "$input" | jq -r '.tool_input.skill // empty' 2>/dev/null || echo "")
    
    # Fire only for the Skill tool
    [ "$tool_name" = "Skill" ] || exit 0
    
    case "$skill_name" in
      superpowers:brainstorming)
        nudge="💉 Compound V — Trigger 0 backstop: run the Trigger 0 gates from phase-0-recon.md if not already done for this brainstorm (reminder only — the gates in that doc decide whether recon actually runs)."
        ;;
      superpowers:writing-plans)
        nudge="💉 Compound V — Trigger 1: the spec has passed brainstorming's user-review gate — writing-plans is invoked only after the user approved the spec, so the approved spec is what the audits must read. BEFORE writing the plan, run the three pre-flights (code-archaeologist ∥ domain-expert ∥ doc-validator) on that approved spec as ONE native Workflow on Engine C: python3 scripts/compound-v-emit-preflight.py --spec <spec> --out … then Workflow({ scriptPath }) — see skills/compound-v/SKILL.md \"Trigger 1\". Then write the plan with the three audits as design-constraint sources. ALL THREE: doc-validator is skipped only when the spec has ZERO technical dependencies — \"no NEW dependency\" is not the rule, because dependencies you already use go stale and acquire CVEs. If this spec RESCOPES work whose earlier features already went through the pipeline, that earlier compliance does not carry: the rescope re-enters at the top."
        ;;
      *)
        exit 0
        ;;
    esac
    
    # Emit context-injection JSON per platform
    if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
      jq -n --arg ctx "$nudge" '{additional_context: $ctx}'
    elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -z "${COPILOT_CLI:-}" ]; then
      jq -n --arg ctx "$nudge" \
        '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $ctx}}'
    else
      jq -n --arg ctx "$nudge" '{additionalContext: $ctx}'
    fi
    
  • hooks/epic-goal-stop.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Compound V — Stop hook: the triage gate and the off-by-default pipeline-bypass
    # correction.  v3.4.
    #
    # The armed-epic-goal rule that used to run first in this file was REMOVED in
    # 3.4.0, because Claude Code's own `/goal` covers it: the harness holds the
    # session open against a user-approved condition and evaluates it itself, so a
    # second, home-grown continuation engine in the highest-blast-radius file of
    # this plugin bought nothing.  `commands/v-epic.md` §0d now offers that native
    # goal instead of arming one here.
    #
    # ┌──────────────────────────────────────────────────────────────────────────┐
    # │ HIGHEST BLAST RADIUS IN THIS PLUGIN.  This file runs at the end of EVERY  │
    # │ turn of EVERY Claude Code session of every user who installs Compound V.  │
    # │ A bug here does not fail a build — it wedges a stranger's session so they │
    # │ cannot end their turn.  Read the invariant below before editing.          │
    # └──────────────────────────────────────────────────────────────────────────┘
    #
    # THE INVARIANT — A BLOCK IS ONLY EVER VALID JSON, NEVER AN EXIT CODE.
    #   A non-zero exit from a `Stop` hook *is itself a block*.  So any ordinary
    #   bash failure — a bad assignment, a missing `jq`, an unbound variable, a
    #   syntax error — would hold the user's turn open forever.  Fail-open is
    #   therefore MECHANICAL, via two independent mechanisms, BOTH required:
    #     (a) hooks.json registers this script as `"<script>" || true`;
    #     (b) this script is an unconditional-`exit 0` wrapper: an EXIT trap that
    #         forces status 0 on every path, plus all fallible logic confined to
    #         `hook_main`, run inside a command substitution whose output is
    #         DISCARDED unless it returned 0.  A half-finished run emits nothing.
    #   WHY (a) IS NOT REDUNDANT, stated precisely because the imprecise version is
    #   easy to write.  A bash PARSE ERROR exits 2 — which is exactly the blocking
    #   code — and bash parses a script INCREMENTALLY, so a malformed command only
    #   bites when execution reaches it.  Probed on bash 3.2: a parse error BELOW the
    #   `trap` line is caught by mechanism (b), because the trap is already
    #   installed; a parse error ABOVE it, or anything that stops this file being
    #   executed at all, exits 2 with no trap registered, and ONLY the `|| true`
    #   registration stands between that and a wedged session.  Everything above the
    #   trap is therefore comments on purpose.  Both directions are asserted in
    #   tests/test-epic-goal-stop.sh, so the day someone moves that trap down the
    #   file, the suite says so.
    #   Deliberately NO `set -e` and NO `set -u`.  `set -u` in particular exits the
    #   whole shell on an unbound variable — `|| true` around a function call does
    #   not save you from it.  Every expansion below is explicitly defaulted
    #   instead, and every pipeline is guarded (under `set -e` a no-match `grep`
    #   aborts the script, and an aborted Stop hook exits non-zero = a block).
    #
    # THIS HOOK WRITES NOTHING OUTSIDE ITS OWN STORE.  The once-per-session markers
    # and the incomplete-check ledger live under the OS temp dir; no file in the
    # repository is created, edited or committed by this script on any path.
    #
    # DECISION TABLE (evaluated top to bottom; exactly ONE state update and exactly
    # ONE JSON response per event):
    #
    #   1. jq / stdin unusable ......................... exit 0, silent
    #   2. hook_event_name != "Stop" ................... exit 0, silent   [GATE FIRST]
    #        SubagentStop / StopFailure / unknown / missing all land here.
    #   3. session_id empty ............................ exit 0 (cannot isolate)
    #   4. THE TRIAGE GATE — only when `.enforcement.triage_gate` in
    #      .claude/compound-v.json — ON when absent as of 3.2.0; set it to `false`
    #      to opt out:
    #      non-exempt files changed && NO pre-eval record COVERS that diff
    #        && this session's own marker unset ........ set marker, BLOCK
    #      a bounded check that could not finish ....... RECORD it, then open
    #   5. THE BYPASS RULE — only if the triage gate did not block, and only when
    #      `.enforcement.pipeline_bypass == true` in .claude/compound-v.json:
    #      source changed && no run record && marker unset ... set marker, BLOCK
    #   6. otherwise ................................... exit 0, silent
    #
    # WHY THE TRIAGE GATE SITS ABOVE THE BYPASS RULE.  Both are "you changed code
    # without X". The triage gate is ON by default as of 3.2.0 (an explicit
    # `enforcement.triage_gate: false` opts out); the bypass rule is still off. Only
    # one response per event is
    # permitted.  The triage gate is the more specific diagnosis, and its correction
    # — `/v:triage` — is the first step of the correction the bypass rule asks for.
    # Firing the general one first would send the reader to a pipeline that now
    # refuses to run without the very record the triage gate is asking them to make.
    # The bypass rule keeps its own relative position, so its behaviour is unchanged
    # from the release that introduced it.
    #
    # COVERAGE, NOT MERE EXISTENCE.  `/v:triage` COMMITS its record, so the record is
    # never in the dirty changed-set — its presence has to be read off disk, and a
    # matching `session_id` alone is not enough.  A record exempts a path only when
    # it is the SAME SESSION and that path lies inside the record's own
    # `declared_paths`.  Otherwise one triage of "change the README" would exempt a
    # later, unrelated edit to this very file in the same session.  A record that is
    # not DIRECT additionally has to be BOUND to a run: `run_id` set, and
    # docs/superpowers/execution/<run_id>/state.json present.  SCOPED and FULL are
    # promises to route through the pipeline; the run directory is the evidence that
    # the promise was kept, and without it the record is an intention, not a cover.
    #
    # THE 1.5 SECOND BUDGET IS SHARED BY EVERY `Stop` HOOK, AND A TIMED-OUT `git` IS
    # A SILENT NO-OP — the dead-guard shape this project has already shipped once
    # (v2.14.1: a link guard that could not fail, 25 of 29 selftests 
  • hooks/lane-guard.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Compound V — PreToolUse lane guard (Feature E, v3.0)
    #
    # WHAT THIS IS
    # ------------
    # A native `PreToolUse` DENY that refuses a write outside the acting job's
    # `write_allowed` lane BEFORE the bytes land, instead of noticing afterwards.
    #
    # WHAT THIS IS NOT
    # ----------------
    # It is NOT a replacement for `scripts/compound-v-scope-check.py`. That script
    # is git-derived, sees every path a job actually touched no matter how it was
    # touched, and REMAINS THE AUTHORITY: a job whose git verdict is BLOCKED is
    # still BLOCKED, and the D1 integration postcondition still decides what enters
    # the tree. This hook is DEFENCE IN DEPTH — a floor under that verdict.
    #
    # The honest reason for the caveat is in `bash_targets()` below: for the `Bash`
    # matcher this hook has to INSPECT A SHELL COMMAND STRING, and shell command
    # inspection is a parsing problem with unbounded evasions (`eval`, an
    # interpreter one-liner, a variable holding the path, a build step, a script
    # that writes on the guard's behalf). Every one of those walks straight past
    # this hook and straight into the git gate. A deny that can be walked around is
    # a supplement, never a replacement — see the spec's Feature E / E2.
    #
    # WHY IT MATCHES `Bash` AT ALL
    # ----------------------------
    # The 1D live probe (commit 0982ce0) established that a `Write|Edit`-only
    # matcher is decorative: this environment actively nudges agents toward `cat`,
    # `sed` and heredocs over the Write tool, and none of those reach a Write|Edit
    # matcher. So `Bash` is matched too, on the understanding above.
    #
    # FAIL-OPEN CONTRACT
    # ------------------
    # A false deny inside a long autonomous run is far more expensive than a missed
    # write the git gate catches anyway. Therefore: ANY uncertainty allows.
    #   * unparseable stdin                 -> allow, log
    #   * a Bash command whose quoting the
    #     tokenizer cannot parse            -> allow, log
    #   * no lane map / job unresolvable    -> allow, log   (the normal case for an
    #                                         ordinary human session)
    #   * an ISOLATED AGENT unresolved
    #     against a LIVE lane map           -> allow, log, record one deduplicated
    #                                         line in the run dir, AND say so once
    #                                         in additionalContext -- see UNRESOLVED
    #                                         IDENTITY below
    #   * manifest missing or malformed     -> allow, log, AND say so in
    #                                         additionalContext (the guard was
    #                                         supposed to be active and could not be)
    #   * a path this hook cannot resolve   -> allow, log
    #   * the interpreter itself crashing   -> allow (the wrapper below discards any
    #                                         non-JSON output and exits 0)
    #   * an interpreter PROBE that runs
    #     out of its budget                 -> allow, log, say so once, and STOP
    #                                         probing (eighth pass, H3)
    #   * the private bytecode-cache dir
    #     cannot be created                 -> allow, log, say so, and load NOTHING
    #                                         (eighth pass, H2)
    # Only a POSITIVELY IDENTIFIED, fully resolved, out-of-lane path denies.
    #
    # COST
    # ----
    # PreToolUse hooks share a tight time budget, so every path here is bounded: at
    # most 8 run directories are inspected, resolution stops at the first match, and
    # the manifest is only parsed AFTER a job has been resolved.
    #
    # EVERY EXTERNAL PROCESS IS BOUNDED TOO, as of the eighth pass (H3): an
    # interpreter probe gets CV_PROBE_TIMEOUT (0.9 s, sub-second on purpose against a
    # ~25 ms ordinary probe), a delegated manifest parse gets _PARSE_BUDGET_S (5 s),
    # and the registration in hooks/hooks.json carries `timeout: 10` so the harness
    # has a bound of its own even if this file grows a path that forgets one. A probe
    # that runs out of budget stops the ladder, says so once, and allows.
    #
    # THE LOG IS BOUNDED IN THE OTHER SENSE TOO (eighth pass, item 6). The
    # interpreter line names the chosen interpreter on every path, which is the only
    # way the viability ladder is observable — but it is written ONCE PER SESSION,
    # not once per call, keyed by a marker beside the log. MEASURED, 50 invocations
    # in one session on this machine (2026-09-03):
    #   unresolved path (an ordinary session)   100 lines before -> 51 after
    #                                           (50 interpreter lines -> 1; the 50
    #                                           `ALLOW (job unresolved)` lines stay)
    #   resolved, in-lane allow                  50 lines before ->  1 after
    # The transition is still logged: a different interpreter, or a candidate newly
    # passed over, is a different message and reappears.
    #
    # RE-MEASURED 2026-09-03 (seventh review pass) on macOS 26.5.2 / arm64 with
    # /usr/bin/python3 3.9.6, 50 invocations per cell, TWO qualifying rounds, against
    # a sandbox project carrying copies of this repository's 48 run directories:
    #
    #   bare interpreter start (`-c pass`)             25.6 / 25.9 ms   the floor
    #   one viability probe (`import yaml`, alone)     38.3 / 38.5 ms
    #   A  unresolved, 1st candidate has PyYAML       148.9 / 150.4 ms  ONE probe
    #   C  unresolved, NO candidate has PyYAML        199.1 / 204.0 ms  THREE probes
    #   R  resolved, write in lane (live lane map)    244.1 / 235.0 ms  ONE probe
    #
    # THE POPULATIONS, NAMED, because the ambient cost is not one number:
    #   * ~149 ms -- the machine whose FIRST candidate imports yaml. This is the
    #     ordinary macOS box (/usr/bin/python3 ships PyYAML) and it pays exactly ONE
    #     probe. It is also the only path a session that never dispatches will take.
    #   * ~200 ms -- the machine where NO candidate imports yaml: two `import yaml`
    #     probes plus one `-c pass` probe, three in all.
    #   * ~175 ms -- the machine whose SECOND candidate imports yaml: two probes.
    #     DERIVED, not measured end to end (A plus one in-loop probe, whose marginal
    #     cos
  • hooks/memory-refresh.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Compound V — V-memory refresh hook (SessionStart + PostToolUse:Write).
    #
    # Non-blocking + SILENT: self-backgrounds a `refresh --quick` and returns in ~ms, so it
    # never stalls SessionStart or a Write. It emits NO context output, so it composes cleanly
    # with session-banner.sh / plan-saved-nudge.sh (those still fire and inject their context).
    #
    # It NEVER installs or downloads: `refresh` without --with-embeddings is FTS5-only and
    # offline. Embeddings are bootstrapped only by the explicit `/v:memory-refresh
    # --with-embeddings` / `bootstrap` path, never from a hook.
    #
    # The index cache lives OUTSIDE the repo (~/.cache/compound-v/memory/<repo-id>/), so a
    # refresh can never write into the working tree and therefore can never dirty a dispatch
    # worker's git scope gate. Concurrent fires (a Write storm during dispatch, or SessionStart
    # racing a Write) are safe: the engine's flock makes every loser an instant no-op.
    
    set -euo pipefail
    if [ "${CV_HEADLESS_CLASSIFY:-}" = "1" ]; then exit 0; fi  # finding 131: never fire inside the headless classifier
    
    input="$(cat 2>/dev/null || true)"
    file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null || echo "")
    
    # PostToolUse:Write carries a file_path — only react to writes under docs/superpowers.
    # SessionStart carries no file_path — always do a quick refresh.
    if [ -n "$file_path" ]; then
      case "$file_path" in
        */docs/superpowers/*) : ;;
        *) exit 0 ;;
      esac
    fi
    
    script="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}/scripts/compound-v-memory.py"
    command -v python3 >/dev/null 2>&1 || exit 0
    [ -f "$script" ] || exit 0
    
    # Detach: nohup + background + redirected fds so the session returns immediately.
    nohup python3 "$script" refresh --quick </dev/null >/dev/null 2>&1 &
    exit 0
    
  • hooks/plan-saved-nudge.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Compound V — PostToolUse(Write) hook
    # Fires after any Write tool call. Reads the hook event JSON from stdin,
    # checks if the written file is a Compound-V-relevant artifact (plan, spec, or
    # recon doc), and if so, emits a context-injection nudge with the next step.
    # Note: the recon arm fires AFTER a recon doc is written — it reinforces the
    # recon→brainstorm handoff but does NOT backstop Trigger 0's pre-fire gap
    # (nothing is written before a brainstorm begins).
    # Note: the spec arm deliberately does NOT start the pre-flights. A spec is
    # written before brainstorming's User Review Gate, not after it
    # (superpowers/6.2.0/skills/brainstorming/SKILL.md:122-127 — "Wait for the
    # user's response ... Only proceed once the user approves"), so dispatching
    # here would audit an unapproved spec and jump that gate. Trigger 1 now fires
    # from hooks/brainstorm-trigger0-nudge.sh when superpowers:writing-plans is
    # invoked, which is the transition the user's approval unlocks (:55-57, :61).
    #
    # Hook input format (Claude Code spec): JSON on stdin with tool_input.file_path
    # (per https://docs.claude.com/en/docs/claude-code/hooks). Patterns use a
    # leading `*` (not `*/`) so RELATIVE paths like docs/superpowers/plans/x.md
    # match too — Write tool_input.file_path is not guaranteed absolute (A20).
    # Output format: JSON on stdout with hookSpecificOutput.additionalContext.
    
    set -euo pipefail
    if [ "${CV_HEADLESS_CLASSIFY:-}" = "1" ]; then exit 0; fi  # finding 131: never fire inside the headless classifier
    
    # Read full hook event from stdin
    input="$(cat)"
    
    # Extract the written file's path. Falls back to empty if missing.
    file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null || echo "")
    
    # No path → not a write we care about
    [ -z "$file_path" ] && exit 0
    
    # Match Compound-V-relevant artifacts
    nudge=""
    case "$file_path" in
      *docs/superpowers/plans/*.md)
        nudge="💉 Compound V — plan saved at $file_path. To execute: run /v:dispatch $file_path yourself at the top level — it materializes the manifest, requires a /v:triage record, runs compound-v:partition-reviewer, then launches Engine C (the native Workflow) and the integration gate. /v:orchestrate $file_path only materializes the manifest. Delegate to compound-v:parallel-dispatcher only if this session has no Workflow tool."
        ;;
      *docs/superpowers/specs/*.md)
        nudge="💉 Compound V — spec saved at $file_path. If this came from brainstorming, the next step is the user's own review of the spec (brainstorming's User Review Gate), not a dispatch. Do NOT start the pre-flights now: they fire when superpowers:writing-plans is invoked (Trigger 1), which happens only after the user approves this spec — that is the whole point of running the audits on an APPROVED spec. If this spec RESCOPES work whose earlier features already went through the pipeline, that earlier compliance does not carry: the rescope re-enters at the top."
        # Trigger 0 runs BEFORE a brainstorm, so by the time a spec exists it can no
        # longer be run -- a retroactive recon is the fabricated-evidence pattern, not
        # a recovery. All this can honestly do is turn a silent skip into a declared
        # one. Absent/empty recon dir => say so; never claim the gate still applies.
        if [ ! -d "docs/superpowers/recon" ] || [ -z "$(ls -A docs/superpowers/recon 2>/dev/null)" ]; then
          nudge="$nudge NOTE: no recon doc exists — Trigger 0 did not run for this brainstorm. It cannot be run retroactively; state the omission to the user rather than passing over it."
        fi
        ;;
      *docs/superpowers/recon/*.md)
        nudge="💉 Compound V — recon saved at $file_path. Start the brainstorm with it: read it before the first question; treat DIRECTIONS as non-exhaustive."
        ;;
      *)
        # Not relevant — exit silently
        exit 0
        ;;
    esac
    
    # Emit context-injection JSON per platform
    if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
      jq -n --arg ctx "$nudge" '{additional_context: $ctx}'
    elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -z "${COPILOT_CLI:-}" ]; then
      jq -n --arg ctx "$nudge" \
        '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}'
    else
      jq -n --arg ctx "$nudge" '{additionalContext: $ctx}'
    fi
    
  • hooks/postcompact-resume.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Compound V — PostCompact resume context (Feature E3, v3.0)
    #
    # WHAT THIS IS
    # ------------
    # `PostCompact` is the precise event for "the conversation was just compacted",
    # and — unlike `SessionStart` — IT RECEIVES THE SUMMARY. v2.19 put a stateful
    # resume banner on `SessionStart` because that was the only event known at the
    # time (docs/superpowers/architecture/native-mechanisms.md: "Сидели на
    # `SessionStart`; не знали, что `PostCompact` отдаёт саммари"). This hook is
    # that row closed: it reads the summary the compaction actually produced and
    # says whether the unfinished work is IN it.
    #
    # WHAT IT REUSES, AND WHY IT REIMPLEMENTS NOTHING
    # -----------------------------------------------
    # `scripts/compound-v-dashboard.py resume` already owns:
    #   * what counts as an unfinished run or epic (`_is_unfinished`)
    #   * how fresh it has to be to still matter (72 h)
    #   * the one-line rendering (`format_resume_line`)
    # and — the part that must not be re-derived here — its freshness comes from the
    # RECORDED timestamp, never a file mtime: git rewrites mtimes on every clone and
    # branch switch, so an mtime-based age would make every historical run in the
    # repository look seconds old and this hook would announce ancient work after
    # every compaction. So the line below is the dashboard's line verbatim.
    #
    # WHAT THIS HOOK ADDS THAT THE BANNER CANNOT
    # ------------------------------------------
    # The summary itself. Having both the active run ids and `compact_summary`, it
    # can say the one thing neither the banner nor the summary can say alone:
    # whether the run survived the compaction in writing. "The summary does not
    # mention run X" is a fact about this compaction, not a reconstruction from
    # disk, and it is exactly the moment the position gets lost.
    #
    # HONESTY ABOUT WHERE THE OUTPUT LANDS — probed, not assumed.
    # In the installed runtime (2.1.238) the PostCompact executor folds hook stdout
    # into `userDisplayMessage` and returns nothing else; that value ends up as the
    # compaction's display text. There is NO `hookSpecificOutput` variant for
    # PostCompact in the runtime's output schema at all (the variants are PreToolUse,
    # UserPromptSubmit, UserPromptExpansion, SessionStart, Setup, SubagentStart,
    # PostToolUse, …), and the message-rendering path injects hook stdout into the
    # MODEL's context for exactly three events: SessionStart, UserPromptSubmit and
    # UserPromptExpansion. So:
    #
    #   this hook's line is shown AT THE COMPACTION BOUNDARY; it is not injected
    #   into the model's context in 2.1.238.
    #
    # It therefore COMPLEMENTS the v2.19 `SessionStart` banner (which fires with
    # source=compact and does reach the model) rather than replacing it. Nothing
    # here should be described as re-injecting context. Because the output shape is
    # plain display text, this hook emits PLAIN TEXT — a JSON object would be
    # rendered to the user as raw JSON, and a `hookSpecificOutput` block naming an
    # event the schema has no variant for is a shape the runtime rejects.
    #
    # FAIL-OPEN / FAIL-SILENT
    #   * unparseable stdin, no jq, no interpreter, missing dashboard → say nothing
    #   * nothing unfinished → say nothing (the overwhelmingly common case)
    #   * every path exits 0; the `|| true` registration is the outer half
    # A compaction is already a bad moment to be interrupted by a broken hook.
    #
    # COST. Two `compound-v-dashboard.py` invocations (the rendered line, then the
    # ids behind it) plus one `jq` per id: ~147 ms measured on the development
    # machine, mean of 10 runs. It runs once per compaction, not once per turn, and
    # `timeout: 10` in hooks/hooks.json bounds it regardless.
    
    # Status 0 on EVERY exit path. `hook_main` clears this trap for itself.
    trap 'exit 0' EXIT
    
    # No `set -e`: this hook must never fail closed.
    set -uo pipefail
    if [ "${CV_HEADLESS_CLASSIFY:-}" = "1" ]; then exit 0; fi  # finding 131: never fire inside the headless classifier
    
    _HOOK_TAG="compound-v/postcompact-resume"
    
    _log() { printf '%s: %s\n' "$_HOOK_TAG" "$*" >&2; }
    
    # From the canonicalized cwd, walk UP to the nearest ancestor holding `.git`;
    # fall back to the cwd itself. Bounded to 40 levels. (Duplicated from
    # epic-goal-stop.sh rather than shared: a hooks/ library file is not in this
    # job's lane, and a sourced file is one more thing each hook must survive the
    # absence of.)
    _project_root() {
      local d="$1" i=0
      while [ "$i" -lt 40 ]; do
        [ -e "$d/.git" ] && { printf '%s' "$d"; return 0; }
        [ "$d" = "/" ] && break
        d="$(dirname "$d")" || break
        [ -n "$d" ] || break
        i=$((i + 1))
      done
      printf '%s' "$1"
    }
    
    # Plugin root first (how the hook actually runs), then a repo-relative sibling
    # (how a source checkout runs).
    # Where hooks/precompact-snapshot.sh put the pre-compaction line. DUPLICATED
    # from that hook on purpose — both are standalone shell hooks with no shared
    # library, which is this repo's house style. The two must agree on the store
    # name and the key, and tests/test-native-points.sh asserts they do by writing
    # with one and reading with the other rather than by comparing the source.
    _snapshot_path() {
      local t="${TMPDIR:-/tmp}" key
      while [ "${t}" != "/" ] && [ "${t%/}" != "${t}" ]; do t="${t%/}"; done
      [ -n "$t" ] || t="/tmp"
      if command -v shasum >/dev/null 2>&1; then
        key="$(printf '%s' "${1}|${2}" | shasum -a 256 2>/dev/null | cut -d' ' -f1)"
      elif command -v sha256sum >/dev/null 2>&1; then
        key="$(printf '%s' "${1}|${2}" | sha256sum 2>/dev/null | cut -d' ' -f1)"
      else
        return 1
      fi
      [ -n "${key:-}" ] || return 1
      printf '%s/compound-v-precompact/snap-%s' "$t" "$key"
    }
    
    _locate_dashboard() {
      local c
      if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
        c="${CLAUDE_PLUGIN_ROOT}/scripts/compound-v-dashboard.py"
        [ -f "$c" ] && { printf '%s' "$c"; return 0; }
      fi
      c="$(dirname "$0")/../scripts/compound-v-dashboard.py"
      [ -f "$c" ] && { printf '%s' "$c"; return 0; }
      return 1
    }
    
    _python() {
      local py="${CV_PYTHON:-}"
      if [ -z "$py" ]; then
        py="$(command -v pytho
  • hooks/precompact-snapshot.shRunsGitHub
  • hooks/session-banner.shRunsGitHub
  • hooks/triage-prompt-nudge.shRunsGitHub

All 9 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withsuperpowers-v

Compound V — a multi-model coding sidekick for Superpowers, running on Claude Code. You describe a feature. Claude sizes the request, plans it, splits it into non-overlapping pieces, and hands each piece to a worker in its own isolated worktree.

Get the whole plugin