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 not currently exposed through that interface.

The hook domain model already exists internally:

  • derive_hook_id produces deterministic IDs in the form {layer}:{sourceId}:hook:{eventName}:{command}.
  • _HOOK_LAYERS restricts hooks to preset and extension layers.
  • EnhancedManifest.iter_contributions() emits hook declarations with their manifest metadata.
  • HookExecutor.get_hooks_for_event() returns every enabled runtime registration for an event, sorted by priority.

The runtime registration state lives in .specify/extensions.yml. Without a JSON introspection surface, consumers must separately parse installed extension manifests and runtime hook configuration, duplicate hook-ID derivation, and reconcile declarations with registrations.

Revised Contract

The original proposal described duplicate hook declarations as replacement layers with one priority-selected winner. Implementation review established that this does not match existing Spec Kit behavior: HookExecutor.register_hooks() preserves entries from different extensions, and get_hooks_for_event() returns every enabled entry. The artifact contract therefore models hooks as additive contributions.

This revision changes only introspection. Hook registration and execution behavior remain unchanged.

Proposed Solution

  1. Extend ArtifactKind to include "hook".

  2. Include one hook artifact row per unique (eventName, targetCommand) pair in specify artifact list --json and support round-trip lookup with:

hook:{eventName}:{targetCommand}
  1. Use this JSON shape:
{
  "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",
  "registered": true,
  "stack": [
    {
      "id": "hook:before_specify:speckit.compliance.pre-check",
      "layer": "extension",
      "sourceId": "compliance-fast",
      "presetId": null,
      "presetName": null,
      "strategy": "additive",
      "active": true,
      "hidden": false,
      "manifestPath": ".specify/extensions/compliance-fast/extension.yml",
      "lookupId": "extension:compliance-fast:hook:before_specify:speckit.compliance.pre-check",
      "priority": 5,
      "optional": false
    },
    {
      "id": "hook:before_specify:speckit.compliance.pre-check",
      "layer": "extension",
      "sourceId": "compliance-audit",
      "presetId": null,
      "presetName": null,
      "strategy": "additive",
      "active": true,
      "hidden": false,
      "manifestPath": ".specify/extensions/compliance-audit/extension.yml",
      "lookupId": "extension:compliance-audit:hook:before_specify:speckit.compliance.pre-check",
      "priority": 10,
      "optional": true
    }
  ]
}

Field Semantics

  • id is the public round-trip shorthand hook:{eventName}:{targetCommand}.
  • stack[].lookupId uses the existing derive_hook_id grammar; no new identifier grammar is introduced.
  • stack[].strategy is always "additive" because enabled declarations from different extensions all execute.
  • stack[].active is true exactly when that declaration matches an enabled runtime registration returned by HookExecutor.get_hooks_for_event(). Multiple entries may be active.
  • A runtime registration matches a declaration only when both its extension and concrete command match; a command-less entry does not act as a wildcard.
  • stack[].active does not identify a priority winner and does not evaluate the optional event-time condition.
  • stack[].hidden is always false; additive hook declarations do not hide one another.
  • stack[].priority and stack[].optional describe the contributing manifest declaration. Spec Kit normally copies these defaults into .specify/extensions.yml during registration.
  • registered is true when any stack entry is active.
  • A declared hook with no matching enabled runtime registration still appears with registered: false.
  • Hooks only appear on preset or extension layers; there is no built-in/core hook layer.
  • Common stack provenance fields (presetId, presetName, hidden, and manifestPath) are retained.

This source split follows Spec Kit's existing hook model: installed manifests declare hooks and their defaults, while .specify/extensions.yml records project registration and enabled state. Consistent with the shared hook configuration API, an invalid or unreadable .specify/extensions.yml is normalized to an empty hook map, so declarations remain visible with registered: false. Reconciling later manual drift between those files or changing this tolerant runtime behavior is outside this issue.

Error Contract

Hook collection uses the existing artifact error taxonomy:

  • Unknown hook shorthand emits the existing unknown artifact envelope.
  • Extension-registry or resolver failures that prevent artifact-layer collection emit artifact resolution failed.
  • Consistent with existing command, template, and script inventory, an individual manifest that cannot be parsed or validated is omitted; manifest diagnosis remains the responsibility of the preset and extension inspection surfaces.
  • Runtime .specify/extensions.yml read and validation failures retain the existing hook behavior and are normalized to an empty registration map.
  • Error responses do not leak partial JSON to stdout.

Alternatives Considered

  • Separate specify hook command: rejected because hooks are contributions and belong in the unified artifact inventory.
  • Winner/replacement model: rejected because it contradicts HookExecutor, which preserves and returns every enabled hook.
  • Runtime registrations only: rejected because declared-but-unregistered hooks must remain discoverable.
  • Wizard-side manifest parsing: rejected because it duplicates schema and identifier logic outside the CLI.

Component

Specify CLI — artifact catalog, extensions, and JSON contracts.

Use Cases

  1. UI and automation consumers can inspect declared hooks, current activation state, and provenance from one CLI contract.
  2. Debuggers can show every hook associated with an event and identify which declarations are currently enabled.
  3. Reproducibility tooling can identify declared-but-unregistered hooks without independently parsing installed manifests.

Acceptance Criteria

  • ArtifactKind includes "hook".
  • artifact list --json includes one row per unique (eventName, targetCommand) pair.
  • artifact info hook:{eventName}:{targetCommand} --json round-trips.
  • Every hook lookupId equals derive_hook_id(layer, sourceId, eventName, targetCommand).
  • Hooks only appear on preset and extension layers.
  • Every hook stack entry uses strategy: "additive" and hidden: false.
  • Every declaration with a matching enabled registration is active: true; duplicate declarations may all be active.
  • A command-less runtime binding does not activate a concrete hook declaration.
  • Top-level registered equals any(stack[].active).
  • Per-entry priority and optional remain visible; no winner-derived top-level values are emitted.
  • Common stack fields and declaring manifestPath are retained.
  • Declared-but-unregistered hooks remain visible with registered: false.
  • Unknown hook shorthand and registry/resolver collection failures preserve the existing artifact error envelopes.
  • Invalid individual hook manifests are omitted consistently with other artifact kinds rather than introducing a hook-specific validation failure.
  • Invalid or unreadable runtime hook configuration retains the existing hook behavior and reports declarations as unregistered.
  • Documentation covers shorthand, additive execution, registration semantics, provenance, and the _HOOK_LAYERS invariant.
  • Tests cover additive duplicate handling, independent activation, stable ordering, registration true/false paths, lookup-ID parity, round-trip lookup, provenance, error behavior, and no regression to existing artifact kinds.

Additional Context

The Spec Kit Wizard was the motivating consumer when this issue was opened. Its current main branch has since reorganized the composition implementation, so adopting this revised JSON contract should be handled as a separate downstream change rather than by preserving the superseded winner schema here.

Depends on #4305. Related to #4210 and #4212.

Scope note: Hook execution is unchanged. This issue exposes existing declarations, activation state, and provenance.

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