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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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