You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Extend ArtifactKind to include "hook".
Include one hook artifact row per unique (eventName, targetCommand) pair in specify artifact list --json and support round-trip lookup with:
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
UI and automation consumers can inspect declared hooks, current activation state, and provenance from one CLI contract.
Debuggers can show every hook associated with an event and identify which declarations are currently enabled.
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.
Problem Statement
specify artifact list --jsonandspecify artifact info --jsonunify preset/extension/built-in provenance forcommand,template, andscriptcontributions. Hook contributions are not currently exposed through that interface.The hook domain model already exists internally:
derive_hook_idproduces deterministic IDs in the form{layer}:{sourceId}:hook:{eventName}:{command}._HOOK_LAYERSrestricts hooks topresetandextensionlayers.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, andget_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
Extend
ArtifactKindto include"hook".Include one hook artifact row per unique
(eventName, targetCommand)pair inspecify artifact list --jsonand support round-trip lookup with:{ "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
idis the public round-trip shorthandhook:{eventName}:{targetCommand}.stack[].lookupIduses the existingderive_hook_idgrammar; no new identifier grammar is introduced.stack[].strategyis always"additive"because enabled declarations from different extensions all execute.stack[].activeistrueexactly when that declaration matches an enabled runtime registration returned byHookExecutor.get_hooks_for_event(). Multiple entries may be active.extensionand concretecommandmatch; a command-less entry does not act as a wildcard.stack[].activedoes not identify a priority winner and does not evaluate the optional event-timecondition.stack[].hiddenis alwaysfalse; additive hook declarations do not hide one another.stack[].priorityandstack[].optionaldescribe the contributing manifest declaration. Spec Kit normally copies these defaults into.specify/extensions.ymlduring registration.registeredistruewhen any stack entry is active.registered: false.presetorextensionlayers; there is no built-in/core hook layer.presetId,presetName,hidden, andmanifestPath) are retained.This source split follows Spec Kit's existing hook model: installed manifests declare hooks and their defaults, while
.specify/extensions.ymlrecords project registration and enabled state. Consistent with the shared hook configuration API, an invalid or unreadable.specify/extensions.ymlis normalized to an empty hook map, so declarations remain visible withregistered: 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 artifactenvelope.artifact resolution failed..specify/extensions.ymlread and validation failures retain the existing hook behavior and are normalized to an empty registration map.Alternatives Considered
specify hookcommand: rejected because hooks are contributions and belong in the unified artifact inventory.HookExecutor, which preserves and returns every enabled hook.Component
Specify CLI — artifact catalog, extensions, and JSON contracts.
Use Cases
Acceptance Criteria
ArtifactKindincludes"hook".artifact list --jsonincludes one row per unique(eventName, targetCommand)pair.artifact info hook:{eventName}:{targetCommand} --jsonround-trips.lookupIdequalsderive_hook_id(layer, sourceId, eventName, targetCommand).presetandextensionlayers.strategy: "additive"andhidden: false.active: true; duplicate declarations may all be active.registeredequalsany(stack[].active).priorityandoptionalremain visible; no winner-derived top-level values are emitted.manifestPathare retained.registered: false._HOOK_LAYERSinvariant.Additional Context
The Spec Kit Wizard was the motivating consumer when this issue was opened. Its current
mainbranch 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.