### Problem Statement [`specify artifact list --json` and `specify artifact info --json`](https://github.com/github/spec-kit/pull/4305) unify preset/extension/built-in provenance for `command`, `template`, and `script` contributions. Hook contributions are deliberately excluded — `ArtifactKind = Literal["command","template","script"]` at `src/specify_cli/artifacts/__init__.py:36`, and `_iter_pack_candidates` at `:790` filters any other kind out before the stack is built. The domain model for hooks already exists internally: - `src/specify_cli/_identifier.py:174 derive_hook_id` produces the deterministic contribution ID `extension:{sourceId}:hook:{eventName}:{command}` — matches the `lookupId` grammar the artifact command uses for other kinds. - `src/specify_cli/_identifier.py:55 _HOOK_LAYERS` restricts hooks to `preset` / `extension` layers. - `src/specify_cli/extensions/__init__.py:795-824 EnhancedManifest.iter_contributions()` already emits `kind:"hook"` entries carrying `layer`, `sourceId`, `name` (`"{eventName}:{command}"`), `id` (via `derive_hook_id`), and the raw manifest fields (`eventName`, `command`, `optional`, `priority`, `description`, `handler`). - `src/specify_cli/extensions/__init__.py:79 DEFAULT_HOOK_PRIORITY`, `:181 normalize_priority`, `:203 coerce_hook_entries`, `:5247` priority-sort — full normalization/ordering infrastructure. The **runtime binding state** — which declared hooks are actually enabled — lives in `.specify/extensions.yml` under a top-level `hooks:` map keyed by event name, managed by `ExtensionConfig` at `src/specify_cli/extensions/__init__.py:4860` and the enable/disable paths at `_commands.py:2639, :2709`. There is no `--json` surface for this state today. Because neither the declared hook contributions nor the runtime binding state are exposed, the [`speckit-wizard-canvas`](https://github.com/github/spec-kit-copilot/tree/main/plugins/spec-kit-copilot-wizard) consumer is forced to re-parse `.specify/extensions/<id>/extension.yml` and `.specify/extensions.yml` from disk (`plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/hooks.mjs`) — the exact anti-pattern [#4210](https://github.com/github/spec-kit/issues/4210), [#4212](https://github.com/github/spec-kit/issues/4212), and [#4305](https://github.com/github/spec-kit/pull/4305) were introduced to eliminate for other artifact kinds. The header comment in that file states the constraint explicitly: > "The `specify artifact` CLI doesn't emit hook metadata — hook attribution is a wizard concern. This module reads extension manifests and `.specify/extensions.yml` directly to feed the hook enrichment step in composition/artifact-cli.mjs." Existing consumers reading hook state from disk must handle: 1. The manifest declaration shape (per-extension `extension.yml`, `hooks:` field, object-of-events or array-of-entries). 2. The runtime binding shape (`.specify/extensions.yml`, `hooks:` field, event-keyed arrays of `{extension, command, optional, description}`). 3. Deriving lookupIds themselves — duplicating `derive_hook_id`'s grammar in JavaScript. 4. Reconciling "declared but not registered" vs "declared and registered" — a wizard-only concept today. ### Proposed Solution 1. Extend `ArtifactKind` to include `"hook"`: ```python ArtifactKind = Literal["command", "template", "script", "hook"] ``` Drop the corresponding filter in `_iter_pack_candidates` (`artifacts/__init__.py:790`) so hook entries from `iter_contributions()` flow through the stack builder. 2. Serialize hook contributions into `specify artifact list --json` and `info --json` using the same top-level shape as other kinds, plus the hook-specific fields already present on `iter_contributions()`: ```json { "id": "hook:before_specify:speckit.compliance.pre-check", "name": "before_specify:speckit.compliance.pre-check", "kind": "hook", "description": "Enforces internal policy checks before specification runs.", "eventName": "before_specify", "targetCommand": "speckit.compliance.pre-check", "optional": false, "priority": 10, "registered": true, "stack": [ { "id": "hook:before_specify:speckit.compliance.pre-check", "layer": "extension", "sourceId": "compliance", "presetId": null, "presetName": null, "strategy": "replace", "active": true, "hidden": false, "manifestPath": ".specify/extensions/compliance/extension.yml", "lookupId": "extension:compliance:hook:before_specify:speckit.compliance.pre-check" } ] } ``` Field notes: - `id` uses the top-level round-trip shorthand `hook:{eventName}:{command}` — analogous to `command:{name}` / `template:{name}` / `script:{name}` — so `specify artifact info hook:before_specify:speckit.compliance.pre-check --json` round-trips. - `stack[].lookupId` uses the existing `derive_hook_id` grammar (`{layer}:{sourceId}:hook:{eventName}:{command}`); no new grammar is invented. - `strategy` is fixed to `"replace"` on hook layers (hooks are additive per (event, command) pair; there is no wrap/prepend/append semantic for hook layers, matching the existing invariant that any `strategy` key on an extension `provides` entry is rejected). - `active` marks the priority-sorted winner among duplicate (event, command) declarations from different sources (already computed by the priority-sort at `extensions/__init__.py:5247`). - `registered` is a **top-level artifact field**, not a per-layer field: it reflects whether **any** layer of this hook is currently enabled in `.specify/extensions.yml`. A declared-but-not-registered hook still appears in the catalog (so the wizard can render "declared but disabled" state) — it just carries `registered: false`. 3. Compute `registered` by reading `.specify/extensions.yml`'s `hooks` map through the existing `ExtensionConfig` API. Match declarations to bindings by `(sourceId, eventName, command)`; when the runtime binding omits `command`, treat all commands from that extension for that event as registered (this matches today's implicit behavior in `enable_hooks`/`disable_hooks` at `_commands.py:2639`/`:2709`). 4. Extend the artifact-error taxonomy so `artifact info` on an unknown hook shorthand returns the same `unknown-artifact` shape as unknown commands/templates/scripts. 5. Do **not** introduce a separate `specify hook` command group. `bundle`, `preset`, and `extension` are installers; `artifact` is the introspection surface for what's composed. Hooks are contributions, not installables, so they belong under `artifact` (the same reasoning that keeps commands/templates/scripts there). 6. Add coverage in `docs/reference/artifacts.md`: extend the fields table, add a "Hook artifacts" subsection covering the `hook:{event}:{command}` shorthand, `registered` semantics, and the invariant that hooks only appear on `preset` and `extension` layers (`_HOOK_LAYERS`). ### Alternatives Considered - **Add a separate `specify hook list --json` command.** Rejected — hooks are contributions, not installables. Splitting them from `artifact` re-fragments the introspection surface the wizard just consolidated in [#4305](https://github.com/github/spec-kit/pull/4305), and every consumer that wants "everything in this project" would have to fan out to two commands. - **Emit hooks only in `artifact info`, not in `artifact list`.** Rejected — the wizard's Composition tab needs every artifact including hooks in one call. Withholding hooks from `list` reintroduces the N+1 shell-out pattern the list-with-stack change removed for other kinds. - **Expose only declared hooks; make `registered` a wizard concern.** Rejected — the wizard would still have to read `.specify/extensions.yml` directly, defeating the "CLI is source of truth" boundary. `ExtensionConfig` already owns that file; folding the flag in at serialization time is one map lookup per hook artifact. - **Emit `registered` per stack layer instead of at the artifact top level.** Rejected — enable/disable in `.specify/extensions.yml` is a per-extension binding, not a per-layer property, and hooks don't stack in the wrap/prepend/append sense that other artifacts do. A top-level `registered` matches the actual semantic. - **Keep parsing `extension.yml` on the wizard side.** Rejected — same reason [#4212](https://github.com/github/spec-kit/issues/4212) rejected keeping `preset resolve` text-parsing: the CLI is the source of truth for the composition graph. Any wizard-side manifest read is a drift risk (schema evolves, wizard silently miscounts) and duplicates ID-derivation logic (`derive_hook_id` grammar) in JavaScript. ### Component Specify CLI — artifact catalog, extensions, JSON contracts. ### AI Agent (if applicable) Not applicable. ### Use Cases 1. [`speckit-wizard-canvas`](https://github.com/github/spec-kit-copilot/tree/main/plugins/spec-kit-copilot-wizard) deletes `composition/hooks.mjs` (~140 lines of manifest parsing + `.specify/extensions.yml` walking) and reads hook metadata straight from the CLI, matching how it already reads commands / templates / scripts after [#4305](https://github.com/github/spec-kit/pull/4305). 2. A debugger renders "which hooks fire on `before_specify`, and are any of them disabled?" from a single `artifact list --json` call filtered by `kind=="hook"` and `eventName=="before_specify"`. 3. Reproducibility tooling can identify every hook contributed to a project — including declared-but-not-registered ones — without walking installed extension manifests on disk. 4. Future consumers (CI, external UIs) get one JSON contract for the composition graph, not one for `{command, template, script}` and a second for `hook`. ### Acceptance Criteria - [ ] `ArtifactKind` at `src/specify_cli/artifacts/__init__.py:36` includes `"hook"`. - [ ] `_iter_pack_candidates` at `src/specify_cli/artifacts/__init__.py:790` accepts `"hook"` and forwards hook contributions from `iter_contributions()` into the stack builder. - [ ] `specify artifact list --json` includes one row per unique `(eventName, command)` hook contribution across all installed presets and extensions, with the same top-level shape as other kinds plus `eventName`, `targetCommand`, `optional`, `priority`, `registered`. - [ ] `specify artifact info hook:{eventName}:{command} --json` returns the full stack for that hook. - [ ] `stack[].lookupId` for every hook layer equals `derive_hook_id(layer, sourceId, eventName, command)` — no re-derivation, no new grammar. - [ ] Hooks only appear on `preset` and `extension` layers; the built-in tier does not synthesize a `core` layer for hooks (they have no baseline). - [ ] `stack[].strategy` is fixed to `"replace"` for hook layers. - [ ] `stack[].active` marks the priority-sorted winner (using existing `normalize_priority` + `DEFAULT_HOOK_PRIORITY`) when multiple layers declare the same `(eventName, command)`. - [ ] Top-level `registered` reflects `.specify/extensions.yml`'s `hooks:` map: `true` when any layer's `sourceId` appears under the event's binding array (with matching or omitted `command`), else `false`. A declared-but-not-registered hook still appears in `list` output. - [ ] `specify artifact info` on an unknown hook shorthand emits the existing `unknown-artifact` error shape. - [ ] Documentation at `docs/reference/artifacts.md` covers the hook kind, `hook:{eventName}:{command}` shorthand, `registered` semantics, and the `_HOOK_LAYERS` invariant. - [ ] Tests cover: hook contribution surfacing from preset and extension layers; lookupId parity with `derive_hook_id`; `active` selection under conflicting priorities; `registered` true/false paths against a scaffolded `.specify/extensions.yml`; round-trip of `hook:{event}:{command}` through `info`; absence of hooks from the built-in tier; error shape on unknown hook. - [ ] No change to `command`, `template`, or `script` shapes — the addition is additive. ### Additional Context Direct consumer: [`plugins/spec-kit-copilot-wizard`](https://github.com/github/spec-kit-copilot/tree/main/plugins/spec-kit-copilot-wizard) — specifically `extensions/speckit-wizard-canvas/composition/hooks.mjs` and the hook-attribution passes in `extensions/speckit-wizard-canvas/composition/artifact-cli.mjs:220-366`. Those files exist solely because the artifact CLI does not surface hook data — the header comment on `hooks.mjs` states this explicitly. Closing this issue lets the wizard delete both the manifest-reading module and the client-side attribution pass, aligning hook handling with the CLI-as-source-of-truth model [#4305](https://github.com/github/spec-kit/pull/4305) introduced for the other three kinds. Depends on [#4305](https://github.com/github/spec-kit/pull/4305) (contribution ID grammar + artifact command). Related to [#4210](https://github.com/github/spec-kit/issues/4210) (stable per-contribution ID scheme) — hooks reuse `derive_hook_id`, which was defined for exactly this purpose. Related to [#4212](https://github.com/github/spec-kit/issues/4212) (the artifact command itself) — this issue extends its coverage to the fourth contribution kind. Independent of #4208 (source provenance) — this issue is about *what artifacts a source contributed*, whereas #4208 is about *where a source came from*. **Scope note (out of scope).** Hook *execution* — the runtime dispatch that fires enabled hooks around lifecycle events — is unchanged. This issue is scoped to introspection: exposing what hooks are declared, what is registered, and where each contribution came from.