# Install hooks
Status: done. Some agent libraries ship tooling (binaries, scripts) that their
skills or agents depend on, which must be built or installed before the items
work. An install hook is a command a source declares (or a user supplies) that
`mind` runs to perform that setup. Because the command is arbitrary code from the
source, `mind` shows it and prompts before running it.
## Overview
An install hook is a shell command associated with a source. The maintainer
declares it in `mind.toml`, or a user supplies one on the command line (for a repo
that ships no `mind.toml`, or to override the declared one, in which case the
override is shown loudly). The hook runs in the source's clone during `meld`, and
re-runs during `upgrade` when the source updates.
The hook is arbitrary code execution: it can run any command with the user's
privileges. So `mind` never runs a hook without first showing the user what will
run and where it came from (source identity, the pin and commit, the clone path,
and the exact command), with an explicit warning, framed by a header so the
disclosure is visibly distinct from the surrounding output. The interactive
prompt offers run (the default, a bare Enter), skip the hook but still install the
source and its items, or abort and install nothing. In a non-TTY context the hook
is skipped instead (HOOK-22); `--accept-hooks` runs it
unattended for users who have already vetted the source. The prompt is the trust
boundary; the shown pin and commit let the user inspect the repo at that exact
point before approving.
The rest of this document states the rules normatively. Source identity is
`host/owner/repo` (see storage.md); a source's pin is its `Pin` (see cli.md,
CLI-17).
## Declaring a hook
- `HOOK-1` `[source].install` in a repo's `mind.toml` is a shell command string
that declares the source's install hook (run to build or install the tooling
the source's items rely on). Deprecated in favor of a `[[hooks]]` install entry
(HOOK-90); still parsed and folded into the hook set (HOOK-50) for back-compat.
- `HOOK-2` `mind meld <repo> --install-hook <cmd>` supplies an install hook, both
for a repo with no `mind.toml` and to override one a source declares. When it
overrides a declared `[source].install`, the override is loud and obvious: the
safety prompt (HOOK-20) shows the source's declared command and the overriding
command, and states that the user-supplied command is what will run, so the user
cannot miss that they replaced the maintainer's hook.
- `HOOK-3` With no `[source].install` and no `--install-hook`, `meld` runs no hook
(behavior is unchanged from a source without one). An empty or whitespace-only value
for either field is treated the same as absent: no hook runs and nothing is recorded.
## When a hook runs
- `HOOK-10` An install hook runs during `meld`, once, in the source's clone
directory, after the working tree is checked out at the resolved pin. It is a
source-level step; `learn` does not run hooks.
- `HOOK-11` `upgrade` re-runs a source's hook (subject to the same prompt) when the
source has advanced to a new commit and a hook is in effect, so the tooling
tracks the source. `sync` alone (which only fetches and records the new commit)
does not run the hook.
## The safety prompt
- `HOOK-20` Before running any hook, `mind` prints a disclosure framed by a
`====== hook: <name> ======` header (so it is visibly distinct from the
surrounding `melded <source>` output): the source identity, the hook's
lifecycle event (HOOK-120), the resolved pin
(the branch, tag, or ref) and the commit, the clone path, and the exact command
that will run, with a clear warning that this executes arbitrary code from the
source. The event is named because the block is otherwise identical across
events: without it, approving an update or uninstall hook looks exactly like
approving an install hook, on the one surface where the answer decides whether
arbitrary source code runs. It then offers `[Y/n/a]`: run the hook
(`y`/`Y`/Enter), skip it but continue installing the source and its items
without building the tooling (`n`/`N`), or abort and install nothing
(`a`/`A`). The default (a bare Enter) is
run; an unrecognized reply skips, so an unclear answer never runs the hook. When
a `--install-hook` overrides the source's declared `[source].install` (HOOK-2),
the prompt also shows the declared command and states plainly that the
user-supplied command is replacing it.
- `HOOK-21` Skipping the hook (`n`) still installs the source and its items: only
the declared tooling is not built. `mind` says so and notes the items may not
work until the hook is run. Aborting (`a`) installs nothing: the source is not
registered, as for a declined meld.
- `HOOK-22` When stdin is not a TTY (a script, CI, or managed-policy auto-meld,
POL-32), `mind` never runs a hook silently and never aborts: regardless of the
interactive default (HOOK-20), it takes the skip path (the source and its items
install, the tooling is not built) and reports it, unless
`--accept-hooks` accepts the hook's event (HOOK-150).
- `HOOK-23` `--accept-hooks` runs the hook without prompting, and is what
enables hooks in non-interactive use. Bare, it accepts every hook;
`--accept-hooks=<events>` accepts only the listed events (HOOK-150, CLI-250).
- `HOOK-150` `--accept-hooks` takes an optional comma-separated list of hook
events: `install`, `update`, `uninstall`, `build`. The flag is repeatable and
the lists union. A hook runs unattended only when its own event is listed: an
`install` hook (source or item) needs `install`, an `update` hook `update`, an
`uninstall` hook `uninstall`, and an item build hook (HOOK-70) `build`. A hook
whose event is not listed takes the ordinary path (prompt on a TTY, HOOK-22
skip without one). So `upgrade --accept-hooks=install` re-runs a source's
pending install hooks but not its update hooks (HOOK-122).
- `HOOK-24` The consent disclosure (HOOK-20) makes both ways to inspect
the code one action away. It labels the on-disk clone path as its own line (the
path HOOK-20 already shows), so the user can open the working tree that will run
locally, and it adds a version-control browse URL pinned to the disclosed commit,
so the user can read the exact code in the forge without cloning. The URL is
derived with the same host rules as the compare URL (CLI-176, CLI-188): an https
GitHub-shaped host yields `https://<host>/<owner>/<repo>/tree/<commit>`; a host
whose name contains "gitlab" or "bitbucket", an SSH remote, and a local or
`file://` source yield no URL (only the clone path is shown, since no correct web
link exists for them). The browse URL is source-derived text and is sanitized on
the consent surface exactly as the other disclosure fields are (HOOK-91).
- `HOOK-91` All source-derived text included in the hook consent disclosure
(identity, pin description, commit, clone path, hook command, declared
command) is sanitized -- ANSI escape sequences, C0/DEL/C1 control characters,
and Unicode bidi-override/separator code points stripped -- before the
disclosure string is printed. This prevents a malicious source from using
cursor/line-clear escapes to overwrite the warning line or bidi-reorder the
displayed command to look innocuous on the exact surface where the user
consents to run arbitrary code. Static literal text in the disclosure (the
WARNING line, field labels) is not sanitized (it is not untrusted). The same
sanitization rule applies to the hook label shown in multi-hook disclosures
(HOOK-52). This is consistent with the TUI model-boundary rule (TUI-60) and
extends it to the CLI consent prompt.
## Execution and recording
- `HOOK-30` A hook runs via the shell in the clone directory with its stdin
closed (it cannot consume `mind`'s input). Its stdout and stderr are INHERITED,
so its output reaches the terminal as it is produced rather than being
collected and replayed when it exits. A hook is often the slowest step of a
meld (a build, a package install); output that arrives only at the end tells
the user nothing while they wait, and gives them nothing to interrupt on.
The run is framed by `====== (hook: <name>) ======` before the spawn and
`====== (end hook: <name>) ======` after it, so the hook's output stays
demarcated from `mind`'s own and from whatever it prints next (e.g. the
install preview). The frame itself is printed on `mind`'s stdout only, never
stderr, so `mind meld > /dev/null` discards both frame lines even though the
hook's own inherited stderr still reaches the terminal, unframed -- an
asymmetry the streaming design does not close, since framing stderr the same
way would mean buffering it, the same cost the emptiness-detection
discussion below rules out. Two consequences follow from streaming and are
intended: the two streams interleave exactly as they would in a terminal, so
there are no longer separate `hook-stdout`/`hook-stderr` blocks, and the
frame is unconditional, since nothing is buffered and `mind` cannot know in
advance whether the hook will print anything. A third consequence is the
visible behavior change: a hook's stderr now lands on `mind`'s STDERR
instead of being replayed onto stdout, so each stream keeps its identity and
a caller's redirection means what it says (`2>/dev/null` silences a noisy
hook's warnings, and a hook's diagnostics no longer contaminate a stdout
pipe). A non-zero exit is a `HookFailed` error and fails the `meld`: the
source is not left registered (the clone is removed, as for any failed
meld), and the error points at the frame already on screen rather than
repeating its contents, since nothing was captured to repeat. It points
there WITHOUT asserting that output exists ("its output, if any, is in the
frame above"): inherited streams mean `mind` never saw a byte, so it cannot
distinguish a silent hook from a talkative one, and a message claiming
either would be wrong half the time. Detecting emptiness would require
piping the streams through `mind`, which costs the hook its terminal:
`isatty` turns false, so tools drop color and progress rendering, and C
stdio switches from line buffering to 4 KiB blocks, delivering a long
build's output in bursts. That trade loses more than the wording gains. A
hook that fails to SPAWN is the exception: it never ran, so that error
carries the spawn failure's own reason instead. Side effects the hook
already had on the system (an installed binary, a global package) are
outside `mind`'s state and are not rolled back.
The message names the hook's EVENT (`install`, `uninstall`, `build`), which
the call site fixes, so a failing uninstall or build hook is never reported
as an install one. When the frame's heading differs from the event it is
named too, quoted, so the error points at the frame the user actually saw:
a source hook is headed by its author's `[[hooks]].name`, or by the command
itself when it declares none (HOOK-51), while a per-item lifecycle hook's
heading already IS its event and is not repeated.
- `HOOK-31` `mind` records the in-effect hook command and the commit it ran at on
the source registry entry, so `upgrade` can detect a changed command or commit
and re-prompt (HOOK-11), and `recall` / `introspect` can report that a source
has an install hook.
- `HOOK-32` Streaming is safe under `--json` (CLI-217) without a special
case, on unix (the redirect is `#[cfg(unix)]`; a non-unix build performs no
redirect and relies on discipline instead): the whole run executes with fd
1 pointed at fd 2 by a real `dup2`, and an inherited child writes to that
same redirected descriptor, so a hook's output cannot reach the result
document on stdout. The descriptor `--json` saves aside to write that
document once the run succeeds (`REAL_STDOUT` in main.rs's `json_stdout`)
is itself opened close-on-exec (`F_DUPFD_CLOEXEC`, not a plain `dup`), so a
hook cannot reach the real stdout through an inherited copy of that
descriptor either (e.g. `>&3`, the lowest fd `Command` leaves unrewired):
only `mind`'s own process keeps it open, and a child's exec closes its copy
before the hook's own code runs. The same holds for the TUI, which
redirects both stdout and stderr by the identical close-on-exec dance while
an action runs (`tui/action.rs`'s `with_captured_stdout`).
## Validation (`mind review`)
- `HOOK-40` `mind review <target>` reports a source's declared `[source].install`
hook as an advisory finding (showing the command), so a consumer can see, before
melding, that the source will ask to run code, and a maintainer can confirm the
hook is what they intend to publish (CLI-130).
## Multiple hooks and lifecycle events
A source may declare more than one hook, for two lifecycle events (install at
`meld`, uninstall at `unmeld`), and mark a hook optional so the user can skip
that step. `[[hooks]]` is the canonical form; the single `[source].install`
string (HOOK-1) is a deprecated shorthand for one required install hook (HOOK-90),
still parsed and folded in (HOOK-50).
- `HOOK-50` A source declares hooks via a `[[hooks]]` array-of-tables in
`mind.toml`. Each hook runs, in declaration order, in the source's clone
directory at its lifecycle event. The legacy `[source].install` (HOOK-1) is
exactly equivalent to one `[[hooks]]` with that command, `optional = false`,
`event = "install"`, folded in ahead of any declared `[[hooks]]`. An empty or
whitespace-only `run` is treated as absent (HOOK-3) and contributes no hook.
- `HOOK-51` A `[[hooks]]` entry's fields are: `run` (the shell command,
required), `name` (an optional label shown in the disclosure; defaults to the
command), `optional` (bool, default `false` = required), and `event`
(`"install"`, `"update"` (HOOK-120), or `"uninstall"`, default `"install"`).
An unknown `event` value is a `mind.toml` schema error naming the bad value and
the legal set.
- `HOOK-52` An optional hook (`optional = true`) is disclosed like any hook but
prompted with a two-way `[Y/n]` choice: run it (`y`/`Y`/Enter, the default), or
skip it (`n`/`N`). `optional` means the user may decline to run the step (skip
it); it offers no abort because skipping is the graceful decline. A required hook
keeps the three-way `[Y/n/a]` prompt (HOOK-20). The interactive default is run
for both; in a non-TTY context every hook is skipped instead (HOOK-22), and
`--accept-hooks` runs every hook it accepts (HOOK-23, HOOK-150), optional and
required alike. `optional` does NOT make the hook's failure tolerable (HOOK-53).
- `HOOK-53` Any hook's non-zero exit is a hard stop, whether the hook is optional
or required: at `meld` the clone is removed and nothing is registered
(HOOK-30); at `unmeld` the unmeld stops and the source remains. `optional`
governs only whether the user may decline to run the hook (HOOK-52), never
whether it may fail.
- `HOOK-60` When a hook runs (a chosen run, or an unattended run under
`--accept-hooks`), `mind` prints a line naming the hook
before running it, so the user sees which step is executing. Re-melding an
already-melded source (CLI-12) re-offers its install hooks that have not run at
the source's current commit (a hook skipped at an earlier meld, or added since),
with the same disclosure and prompt as a fresh meld, before the install step.
So `mind meld` in an already-melded project still prompts for a pending optional
or required hook. `meld --force` re-offers ALL of the source's install hooks on
a re-meld, even those already run at the current commit (alongside forcing the
clobber overwrite, CLI-35).
- `HOOK-54` Uninstall hooks (`event = "uninstall"`) run at `unmeld`, in the
source's clone, before the clone and registry entry are removed (so cleanup can
use the working tree). On the default unmeld path (CLI-21), the multi-item
removal confirmation (CLI-42) runs first; uninstall hooks only run after the
user confirms (or `--yes` skips the confirm). A user who declines the confirm
does not trigger any hook. On the `--unlink-only` path (CLI-22), which has no
multi-item confirm, hooks run before the source is removed. They use
the same prompt model as install hooks: required = run / skip / abort-the-unmeld;
optional = run / skip; a non-TTY `unmeld` skips them and notes it; `mind unmeld
--accept-hooks` (or `--accept-hooks=uninstall`, CLI-250) runs them unattended. A required
uninstall hook that fails or is aborted leaves the source melded. Item uninstall
hooks run before source uninstall hooks, making teardown the reverse of install
(HOOK-87).
- `HOOK-55` Install hooks are recorded as a set on the source's registry entry
(`install_hooks`: each an effective command plus the commit it last ran at, or
null when skipped), superseding the single `[source].install`/commit pair
(HOOK-31), which is migrated into the set when an older `sources.json` is
loaded. `upgrade` re-offers each install hook that is pending: a hook is pending
when its recorded run-commit is null (never ran or was skipped), or when it
differs from the source's current commit (the source advanced). A null run-commit
is always treated as pending regardless of whether the source's commit is also
null (a commitless linked source). Uninstall hooks are not recorded, since they
only fire at `unmeld`.
The record says a command ran, never that it is still wanted: `upgrade`
re-offers a pending record only while the source's clone STILL declares that
command as an install hook. A record whose declaration is gone (the source
withdrew the hook, or its `mind.toml` no longer parses) is not re-offered,
because the disclosure would then propose running a command no site declares,
at a commit the author controls, under an `Event: install` heading. The two
records that have no declaration to match are exempt, since the clone is not
where they come from: a consumer override (HOOK-56) and a curated hook
(HOOK-127).
- `HOOK-56` `meld --install-hook <cmd>` (HOOK-2) replaces all of a source's
declared install hooks with one required install hook running `<cmd>`; the loud
override disclosure (HOOK-2) shows the declared command(s) it replaced. Declared
uninstall hooks are unaffected by `--install-hook`.
The override is recorded on the source and covers the install AND the update
event: at every later `upgrade` it replaces the source's declared hooks for
whichever of the two events is in effect (HOOK-122), with the same loud
disclosure naming what it replaced. A source that moves its command from
`event = "install"` to `event = "update"` therefore does not escape a consumer
who replaced it, and the consumer's own command does not silently stop being
re-run. There is no separate `--update-hook`: one override covers both.
- `HOOK-57` `init-source` scaffolds commented `[[hooks]]` examples in the
`mind.toml` it writes: at least one install hook and one uninstall hook, with
one marked `optional = true`, each showing `run`, `name`, and `event`, all
commented out so they are inert until the maintainer fills them in. The
`optional` example's comment states that optional lets the user decline running
the hook (it does not mean the hook may fail).
- `HOOK-58` `recall --sources` marks a source carrying install hooks with a
count-aware token; `mind review <target>` lists every declared hook (install and
uninstall), showing each hook's command, event, and whether it is required or
optional (extending HOOK-40).
- `HOOK-59` `unmeld --uninstall-hook <cmd>` supplies or overrides a source's
uninstall hook: it replaces all the source's declared uninstall hooks with one
required uninstall hook running `<cmd>`, shown loudly in the disclosure (the
uninstall-event counterpart to `meld --install-hook`, HOOK-56). Declared install
hooks are unaffected.
## Deprecating `[source].install`
- `HOOK-90` `[source].install` (HOOK-1) is deprecated in favor of a `[[hooks]]`
install entry (HOOK-50/51), which is strictly more expressive (it can be named,
optional, an uninstall hook, or one of several). The field stays parsed and
folded into the hook set (HOOK-50), so existing sources keep working unchanged;
only new authoring is steered away. `mind review <target>` reports a source that
declares `[source].install` as an advisory `deprecated-field` finding, naming the
equivalent `[[hooks]]` entry (`run = <cmd>`, `event = "install"`), in addition to
disclosing the hook itself (HOOK-40). `init-source` never writes `[source].install`:
its scaffold offers only commented `[[hooks]]` examples (HOOK-57).
## Item build hooks
Source-level hooks (HOOK-50) run once in the clone at `meld`/`unmeld`. Tooling
that an individual item ships and must build before use (a compiled binary, a
vendored dependency) instead uses an item-level build hook: a command tied to one
item that runs when that item is installed, in the item's staging copy, so its
output is captured into the store transactionally. Build hooks back the `tool`
kind and `{{tools:name}}` (tooling.md, TOOL-12); `build` (like `bin`) is valid
only on a tool, and a schema error on any other kind (TOOL-7).
- `HOOK-70` An item declares a build hook with `build`, a shell command:
`[[items]].build` in `mind.toml`, or `build:` in a tool's `TOOL.md`
frontmatter. It is distinct from a source's install/uninstall hooks (HOOK-50):
it is item-scoped and runs per item at install, not once per source at meld. An
empty or whitespace-only `build` is treated as absent (HOOK-3). `build` is
valid only on a `tool` item; declaring it on any other kind is a `mind.toml`
schema error (TOOL-7).
- `HOOK-71` A build hook runs in the item's staging directory
(`~/.mind/.tmp/staging/<kind>/<name>/`) as the working directory, after
reference/token expansion (NS-11, TOOL-13) and before the store swap (LIFE-1),
so its output lands in the store atomically on success. A non-zero exit is a
hard stop (HOOK-53): the staging copy is discarded and the live install is left
untouched (LIFE-4), as for any failed install.
- `HOOK-72` A build hook is arbitrary code, so it is disclosed before running and
its output is framed (HOOK-30). On a TTY it is prompted two-way: run it, or skip
it and install the item with its tooling unbuilt (`mind` says so, HOOK-21, and a
`{{tools:ref}}` then points at an unbuilt path until the build runs). A non-TTY
context skips it (the item installs unbuilt). Skipping is the graceful decline;
a build hook offers no abort, so a single item's build never aborts a batch
install.
- `HOOK-73` A build hook re-runs whenever its item is (re)installed or upgraded,
since the store copy is rebuilt from staging each time. `learn`/`upgrade`
disclose and prompt for it as part of installing the item; nothing
beyond the item's content hash is recorded for it.
- `HOOK-74` `--accept-hooks` (bare, or a list naming `build`, HOOK-150) on
`learn`, `upgrade`, `meld` (for its install-all path), `sync --upgrade`,
`curate`, and `hooks run --event build` runs an item's build hook
non-interactively (no prompt, treated as approved), enabling build hooks in CI
and headless scripts. Without it, the HOOK-72 behavior is unchanged: a non-TTY
context skips the build hook and prints a note. A non-zero exit from the build hook under this flag is still a
hard stop: the staging copy is discarded and the install is left untouched
(HOOK-71). Approving the run headlessly does not waive the failure contract.
## Item install and uninstall hooks
Status: done. A `build` hook (HOOK-70) produces an item's store content in
staging. Separately, an item may need a side-effect command on the host when it
is installed (set up a venv, register a helper) and a matching cleanup command
when it is removed. These are the item-level analog of a source's install and
uninstall hooks (HOOK-50), scoped to one item and tied to its install/removal
rather than to `meld`/`unmeld`. They are distinct from `build`: `build` runs in
staging before the store swap and produces content; an install hook runs after
the item is in place and is for host side effects.
- `HOOK-80` An item declares lifecycle hooks with `install` and `uninstall` shell
commands: `[[items]].install` / `[[items]].uninstall` in `mind.toml`, or
`install:` / `uninstall:` in a tool's `TOOL.md` frontmatter. They are valid on
any kind and are distinct from `build` (HOOK-70). An empty or whitespace-only
value is treated as absent (HOOK-3). (HOOK-86 adds a `[[items.hooks]]`
array form for multiple/named/optional item hooks; these scalars are then its
shorthand.)
- `HOOK-81` An item's install hook runs as the final step of installing the item:
after its store copy is swapped in and its links are created (LIFE-1), with the
working directory set to the item's installed store location (the item directory
`~/.mind/store/<kind>/<name>/` for directory-backed kinds like skill and tool;
the parent `~/.mind/store/<kind>/` for single-file agent and rule items, which
have no `<name>/` directory), and after the item's `build` hook (which runs
earlier, in staging).
It runs at `learn` and re-runs whenever the item is reinstalled or upgraded
(`upgrade`), so the side effect tracks the item's content, mirroring the build
hook's re-run rule (HOOK-73). A non-zero exit is a `HookFailed` hard stop that
rolls back that item's install (its store copy and links are removed, leaving it
not installed); host side effects the hook already performed are outside `mind`'s
state and are not rolled back (HOOK-30).
- `HOOK-82` An item's uninstall hook runs when the item is removed: at `forget`,
at `unmeld` (which removes the source's items), and for the OLD item when an
`upgrade` renames it (a namespace change removes the old item via its registry).
It runs with the same working directory as the install hook (HOOK-81) before the
store copy and links are removed, so cleanup can use the installed files. It
does NOT run on an in-place upgrade (same effective name, content swapped),
since the item is not removed, only its install hook re-runs (HOOK-81). A
non-zero exit is a hard stop (HOOK-53): the removal stops and the item is
left installed, mirroring a failed source uninstall hook leaving the source
melded (HOOK-54).
- `HOOK-83` Item install and uninstall hooks are arbitrary code, so each is
disclosed and its output framed exactly as other hooks (HOOK-20, HOOK-30). On a
TTY each is prompted two-way (run / skip), like the build hook (HOOK-72): for an
install hook, skipping installs the item without the side effect; for an
uninstall hook, skipping removes the item without running cleanup. There is no
abort, so one item's hook never aborts a multi-item `learn`/`forget`. A non-TTY
context skips them, and `--accept-hooks` runs them
unattended (HOOK-22, HOOK-23).
- `HOOK-84` Nothing beyond the item's content hash is recorded for an item's
install/uninstall hooks (as for `build`, HOOK-73): the install hook re-runs on
every (re)install or upgrade of the item, and the uninstall hook fires on every
removal. Together with the source-level rule (a source's install hooks re-run
when the source advances, HOOK-11/55), an install hook re-runs whenever its
source OR its item is upgraded.
- `HOOK-85` `mind review <target>` lists an item's declared install and uninstall
hooks as advisory findings, showing each command and event, so a consumer sees
before installing that an item will run code (the item-level counterpart of the
source-hook disclosure, HOOK-40/58).
- `HOOK-86` An item may declare lifecycle hooks as an array, `[[items.hooks]]` in
`mind.toml`, with the same fields and semantics as a source's `[[hooks]]`
(HOOK-51): `run`, `name`, `optional`, and `event` (`"install"` | `"uninstall"`),
run in declaration order at the item's install/removal. This is the per-item
analog of the source `[[hooks]]` array (HOOK-50), giving items full parity:
multiple, named, and optional hooks per event. The scalar `[[items]].install` /
`[[items]].uninstall` (HOOK-80) remain as a shorthand for one required hook of
each event, folded in ahead of any `[[items.hooks]]` entries (the item-level
mirror of HOOK-50's fold-in of `[source].install`). In a tool's `TOOL.md`
frontmatter the scalar `install:` / `uninstall:` keys stay the only form, since
the frontmatter reader is scalar-only (DSC-21); the array form requires
`mind.toml`. Each `[[items.hooks]]` entry is disclosed, prompted, recorded, and
fails exactly as the scalar item hooks do (HOOK-81..84).
- `HOOK-87` Install and uninstall hooks nest: a source is the outer scope, its
items the inner. Setup runs outer-to-inner and teardown inner-to-outer. On a
`meld` that installs, the source's install hooks (HOOK-50) run first, then each
item installs with its `build` (HOOK-71) and install hooks (HOOK-81). On
teardown the order reverses: each item's uninstall hooks (HOOK-82/86) run before
the source's uninstall hooks (HOOK-54). This reorders the current `unmeld`
behavior, where the source's uninstall hooks run before the items'; under
HOOK-87 the source's uninstall hooks run last, in the clone, after every item
has been removed and before the clone and registry entry are dropped (the
multi-item confirmation, CLI-42, still gates all of it). The end-to-end flow is
`source.install -> item.install* ... item.uninstall* -> source.uninstall`.
## Running hooks on demand (`mind hooks run`)
Status: done. Hooks normally run as a step of another verb: install hooks at
`meld`/`upgrade` (HOOK-10/11), uninstall hooks at `unmeld` (HOOK-54), and item
hooks at `learn`/`forget`/`upgrade` (HOOK-73/81/82). That leaves no way to run a
hook the user earlier skipped, to re-run a hook whose effect was later lost (a
deleted build output or side effect), or to re-run an install or uninstall whose
prior run failed for a transient reason -- short of a full re-meld or reinstall.
`mind hooks run` fills that gap: it runs a source's or an item's hooks on demand,
outside the surrounding verb, under the same consent model.
- `HOOK-100` `mind hooks run <target>` executes hooks on demand, outside the
meld/learn/forget/upgrade flows. It reuses those flows' machinery unchanged: the
disclosure (HOOK-20, sanitized per HOOK-91), the consent prompt (three-way for a
required hook, two-way for an optional, build, or item hook, HOOK-52/72/83), the
non-TTY skip (HOOK-22/72/83), and the `--accept-hooks` bypass
(HOOK-23/74/150). It is therefore
neither more nor less guarded than an automatic run. A hook's non-zero exit is a
hard stop with the same contract as an automatic run (HOOK-53); a required hook's
failure or abort is a non-zero exit for the command.
- `HOOK-101` A source target runs the source's `[[hooks]]` for the selected event
(`--event`, CLI-195; default `install`) in declaration order in the clone
(HOOK-50/54). For the install event, by default only *pending* install hooks run
-- a hook that never ran or whose recorded run-commit differs from the source's
current commit (HOOK-55) -- so a plain `mind hooks run <source>` runs exactly the
install hooks a skip or an added hook left outstanding. `--force` runs every
install hook of the source regardless of its recorded commit (the lost-output or
transient-failure case), mirroring `meld --force` (HOOK-60). Each install hook
that runs updates its recorded run-commit exactly as an automatic run does
(HOOK-55); uninstall hooks stay unrecorded (they fire only here and at `unmeld`).
- `HOOK-102` An item target (`<source>#<item>`) runs that item's install or
uninstall hooks (HOOK-80/86) for the selected event, in place: with the working
directory set to the item's installed store location (HOOK-81), against the
current store copy, with no reinstall. For the install event, `hooks run`
offers only the item's *pending* install hooks (HOOK-110): a hook already
recorded as run at the item's current commit is filtered out before it is
offered, mirroring HOOK-101's source-side filter. Uninstall hooks carry no
recorded run-state (HOOK-84) and always run. `hooks run` requires the target
item to be installed; running against an item that is not installed is an
error naming it (there is no store copy to run against).
- `HOOK-103` `--event build` is valid only for an item target and re-runs the
item's build hook (HOOK-70). Because a build hook is staging-bound and its output
must land in the store transactionally (HOOK-71), `hooks run --event build`
re-installs the item through the normal transactional path (stage, expand, build,
swap; LIFE-1/LIFE-4) rather than running the build command against the live store
copy, so a failed rebuild leaves the existing copy untouched. This is the
supported way to rebuild an item whose build output was deleted or corrupted.
- `HOOK-104` `mind hooks list <target>` reports the hooks in effect for a source
(and its installed items) without running any: per hook, its event, its
required/optional flag, its command, and -- for a recorded source install hook
(HOOK-55) -- whether it is pending and the commit it last ran at. It is the
read-only companion to `hooks run` and the detail view behind the `recall
--sources` hook marker (HOOK-58).
- `HOOK-105` Before applying the `#`-split heuristic (source selector vs.
`<source>#<item>` item ref), `hooks run`/`hooks list` check whether the whole
target string, trimmed, exactly matches a registered source's identity. When
it does, the target is a source target even though it contains `#`. This
covers an item-link instance (LNK-4), whose own identity is
`host/owner/repo#<path>`: without this check the exact identity parses as an
item ref that matches no installed item (`NotInstalled`), leaving the
instance's source-level hooks reachable only through an over-broad trailing
glob. A target that does not exactly match any registered source identity
keeps the ordinary `#`-split behavior: a plain `source#item` resolves as an
item ref, and so does the `<link-identity>#<item>` form for an item installed
from a link instance (the identity's own embedded `#` composes with the
item-ref split, since the split is on the LAST `#`).
A `#`-carrying target string can exactly match a registered source's
identity AND, under the ordinary `#`-split reading of that same string, also
name an installed item -- e.g. a link instance whose linked skill sits at a
single top-level path segment `foo` has identity `host/owner/repo#foo`,
spelled identically to item `foo` in a plain-melded source
`host/owner/repo`. Which reading is meant then depends on registry state
invisible to the caller (whether that link instance happens to be melded),
so resolving it silently either way would change a script's meaning as a
side effect of an unrelated meld. `hooks run`/`hooks list` therefore report
this specific case as `AmbiguousHookTarget`, naming the ambiguous string and
the item(s) it also matches, rather than picking the source (or the item) as
a winner. Two escapes disambiguate explicitly and always take priority over
both the exact-identity check and the ambiguity check, so they work whether
or not a given target happens to collide:
- A leading `source:` prefix (e.g. `source:host/owner/repo#foo`) always
forces the source reading; the prefix is stripped before the rest of
target resolution runs.
- A kind-qualified item ref (`<source>#<kind>:<name>`, e.g.
`host/owner/repo#skill:foo`) always forces the item reading: it never
equals a registered source's exact identity (an identity carries no
`kind:` segment), so it falls through to the ordinary `#`-split item-ref
parsing unconditionally.
A target with no `#` at all is never ambiguous: the CLI-194 heuristic reads
it as a source selector unconditionally, so it is never read as an item ref
in `hooks run`/`hooks list` regardless of registry state.
- `HOOK-106` When `hooks run` skips a source hook because the run is non-TTY
(HOOK-22) rather than because a user interactively declined it, the skip
note names both the cause and the exact remedy instead of stating only the
bare consequence: `note: skipped <event> hook '<label>' for <source> (not a
terminal); re-run unattended with:` followed, on its own indented line, by
`mind hooks run '<source>' --event <event>
--accept-hooks=<event>`. `<source>` is
substituted with the RESOLVED source identity, never the raw selector the
user typed (which may be a glob or an abbreviated form), and `<event>` with
the event the run selected, so the printed command is copy-pasteable with no
placeholder to fill in and re-runs the hooks that were skipped. The `<source>`
identity inside the runnable command is single-quoted (the POSIX `'\''` idiom)
by exactly the same rule and for exactly the same reason as the single-match
`HooksNotRun` remedy described below: the note's runnable command carries the
same attacker-influenced identity, so copy-pasting it must not be a way to
run anything beyond `mind hooks run`. The runnable command is printed on its
own line with no surrounding shell-quote character -- earlier revisions
framed it in double quotes, but a double-quote presentation frame is itself
copyable: pasting the frame's own `"` characters together with the
(already single-quoted) identity keeps `$`/backtick special inside a
double-quoted context, so a `$(...)`/backtick payload inside the identity
could fire again if the frame were included in the paste even though the
identity is safely single-quoted on its own. Putting the command on its own
line removes any shell-quote character from the presentation entirely, so a
verbatim paste of the whole remedy -- frame included -- stays inert. The bare
`for <source>` earlier in the note is English prose, not a shell command, so
it is not quoted. Every other runnable `mind hooks run <arg>` example `mind`
prints for this same target family -- including `AmbiguousHookTarget`'s
(HOOK-105) two disambiguating examples (`mind hooks run 'source:<target>'`
and the kind-qualified item ref example) -- shell-quotes its identity the
same way; none of the family's printed remedies interpolates an identity bare.
The
`--event` segment is not optional dressing: `--event` defaults to `install`
(CLI-195), so a remedy that omitted it would, after an `--event uninstall`
run, silently name a command that executes the source's install hooks
instead. An interactive decline (the user is on a TTY and answered no/skip)
keeps the plain `skipped ... hook ... for ...` note: there is no "cause" to
name when the user made an active choice.
The `HooksNotRun` message (HOOK-107/HOOK-108) aggregates across every
matched source or item, so it cannot always follow the same one-remedy shape
as the per-hook note above: when exactly one source or item contributed a
skipped hook, the message substitutes that one resolved identity and carries
the identical paste-able remedy; when a selector matched SEVERAL sources or
items (a glob) that all contributed, no single command is synthesized from
the raw selector -- doing so for a selector like `'*'` would print an
unquoted shell glob that expands against the caller's cwd if pasted, naming
something that may not even be a source. Instead the message lists every
resolved identity that had work, so the reader substitutes one at a time; it
never echoes the raw multi-match selector back into a command-shaped string.
An item ref that itself resolved to exactly one item, and carries no glob
metacharacter (`*`, `?`, `[`), keeps that exact typed form in its
single-target remedy (whatever form it was spelled in -- bare, a
kind-qualified escape, or an abbreviated source selector), since rewriting
it into a different form risks landing back in the HOOK-105 source/item
ambiguity a kind-qualified escape exists specifically to avoid. An item ref
that resolved to exactly one item but DOES carry a glob metacharacter (e.g.
`agents#skill:sc*`, which happens to match only one installed item) does
NOT keep its typed form for this reason: the glob text itself is not safe to
paste into a shell (it would expand against the caller's cwd rather than
necessarily naming the item), so it is replaced with the resolved concrete
`<source>#<kind>:<name>` identity exactly as the multi-match case is, even
though only one item is being named.
Every resolved identity the `HooksNotRun` message substitutes into a
runnable command -- whether the single-match remedy or the parenthetical
list in the multi-match message -- is single-quoted (the POSIX `'\''` idiom
for an embedded quote) before it is placed there, unconditionally, whether
or not it happens to contain a shell metacharacter. A source name, a `meld
--as`/`[source].prefix` alias, and an item-link's own `#<path>`-suffixed
identity are none of them restricted against characters a shell treats
specially (`;`, `$(...)`, a backtick, whitespace, a quote), so pasting an
unquoted identity into the remedy would let it run more than `mind hooks
run`. This is what makes the promise above ("copy-pasteable with no
placeholder to fill in") also mean "and copy-pasting it is not itself a way
to run something else": quoting closes that gap regardless of which kind of
identity ended up in `resolved`. The single-match remedy puts the runnable
command on its own indented line after a "re-run unattended with:" lead,
with no surrounding shell-quote character, for the same paste-safety reason
given under HOOK-106 above: a quote-character presentation frame is itself
copyable, and pasting it along with the (already single-quoted) identity can
re-expose a metacharacter the identity's own quoting had already neutralized.
- `HOOK-107` `hooks run <target>` on a source target counts, across every
matched source, how many hooks for the selected event EXISTED (were
actually considered: not filtered out as already-up-to-date at the current
commit, HOOK-101), how many of those RAN, and how many were SKIPPED
specifically for want of consent (the HOOK-106 non-TTY case, not an
interactive decline). When at least one hook existed, none ran, and at
least one was skipped for want of consent, `hooks run` returns
`MindError::HooksNotRun` (naming the target and the skipped count) instead
of the silent exit 0 a non-TTY invocation previously produced -- the
provisioning-script case U43 identifies, where "ran nothing" and "skipped
everything because there was no terminal to ask" are indistinguishable
under a bare exit code. A run with nothing to do -- no hooks declared for
the event, or every install hook already recorded at the current commit --
is unaffected and stays exit 0: only the specific "there was work, and
consent was unavailable for all of it" case is an error. A required hook's
interactive abort (HOOK-100) is unaffected; it already reports
`HookAborted`. This also gives `hooks run --json` a structured result on the
no-op path (CLI-181's error envelope), where it previously produced only an
unstructured `note: skipped <event> hook '<label>' for <source>` line at exit
0.
- `HOOK-108` The HOOK-107 accounting applies to an item target
(`<source>#<item>`) as well: `hooks run --event install|uninstall` counts,
across every item the ref matched (CLI-194), how many of that item's hooks
for the selected event existed, how many ran, and how many were skipped for
want of consent, and reports `MindError::HooksNotRun` under the same
predicate (at least one existed, none ran, at least one was skipped for want
of consent). For the install event, "existed" excludes any hook already
recorded as run at the item's current commit (HOOK-110): a hook filtered out
that way was never offered, so it counts toward neither "existed" nor "ran"
nor "skipped". The provisioning-script case U43 describes does not care
whether the target was spelled as a source or as one of its items, so
neither does the report. The error names the target as typed and carries the
same remedy as HOOK-106. An item with no hooks declared for the selected
event, or whose install hooks are all already recorded as run at the current
commit, stays exit 0, and an interactive decline stays exit 0, exactly as for
a source target. `--event build` is outside this accounting: it re-installs
the item through the transactional path (HOOK-103), where the build hook's
consent outcome is a step of the install rather than a result reported back
to `hooks run`.
- `HOOK-110` An installed item records its install hooks as a set
(`InstalledItem.install_hooks`, STO-75), the per-item counterpart of a
source's `install_hooks` record (HOOK-55): each entry is an effective
install-hook command plus the commit it last ran at, or absent (`None`) when
it was offered and skipped rather than run. The record is written by every
path that runs an item's install hooks -- `learn`, `upgrade`'s reinstall
cycle, and `hooks run` -- not just `hooks run`, so a hook `learn` skipped
(non-TTY) or ran (`--accept-hooks`) is reflected
immediately, before any `hooks run` of that item. `learn` and `upgrade`
build the record fresh on every (re)install (HOOK-84 is unaffected: they
still run or offer every declared install hook regardless of any prior
record); only `hooks run` reads the record, to filter its HOOK-108
accounting down to pending hooks (HOOK-102), UNLESS `--force` is given, in
which case an item target skips the filter exactly as a source target does
(HOOK-101): every declared install hook is offered regardless of its
recorded commit, for the same lost-output/transient-failure case `--force`
exists for on the source side. This closes the gap where a
provisioning script doing `mind learn x --accept-hooks
&& mind hooks run src#x` failed on the second command forever, with a remedy
that re-runs a side effect the first command already applied. Because
`learn`/`upgrade` rebuild the record from scratch on every (re)install rather
than merging it with what was there before, a record tied to an old commit
never suppresses a hook at a new one, and `forget` (which removes the whole
manifest entry) removes the record along with the item. When a `hooks run`
batch aborts partway through (HOOK-53: a hook's non-zero exit is a hard
stop), the hooks that already ran or were offered before the failing one
keep their persisted records rather than being discarded with the batch,
mirroring the source-level record's same guarantee (HOOK-101/HOOK-55): the
save happens before the error propagates, so a retry after fixing the
failing hook does not re-offer (and, under
`--accept-hooks`, re-run) a sibling hook that already
applied its side effect. This applies only where the item stays installed
regardless of the batch's outcome, i.e. `hooks run`; on the `learn`/
`upgrade` install path a hook's failure rolls the whole just-installed item
back (HOOK-86), so there is no manifest entry left to attach a partial
record to and nothing is persisted for that path.
- `HOOK-109` The interactive-terminal test (HOOK-22's gate, `hook::is_tty`)
reads `$MIND_TTY` before falling back to inspecting stdin. When the variable
is set, its value alone decides: `0`, `false`, `no`, `off`, and the empty
string (any case, surrounding whitespace ignored) mean "not a terminal", and
any other value means "a terminal". This follows the `MIND_HOME` /
`CLAUDE_HOME` / `MIND_DETECT_HOME` test-isolation precedent (STO-1, HARN-5)
and exists for the same reason: without it, every consent path that turns on
being interactive (the HOOK-20/HOOK-52 prompts, the HOOK-106 distinction
between a non-TTY skip and an interactive decline) is unreachable from a
headless test, so the branch that decides whether arbitrary source code runs
is the one branch nothing verifies. The override changes only which branch is
taken; it never bypasses a prompt or consents on the user's behalf, and a
prompt that reaches EOF still skips (HOOK-22).
## Update hooks
Status: done. An install hook runs at `meld`, and `upgrade` re-runs it when the
source advances (HOOK-11), so an install hook is executed many times over a
source's life. That is the right default -- tooling must track the source it was
built from -- but it makes idempotence a requirement rather than a courtesy, and
some setup steps cannot be made idempotent (a first-run migration, a one-shot
registration). The `update` event is the escape: a source (or an item) that needs
different behavior on a re-run declares an update hook, which runs *instead of*
the install hooks when the thing it belongs to is updated rather than first
installed.
- `HOOK-120` `event = "update"` is a third lifecycle event for a `[[hooks]]`
entry (HOOK-51), valid on a source's `[[hooks]]` and on an item's hooks
(`[[items.hooks]]`, the item manifest HOOK-131, the `update:` frontmatter
scalar HOOK-130). It is otherwise an ordinary hook: same fields, same
declaration-order execution, same disclosure, consent, and failure contract as
an install hook (HOOK-20, HOOK-30, HOOK-52, HOOK-53, HOOK-83). The
disclosure and every note name the event as `update`, so a user consenting to
one is never told they are approving an install hook.
- `HOOK-121` A source's update hooks run at `upgrade` (including `sync
--upgrade`), in the source's clone, when the source has a pending hook to run
(HOOK-55's pending rule, applied to the update hooks). They do NOT run at
`meld`, at a re-meld (HOOK-60), or at `unmeld`: the update event means "this
source was already melded and has moved", which is exactly the state `meld`
is not in.
`meld` therefore records each declared update hook at the meld commit as a
BASELINE: a record that never ran, so the hook is not pending until the source
actually moves. Without the baseline the hook has no record at all, absent
reads as pending (HOOK-55), and the first `upgrade` after a `meld` runs the
update hook at the unmoved meld commit, on top of the install hook that just
ran. A baseline never overwrites a real run, and `hooks list` reports it as
"not pending (recorded at meld <commit>)" rather than as a run. It holds back
only the automatic pass: an on-demand `mind hooks run <source> --event update`
(HOOK-126) still considers the hook, since there the user named the target and
the event and no run has happened.
- `HOOK-122` Update hooks supersede install hooks on an update, and only on an
update. When a source declares at least one update hook, `upgrade` offers its
pending update hooks and does NOT re-run the source's install hooks. When it
declares none, `upgrade` re-offers the pending install hooks exactly as before
(HOOK-11, HOOK-55). So the default behavior is unchanged for every existing
source, and declaring an update hook is the opt-in to a distinct update path.
Superseding is per scope: a source's update hooks replace the source's install
hooks, and an item's update hooks replace that item's install hooks (HOOK-125);
neither replaces the other's.
A consumer `--install-hook` override outranks the supersession: when one is
recorded (HOOK-56), it replaces whichever event's hooks are in effect, so the
consumer's command is what is offered on either path.
- `HOOK-123` Because the default is a re-run, an install hook is expected to be
idempotent: safe to run again against a host it already set up. `mind` does not
and cannot enforce this -- a hook is an opaque shell command -- so it states it
where an author meets the field: the `init-source` scaffold's commented
`[[hooks]]` examples say so and show the `update` event as the alternative
(HOOK-57), and the documentation says so. An author whose setup cannot be made
idempotent declares an update hook instead of relying on the install hook's
re-run.
- `HOOK-124` A source's update hooks are recorded in the same recorded-hook set
as its install hooks (`install_hooks`, HOOK-55: an effective command plus the
commit it last ran at, or null when skipped), keyed by the command AND the
event. A pending update hook is therefore one whose recorded run-commit is
absent or differs from the source's current commit, the same rule install
hooks use, and `--force` re-offers every one regardless. An update hook that
has already run at the source's current commit is not re-offered by a second
`upgrade` at that commit.
The event is half the key because the same command is a plausible declaration
for both events ("same script, different trigger"): keyed by command alone, an
install run would settle the update hook, `hooks run <src> --event update`
would exit 0 having run nothing, and a run recorded under one event could be
re-offered under the other's disclosure. A record written before the event was
stored carries none; it reads as an install record, since that is the only
event that existed when it was written.
- `HOOK-125` An item's update hooks run in place of its install hooks whenever
the item is re-installed over an existing install of the same effective name,
with the same working directory, ordering, recording, and rollback contract as
the install hooks they replace (HOOK-81, HOOK-110). On a first install the
install hooks run and the update hooks do not. In practice the re-install path
is `upgrade`'s in-place swap: a `learn` naming an already-installed item is a
no-op that installs nothing and so runs no hook at all (CLI-157, DEP-23), and
an `upgrade` that RENAMES an item (a namespace change) is not a re-install
either -- the old name is removed with its uninstall hooks and the new name is
a first install. `hooks run --event update` (HOOK-126) runs an item's update
hooks on demand without reinstalling it. An item that declares no update hook re-runs
its install hooks on every (re)install, unchanged (HOOK-84). An item's
`build` hook is unaffected: it is content-producing and always runs (HOOK-73).
- `HOOK-126` `--event update` is valid for `hooks run` (CLI-195) on both a source
and an item target, and behaves as `--event install` does at that target: it
runs the target's pending update hooks (all of them under `--force`), records
each run exactly as an install run does (HOOK-101, HOOK-110), and participates
in the HOOK-107/HOOK-108 tally and the `HooksNotRun` report. `hooks list` takes
no `--event` filter (CLI-196): it reports every event, so a declared update
hook appears alongside the install and uninstall ones with its event,
required/optional flag, command, and pending state. `mind review` lists a
declared update hook the same way (HOOK-58, HOOK-85).
When `hooks run` finds hooks for the selected event but the pending filter
holds every one of them back, it says so: a note naming the hooks, the commit
they already ran at, and the `--force` invocation that runs them anyway. A
silent exit 0 is indistinguishable from "this source declares no hooks",
which is the opposite diagnosis.
- `HOOK-127` A curator's `[[discover.sources.hooks]]` entries (DSC-61) are
recorded on the nested source at `meld`, marked as curated, and are offered by
`upgrade` from that record. They are declared in the PARENT super-source's
manifest, so the nested clone can never be asked what it declares (HOOK-55),
and they apply exactly when the nested source ships no `mind.toml` of its own
(the DSC-60 gate). Without the record a curated `event = "update"` entry could
never run at all (it is filtered out at `meld` by HOOK-121 and is invisible in
the clone), and curated install hooks would be re-offered at every `upgrade`
in defiance of HOOK-122's superseding rule. A curated record stops applying
once the nested source gains a `mind.toml`, which is the same gate evaluated
at `meld`. The record carries the entry's `name` and `optional` flag, since
there is no declaration to read them back from.
## Where an item declares its hooks
Status: done. An item's lifecycle hooks (HOOK-80/86) could originally be declared
only in the source's root `mind.toml`, except for a tool, which could also carry
scalar `install:`/`uninstall:` keys in its `TOOL.md` frontmatter. That put the
declaration far from the thing it belongs to for every other kind: a skill that
needs a setup command had to make its source's root manifest authoritative
(turning off convention discovery for the whole repo) to say so. An item declares
its own hooks instead, next to the item.
- `HOOK-130` The scalar lifecycle keys `install:`, `update:`, and `uninstall:`
are read from ANY kind's meta file frontmatter -- a skill's `SKILL.md`, a
tool's `TOOL.md`, and an agent's or rule's own `.md` -- not only a tool's
`TOOL.md`. This supersedes HOOK-80's tool-only restriction on the frontmatter
spelling (the `mind.toml` spelling was already valid on any kind). Each is one
required hook of its event (HOOK-80), and the frontmatter reader stays
scalar-only (DSC-21), so the array form still requires a manifest. An empty or
whitespace-only value is absent (HOOK-3).
Compatibility: frontmatter is free-form, and `mind` previously ignored these
keys on every kind but a tool. A repo whose `SKILL.md` carries `install: pip
install foo` as documentation therefore now ships a hook to every consumer,
who is prompted to run a command the author never meant as one. An author in
that position renames the key (there is no reserved-prefix escape: the three
names are the hook spelling). A consumer sees it before installing anything,
because `mind review` reports every declared item hook (HOOK-85, HOOK-134).
- `HOOK-131` A directory-backed item (a skill or a tool) may ship its own
`mind.toml` in its item directory: a scoped-down manifest whose only table is
`[[hooks]]`, with exactly the fields and semantics of a source's `[[hooks]]`
(HOOK-51 plus the `update` event, HOOK-120). Any other key is a `mind.toml`
schema error naming the file, so a root manifest copied into an item directory
fails loudly instead of being half-read. On a catalog scan the failure is
scoped to the affected item: that one item is dropped with a warning naming
the file, so one stray manifest cannot take down the source or the registry
walk (see [discovery.md](discovery.md)). An item manifest is read only from an
item's own directory and never from the source root, and the source's root
`mind.toml` is never read as an item manifest, even for a source root that is
also an item path.
- `HOOK-132` The three declaration sites do not merge; exactly one supplies an
item's hooks, in this precedence: (1) a root `mind.toml` `[[items]]`
declaration for that item that declares any hook (a scalar `install`/`update`/
`uninstall` or an `[[items.hooks]]` entry) is authoritative and its hooks are
the item's hooks; else (2) the item directory's own `mind.toml` `[[hooks]]`
(HOOK-131), when it declares any; else (3) the item's frontmatter scalars
(HOOK-130). Replacement rather than composition keeps an item's effective hook
list traceable to one origin, so a consumer reading `mind review` or `hooks
list` sees a set that exists somewhere as written.
Replacement is whole-site, which is the trap: a site that declares one hook
takes over the item's whole hook list, and the hooks the losing sites declared
are silently gone. A `[[items]]` entry with only `install = "./setup.sh"`
drops the item's own `uninstall:` frontmatter, so its teardown never runs; an
item-directory `mind.toml` (HOOK-131) declaring only an update hook drops the
frontmatter `install:`, so a first install runs nothing. An author who wants
both writes both at the SAME site.
- `HOOK-133` An item manifest is part of the item's content: it is copied into
the store with the rest of the item and participates in the content hash, so
editing it upstream is drift like any other file change and `upgrade` picks it
up. It is not linked or interpreted by any agent harness; only `mind` reads it.
- `HOOK-134` `mind review` (HOOK-85) and `hooks list` (HOOK-104) report an
item's hooks from its RESOLVED hook list, whichever of the three sites
(HOOK-132) supplied it, rather than from the root manifest's scalar fields
alone. So an `[[items.hooks]]` array entry, an item-manifest hook, and a
frontmatter scalar are all disclosed, each naming its event.
## Hook environment
Status: done. A source with install profiles (spec/profiles.md) may want its
hooks to do different setup depending on which profiles the consumer enabled.
`mind` tells each hook through its environment.
- `HOOK-140` Every hook `mind` runs gets two environment variables: a source's
install and uninstall hooks, an item's install, update, and uninstall hooks, a
tool build hook, and each of these when run through `hooks run`.
- `MIND_PROFILES`: the enabled profiles of the hook's source.
- `MIND_ITEM_PROFILES`: for an item hook or a build hook, the profiles the
item is installed for (PROF-20). Always empty for a source hook.
Each value is a sorted, comma-separated list with no spaces. Profile names
follow PROF-2's syntax, so the values need no quoting. Both variables are
always set, empty when there is nothing to report, so a value inherited from
the environment `mind` itself runs in never reaches the hook.
- `HOOK-141` The values describe the profile state the operation leaves behind:
- While a profile install is applied (`meld`, a re-meld, `learn <source>
--profile`, `curate`, an `upgrade` addition per PROF-21), `MIND_PROFILES`
is the recorded set plus the profiles being enabled. An installed item's
`MIND_ITEM_PROFILES` includes the profiles it is being installed for. For a
closure item, those are the profiles of the member that pulled it in
(PROF-6).
- During `forget <source> --profile <NAME>`, `MIND_PROFILES` is the set that
stays enabled. A removed item's `MIND_ITEM_PROFILES` is its recorded
reasons, NAME included.
- Otherwise (a plain `learn`, `upgrade`, `forget`, `unmeld`, `hooks run`),
they are the source's recorded set and the item's recorded reasons. A source
that is not profile-tracked (PROF-19) has an empty `MIND_PROFILES`.
- `HOOK-142` A source's install hooks at `meld` see the selection the meld is
about to apply: the profile flags plus, on an interactive meld, the PROF-13
picker's answer. The picker is therefore asked before the hooks run. A re-meld's
re-offered hooks see the re-meld's selection (PROF-16). The value is the
selection, not the outcome. The hooks run before the install prompt, so a hook
still sees a selection whose install is later declined. A name the source does
not declare (PROF-14) is left out. `MIND_PROFILES` is empty when no profile
install follows: `--register-only`, the CLI-23 install-all offer, and a nested
source whose curator entry routes outside profiles (PROF-26).
- `HOOK-143` Enabling or disabling a profile later does not re-offer a source's
install hooks; HOOK-60 is unchanged. A setup step that depends on the profile
set belongs in an item hook, which runs when the item it serves is installed or
removed.
## Managed-policy composition (research needed)
Install hooks are arbitrary code execution, which is exactly what an enterprise
managed policy (policy.md) exists to control, so the two compose. This section is
NOT yet specified: it records the design space and open questions to research
before any normative rule or stable ID is added. The default in the meantime is
the unmanaged behavior above (prompt, default No; non-TTY skips per HOOK-22).
Open questions to resolve:
- Stance. Should a policy forbid hooks outright (refuse to meld a source that
declares one, or always skip hooks), allow them with the prompt unchanged, or
pre-approve a specific set? A locked-down org likely wants "forbid" or
"pre-approve", not "prompt".
- Pre-approval shape. If pre-approving, what is the unit: a source identity plus an
expected exact command, plus a pinned commit, so a hook runs unattended only when
it matches the approved (source, command, commit) triple and is refused otherwise?
- Bypass control. Should a policy be able to disallow
`--accept-hooks` (force the prompt, or forbid running
hooks at all), so a user cannot opt out of the policy's stance?
- Non-interactive provisioning. `auto_meld` (POL-32) runs during `sync` with no
TTY, so a declared hook is skipped today (HOOK-22). This is partly resolved for
build hooks: an `auto_meld` entry with `install = true` (POL-58) installs the
source's items during provisioning, with build hooks skipped by default and
opted in per entry via `run-build-hooks = true` (POL-59). Install hooks remain
skipped per HOOK-22 (a policy provisioning a source whose tooling depends on an
install hook still leaves that tooling unrun); pre-approving install hooks for
unattended runs is the open part of this question.
- Audit. Whether a managed deployment should record each hook execution (source,
command, commit, and how it was approved) for compliance.
The crux is the threat model: a hook is the most dangerous surface mind has, so the
policy's relationship to it should be deliberate. Resolve these before assigning
stable IDs and folding the rules into policy.md.