Skip to content

[Feature]: Add specify artifact command exposing composition stacks as JSON #4212

Description

@nicolehaugen

Problem Statement

PresetResolver.collect_all_layers() (presets/__init__.py:5460) and resolve_content() (presets/__init__.py:5688) already implement the composition math needed to expose an artifact's layer stack — ordered layer stack, per-layer strategy, winner selection, and shadowing — but nothing serializes it. speckit-wizard-canvas resorts to shelling specify preset resolve <id> and parsing text output one line at a time (composition/collect.mjs::batchResolveTemplates), and re-implements strategy resolution and shadowing itself in composition/assembler.mjs (~23 KB). The wizard's own header comment on composition/collect.mjs says:

"Once the CLI grows equivalent commands (e.g. specify composition list --json), this whole file goes away and the scanner calls those commands directly."

This is a direct in-code TODO for this issue.

Proposed Solution

Add a new specify artifact command group:

  • specify artifact list --json — every artifact Spec Kit knows about (commands, templates, scripts), one row each:

    { "id": "command:speckit.constitution", "name": "speckit.constitution", "kind": "command",
      "description": "" }
  • specify artifact info <name> --json — the full stack for one artifact:

    {
      "id": "command:speckit.constitution", "name": "speckit.constitution", "kind": "command",
      "description": "",
      "stack": [
        { "id": "command:speckit.constitution",
          "layer": "preset", "sourceId": "speckit.corp",
          "presetId": "speckit.corp", "presetName": "Corp Preset",
          "strategy": "wrap", "active": true, "hidden": false,
          "manifestPath": ".specify/presets/speckit.corp/preset.yml",
          "lookupId": "preset:speckit.corp:command:speckit.constitution" },
        { "id": "command:speckit.constitution",
          "layer": "preset", "sourceId": "speckit.core",
          "presetId": "speckit.core", "presetName": "",
          "strategy": "replace", "active": false, "hidden": true,
          "manifestPath": ".specify/presets/speckit.core/preset.yml",
          "lookupId": "preset:speckit.core:command:speckit.constitution" },
        { "id": "command:speckit.constitution",
          "layer": null, "sourceId": null,
          "presetId": null, "presetName": null,
          "strategy": "replace", "active": false, "hidden": true,
          "manifestPath": null, "lookupId": null }
      ]
    }

Stack rules:

  • Ordered highest-priority-first (matches collect_all_layers today).
  • Exactly one layer has active: true (the winner from resolve_content).
  • Layers below a higher-priority replace get hidden: true (they stay in the stack for provenance/debug).
  • Each stack ends with a built-in baseline row when Spec Kit ships that artifact. Built-in rows are recognized by absent provenance: layer, sourceId, and lookupId are null.
  • The top-level and per-row id use the source-agnostic {kind}:{name} form and are the round-trip key accepted by specify artifact info, including for built-ins.
  • lookupId is present only on provenance-backed non-built-in layers. Manifest-declared preset and extension values match the contribution IDs exposed by the companion preset info --json / extension info --json issue.

Implementation: wrap the existing collect_all_layers + resolve_content output; add a small enumerator for core baseline artifacts (command names come from CORE_COMMAND_NAMES at extensions/__init__.py:119; templates/scripts are enumerated via a frontmatter parser over the shipped .md/.sh/.ps1/.py files, reusing the existing _locate_core_pack() / _repo_root() canonical asset resolvers).

Alternatives Considered

  • Extend specify preset resolve <id> with --json. Rejected — that command answers "which file wins?", not "what does the stack look like?" Consumers would still have to compose multiple calls.
  • Fold the core inventory into a separate specify core list --json. Rejected as the primary surface — same data, splits consumers. Filed as a separate specify core inventory issue for the inventory case where a UI wants the baseline without walking every artifact stack.
  • Include the resolved content in info --json. Rejected — content can be large; a separate artifact resolve (already exists as preset resolve) covers that.
  • Emit synthetic core:_:... lookup IDs for built-ins. Rejected because built-ins have no originating manifest contribution; their source-agnostic public artifact id already provides a stable round-trip key.

Component

Specify CLI (initialization, commands)

AI Agent (if applicable)

Not applicable

Use Cases

  1. speckit-wizard-canvas deletes composition/assembler.mjs and composition/collect.mjs::batchResolveTemplates, calling specify artifact info <name> --json for each artifact it needs to render.
  2. A debugger renders "why is my speckit.plan command not what I expect?" by walking the stack — the answer is the active: true row's provider and strategy; a row with null provenance is the built-in fallback.
  3. A CI diff shows "before/after" of the artifact stack when a preset is added or its priority changes.

Acceptance Criteria

  • specify artifact list --json emits one row per artifact with id, name, kind (command|template|script), description, and stack.
  • specify artifact info <name> --json emits {id, name, kind, description, stack: [...]} where every stack entry has id, layer, sourceId, presetId, presetName, strategy, active, hidden, manifestPath, and lookupId; provenance fields may be null for built-ins.
  • Exactly one stack entry has active: true; entries shadowed by a higher-priority replace have hidden: true.
  • A built-in baseline row appears at the bottom of every stack whose artifact ships with Spec Kit itself; its layer, sourceId, presetId, presetName, manifestPath, and lookupId are null.
  • Every artifact and stack row carries the source-agnostic {kind}:{name} id, which round-trips through specify artifact info.
  • Manifest-declared preset and extension lookupId values conform to the stable contribution-ID scheme and match the IDs returned by the companion preset info --json / extension info --json issue.
  • Every stack row with layer: "extension" has strategy: "replace" (invariant already enforced by extension validation).
  • Error handling: unknown artifact name → non-zero exit, stderr JSON {"error": "unknown artifact <name>"}.
  • Tests: composition parity against resolve_content (winner matches), shadow flag correctness, built-in-row presence for baseline artifacts, absence for extension-only artifacts, manifest-backed lookupId joins, and built-in public-ID round-trip.
  • Docs: new specify artifact section in the CLI reference with the full JSON shape and stack semantics.

Additional Context

Direct replacement for plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/assembler.mjs and composition/collect.mjs::batchResolveTemplates in github/spec-kit-copilot. Depends on the companion stable contribution ID / lookupId issue; benefits from structured source provenance once available.

Scope note (out of scope). A separate WorkflowResolver exists in src/specify_cli/workflows/overlays/__init__.py with its own collect_all_layers() for .specify/workflows/overlays/* composition. That is a distinct system from preset artifact composition and is not covered by this issue — this issue is scoped to commands, templates, and scripts. A future specify workflow composition view would be a separate issue against the workflow resolver.

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions