<?xml version="1.0" encoding="UTF-8"?>
<spec xmlns="https://vibevm.org/spec/1">
  <title id="root">PROP-020 — Install hooks</title>
  <status stage="impl" state="done" comment="C 2026-07-25: the hook subsystem ships (HooksDecl parse + hooks.rs runner + apply wiring); motivation, rejected and out-of-scope facts stay spec-stage; fact grain 2026-07-24"/>
  <p p="1"><fact id="status-line" status="impl/done">**Status: IMPLEMENTED** (specified 2026-06-24 in an owner-requested design
session; verified against the tree 2026-07-25 by the spec-actualization
campaign). `[hooks]` parses in `vibe-core` (`manifest/package/hooks.rs`),
`vibe-workspace/src/hooks.rs` runs the whole contract — the pre/post phases, the
Git-Bash-first Windows interpreter selection, the trust gate over
`DEFAULT_ALLOWED_GROUPS`, the ran / skipped / failed statuses and the
  `VIBE_HOOK_*` environment — and `vibe-install`'s apply pipeline drives it.
  R1 successor `4503fdb6`/`9c545f0d` makes the exact nonempty materialisation
  report the sole hook-rerun trigger, including verify repair. One of
four orthogonal specs carved from the bridge-packages design (the others:
[PROP-021](../vibe-registry/PROP-021-submodule-sources.xml) submodule sources,
[PROP-022](PROP-022-materialization-modes.xml) materialization modes,
[PROP-023](../vibe-registry/PROP-023-bridge-packages.xml) bridge packages). The
four compose to solve bridge packages but each stands alone — hooks exist for
*any* package, not only bridges.</fact></p>
  <p p="2"><fact id="related" status="spec/done">**Related:** [PROP-009](PROP-009-loading-model.xml) (the install/materialise
pipeline hooks slot into), [PROP-007](PROP-007-workspace.xml) (workspace +
`vibedeps/`), [PROP-022](PROP-022-materialization-modes.xml) (a hook's
working tree is the materialised slot; how its edits are reset on update is a
materialization-mode property), [PROP-015 §2.6](../vibe-mcp/PROP-015-mcp-integration.xml#skill)
(skill projection reads the slot a hook prepared), [PROP-019 §2.13](../../common/PROP-019-version-manager.xml#security)
(the same "a build/script the user installs is code they chose to run" trust
posture), [PROP-000 §20](../../common/PROP-000.xml#token-secrecy) (the publish
token a hook never sees).</fact></p>
  <section id="motivation" title="1. Motivation">
    <section id="problem" title="1.1 The problem — install is pure file I/O, with no preparation step">
      <list ordered="false" p="3">
        <item><fact id="install-pure-io" status="impl/done">Today `vibe install` resolves, fetches, materialises a package's tree into
  `vibedeps/`, regenerates boot artefacts, and writes the lockfile — all pure
  file copying.</fact></item>
        <item><fact id="no-preparation-step" status="impl/done">A package that needs a *preparation* step after its content
  lands (normalise a vendored layout, generate a derived file, assemble a clean
  skill subtree out of an upstream repo's mess) has nowhere to put it.</fact></item>
      </list>
      <list ordered="false" p="4">
        <item><fact id="forcing-case-bridges" status="spec/done">The forcing case is bridge packages ([PROP-023](../vibe-registry/PROP-023-bridge-packages.xml)):
  a maintainer wraps someone else's repository whose structure does not match
  vibevm conventions, and needs to *bring it into order* before vibevm's skill
  machinery reads it.</fact></item>
        <item><fact id="hooks-universal" status="spec/done">But the need is general — any package may want a
  post-materialise step — so hooks are a **universal** mechanism, not a
  bridge-only feature.</fact></item>
      </list>
    </section>
    <section id="what" title="1.2 What this is — declared lifecycle scripts, run per package">
      <list ordered="false" p="5">
        <item><fact id="WHAT-HOOKS-ARE" status="impl/done">A package may declare `pre-install` / `post-install` scripts in its manifest.
  vibevm runs them at fixed points in the install pipeline, in the package's own
  materialised slot, choosing the right interpreter for the host OS.</fact></item>
        <item><fact id="EFFECTS-EPHEMERAL" status="impl/done" action="continue" actionstage="doc" audience="author">Only a hook's edits
  to materialiser-owned recorded payload are ephemeral: reinstall, update or
  integrity repair restores those bytes per
  [PROP-022](PROP-022-materialization-modes.xml), then reruns hooks exactly
  when that payload diff is nonempty. Hook-created unrecorded state is outside
  `.vibe-slot.toml` ownership and survives by design; a rerun may compound it,
  so hooks must be idempotent until a separate hook-output ownership contract
  exists.</fact></item>
      </list>
    </section>
  </section>
  <section id="decisions" title="2. Decisions">
    <section id="phases" title="2.1 Two phases, anchored to the materialise pipeline">
      <p p="6"><fact id="req-phases" status="impl/done">`req r1`</fact></p>
      <p p="7"><fact id="ONE-PER-PHASE" status="impl/done">A package declares at most one script per phase:</fact></p>
      <list ordered="false" p="8">
        <item><fact id="PHASE-PRE-INSTALL" status="impl/done" action="continue" actionstage="doc" audience="author">**`pre-install`** — runs immediately after the package's slot is fully
  populated (content materialised, submodules fetched per
  [PROP-021](../vibe-registry/PROP-021-submodule-sources.xml)) and **before**
  vibevm uses the slot (before boot regeneration, before any later
  `vibe skill` projection reads it). This is the "bring the tree into order"
  hook.</fact></item>
        <item><fact id="PHASE-POST-INSTALL" status="impl/done" action="continue" actionstage="doc" audience="author">**`post-install`** — runs after the install run is durable for that package
  (lockfile written, boot artefacts regenerated). For finalisation that needs
  the package already registered.</fact></item>
      </list>
      <p p="9"><fact id="CWD-IS-SLOT" status="impl/done" action="continue" actionstage="doc" audience="author">The hook's **working directory is the package's materialised slot**; it sees
exactly the tree vibevm will use.</fact></p>
      <list ordered="false" p="10">
        <item><fact id="RESET-THEN-RERUN" status="impl/done">**On update, reinstall or integrity repair, the materialiser restores its recorded payload, then hooks rerun if and only if that payload diff is nonempty.** For `copy`/`hardlink`, restoration is the PROP-054 §9.3 record diff: an unrecorded hook-created path survives and may be changed again by the rerun. `in-place` remains a separate git-native reset whose `git clean -dfx` removes untracked hook output. The landed `cli_hook_rerun::reinstall_runs_post_hook_once_only_for_a_nonempty_force_diff` proof records the copy-slot boundary directly: `.hook-count` is `1` after install, remains `1` after empty reinstall, and becomes `2` after a recorded-payload repair. Copy/hardlink hooks therefore remain responsible for idempotence until a separate hook-output ownership contract exists.</fact></item>
        <item><fact id="RESET-IS-MODE-PROPERTY" status="impl/done">Reset remains a
  materialization-mode property: `copy`/`hardlink` restore only the recorded
  footprint and preserve unrecorded outputs, while `in-place` reports its
  git-native change. Hooks consume that exact change report to decide whether
  the one rerun occurs.</fact></item>
      </list>
    </section>
    <section id="script-selection" title="2.2 Interpreter selection is OS-derived">
      <p p="11"><fact id="req-interpreter" status="impl/done">`req r1`</fact></p>
      <p p="12"><fact id="SCRIPT-FORMS" status="impl/done" action="continue" actionstage="doc" audience="author">A package ships a phase script as `&lt;base&gt;.sh` (portable, POSIX shell) and/or
`&lt;base&gt;.ps1` (PowerShell). The runner picks per host:</fact></p>
      <list ordered="false" p="13">
        <item><fact id="UNIX-SELECTION" status="impl/done">**Unix (macOS / Linux):** run `&lt;base&gt;.sh` via `bash`. A `.ps1` is ignored.</fact></item>
        <item><fact id="WINDOWS-SELECTION" status="impl/done">**Windows:** prefer `&lt;base&gt;.sh` via **Git Bash** when a `bash` is found
  (one cross-platform script for `.sh` packages); else fall back to
  `&lt;base&gt;.ps1` via **PowerShell** when one is found. A phase that declares a
  script but finds no usable interpreter is a hard error with a remediation
  hint — never a silent skip.</fact></item>
      </list>
      <list ordered="false" p="14">
        <item><fact id="HOOK-ENV" status="impl/done" action="continue" actionstage="doc" audience="author">The runner passes a documented environment: `VIBE_PACKAGE_GROUP`,
  `VIBE_PACKAGE_NAME`, `VIBE_PACKAGE_VERSION`, `VIBE_PACKAGE_KIND`,
  `VIBE_PACKAGE_DIR` (the slot, also CWD), `VIBE_HOOK_PHASE`.
  ([PROP-024 §2.3](../../common/PROP-024-code-bearing-packages.xml#build) adds
  `VIBE_PROJECT_ROOT`, the workspace absolute root, so a build hook can target a
  gitignored build dir *outside* the slot; it lands with that work.)</fact></item>
        <item><fact id="TOKEN-NEVER-IN-ENV" status="impl/done">The publish token
  ([PROP-000 §20](../../common/PROP-000.xml#token-secrecy)) is **never** placed in
  a hook's environment.</fact></item>
      </list>
      <p p="15"><fact id="RUNNER-SEAM" status="impl/done">The process runner is an injectable seam (`HookRunner`) so tests assert the
selection logic and argument/env shape without spawning real processes.</fact></p>
    </section>
    <section id="trust-gate" title="2.3 Trust posture — installation is consent, observability is the gate">
      <p p="16"><fact id="req-trust" status="impl/done">`req r1`</fact></p>
      <p p="17"><fact id="TRUST-CHEAP" status="spec/done">**Historical proposal, superseded.** Running a package's hook is running
third-party code at install time. This section originally proposed an
allow-list and per-run consent; PROP-054 `##INSTALL-IS-CONSENT` later replaced
that permission model for every extension handler.</fact></p>
      <list ordered="false" p="18">
        <item><fact id="ALLOW-LIST" status="spec/done">**Superseded design: allow-listed groups.** A config key (global
  `~/.vibe/config.toml` `[hooks].allowed_groups`, with a project-level
  override) lists trusted package groups. **`org.vibevm` is in the allow-list
  by default.** A package whose group is allow-listed runs its hooks with no
  prompt. This key was never a product input and is not part of the current
  configuration grammar.</fact></item>
        <item><fact id="FIRST-RUN-CONSENT" status="spec/done">**Superseded design: first-run consent.** On the first hook run of a non-allow-listed
  package, vibevm prints what will run (phase, script path, group) and asks
  `y/n`. Declining skips the hook and marks the package install as
  hooks-skipped (surfaced, not silent).</fact></item>
        <item><fact id="NON-INTERACTIVE-SAFETY" status="spec/done">**Superseded design: non-interactive abort.** With `--assume-yes` / in CI, allow-listed
  packages still run; a non-allow-listed package's hook is **not** run
  silently — the install **aborts** with a hint to either allow-list the group
  or pass an explicit `--allow-hooks` opt-in. A script must never execute
  unseen third-party code by default.</fact></item>
      </list>
      <p p="19"><fact id="INSTALLATION-CONSENT-SUCCESSOR" status="impl/done" action="continue" actionstage="doc" audience="user">**Current law.** Installing the package is the consent to run its declared
hooks. There is no `[hooks].allowed_groups`, first-run prompt, or
`--allow-hooks` permission layer. Safety is the PROP-054 observability model:
the manifest is statically inspectable, selected contributions are narrated,
and durable run evidence identifies what ran and which package supplied it.</fact></p>
    </section>
    <section id="manifest" title="2.4 Hooks are declared in the manifest">
      <p p="20"><fact id="req-manifest-hooks" status="impl/done">`req r1`</fact></p>
      <p p="21"><fact id="HOOKS-TABLE" status="impl/done" action="continue" actionstage="doc" audience="author">Hooks live in a package-role `[hooks]` table in `vibe.toml`:</fact></p>
      <fence lang="toml" p="22">[hooks]
pre-install  = "hooks/prepare"   # base path, relative to package root
post-install = "hooks/finalise"</fence>
      <list ordered="false" p="23">
        <item><fact id="BASE-PATH-VALUE" status="impl/done" action="continue" actionstage="doc" audience="author">The value is a **base path without extension**; the runner resolves `.sh` /
  `.ps1` beside it per §2.2.</fact></item>
        <item><fact id="PACKAGE-ONLY-TABLE" status="impl/done">The table is package-only (its presence on a
  `[project]`-role manifest is a validation error, like the other package-only
  sections).</fact></item>
        <item><fact id="ABSENT-DEFAULT" status="impl/done">An empty/absent `[hooks]` means no hooks — the common case.</fact></item>
      </list>
    </section>
    <section id="failure" title="2.5 Failure semantics are phase-specific">
      <p p="24"><fact id="req-failure" status="impl/done">`req r1`</fact></p>
      <p p="25"><fact id="STREAMS-AND-EXIT" status="impl/done">A hook's stdout/stderr stream to the user. A non-zero exit is handled by
phase:</fact></p>
      <list ordered="false" p="26">
        <item><fact id="FAIL-PRE-ABORTS" status="impl/done">**`pre-install` failure → the package install aborts.** The slot is rolled
  back (removed) and the install reports the failing package; vibevm never
  registers or projects from a package whose preparation failed.</fact></item>
        <item><fact id="FAIL-POST-FLAGS" status="impl/done">**`post-install` failure → the package is installed but flagged.** The
  package is already durable (lockfile written); the failure surfaces as a
  warning with the captured output, never a silent success.</fact></item>
      </list>
    </section>
  </section>
  <section id="rejected" title="3. Rejected alternatives">
    <list ordered="false" p="27">
      <item><fact id="REJ-INLINE-COMMANDS" status="spec/done">**Inline `command = "..."` strings in the manifest** instead of files —
  rejected: a versioned script file is auditable, diffable, and platform-split
  (`.sh`/`.ps1`); an inline string hides the code in TOML and resists review.</fact></item>
      <item><fact id="REJ-BOTH-EXTENSIONS" status="spec/done">**Running every matching extension on Windows** (`.sh` *and* `.ps1`) —
  rejected: one logical hook per phase keeps behaviour predictable; the
  selection is a single deterministic choice (§2.2).</fact></item>
      <item><fact id="REJ-GENERAL-LIFECYCLE" status="spec/done">**A general lifecycle (`pre-uninstall`, `pre-build`, …)** now — deferred:
  only the two phases the bridge case needs are specified; more can be added
  later behind their own anchors without disturbing these.</fact></item>
    </list>
  </section>
  <section id="out-of-scope" title="4. Out of scope">
    <list ordered="false" p="28">
      <item><fact id="OOS-CONTENT-SCANNING" status="spec/done">**Content scanning / the LLM "antivirus".** A future gate that inspects a
  package's hooks (and code) for malicious behaviour is far-backlog. Until it
  lands, hook execution is an **explicitly accepted risk** governed by
  dependency selection plus PROP-054 observability. This is the deliberate posture, not an
  oversight — recorded here as the project's stance.</fact></item>
      <item><fact id="OOS-SANDBOXING" status="spec/done">**Sandboxing / capability-limiting** hooks (containers, seccomp). Hooks run
  with the user's privileges, like `cargo build` scripts or `npm postinstall`.</fact></item>
      <item><fact id="OOS-NON-GIT-RESET" status="spec/done">**Non-git slot reset for `in-place`** — a hook over an `in-place`
  ([PROP-022 §2.4](PROP-022-materialization-modes.xml#in-place)) package whose
  source is not git has no cheap reset; `in-place` therefore requires a git
  source (PROP-022 §4).</fact></item>
    </list>
  </section>
  <section id="acceptance" title="5. Acceptance">
    <list ordered="false" p="29">
      <item><fact id="ACC-PHASE-TIMING" status="impl/done">A package with `[hooks].pre-install` runs the script in its slot before boot
  regeneration; `post-install` runs after the lockfile is written.</fact></item>
      <item><fact id="ACC-INTERPRETER" status="impl/done">Interpreter selection follows §2.2 on each OS; a declared hook with no usable
  interpreter errors rather than silently skipping.</fact></item>
      <item><fact id="ACC-CONSENT" status="impl/done">Any installed package's declared hook is eligible without a second consent
  prompt or group allow-list; the execution plan and resulting run evidence
  identify the hook, phase and providing package.</fact></item>
      <item><fact id="ACC-FAILURE-SEMANTICS" status="impl/done">A `pre-install` non-zero exit rolls back the slot and fails the package; a
  `post-install` failure installs-but-flags.</fact></item>
      <item><fact id="ACC-RESET-RERUN" status="impl/done">Updating, reinstalling or integrity-repairing a package restores the prior hook's recorded payload edits (per the materialization mode); a nonempty payload change reruns hooks exactly once and an empty change runs none.</fact></item>
      <item><fact id="ACC-RUNNER-SEAM" status="impl/done">The `HookRunner` seam lets tests assert selection/env/failure paths without
  spawning real processes.</fact></item>
      <item><fact id="ACC-FLOOR-GREEN" status="impl/done">Full `self-check.sh` green; conform 0/0/0; specmap clean.</fact></item>
    </list>
  </section>
</spec>
