Skip to content

[Feature]: Expose hook contributions and runtime bindings via specify artifact #4343

Description

@nicolehaugen

Problem Statement

specify artifact list --json and specify artifact info --json 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 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, #4212, and #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":
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.

  1. 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():
{
  "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.
  1. 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).

  2. Extend the artifact-error taxonomy so artifact info on an unknown hook shorthand returns the same unknown-artifact shape as unknown commands/templates/scripts.

  3. 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).

  4. 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, 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 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 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.
  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 — 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 introduced for the other three kinds.

Depends on #4305 (contribution ID grammar + artifact command). Related to #4210 (stable per-contribution ID scheme) — hooks reuse derive_hook_id, which was defined for exactly this purpose. Related to #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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementfeature-assessRun the Spec Kit idea-assessment pipeline on this feature requestfeature-goFeature assessment verdict: go — ready to hand off to /speckit.specify

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions