Skip to content

[Feature]: specify preset update — in-place preset update with rollback and single-pass reconciliation #4427

Description

@digimangos

Summary

There is currently no specify preset update <id> command. The only way to move an installed preset to a new version is preset remove <id> followed by preset add <id>. That workaround has real correctness problems beyond performance, because both remove() and install_from_directory() independently trigger full-stack reconciliation (composed commands/skills, and — when constitution-sync is enabled — memory/constitution.md) whenever the touched preset provides a constitution template. A version bump therefore reconciles the constitution twice: once against the stack with the preset temporarily absent, once against the reinstalled stack — with a real window in between where on-disk state reflects neither the old nor the new version.

This was raised as a valid feature-parity gap in #4415, with a request to open a tracking issue including acceptance criteria for: single/all updates, catalog-only eligibility, preserving priority/enabled state, rollback on failure, and command/skill/constitution reconciliation.

Current behavior (traced in source)

  • PresetManager.install_from_directory (presets/__init__.py) refuses to install over an already-installed preset id unless force=True, which itself calls self.remove() first — i.e. there is no in-place update path, only teardown+rebuild.
  • PresetResolver.collect_all_layers / resolve_content recompute composed content from the entire installed-preset priority stack on every reconciliation call — never a diff against a single preset's previous version.
  • _reconcile_constitution / _materialize_constitution_template unconditionally overwrite .specify/memory/constitution.md (when constitution-sync is enabled and the file is still detected as machine-generated) with no check for whether the resolved content actually changed.
  • PresetRegistry stores priority and enabled per installed preset, but neither remove() nor install_from_directory() reads the previous entry back when reinstalling — so a manual remove+add workaround also risks losing an explicit --priority (if omitted, it silently falls back to the default of 10) and always resets enabled to True regardless of whether the preset was previously disabled.
  • Neither remove nor add ever holds both the old and new manifest at the same time, so today there's no way to report what an update actually changed.

Proposed command

specify preset update <id> [--from URL | --dev PATH] [--priority N] [--all]

Acceptance criteria

Single-preset update

  • specify preset update <id> updates one named, already-installed preset to a new version from a supplied source (--from URL / --dev PATH) or, when eligible (see catalog-eligibility below), by re-resolving from its original catalog source.
  • Running preset update against a preset id that is not installed fails with a clear error directing the user to preset add instead — it must not silently fall through to an install path.
  • Old vs. new preset.yml manifests are diffed (added / removed / changed template names) before any files are touched, and this diff drives which command/skill files are registered vs. unregistered — not a blind re-register of everything the new manifest declares.

Bulk update (--all)

  • specify preset update --all checks every installed preset for a newer compatible version and updates only those that have one; presets already at the latest version are reported as skipped, not reinstalled with identical content.
  • A failure updating one preset in a bulk run is isolated to that preset (full rollback per the rollback criteria below) and does not abort or skip processing of the remaining presets.
  • Bulk update produces a per-preset outcome summary (updated / already-latest / failed-with-reason / skipped-and-why).
  • Behavior for disabled presets under --all is explicitly decided and documented (skip entirely vs. update files but leave disabled) rather than left as an implementation accident.

Catalog-only eligibility

  • When no --from/--dev is supplied, automatic re-resolution of "the newer version" is only attempted when the preset's original install source is a catalog entry (or otherwise durably re-resolvable); presets installed via --dev or a one-off --from URL fail with a clear "source not re-resolvable, supply --from/--dev explicitly" error rather than failing silently or guessing.
  • Update re-applies the same catalog install-allowed / discovery-only check that preset add already enforces (_install_allowed) — a discovery-only catalog preset cannot be updated any more than it can be freshly installed, if that's still the intended catalog semantics.
  • This dovetails with [Feature]: Record install source (URL / ref / commit) for extensions, presets, and workflows installed via --from, and support them in update #3533 (recording install source/ref for extensions, presets, and workflows installed via --from, to support update) — provenance persisted for that issue should be reused here rather than re-invented per artifact type.

Preserving priority / enabled state

  • Omitting --priority on preset update preserves the preset's currently registered priority (read via the existing PresetRegistry.get(pack_id)["priority"]) rather than resetting to the default of 10.
  • Passing --priority N explicitly still allows a deliberate reprioritize as part of the same update call.
  • A preset's enabled/disabled state (set via preset enable/preset disable) is preserved across an update — updating a disabled preset must not silently re-enable it.

Rollback on failure

  • Compatibility/manifest validation failures on the new version occur before any live preset directory or registry entry is modified — a validation failure is a true no-op.
  • New content is staged (e.g. presets/<id>.staging/) and validated before being swapped in; if staging or pre-swap validation fails, the previously installed version remains fully installed and functional.
  • The swap from old to new content is atomic at the directory level (rename-based, not a recursive file-by-file overwrite), minimizing any window where the preset directory is partial or missing.
  • A documented recovery path exists for the rare case of a crash during the swap step itself (e.g. a .bak directory retained, or a preset repair command).
  • A reconciliation failure after a successful swap does not roll back the already-successful content/registry update (the new version is genuinely installed) — it surfaces a clear, actionable warning instead of silently succeeding or incorrectly reverting a good install.

Command / skill / constitution reconciliation

  • Command and skill registration reconciliation runs exactly once per update, over the union of added/removed/changed names from the manifest diff — not once via an implicit "remove" pass and again via an implicit "add" pass.
  • memory/constitution.md reconciliation (when constitution-sync is enabled) runs at most once per update, against the final post-update stack — never against an intermediate stack with the preset transiently absent.
  • Reconciliation compares the newly resolved constitution content's hash against the current on-disk content and skips the write entirely when they match, so a version bump that doesn't change the resolved constitution produces zero file changes.
  • The existing "don't clobber a hand-edited constitution" guard (_constitution_is_generated, based on the provenance sidecar hash) is reused unchanged by update(), not reimplemented.
  • preset update prints a real diff summary (e.g. +2 commands, -1 command, constitution unchanged, priority kept at 10), since it's the first place both the old and new manifest are available together.

Compatibility with existing workflows

  • preset add and preset remove continue to behave exactly as they do today; preset update is additive, not a replacement.
  • A new preset version that renames the preset id or otherwise restructures its layout in a way a diff can't reconcile causes update to fail clearly and point the user to the remove+add workaround, rather than attempting a partial/corrupt diff-based update.

Implementation

I plan to work on this together with @markuswondrak.

Related

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

    feature-assessRun the Spec Kit idea-assessment pipeline on this feature requestfeature-needs-clarificationFeature assessment verdict: needs clarification

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions