Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
3807522
Add deterministic contribution IDs and stack lookup IDs for resolved …
nicolehaugen Aug 21, 2026
9a441fe
feat: add `specify artifact` command exposing composition stacks as JSON
nicolehaugen Aug 21, 2026
c47371e
Potential fix for pull request finding 'Module is imported with 'impo…
nicolehaugen Aug 24, 2026
ea2636f
Potential fix for pull request finding 'Module is imported with 'impo…
nicolehaugen Aug 24, 2026
b1d6a75
Potential fix for pull request finding 'Unused import'
nicolehaugen Aug 24, 2026
424ca0f
Project preset artifacts by entry type
Copilot Aug 24, 2026
ac90181
Represent project override artifact layers
Copilot Aug 24, 2026
ed2f3db
Preserve artifact JSON init-dir errors
Copilot Aug 24, 2026
7eb1a52
Canonicalize core script artifacts
Copilot Aug 24, 2026
70965d7
Potential fix for pull request finding
nicolehaugen Aug 24, 2026
70a937e
Fix artifact inventory resolver filtering
Copilot Aug 24, 2026
df4afef
Add resolver tests for single-runtime core scripts
Copilot Aug 24, 2026
028fcd8
Cache artifact resolver lookups
Copilot Aug 24, 2026
8e7cf83
Handle artifact resolver failures
Copilot Aug 24, 2026
fcb72b1
Document artifact resolution error
Copilot Aug 24, 2026
69baa87
Include convention-based artifacts in inventory
Copilot Aug 24, 2026
e4347b0
Restore legacy flat core script lookup
Copilot Aug 24, 2026
2c36d57
Extend convention discovery to presets in artifact inventory
Copilot Aug 24, 2026
f6eacd2
Fix manifest path portability and export ArtifactResolutionError
Copilot Aug 24, 2026
f601efa
Bound artifact manifest search to project root
Copilot Aug 24, 2026
5410f72
Cover project-root artifact manifests
Copilot Aug 24, 2026
9b68bac
Handle directory artifact manifest lookups
Copilot Aug 24, 2026
f9ee35c
Fall back to top-level preset name in artifact stacks
Copilot Aug 24, 2026
95bbd99
Include project-local core artifacts in inventory
Copilot Aug 24, 2026
aacb49b
Address inline review feedback on artifact resolver helpers
Copilot Aug 24, 2026
fef72da
Reuse manifest/registry APIs in artifact contribution enumeration
Copilot Aug 24, 2026
442cd23
Pass layer explicitly to _iter_pack_contributions instead of inferrin…
Copilot Aug 24, 2026
ddadd5f
Fix core command namespacing and validate names for kind-scoped lookups
Copilot Aug 24, 2026
602f042
Skip manifest contributions without a usable identifier
Copilot Aug 24, 2026
63237cf
Hoist test-local imports to module scope in artifact/assets tests
Copilot Aug 24, 2026
67c107f
fix: resolve artifact inventory and validation review regressions
Copilot Aug 24, 2026
2514302
perf: avoid duplicate read in core command inventory
Copilot Aug 24, 2026
f93f526
fix: classify dotted override-only artifacts as commands
Copilot Aug 24, 2026
5b08932
fix: accept single-segment artifact commands
Copilot Aug 24, 2026
355ed82
fix: fail closed on corrupt artifact registries
Copilot Aug 24, 2026
d0f9f25
fix: trust inventory for artifact info lookups
Copilot Aug 24, 2026
b4de317
fix: validate registry before artifact info
Copilot Aug 24, 2026
4857201
fix: resolve artifact description by layer precedence, not enumeratio…
Copilot Aug 24, 2026
a20502d
fix: validate subdir before wheel bundle lookup in _locate_core_asset…
Copilot Aug 24, 2026
a149714
fix: detect duplicate hooks after command canonicalization
Copilot Aug 24, 2026
9fd43d7
fix: reuse normalized hook entries for duplicate detection
Copilot Aug 24, 2026
4682021
fix: align core command candidate ordering
Copilot Aug 24, 2026
c816a29
test: cover manifest-backed artifact parity
Copilot Aug 24, 2026
60050a8
fix: align artifact IDs with resolver identity
Copilot Aug 24, 2026
1c4842f
fix: skip invalid local artifact name components
Copilot Aug 24, 2026
a3293eb
fix: filter invalid local artifact IDs from inventory
Copilot Aug 24, 2026
0377861
fix: align artifact preset enumeration with resolver
Copilot Aug 24, 2026
1739a09
test: remove tautological artifact tests and strengthen id assertion
Copilot Aug 24, 2026
866b3a5
fix: preserve documented hook duplicate semantics
Copilot Aug 24, 2026
467ddca
fix: dedupe hook contributions last-wins
Copilot Aug 25, 2026
a2fc586
docs: clarify hook identifier deduplication
Copilot Aug 25, 2026
f9efe06
docs: remove hook discriminator references
Copilot Aug 25, 2026
9ae2682
style: space identifier declarations
Copilot Aug 25, 2026
7e0e7dc
fix: address unresolved review feedback on PR #4305
Copilot Aug 25, 2026
e1a63ce
fix: keep on-disk preset/extension identity separate from lookupId
Copilot Aug 25, 2026
fd2b268
fix: remove stale lookupId parsing fallback and tighten malformed loo…
Copilot Aug 25, 2026
1c4319c
fix: route resolver core fallback through shared asset resolver, desc…
Copilot Aug 25, 2026
f7b549f
refactor: drop redundant derive_named_id import-visibility assignment
Copilot Aug 25, 2026
083af4c
fix: align artifact inventory and lookup ID validation
nicolehaugen Aug 25, 2026
bd77448
fix: fail closed on malformed artifact registries
nicolehaugen Aug 25, 2026
46394a9
Merge remote-tracking branch 'origin/nicolehaugen-contribution-ids-an…
nicolehaugen Aug 25, 2026
436f394
fix: preserve convention artifact descriptions
nicolehaugen Aug 25, 2026
7416247
fix: align artifact override resolution
Copilot Aug 25, 2026
4b8b381
Refactor artifact inventory candidates
Copilot Aug 25, 2026
19beb04
Address artifact inventory review
Copilot Aug 25, 2026
dd9ef64
Address artifact inventory review
Copilot Aug 25, 2026
8c29bfe
Address artifact inventory review
Copilot Aug 25, 2026
8678014
source-agnostic artifact IDs; built-in tier recognized by exclusion, …
Copilot Aug 25, 2026
05eaf72
fix: reject malformed artifact layer provenance
Copilot Aug 25, 2026
b6f4481
Tighten artifact provenance handling
Copilot Aug 25, 2026
cea47c7
Refactor shared asset directory lookup
Copilot Aug 25, 2026
2f3ec02
Document shared asset families
Copilot Aug 25, 2026
38ce9f6
Avoid full artifact content scans
Copilot Aug 25, 2026
f881074
Clarify artifact resolution guard
Copilot Aug 25, 2026
9c73550
Restore resolver core provenance
Copilot Aug 25, 2026
ef7fca3
Reuse artifact inventory layers
Copilot Aug 25, 2026
2b8ce69
Simplify preset resolve assertion
Copilot Aug 25, 2026
73832c7
Restore source-agnostic artifact provenance
Copilot Aug 25, 2026
bf8aeb3
artifact catalog: `id` is the source-agnostic round-trip key; `info` …
Copilot Aug 25, 2026
d7babbb
fix: keep layer_kind_from_lookup_id and derive_hook_id in agreement o…
Copilot Aug 25, 2026
6efff92
artifact: reuse shared project resolver, rename handlers, dedupe vali…
Copilot Aug 25, 2026
bd483da
fix: align artifact info existence and resolver naming
Copilot Aug 25, 2026
e67b1cd
artifact: reuse PresetResolver.templates_dir in _project_core_asset_root
Copilot Aug 25, 2026
e7bdc62
fix: guard stale registry entries in artifact convention discovery
Copilot Aug 25, 2026
9977faa
fix: include stack in artifact list json
Copilot Aug 25, 2026
d9d1ef7
docs: document artifact list stack records
Copilot Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions docs/reference/artifacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Artifacts

An **artifact** is any command, template, or script Spec Kit exposes in a project, regardless of which layer contributes it — built-in assets, an installed preset, an installed extension, or a project-local override in `.specify/templates/overrides/`.

The `specify artifact` command group is the read-only introspection surface for that inventory. `specify preset resolve <name>` answers "which file wins for this preset-managed name?"; `specify artifact` answers "what exists at all, and what is the full composition stack behind it?" — including built-in artifacts that no preset touches.

Both subcommands currently require `--json`. Omitting it exits with code `2` and prints a usage message on stderr; no stdout is produced. Text rendering is deliberately deferred so the JSON shapes below are the only contract, and adding a default text renderer later stays a non-breaking, additive change.

## List Artifacts

```bash
specify artifact list --json
```

| Option | Description |
| -------- | -------------------------------------------------------- |
| `--json` | Required. Emit the inventory as a JSON array on stdout. |

Prints the full inventory of every visible artifact — one row per `(kind, name)` pair, including its composition `stack` — sorted by kind (`command`, then `template`, then `script`) and then by name.

```json
[
{
"id": "command:speckit.specify",
"name": "speckit.specify",
"kind": "command",
"description": "Create or update the feature specification.",
"stack": [
{
"id": "command:speckit.specify",
"layer": null,
"sourceId": null,
"presetId": null,
"presetName": null,
"strategy": "replace",
"active": true,
"hidden": false,
"manifestPath": null,
"lookupId": null
}
]
},
{
"id": "script:create-new-feature",
"name": "create-new-feature",
"kind": "script",
"description": "Create a new feature branch and spec directory.",
"stack": [
{
"id": "script:create-new-feature",
"layer": null,
"sourceId": null,
"presetId": null,
"presetName": null,
"strategy": "replace",
"active": true,
"hidden": false,
"manifestPath": null,
"lookupId": null
}
]
}
]
```

| Field | Description |
| ------------- | ------------------------------------------------------------------------- |
| `id` | `{kind}:{name}` — the shorthand `artifact info` accepts as its argument |
| `name` | Logical artifact name (commands use the `speckit.<stem>` namespace) |
| `kind` | One of `command`, `template`, `script` |
| `description` | Description from the highest-precedence layer that declares one, else `""` |
| `stack` | Composition stack for this artifact, using the same row shape as `artifact info` |

Built-in artifacts always appear, even when nothing overrides them. Descriptions come from the highest-priority layer that has one — a preset or project override that hides a built-in command reports its own description, not the hidden built-in text. Skills (`.github/skills/**/SKILL.md`) are excluded: they are integration-specific output, not a shipped asset family.

## Artifact Info

```bash
specify artifact info <name> --json
```

| Option | Description |
| ---------------- | ------------------------------------------------------------------- |
| `--json` | Required. Emit the composition stack as a JSON object on stdout. |
| `--kind <kind>` | Narrow the lookup to `command`, `template`, or `script` |

`<name>` accepts either a bare name (`speckit.specify`) or the `kind:name` shorthand (`command:speckit.specify`). When both the shorthand and `--kind` are supplied they must agree.

```json
{
"id": "command:speckit.specify",
"name": "speckit.specify",
"kind": "command",
"description": "Create or update the feature specification.",
"stack": [
{
"id": "command:speckit.specify",
"layer": "preset",
"sourceId": "compliance",
"presetId": "compliance",
"presetName": "Compliance Preset",
"strategy": "replace",
"active": true,
"hidden": false,
"manifestPath": ".specify/presets/compliance/preset.yml",
"lookupId": "preset:compliance:command:speckit.specify"
},
{
"id": "command:speckit.specify",
"layer": null,
"sourceId": null,
"presetId": null,
"presetName": null,
"strategy": "replace",
"active": false,
"hidden": true,
"manifestPath": null,
"lookupId": null
}
]
}
```

The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the corresponding row on `artifact list --json`.

### Stack semantics

`stack` is ordered by resolution precedence: index `0` is the layer that wins. Each row describes one contributing layer:

| Field | Description |
| -------------- | -------------------------------------------------------------------------------- |
| `id` | `{kind}:{name}` — the source-agnostic round-trip key, identical on every row of the same artifact's stack |
| `layer` | `project`, `preset`, or `extension`; `null` for built-in layers |
| `sourceId` | Source component of `lookupId`, or `null` when the layer has no provenance |
| `presetId` | Preset pack directory id; `null` on built-in, `project`, and `extension` rows |
| `presetName` | Preset display name when its manifest declares one, else the pack id; `null` when `presetId` is `null` |
| `strategy` | `replace`, `wrap`, `prepend`, or `append` |
| `active` | `true` only for index `0` — the layer whose content is served |
| `hidden` | `true` when a lower-index `replace` layer cuts this layer out of the composition |
| `manifestPath` | Project-relative path to the declaring manifest, or `null` when none applies |
| `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier, or `null` for built-in layers |

`active` and `hidden` are independent labels, not opposites. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`.

Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. `lookupId` is manifest-backed layer provenance, not the round-trip key — use `id` for that.

## JSON Errors

On failure, nothing is written to stdout. A single-key JSON envelope is written to stderr and the process exits with code `1`:

```json
{ "error": "unknown artifact command:nope" }
```

| Message | Cause |
| --------------------------------------------------- | ---------------------------------------------------------------- |
| `not a Spec Kit project: no .specify/ directory found` | Run outside an initialized project |
| `unknown artifact <name>` | No artifact matches the requested name (and kind, when given) |
| `ambiguous artifact <name>: matches kinds [...]` | The bare name matches more than one kind — re-run with `--kind` |
| `artifact resolution failed` | The preset/extension registries could not be read, or artifact content could not be composed |

Exit code `2` is reserved for usage errors — a missing `--json` flag or an invalid `--kind` value — and emits a plain-text message on stderr rather than a JSON envelope.
6 changes: 6 additions & 0 deletions docs/reference/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Presets customize how Spec Kit works — overriding command files, template file

[Presets reference →](presets.md)

## Artifacts

Artifacts are the commands, templates, and scripts a project exposes, whichever layer contributes them. The `specify artifact` command group is the read-only introspection surface over that inventory — a flat list of everything visible, plus the full composition stack behind any single entry, including which layer wins and which layers are hidden.

[Artifacts reference →](artifacts.md)

## Workflows

Workflows automate multi-step Spec-Driven Development processes into repeatable sequences. They chain commands, prompts, shell steps, and human checkpoints together, with support for conditional logic, loops, fan-out/fan-in, and the ability to pause and resume from the exact point of interruption.
Expand Down
21 changes: 21 additions & 0 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,27 @@ specify preset add team-workflow --priority 10

For any file that both provide, `compliance` wins (priority 5 < 10). For files only one provides, that one is used. For files neither provides, the core default is used.

## Contribution Identifiers

Every command, template, and script contributed by a preset or extension is addressable at read time by a deterministic opaque identifier of the form:

```text
{layer}:{sourceId}:{kind}:{name}
```

- `layer` is one of `preset` or `extension`.
- `sourceId` is the preset pack id for `preset`, or the extension id for `extension`.
- `kind` is one of `command`, `template`, or `script`.
- `name` is the entry's declared `name` field.

Identifiers are computed on demand from author-declared manifest content and are never persisted to `.specify/` or any cache. Copying a preset to another machine (or touching its files) does not change the identifiers it produces.

`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for preset, extension, and project-override layers. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the manifest's validated `id:` field, so it joins directly to the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` even when the installed directory was renamed. That join is guaranteed by the implementation, so consumers can key off `lookupId` directly rather than re-deriving the contribution id. Convention-only layers (undeclared in any manifest) have no manifest id to consult, so their `lookupId`'s `sourceId` falls back to the resolver's registry key or on-disk directory name instead; those layers have no manifest contribution to join to. Built-in fallback layers omit `lookupId`. Use `layer_kind_from_lookup_id` to classify lookup IDs rather than parsing the string yourself. Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they carry a synthetic `project:_:{kind}:{name}` `lookupId` that intentionally does not match any manifest contribution.
Comment thread
nicolehaugen marked this conversation as resolved.

The `id` field (shape `kind:name`) is the stable round-trip key for every artifact and is accepted as input by `specify artifact info`. The `lookupId` field carries manifest-backed layer provenance and is present only for artifacts contributed by presets, extensions, or project overrides. Built-in-tier artifacts have no `lookupId`; use `id` to round-trip them. For example, given a stack row for a built-in artifact with only `id` populated, the round-trip is `specify artifact info command:speckit.plan --json`, which resolves the same artifact as `specify artifact info speckit.plan --json`.

For the full grammar, including the hook name-component convention and last-write-wins deduplication used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section.

## FAQ

### Can I use multiple presets at the same time?
Expand Down
2 changes: 2 additions & 0 deletions docs/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
href: reference/extensions.md
- name: Presets
href: reference/presets.md
- name: Artifacts
href: reference/artifacts.md
- name: Workflows
href: reference/workflows.md
- name: Bundles
Expand Down
63 changes: 63 additions & 0 deletions extensions/EXTENSION-API-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Technical reference for Spec Kit extension system APIs and manifest schema.
4. [Configuration Schema](#configuration-schema)
5. [Hook System](#hook-system)
6. [CLI Commands](#cli-commands)
7. [Contribution Identifiers](#contribution-identifiers)
8. [File System Layout](#file-system-layout)

---

Expand Down Expand Up @@ -859,6 +861,67 @@ satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool

---

## Contribution Identifiers

Every command, template, script, and hook contributed by an extension or preset is addressable at read time by a deterministic opaque identifier. Resolved artifact-stack layers carry a matching `lookupId` field when they have provenance. Manifest-declared preset and extension layers use the manifest's validated `id:` for `lookupId`'s `sourceId` component, so their `lookupId` joins directly to the matching `iter_contributions()` entry even after the installed directory is renamed; convention-only contributions have no manifest `id:` to consult and fall back to the on-disk directory / registry key instead (see [Determinism guarantees](#determinism-guarantees) below). Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file.

### Grammar

Named contributions (commands, templates, scripts) follow:

```text
{layer}:{sourceId}:{kind}:{name}
```

- `layer` is one of `preset` or `extension`.
- `sourceId` is the preset pack id for `preset`, or the extension id for `extension`.
- `kind` is one of `command`, `template`, or `script`.
- `name` is the contribution's declared `name` field.

Hook contributions use a compound name-component built from the event and command:

```text
{layer}:{sourceId}:hook:{eventName}:{command}
```

Within a single event list, repeated `command` values collapse last-write-wins and
move to the end, so each surviving `(eventName, command)` pair has the same
identifier form above with no suffix.

### Reserved character

`:` is reserved as the identifier component separator. It cannot appear inside any of `layer`, `sourceId`, `kind`, `name`, `eventName`, or `command`. Extension ids, command names, template names, and script names are already constrained by their existing regex patterns (`^[a-z0-9-]+$` and friends), which forbid `:`. Hook event names (mapping keys) and hook `command` values are additionally validated to reject `:` at manifest load.

### Project-local overrides

Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they have no backing manifest and cannot appear in `iter_contributions()`. Layers of that kind carry a synthetic `lookupId` of the form `project:_:{kind}:{name}` so consumers that reverse-lookup the id always see "not found", which is the intended behaviour: overrides are addressable at the stack level, not as first-class contributions.

### Python API

`ExtensionManifest.iter_contributions()` yields dicts of the form `{layer, sourceId, kind, name, id, ...author-declared fields}`; each entry's `id` is the computed identifier. `ExtensionManifest.contribution_id(kind, name)` returns the id for a single lookup, or `None` if no contribution matches. `PresetManifest` exposes the same two methods.

`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for project overrides, preset contributions, and extension contributions. Manifest-declared preset and extension layers use the manifest's validated `id:` as the `lookupId` source id, so it matches the id `iter_contributions()` yields for that same contribution. Convention-only layers (no manifest entry declares the contribution) have no manifest id to consult, so their `lookupId` falls back to the resolver's registry key or on-disk directory name. Built-in fallback layers omit `lookupId`.
Comment thread
nicolehaugen marked this conversation as resolved.

### Round-trip via the public `id`

The `id` field (shape `kind:name`) is the stable round-trip key for every artifact and is accepted as input by `specify artifact info`. The `lookupId` field carries manifest-backed layer provenance and is present only for artifacts contributed by presets, extensions, or project overrides. Built-in-tier artifacts have no `lookupId`; use `id` to round-trip them.

For example, given a `specify artifact list --json` / `specify artifact info` stack row for a built-in artifact — which has only `id` populated (`layer`, `sourceId`, and `lookupId` are `null`) — the round-trip is:

```bash
specify artifact info command:speckit.plan --json
```

This resolves the same artifact as `specify artifact info speckit.plan --json`, because `id` (not `lookupId`) is the source-agnostic identifier every artifact carries.

### Determinism guarantees

Manifest contribution identifier derivation reads only the in-memory declared manifest content. No filesystem paths, no `os.environ`, no timestamps, and no file-content hashes contribute to those manifest ids. Copying an extension or preset to a different machine (or touching its files) does not change the identifiers it produces. Manifest-declared resolver `lookupId` values share this stability — renaming the installed directory of a preset or extension that declares an `id:` does not change its `lookupId`. Only convention-only contributions (undeclared in any manifest) derive their `lookupId` from the on-disk directory name or registry key, so renaming that directory does change their `lookupId`.

### Opacity guidance

Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Do not parse them by string-splitting on `:` — hook ids contain a compound `{eventName}:{command}` component and future grammar extensions may otherwise catch you out. If you only need to classify a stack entry's layer, use `layer_kind_from_lookup_id`; `derive_named_id` and `derive_hook_id` construct new identifiers rather than parsing existing ones.

## File System Layout

```text
Expand Down
7 changes: 7 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,13 @@ def _require_specify_project() -> Path:
_register_preset_cmds(app)


# ===== Artifact Commands =====

# Read-only introspection over the composed inventory (commands/templates/scripts).
from .artifacts._commands import register as _register_artifact_cmds # noqa: E402
_register_artifact_cmds(app)


# ===== Bundle Commands =====

# Bundler subcommand group (specify bundle ...) — see commands/bundle/.
Expand Down
Loading