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
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).
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:
The manifest declaration shape (per-extension extension.yml, hooks: field, object-of-events or array-of-entries).
The runtime binding shape (.specify/extensions.yml, hooks: field, event-keyed arrays of {extension, command, optional, description}).
Deriving lookupIds themselves — duplicating derive_hook_id's grammar in JavaScript.
Reconciling "declared but not registered" vs "declared and registered" — a wizard-only concept today.
Drop the corresponding filter in _iter_pack_candidates (artifacts/__init__.py:790) so hook entries from iter_contributions() flow through the stack builder.
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 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.
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).
Extend the artifact-error taxonomy so artifact info on an unknown hook shorthand returns the same unknown-artifact shape as unknown commands/templates/scripts.
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).
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.
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.
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".
Reproducibility tooling can identify every hook contributed to a project — including declared-but-not-registered ones — without walking installed extension manifests on disk.
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.
Problem Statement
specify artifact list --jsonandspecify artifact info --jsonunify preset/extension/built-in provenance forcommand,template, andscriptcontributions. Hook contributions are deliberately excluded —ArtifactKind = Literal["command","template","script"]atsrc/specify_cli/artifacts/__init__.py:36, and_iter_pack_candidatesat:790filters 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_idproduces the deterministic contribution IDextension:{sourceId}:hook:{eventName}:{command}— matches thelookupIdgrammar the artifact command uses for other kinds.src/specify_cli/_identifier.py:55 _HOOK_LAYERSrestricts hooks topreset/extensionlayers.src/specify_cli/extensions/__init__.py:795-824 EnhancedManifest.iter_contributions()already emitskind:"hook"entries carryinglayer,sourceId,name("{eventName}:{command}"),id(viaderive_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,:5247priority-sort — full normalization/ordering infrastructure.The runtime binding state — which declared hooks are actually enabled — lives in
.specify/extensions.ymlunder a top-levelhooks:map keyed by event name, managed byExtensionConfigatsrc/specify_cli/extensions/__init__.py:4860and the enable/disable paths at_commands.py:2639, :2709. There is no--jsonsurface for this state today.Because neither the declared hook contributions nor the runtime binding state are exposed, the
speckit-wizard-canvasconsumer is forced to re-parse.specify/extensions/<id>/extension.ymland.specify/extensions.ymlfrom 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:Existing consumers reading hook state from disk must handle:
extension.yml,hooks:field, object-of-events or array-of-entries)..specify/extensions.yml,hooks:field, event-keyed arrays of{extension, command, optional, description}).derive_hook_id's grammar in JavaScript.Proposed Solution
ArtifactKindto include"hook":Drop the corresponding filter in
_iter_pack_candidates(artifacts/__init__.py:790) so hook entries fromiter_contributions()flow through the stack builder.specify artifact list --jsonandinfo --jsonusing the same top-level shape as other kinds, plus the hook-specific fields already present oniter_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:
iduses the top-level round-trip shorthandhook:{eventName}:{command}— analogous tocommand:{name}/template:{name}/script:{name}— sospecify artifact info hook:before_specify:speckit.compliance.pre-check --jsonround-trips.stack[].lookupIduses the existingderive_hook_idgrammar ({layer}:{sourceId}:hook:{eventName}:{command}); no new grammar is invented.strategyis 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 anystrategykey on an extensionprovidesentry is rejected).activemarks the priority-sorted winner among duplicate (event, command) declarations from different sources (already computed by the priority-sort atextensions/__init__.py:5247).registeredis 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 carriesregistered: false.Compute
registeredby reading.specify/extensions.yml'shooksmap through the existingExtensionConfigAPI. Match declarations to bindings by(sourceId, eventName, command); when the runtime binding omitscommand, treat all commands from that extension for that event as registered (this matches today's implicit behavior inenable_hooks/disable_hooksat_commands.py:2639/:2709).Extend the artifact-error taxonomy so
artifact infoon an unknown hook shorthand returns the sameunknown-artifactshape as unknown commands/templates/scripts.Do not introduce a separate
specify hookcommand group.bundle,preset, andextensionare installers;artifactis the introspection surface for what's composed. Hooks are contributions, not installables, so they belong underartifact(the same reasoning that keeps commands/templates/scripts there).Add coverage in
docs/reference/artifacts.md: extend the fields table, add a "Hook artifacts" subsection covering thehook:{event}:{command}shorthand,registeredsemantics, and the invariant that hooks only appear onpresetandextensionlayers (_HOOK_LAYERS).Alternatives Considered
specify hook list --jsoncommand. Rejected — hooks are contributions, not installables. Splitting them fromartifactre-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.artifact info, not inartifact list. Rejected — the wizard's Composition tab needs every artifact including hooks in one call. Withholding hooks fromlistreintroduces the N+1 shell-out pattern the list-with-stack change removed for other kinds.registereda wizard concern. Rejected — the wizard would still have to read.specify/extensions.ymldirectly, defeating the "CLI is source of truth" boundary.ExtensionConfigalready owns that file; folding the flag in at serialization time is one map lookup per hook artifact.registeredper stack layer instead of at the artifact top level. Rejected — enable/disable in.specify/extensions.ymlis 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-levelregisteredmatches the actual semantic.extension.ymlon the wizard side. Rejected — same reason #4212 rejected keepingpreset resolvetext-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_idgrammar) in JavaScript.Component
Specify CLI — artifact catalog, extensions, JSON contracts.
AI Agent (if applicable)
Not applicable.
Use Cases
speckit-wizard-canvasdeletescomposition/hooks.mjs(~140 lines of manifest parsing +.specify/extensions.ymlwalking) and reads hook metadata straight from the CLI, matching how it already reads commands / templates / scripts after #4305.before_specify, and are any of them disabled?" from a singleartifact list --jsoncall filtered bykind=="hook"andeventName=="before_specify".{command, template, script}and a second forhook.Acceptance Criteria
ArtifactKindatsrc/specify_cli/artifacts/__init__.py:36includes"hook"._iter_pack_candidatesatsrc/specify_cli/artifacts/__init__.py:790accepts"hook"and forwards hook contributions fromiter_contributions()into the stack builder.specify artifact list --jsonincludes one row per unique(eventName, command)hook contribution across all installed presets and extensions, with the same top-level shape as other kinds pluseventName,targetCommand,optional,priority,registered.specify artifact info hook:{eventName}:{command} --jsonreturns the full stack for that hook.stack[].lookupIdfor every hook layer equalsderive_hook_id(layer, sourceId, eventName, command)— no re-derivation, no new grammar.presetandextensionlayers; the built-in tier does not synthesize acorelayer for hooks (they have no baseline).stack[].strategyis fixed to"replace"for hook layers.stack[].activemarks the priority-sorted winner (using existingnormalize_priority+DEFAULT_HOOK_PRIORITY) when multiple layers declare the same(eventName, command).registeredreflects.specify/extensions.yml'shooks:map:truewhen any layer'ssourceIdappears under the event's binding array (with matching or omittedcommand), elsefalse. A declared-but-not-registered hook still appears inlistoutput.specify artifact infoon an unknown hook shorthand emits the existingunknown-artifacterror shape.docs/reference/artifacts.mdcovers the hook kind,hook:{eventName}:{command}shorthand,registeredsemantics, and the_HOOK_LAYERSinvariant.derive_hook_id;activeselection under conflicting priorities;registeredtrue/false paths against a scaffolded.specify/extensions.yml; round-trip ofhook:{event}:{command}throughinfo; absence of hooks from the built-in tier; error shape on unknown hook.command,template, orscriptshapes — the addition is additive.Additional Context
Direct consumer:
plugins/spec-kit-copilot-wizard— specificallyextensions/speckit-wizard-canvas/composition/hooks.mjsand the hook-attribution passes inextensions/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 onhooks.mjsstates 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.