Skip to content

feat: add preset update command - #4441

Open
digimangos wants to merge 12 commits into
github:mainfrom
digimangos:digimangos-preset-update-command-869
Open

feat: add preset update command#4441
digimangos wants to merge 12 commits into
github:mainfrom
digimangos:digimangos-preset-update-command-869

Conversation

@digimangos

@digimangos digimangos commented Sep 4, 2026

Copy link
Copy Markdown

Summary

Description

Implements specify preset update for single and bulk preset updates. To align functionality with specify extension update with the addition of supporting --from, and --dev update sources based on ID.

  • Supports dry runs, manifest diffs, staged validation, atomic swaps, rollback, and preserved priority and enabled state.
  • Reconciles only added, removed, and changed commands and skills.
  • Preserves hand-edited constitutions and avoids unnecessary sidecar writes.
  • Handles bundled presets and discovery-only catalogue permissions.
  • Adds regression coverage and documentation.

Testing

  • Tested locally with uv run specify --help
  • Ran existing tests with uv sync && uv run pytest
  • Tested with a sample project (if applicable)
    • Copilot ran through a matrix of test scenarios with copilot executing commands, artificially modifying manifest versions

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance (describe below)

Copilot Desktop App, Copilot CLI, detailed implementation plan, (too many) rounds with copilot code review agent, simulated real world smoke testing.

Closes #4427.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Moderate issues remain in source provenance, staged-state handling, reconciliation, cleanup, and bulk failure isolation.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 8 Medium severity · 1 Low severity

New issues introduced by this change (9)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — The staged tree is validated, but its returned manifest and diff are discarded. If a --dev source…
Medium severity src/​specify_cli/​presets/​__init__.py — For a changed command entry, item.get("new") or item.get("old") collects only the new aliases. If…
Medium severity src/​specify_cli/​presets/​__init__.py — This detects only manifest-declared constitution layers. Presets also support convention-only…
Medium severity src/​specify_cli/​presets/​_commands.py — If write_bytes(), archive detection, or suffix renaming fails, this helper raises without…
Medium severity src/​specify_cli/​presets/​_commands.py — Bulk auto-resolution checks only whether the ID exists in the current catalog, not whether this…
Medium severity src/​specify_cli/​presets/​_commands.py — Bundled presets intentionally store None in catalog_archives, so cancelling a bulk update…
Medium severity src/​specify_cli/​presets/​_commands.py — This resolves every source-less update by catalog ID, regardless of how the installed preset was…
Medium severity src/​specify_cli/​presets/​_commands.py — The bulk apply loop catches only preset-domain exceptions. Filesystem failures from staging or…
Low severity docs/​reference/​presets.md — The linked requirements call for a documented recovery path if the process crashes between…
What changed in this PR

Adds single and bulk preset updates from catalog, URL, and local sources.

Changes:

  • Adds staged validation, manifest diffs, atomic swaps, rollback, and reconciliation.
  • Adds dry-run and bulk-update support.
  • Adds regression tests and documentation.
File Description
tests/​test_presets.py Adds preset-update regression coverage.
src/​specify_cli/​presets/​_commands.py Implements update commands, source resolution, and downloads.
src/​specify_cli/​presets/​__init__.py Implements update, swap, and reconciliation logic.
docs/​reference/​presets.md Documents preset updates and options.
Suppressed comments (3)

src/specify_cli/presets/init.py:4300

  • Removed commands are unregistered for every recorded agent above, but this merge drops affected names only for the currently active agent. Inactive agents therefore remain falsely owned in this preset's registry; a later preset removal can delete a lower-priority command that reconciliation restored there. Remove removed_command_names from every agent while retaining changed names for historical agents.
                    retained = (
                        [name for name in names if name not in command_names]
                        if agent == active_agent
                        else list(names)
                    )

src/specify_cli/presets/init.py:4354

  • The same stale-ownership problem occurs for skills: removed skills are deleted for every recorded agent, but inactive-agent entries are copied unchanged into the updated preset's metadata. A later removal can then delete a restored skill owned by another layer. Filter removed_skill_names globally and filter the remaining affected names only for the active agent.
                            [
                                name
                                for name in names
                                if name not in affected_skill_names
                            ]

src/specify_cli/presets/init.py:4406

  • The warning states that reconciliation failed but gives no recovery action, even though the linked acceptance criterion requires an actionable warning after a successful swap. Explain that generated command/skill/constitution files may be stale and provide the supported command or source-based update procedure for repairing them.
            warnings.warn(
                f"Preset '{target_id}' was swapped, but post-update "
                f"reconciliation failed: {exc}",
                stacklevel=2,

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/_commands.py Outdated
Comment thread src/specify_cli/presets/_commands.py
Comment thread src/specify_cli/presets/_commands.py Outdated
Comment thread src/specify_cli/presets/_commands.py
Comment thread src/specify_cli/presets/_commands.py
Comment thread docs/reference/presets.md
Copilot AI review requested due to automatic review settings September 4, 2026 11:50
@digimangos

Copy link
Copy Markdown
Author

Posted on behalf of @digimangos by GitHub Copilot (model: gpt-5.6-luna).

Addressed the valid review findings in commit b87a37a8: staged validation is authoritative, aliases are reconciled on both old and new manifests, inactive-agent ownership is cleaned up, convention-only constitution layers are detected, archive temporary files are cleaned on failure, bulk cancellation handles missing archive paths, per-item bulk OSError failures are isolated, and post-swap warnings are actionable. Added regression coverage and recovery documentation.

Validation: 7,715 tests passed, 16 skipped; package build passed. The repository-wide Ruff baseline remains unchanged.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical version validation and multiple source, dry-run, cleanup, and reconciliation issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 1 High severity · 6 Medium severity

New issues introduced by this change (5)
Severity Finding
High severity src/​specify_cli/​presets/​_commands.py — This compares only the catalogue metadata version; the downloaded/bundled manifest is never…
Medium severity src/​specify_cli/​presets/​__init__.py — Including aliases here sends each alias through _reconcile_composed_commands, but…
Medium severity src/​specify_cli/​presets/​__init__.py — These conditions explicitly include symlinks, but shutil.rmtree() refuses to operate on a…
Medium severity src/​specify_cli/​presets/​__init__.py — Legacy flat-list skill provenance is not normalized here. _register_skills may migrate it in the…
Medium severity src/​specify_cli/​presets/​__init__.py — Changed commands and skills have already been written by _register_commands and…
Pre-existing issues (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — This resolves every source-less update by catalog ID, regardless of how the installed preset was… View comment
Medium severity src/​specify_cli/​presets/​_commands.py — Bulk auto-resolution checks only whether the ID exists in the current catalog, not whether this… View comment
Issues resolved since last review (7)
Severity Finding
Low severity docs/​reference/​presets.md — The linked requirements call for a documented recovery path if the process crashes between… View resolved comment
Medium severity src/​specify_cli/​presets/​_commands.py — The bulk apply loop catches only preset-domain exceptions. Filesystem failures from staging or… View resolved comment
Medium severity src/​specify_cli/​presets/​_commands.py — Bundled presets intentionally store None in catalog_archives, so cancelling a bulk update… View resolved comment
Medium severity src/​specify_cli/​presets/​_commands.py — If write_bytes(), archive detection, or suffix renaming fails, this helper raises without… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — This detects only manifest-declared constitution layers. Presets also support convention-only… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — For a changed command entry, item.get("new") or item.get("old") collects only the new aliases. If… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — The staged tree is validated, but its returned manifest and diff are discarded. If a --dev source… View resolved comment
Suppressed comments (7)

Previously missed (2) — in code that hasn't changed since the last review.

src/specify_cli/presets/_commands.py:567

  • During a dry run, download_pack() defaults to .specify/presets/.cache/downloads, and catalogue lookup can also refresh files under .cache; unlinking the archive later still leaves cache files/directories behind. This contradicts the linked acceptance criterion that --dry-run modify no file or directory. Use a non-project temporary download/cache location or a no-write catalogue mode for dry runs.

This issue also appears on line 680 of the same file.
src/specify_cli/presets/_commands.py:722

  • For dry runs this reports constitution reconciled whenever there is no unchanged manifest entry named constitution-template. Ordinary presets with no constitution layer—and unchanged convention-only constitution layers—therefore get a false reconciliation status. Determine whether the old and new resolved constitution layers/content differ; absence of a constitution layer should report unchanged.

docs/reference/presets.md:51

  • The command does not enforce reuse of the same local directory or archive URL: it accepts any explicit same-ID source, and automatic catalogue resolution is by ID because no source provenance is stored. This wording therefore gives users an incorrect constraint. Describe the actual rule: presets absent from an install-allowed catalogue require an explicit --dev or --from source.
Catalogue updates are resolved by preset ID and only use catalogues that allow
installation. A preset installed from a local directory or an archive URL must
be updated with the same `--dev <path>` or `--from <url>` source.

src/specify_cli/presets/_commands.py:533

  • Bulk auto-update resolves every installed preset solely by matching its ID in the current catalogue. Installation currently records source: local for every source (src/specify_cli/presets/__init__.py:3980-3983), so a preset originally installed via --dev or --from can be silently replaced by unrelated catalogue content when its ID happens to match. This contradicts #4427’s catalogue-only eligibility requirement; persist and consult source provenance, or require an explicit source when verified catalogue provenance is unavailable.
                pack_info = catalog.get_pack_info(item_id)

src/specify_cli/presets/_commands.py:647

  • The single-preset path also treats any matching catalogue ID as a re-resolvable source, without checking how the installed preset was obtained. A local/URL installation sharing that ID is therefore replaced from the catalogue instead of producing the documented “supply --from/--dev explicitly” error. Gate this lookup on durable catalogue-source provenance.
                pack_info = catalog_candidates.get(item_id) or catalog.get_pack_info(item_id)

src/specify_cli/presets/_commands.py:680

  • The single-preset catalogue path also writes through download_pack() during --dry-run, leaving project cache files/directories despite promising no changes. Route both catalogue refreshes and downloaded archives to a non-project/no-write path when dry_run is set.
                    archive_path = catalog.download_pack(item_id)

src/specify_cli/presets/_commands.py:662

  • The single-update path likewise trusts only pack_info["version"]; update_from_directory/archive validates ID and compatibility but does not ensure the staged manifest equals the advertised version or remains newer than the installed version. A mismatched catalogue payload can silently downgrade the installation. Enforce the expected catalogue version during staged validation.
                    catalog_version = pkg_version.Version(str(pack_info["version"]))
                except (KeyError, TypeError, ValueError) as exc:
                    raise PresetError(
                        f"catalog entry for preset '{item_id}' has an invalid version"
                    ) from exc

Comment thread src/specify_cli/presets/_commands.py Outdated
Comment thread src/specify_cli/presets/__init__.py
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py
Copilot AI review requested due to automatic review settings September 4, 2026 12:13
@digimangos

Copy link
Copy Markdown
Author

Posted on behalf of @digimangos by GitHub Copilot (model: gpt-5.6-luna).

Follow-up review fixes are in commit 963db02b: catalogue updates now verify the staged manifest version against catalogue metadata, dry-run catalogue reads and downloads use an isolated temporary cache, swap cleanup is symlink-safe, legacy flat skill ownership is normalised, aliases are excluded from independent composition reconciliation, and dry-run constitution reporting correctly treats absent or unchanged layers as unchanged.

Validation: targeted preset tests 691 passed; full suite 7,715 passed, 16 skipped; package build passed. The existing repository-wide Ruff baseline remains unchanged.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Multiple moderate correctness issues remain in dry-run validation, caching, and artifact reconciliation.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 8 Medium severity

New issues introduced by this change (4)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — The dry-run return bypasses the expected_version check below. If a catalogue advertises v2 but…
Medium severity src/​specify_cli/​presets/​__init__.py — When --priority changes the registry priority, this set still contains only…
Medium severity src/​specify_cli/​presets/​_commands.py — This deletes the shared dry-run cache while the bulk preflight loop still needs it. Every later…
Medium severity src/​specify_cli/​presets/​_commands.py — The dry-run constitution status only inspects manifest entries, but convention-only…
Pre-existing issues (4)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — Changed commands and skills have already been written by _register_commands and… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — Including aliases here sends each alias through _reconcile_composed_commands, but… View comment
Medium severity src/​specify_cli/​presets/​_commands.py — This resolves every source-less update by catalog ID, regardless of how the installed preset was… View comment
Medium severity src/​specify_cli/​presets/​_commands.py — Bulk auto-resolution checks only whether the ID exists in the current catalog, not whether this… View comment
Issues resolved since last review (3)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — Legacy flat-list skill provenance is not normalized here. _register_skills may migrate it in the… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — These conditions explicitly include symlinks, but shutil.rmtree() refuses to operate on a… View resolved comment
High severity src/​specify_cli/​presets/​_commands.py — This compares only the catalogue metadata version; the downloaded/bundled manifest is never… View resolved comment
Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

src/specify_cli/presets/init.py:4402

  • For a disabled target, reconciliation excludes that preset via list_by_priority(), so these calls can overwrite the newly registered command/skill with a lower layer. This conflicts with the existing CLI contract that disabled presets keep registered commands/skills active until removal (_commands.py:1145) and means updating a disabled preset may not update its generated artifacts. Reconcile with the updated target included for this case, or consistently change the disable semantics.

docs/reference/presets.md:51

  • This source requirement does not match the implementation or the clarified acceptance criteria: automatic updates look up any installed preset by ID, regardless of whether it was originally installed via --dev or --from. Describe the actual fallback condition—an explicit source is required only when the ID cannot be resolved from an install-enabled catalogue.
Updates one installed preset, or all installed presets when no ID is given.
Catalogue updates are resolved by preset ID and only use catalogues that allow
installation. A preset installed from a local directory or an archive URL must
be updated with the same `--dev <path>` or `--from <url>` source.

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/_commands.py
Comment thread src/specify_cli/presets/_commands.py Outdated
@digimangos
digimangos marked this pull request as draft September 4, 2026 12:34
Copilot AI review requested due to automatic review settings September 4, 2026 13:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Multiple moderate reconciliation, reporting, and bulk-isolation issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 6 Medium severity

New issues introduced by this change (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.pyinclude_disabled_id changes layer resolution, but the ownership list later still comes from…
Medium severity src/​specify_cli/​presets/​__init__.py — The affected-name set is expanded with every alias before priority changes add the remaining…
Pre-existing issues (4)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — The dry-run constitution status only inspects manifest entries, but convention-only… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — Changed commands and skills have already been written by _register_commands and… View comment
Medium severity src/​specify_cli/​presets/​_commands.py — This resolves every source-less update by catalog ID, regardless of how the installed preset was… View comment
Medium severity src/​specify_cli/​presets/​_commands.py — Bulk auto-resolution checks only whether the ID exists in the current catalog, not whether this… View comment
Issues resolved since last review (4)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — This deletes the shared dry-run cache while the bulk preflight loop still needs it. Every later… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — When --priority changes the registry priority, this set still contains only… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — The dry-run return bypasses the expected_version check below. If a catalogue advertises v2 but… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — Including aliases here sends each alias through _reconcile_composed_commands, but… View resolved comment
Suppressed comments (6)

Previously missed (2) — in code that hasn't changed since the last review.

src/specify_cli/presets/_commands.py:724

  • A successful update never runs the existing unmet-extension dependency warning. If the new manifest adds or tightens an extension requirement, the preset is installed but its dependent behavior can silently become inert or unsupported; preset add calls this warning for the same resulting state. Invoke it after non-dry-run updates.
    docs/reference/presets.md:51
  • This states that all --dev/--from installations must retain that source, but update resolution intentionally mirrors extension update and looks up by preset ID without persisted provenance. Therefore a locally installed preset whose ID exists in an allowed catalogue can be updated from that catalogue. Describe the actual “catalogue entry resolvable by ID” rule instead.

src/specify_cli/presets/init.py:2519

  • The resolver includes the disabled preset, but presets_by_priority below excludes it. When that preset owns the winning skill layer, reconciliation classifies it as a non-preset source and restores core/extension content, undoing the skill written for the updated disabled preset. Derive the owner list from the same resolver.
        resolver = PresetResolver(
            self.project_root, include_disabled_id=include_disabled_id
        )

src/specify_cli/presets/_commands.py:738

  • This dry-run prediction treats only a manifest-declared constitution-template diff as a reconciliation. Convention-only constitution files are supported and tested below, so changing templates/constitution-template.md without declaring it reports “constitution unchanged” even though the real update rewrites it; a priority-only change can likewise change the winner. Compute the projected resolved constitution (including the requested priority), or avoid claiming it is unchanged.
            constitution_unchanged = (
                not any(
                    entry["identity"] == ("constitution-template", "template")
                    for category in ("added", "removed", "changed")
                    for entry in diff[category]

src/specify_cli/presets/_commands.py:745

  • The normal success summary omits changed commands, so an update that only changes command content, strategy, or aliases reports +0 commands, -0 commands despite having reconciled a command. Include the changed-command count so the advertised manifest diff is accurate outside --dry-run.
                f"(+{added_commands} commands, -{removed_commands} commands, "

src/specify_cli/presets/_commands.py:602

  • Bulk preflight does not catch OSError, even though _diff_preset_template_files() reads referenced files and can raise it. A single unreadable preset therefore aborts the entire bulk command before the remaining IDs are processed, contrary to the per-item isolation contract; the execution loop already handles this exception per item.
            except (PresetValidationError, PresetError) as exc:

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Copilot AI review requested due to automatic review settings September 4, 2026 13:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Unresolved moderate issues affect provenance, validation, failure handling, bulk isolation, and dry-run accuracy.

Review tier: Balanced
Findings: 4 Medium severity

Pre-existing issues (4)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — The dry-run constitution status only inspects manifest entries, but convention-only… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — Changed commands and skills have already been written by _register_commands and… View comment
Medium severity src/​specify_cli/​presets/​_commands.py — This resolves every source-less update by catalog ID, regardless of how the installed preset was… View comment
Medium severity src/​specify_cli/​presets/​_commands.py — Bulk auto-resolution checks only whether the ID exists in the current catalog, not whether this… View comment
Issues resolved since last review (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — The affected-name set is expanded with every alias before priority changes add the remaining… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.pyinclude_disabled_id changes layer resolution, but the ownership list later still comes from… View resolved comment
Suppressed comments (7)

Previously missed (2) — in code that hasn't changed since the last review.

src/specify_cli/presets/init.py:4288

  • PresetManifest does not validate the aliases container, and the registrar explicitly accepts aliases: null as no aliases. This iteration runs after the directory and registry swap and outside the reconciliation warning handler, so such an incoming manifest raises TypeError after the update has already committed, producing a traceback without the promised actionable warning. Validate or normalize aliases during pre-swap validation and use the normalized list throughout reconciliation.

This issue also appears on line 4306 of the same file.
src/specify_cli/presets/_commands.py:769

  • A compatibility failure during a single-preset update is recorded as skipped, so the final failure check does not raise and the CLI exits successfully even for an explicitly supplied incompatible --dev/--from source. Keep the bulk skip behavior, but treat this as a failure for single updates, consistent with preset add.

src/specify_cli/presets/init.py:4314

  • The reconciliation set is captured before aliases from changed entries are added below. Registration or unregistration can therefore overwrite/delete an alias output, while only the primary name is re-resolved; if another preset, extension, or core command should win that alias, the generated file remains stale or missing. Build the reconciliation set after collecting affected aliases and include the expanded primary set.
        reconcile_command_names = set(primary_command_names)
        for item in diff["added"] + diff["removed"] + diff["changed"]:
            for template in (item.get("old"), item.get("new")):
                if template and template.get("type") == "command":
                    command_names.update(
                        alias
                        for alias in template.get("aliases", [])
                        if isinstance(alias, str)
                    )

src/specify_cli/presets/_commands.py:661

  • No-source updates select a catalog entry solely by preset ID, without verifying that the installed preset came from that catalog. All current install paths record source: "local" (src/specify_cli/presets/init.py:3993-4000), so a preset installed via --dev or --from can be silently replaced by an unrelated catalog entry with the same ID. This contradicts docs/reference/presets.md:49-51 and the linked issue's catalog-eligibility requirement. Persist catalog provenance during installation and require it here; otherwise require an explicit source.
                pack_info = catalog_candidates.get(item_id) or catalog.get_pack_info(item_id)

src/specify_cli/presets/_commands.py:741

  • Dry-run constitution status is inferred only from manifest diff entries. A convention-only templates/constitution-template.md is intentionally supported by this update path, but it has no manifest identity, so changing its content still prints constitution unchanged even though the real update reconciles it. Compare the old and staged conventional constitution layers (or include them in the diff) before reporting this status.
            constitution_unchanged = (
                not any(
                    entry["identity"] == ("constitution-template", "template")
                    for category in ("added", "removed", "changed")
                    for entry in diff[category]
                )
                if dry_run
                else constitution_before == constitution_after

src/specify_cli/presets/_commands.py:602

  • Bulk preflight does not catch OSError, although the execution loop does. An unreadable installed template or filesystem error from dry-run validation therefore aborts the entire bulk command instead of recording this preset as failed and continuing with the remaining presets, violating per-preset failure isolation.
            except (PresetValidationError, PresetError) as exc:

src/specify_cli/presets/_commands.py:724

  • Successful updates omit the unmet-extension dependency warning that every preset add path emits at src/specify_cli/presets/_commands.py:426-430. If a new preset version introduces requires.extensions, its overrides can silently become inert after update. Invoke _warn_unmet_extension_dependencies(manager, manifest) after a successful non-dry-run update.
            action = "would update" if dry_run else "updated"

Copilot AI review requested due to automatic review settings September 4, 2026 14:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Moderate issues remain in alias validation and reconciliation, version handling, dry-run accuracy, and update reporting.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 5 Medium severity

New issues introduced by this change (4)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — Alias validation stops at the value type, so an incoming command alias such as ../escape passes…
Medium severity src/​specify_cli/​presets/​__init__.py — Aliases from every command in both manifests are added here, even when their template is unchanged.…
Medium severity src/​specify_cli/​presets/​__init__.py — The final stack reconciliation drops aliases because it uses only primary_command_names.…
Medium severity src/​specify_cli/​presets/​_commands.py — For a dry run with --priority, this reports the constitution as unchanged solely from the…
Pre-existing issues (1)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — Changed commands and skills have already been written by _register_commands and… View comment
Issues resolved since last review (4)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.pyinclude_disabled_id changes layer resolution, but the ownership list later still comes from… View resolved comment
Medium severity src/​specify_cli/​presets/​_commands.py — The dry-run constitution status only inspects manifest entries, but convention-only… View resolved comment
Medium severity src/​specify_cli/​presets/​_commands.py — This resolves every source-less update by catalog ID, regardless of how the installed preset was… View resolved comment
Medium severity src/​specify_cli/​presets/​_commands.py — Bulk auto-resolution checks only whether the ID exists in the current catalog, not whether this… View resolved comment
Suppressed comments (8)

Previously missed (2) — in code that hasn't changed since the last review.

src/specify_cli/presets/init.py:4230

  • This validates the catalogue version by raw string equality even though update eligibility uses PEP 440 versions. Equivalent versions such as catalogue 2.0 and manifest 2.0.0 compare equal under PEP 440 but are rejected here. Compare parsed Version values so valid catalogue releases are not blocked.

This issue also appears on line 4260 of the same file.
src/specify_cli/presets/_commands.py:730

  • The normal success summary counts only added and removed commands, so a content-, strategy-, or alias-only command update reports +0 commands, -0 commands even though the manifest diff contains changed commands. Include a changed-command count in the success line so the advertised diff summary accurately reports these updates.

docs/reference/presets.md:58

  • The option table omits the public --all flag even though the command exposes it and the surrounding text discusses bulk updates. Add it so users can discover the explicit bulk form from the reference.
resolved through an installation-enabled catalogue, provide an explicit
`--dev <path>` or `--from <url>` source.

This means a preset installed from a catalogue can later be updated from the
catalogue entry currently associated with its ID, while a development or
one-off archive installation remains updateable only when an explicit source

docs/reference/presets.md:51

  • This source-provenance claim does not match the implementation or the clarified acceptance criteria: automatic updates use catalogue lookup by ID only, and every install path currently records source: local. Therefore a preset installed via --dev or --from whose ID exists in a catalogue can be updated from that catalogue without repeating its original source. Document ID-based eligibility instead.
Catalogue updates are resolved by preset ID alone. Spec Kit searches the active
catalogues for a matching entry and only uses entries from catalogues that
allow installation. It does not retain or replay the original URL or local

src/specify_cli/presets/init.py:4260

  • The staged catalogue-version check repeats the raw string comparison, so PEP 440-equivalent versions (for example 2.0 and 2.0.0) still fail after staging even if the earlier check is normalized. Compare parsed versions here as well.
            if expected_version is not None and staged_manifest.version != expected_version:

src/specify_cli/presets/init.py:160

  • The new no-op write path is not covered by a regression test. Add a test that materializes identical resolved content and verifies both the constitution and its provenance sidecar are not rewritten; content equality alone would not catch the unnecessary sidecar write this change is intended to prevent.
    if memory_constitution.exists() and memory_constitution.read_bytes() == content:
        return "unchanged"

src/specify_cli/presets/_commands.py:528

  • The new bulk path has no CLI regression coverage: the added tests invoke only a single --dev --dry-run update. Please cover bare/--all confirmation, cancellation, per-item failure isolation, and disabled-state preservation; otherwise the core half of this command can regress without detection.
    if bulk:
        actionable_ids = []
        catalog_archives = {}
        for item_id in ids:
            safe_id = _escape_markup(str(item_id))

tests/test_presets.py:14985

  • The new bulk-update branch has no regression coverage: the added CLI test exercises only a single --dev --dry-run. Please cover bulk preflight/confirmation, continuing after one item fails, disabled-preset preservation, and aggregate exit status; these are distinct paths from the tested manager-level single update.
    def test_cli_single_dry_run_does_not_prompt(

Comment thread src/specify_cli/presets/__init__.py
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py
Comment thread src/specify_cli/presets/_commands.py Outdated
Copilot AI review requested due to automatic review settings September 4, 2026 14:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Moderate alias-safety, reconciliation, single-pass behavior, and bulk-test coverage issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 5 Medium severity

New issues introduced by this change (1)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — The bulk-update path is not exercised by the added tests: current CLI coverage only invokes a…
Pre-existing issues (4)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — For a dry run with --priority, this reports the constitution as unchanged solely from the… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — The final stack reconciliation drops aliases because it uses only primary_command_names.… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — Aliases from every command in both manifests are added here, even when their template is unchanged.… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — Alias validation stops at the value type, so an incoming command alias such as ../escape passes… View comment
Issues resolved since last review (1)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — Changed commands and skills have already been written by _register_commands and… View resolved comment
Suppressed comments (5)

src/specify_cli/presets/init.py:4312

  • command_names is expanded with every alias in both manifests, including aliases owned by unchanged commands. Later, the active agent drops every name in this set from registered_commands, while _register_commands() re-adds only templates selected by primary_command_names; unrelated aliases therefore lose registry ownership even though their files remain, so later removal cannot unregister them. Keep this set limited to affected primaries here—the changed-entry loop below already adds aliases belonging to affected templates.
        command_names.update(
            alias
            for manifest in (old_manifest, new_manifest)
            for item in manifest.templates
            if item.get("type") == "command"

src/specify_cli/presets/init.py:468

  • This accepts traversal/anchored aliases such as ../victim or C:foo. CommandRegistrar.register_commands() rejects those later via relative_extension_path_violation() (src/specify_cli/agents.py:694-710), but an update reaches that check only after the preset directory and registry version have been swapped, leaving stale generated artifacts behind with only a reconciliation warning. Validate each alias with the shared path-safety helper here so malformed updates fail before the swap, matching extension alias validation at src/specify_cli/extensions/__init__.py:1121-1125.
            if not isinstance(aliases, list) or not all(
                isinstance(alias, str) for alias in aliases
            ):
                raise PresetValidationError(
                    "Invalid template aliases: expected a list of strings"

src/specify_cli/presets/init.py:4365

  • Removed aliases are unregistered here but never added to reconcile_command_names, which currently contains only primary identities. If a lower-priority preset, extension, or core command provides that alias name, updating the preset deletes the generated command/skill and does not restore the surviving layer. Add removed names to the reconciliation set before the reconciliation calls.
            removed_command_names = old_command_names - new_command_names

src/specify_cli/presets/init.py:4408

  • For a priority-only update, primary_command_names is expanded after command_names was created, so this loop produces an empty managed-skill set. Historical skill directories are then passed to _reconcile_skills() with no names and retain content from the old precedence order. Derive these names from the final reconciliation set instead.
            for name in command_names:

src/specify_cli/presets/init.py:4388

  • This directly writes each changed command before the later full-stack reconciliation writes the same command again; _register_skills() and _reconcile_skills() repeat the same two-pass pattern. Besides violating #4427's retained single-pass reconciliation requirement, a lower-priority updated preset is briefly published before the resolver restores the actual winner. Use the single full-stack reconciliation pass to perform registration and derive tracking from its returned writes.
            registered_commands = self._register_commands(
                new_manifest, current_dir, command_names=primary_command_names
            )

Comment thread src/specify_cli/presets/_commands.py
Copilot AI review requested due to automatic review settings September 4, 2026 14:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unsafe concurrent staging and multiple reconciliation correctness issues must be resolved before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 1 High severity · 2 Medium severity

New issues introduced by this change (1)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py — These fixed per-preset staging and backup paths are unsafe across concurrent update processes. One…
Pre-existing issues (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — The bulk-update path is not exercised by the added tests: current CLI coverage only invokes a… View comment
Medium severity src/​specify_cli/​presets/​_commands.py — For a dry run with --priority, this reports the constitution as unchanged solely from the… View comment
Issues resolved since last review (4)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — The final stack reconciliation drops aliases because it uses only primary_command_names.… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — Aliases from every command in both manifests are added here, even when their template is unchanged.… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — Alias validation stops at the value type, so an incoming command alias such as ../escape passes… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.pyinclude_disabled_id changes layer resolution, but the ownership list later still comes from… View resolved comment
Suppressed comments (6)

Previously missed (1) — in code that hasn't changed since the last review.

src/specify_cli/presets/init.py:4266

  • The staged copy retains the source's generated .composed directory. If --dev points at (or copies) an installed preset and a command changes from wrap/prepend/append to replace, _register_skills() still prefers the stale .composed/<name>.md over the new command file (lines 3134-3138), leaving the skill on the old content. Remove generated composition artifacts from staging; composition reconciliation will regenerate those still needed.

docs/reference/presets.md:68

  • The command exposes an explicit --all option, but this option table omits it even though the following paragraphs discuss bulk updates. Add it so the documented interface includes every supported update mode.
| `--dry-run`      | Show the manifest diff without changing anything |

src/specify_cli/presets/init.py:4392

  • This directly writes every changed command, and the later _reconcile_composed_commands() call writes the same affected names again; skills follow the same duplicate _register_skills()/_reconcile_skills() pattern. For a lower-priority preset, the first pass can even publish the non-winning content before the second pass corrects it. This contradicts #4427's requirement that command/skill reconciliation run exactly once and the PR's claim to avoid unnecessary sidecar writes; make the single full-stack reconciliation pass perform registration and ownership updates.
            registered_commands = self._register_commands(
                new_manifest, current_dir, command_names=primary_command_names
            )

src/specify_cli/presets/init.py:4322

  • Priority-only updates add every primary command to reconciliation, but omit their aliases because the alias loop below only visits manifest-diff entries. If this preset has A aliased as B and another preset provides primary B, reprioritizing this preset downward first writes its raw alias B, then reconciles only A; the other preset's winning B is never restored. Add aliases from all old/new command templates whenever priority changes.
        if preserved_priority != original_priority:

src/specify_cli/presets/_commands.py:524

  • The new bulk-update path has no CLI regression coverage: the added tests invoke only a missing ID and one single-preset --dev --dry-run case. Please cover --all confirmation, already-current and incompatible skips, per-preset failure isolation/exit status, and preserved disabled/priority state so this core path cannot regress unnoticed.
    if bulk:

src/specify_cli/presets/_commands.py:742

  • The dry-run constitution status is inferred only from this preset's file diff, not the projected resolved state. For example, changing a constitution template while constitution-sync is absent or the constitution is hand-edited reports “constitution reconciled” even though the real update writes nothing; conversely, a priority-only change can switch the winning constitution while this reports “unchanged.” Compute the status against the projected post-update stack without writing, or describe it as a manifest-level change rather than a reconciliation result.
            constitution_unchanged = (
                not diff.get("_constitution_changed", False)
                and not any(
                    entry["identity"] == ("constitution-template", "template")
                    for category in ("added", "removed", "changed")
                    for entry in diff[category]
                )
                if dry_run
                else constitution_before == constitution_after

Comment thread src/specify_cli/presets/__init__.py Outdated
Copilot AI review requested due to automatic review settings September 4, 2026 14:43
Copilot AI review requested due to automatic review settings September 5, 2026 11:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The unresolved critical recursive-copy issue and moderate reconciliation defects must be addressed.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 1 High severity · 2 Medium severity

New issues introduced by this change (1)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py — When --dev points at the project root (a case explicitly supported by the new regression test),…
Pre-existing issues (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — Alias matching only selects the containing template; the rendering loop below still derives… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — This lock does not serialize against the existing preset mutators. preset set-priority, enable,… View comment
Issues resolved since last review (6)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py--dev can legitimately point at the project root (for example, --dev . when that directory… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — This registers changed commands against the currently active integration even when the preserved… View resolved comment
High severity src/​specify_cli/​presets/​__init__.py — Legacy installations store registered_skills as a flat list, but this call omits… View resolved comment
Medium severity src/​specify_cli/​presets/​_commands.py — The dry-run predicate does not mirror the actual reconciliation path: the update below calls… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — Priority changes reconcile inactive command agents stack-wide, but inactive skill directories are… View resolved comment
High severity src/​specify_cli/​presets/​__init__.py — Do not exempt priority changes from the post-lock downgrade check. A single catalogue update can… View resolved comment
Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

src/specify_cli/presets/_commands.py:632

  • When every preset is already current (or all preflights fail), this early return runs before the --priority is ignored notice below. That contradicts the documented behavior and leaves preset update --all --priority N appearing to accept the option silently. Emit the bulk-priority notice immediately after preflight, before this no-actionable-items branch.

src/specify_cli/presets/init.py:6201

  • Alias-aware resolution is not carried through the reconciliation ownership paths. _reconcile_composed_commands still identifies a preset template only with tmpl["name"] == cmd_name (lines 2193 and 2305), so reconciling an alias falls through to generic/shared registration and does not merge the written artifact into the preset's registered_commands. For example, adding an alias while another agent is inactive can write that alias into the historical agent directory without tracking it, leaving it behind when the preset is removed. Update those owner/filter checks to match aliases as well as the primary name.
                    tmpl.get("name") == template_name
                    or (
                        isinstance(aliases, list)
                        and template_name in aliases
                    )

src/specify_cli/presets/init.py:4531

  • Changed commands are written here by _register_commands, then written again by _reconcile_composed_commands below; skills follow the same two-pass pattern. This violates #4427's single-pass reconciliation requirement and creates an avoidable intermediate state where a reprioritized preset's losing content is installed before the resolver overwrites it. Drive generated-file writes through one reconciliation pass and update ownership metadata from that pass instead.
            registered_commands = self._register_commands(
                new_manifest, current_dir, command_names=primary_command_names
            )

src/specify_cli/presets/_commands.py:475

  • The help text says the positional argument can be all, but the implementation treats only an omitted ID or --all as bulk mode. specify preset update all therefore attempts to update a preset literally named all. Remove “(or all)” from this help text, or implement the advertised positional alias.
    preset_id: str = typer.Argument(None, help="Preset ID to update (or all)"),

Comment thread src/specify_cli/presets/__init__.py Outdated
When a development preset source is the project root, avoid copying .specify/presets into the staged preset so update staging does not include registry, cache, or other installed preset state.

Assisted-by: GitHub Copilot (model: Auto, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: ab5495b7-024f-486a-8abd-7cbfe54056d6
Copilot AI review requested due to automatic review settings September 5, 2026 11:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The critical registry concurrency issue must be resolved before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 1 High severity

New issues introduced by this change (1)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py — This lock does not serialize the update against other preset mutations. preset disable, enable,…
Issues resolved since last review (3)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py — When --dev points at the project root (a case explicitly supported by the new regression test),… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — Alias matching only selects the containing template; the rendering loop below still derives… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — This lock does not serialize against the existing preset mutators. preset set-priority, enable,… View resolved comment
Suppressed comments (8)

src/specify_cli/presets/init.py:6202

  • Treating an alias as an ordinary layer also applies the primary entry's composition strategy to the alias's own stack. For a command such as primary speckit.foo, alias speckit.f, strategy append, and a base that exists only for speckit.foo, resolve_content("speckit.f") sees one append layer and returns None; _reconcile_composed_commands then unregisters the alias as stale. Alias lookup needs to resolve composition through the primary identity (or reconciliation must explicitly map aliases to the primary's composed result) rather than composing each alias independently.
            aliases = tmpl.get("aliases", [])
            if (
                tmpl.get("type") == template_type
                and (
                    tmpl.get("name") == template_name
                    or (
                        isinstance(aliases, list)
                        and template_name in aliases
                    )
                )

src/specify_cli/presets/init.py:4779

  • These cleanup calls execute after a successful swap and registry update, but remove_swap_path propagates OSError. A locked/read-only file can therefore make the CLI report the update as failed even though the new preset is already installed, contradicting the command's resulting state. Catch cleanup failures here and warn with the retained recovery path instead of turning a committed update into a failure.
        finally:
            remove_swap_path(backup_dir)
            remove_swap_path(staging_dir)

src/specify_cli/presets/init.py:4441

  • The directory and registry have already committed before this cleanup runs. If removing the backup or staging tree raises (for example, because a file is locked on Windows), the disabled-preset update is reported as failed even though the new version is live and recorded in the registry. Post-commit cleanup should be best-effort and emit a recovery warning; only pre-commit cleanup should remain fatal.
            remove_swap_path(backup_dir)
            remove_swap_path(staging_dir)
            return new_manifest, diff

src/specify_cli/presets/init.py:4637

  • Sorting these logical names can leave an alias with the wrong winner. _reconcile_composed_commands re-emits all aliases when it processes a primary template (lines 2192-2197); for a primary such as speckit.specify and alias speckit.spec, the alias is reconciled first and then overwritten by the primary's layer, even when a higher-priority preset or project override wins speckit.spec. Reconcile each logical name without re-emitting its siblings, or run a final winner-aware alias pass.
                self._reconcile_composed_commands(
                    sorted(reconcile_command_names),
                    extra_agents=historical_agents,
                )

src/specify_cli/presets/init.py:4531

  • This directly writes the changed commands, and the later _reconcile_composed_commands call writes the same affected names again; skills follow the same duplicate _register_skills/_reconcile_skills pattern. The linked acceptance criteria require one reconciliation pass over the added/removed/changed union, so this currently causes duplicate writes and transiently installs a non-winning layer. Consolidate registration and full-stack reconciliation into a single winner-aware pass.
            registered_commands = self._register_commands(
                new_manifest, current_dir, command_names=primary_command_names
            )

src/specify_cli/presets/init.py:4370

  • For the supported case where source_dir is the project root, this ignore callback excludes only .specify/presets. The transaction has already created .specify/.workflow-install.lock, so that lock—and the rest of the project's .specify state such as memory and extension data—is copied into the installed preset. Exclude the project-owned .specify tree when staging from the project root, or copy only the preset payload.
        def ignore_staging_state(directory: str, names: List[str]) -> Set[str]:
            directory_path = Path(directory)
            return {
                name
                for name in names
                if directory_path / name in (self.presets_dir, staging_dir, backup_dir)
            }

src/specify_cli/presets/init.py:4441

  • Returning here leaves every generated command/skill at the pre-update version. preset_enable only flips enabled and reconciles the constitution (presets/_commands.py:1173-1177), so after updating a disabled preset, newly added commands never appear and changed commands remain stale even after re-enabling it. The enable path needs to reconcile the now-current manifest (or this branch must record deferred reconciliation work).
        if not enabled:
            # Disabled presets keep their existing generated artifacts and
            # provenance until removal. Updating only replaces the source and
            # metadata while retaining enough ownership data for a later
            # removal to clean up artifacts left on disk.
            remove_swap_path(backup_dir)
            remove_swap_path(staging_dir)
            return new_manifest, diff

src/specify_cli/presets/_commands.py:67

  • This message is misleading for a public HTTPS redirect to an HTTPS loopback address: is_safe_download_redirect rejects that transition, but the message says the target merely needs HTTPS. State the actual policy so users can diagnose the rejection.
    def validate_redirect(old_url, new_url):
        if not is_safe_download_redirect(old_url, new_url):
            raise error_type(
                "redirect target must use HTTPS or remain on localhost"
            )

Comment thread src/specify_cli/presets/__init__.py
Exclude the project .specify directory when a development preset update stages from the project root, avoiding recursive preset state and Windows lock-file copy failures.

Assisted-by: GitHub Copilot (model: Auto, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: ab5495b7-024f-486a-8abd-7cbfe54056d6
Copilot AI review requested due to automatic review settings September 5, 2026 11:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Three moderate reconciliation and bundled-version issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 1 High severity · 2 Medium severity

New issues introduced by this change (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — A disabled update returns after replacing the source while deliberately retaining the old generated…
Medium severity src/​specify_cli/​presets/​_commands.py — Bundled updates assume that the copy shipped with the running Spec Kit has exactly the version…
Pre-existing issues (1)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py — This lock does not serialize the update against other preset mutations. preset disable, enable,… View comment
Suppressed comments (1)

src/specify_cli/presets/init.py:4537

  • The update performs two registration passes for the same affected names: this call writes the new preset's command files directly, and the same block later calls _reconcile_composed_commands; likewise _register_skills is followed by _reconcile_skills. For a lower-priority preset this briefly writes the wrong winner, and if the second pass fails the warning leaves that wrong output in place. It also contradicts #4427's single-pass reconciliation requirement. Route the added/removed/changed union through one stack-aware reconciliation pass and derive the ownership metadata from that result instead of registering the incoming preset first.
            registered_commands = self._register_commands(
                new_manifest, current_dir, command_names=primary_command_names
            )

Comment thread src/specify_cli/presets/__init__.py
Comment thread src/specify_cli/presets/_commands.py
Refresh generated commands and skills when a preset is re-enabled after a disabled update, removing stale artefacts before re-registering the active agent output.

Use the locally bundled preset version for bundled updates and fail clearly when the installed spec-kit package lags behind the catalogue.

Assisted-by: GitHub Copilot (model: Auto, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: ab5495b7-024f-486a-8abd-7cbfe54056d6
Copilot AI review requested due to automatic review settings September 5, 2026 12:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Three moderate enable-path issues can leave registry state or generated artifacts inconsistent or stale.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 1 High severity · 4 Medium severity

New issues introduced by this change (3)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — Validate the installed manifest before setting enabled. As written, a missing or corrupt…
Medium severity src/​specify_cli/​presets/​_commands.py — Legacy registries store registered_skills as a flat list, but _normalize_registered_skills()
Medium severity src/​specify_cli/​presets/​_commands.pyresolve_active_agent_for_registration() returns the non-string MISSING_INIT_OPTIONS_FILE
Pre-existing issues (2)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py — This lock does not serialize the update against other preset mutations. preset disable, enable,… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — A disabled update returns after replacing the source while deliberately retaining the old generated… View comment
Issues resolved since last review (1)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — Bundled updates assume that the copy shipped with the running Spec Kit has exactly the version… View resolved comment

Comment thread src/specify_cli/presets/_commands.py Outdated
Comment thread src/specify_cli/presets/_commands.py Outdated
Comment thread src/specify_cli/presets/_commands.py Outdated
- Validate the installed preset.yml before flipping enabled=True, so a
  missing/corrupt manifest fails closed with the preset left disabled
  instead of enabling first and erroring after mutating the registry.
- Fix legacy flat-list registered_skills handling: use
  _infer_legacy_skill_provenance when the raw registry value is a list,
  so stale-skill cleanup on enable correctly identifies per-agent
  ownership instead of silently doing nothing.
- Fall back to registering commands/skills for every detected agent
  when resolve_active_agent_for_registration returns
  MISSING_INIT_OPTIONS_FILE (legacy pre-init-options projects), instead
  of silently skipping all rescaffolding, matching install-time
  behaviour elsewhere in the codebase.

Adds regression tests for all three scenarios under
TestPresetEnableDisable.

Assisted-by: GitHub Copilot (model: Auto, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ab5495b7-024f-486a-8abd-7cbfe54056d6
Copilot AI review requested due to automatic review settings September 5, 2026 12:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Six moderate correctness and transactional-safety issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 1 High severity · 3 Medium severity

New issues introduced by this change (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — The preset is persisted as enabled before the newly added unregister/register/reconcile sequence,…
Medium severity src/​specify_cli/​presets/​_commands.py — This cleanup restores only extension/core content; it does not resolve a surviving lower-priority…
Pre-existing issues (2)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py — This lock does not serialize the update against other preset mutations. preset disable, enable,… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — A disabled update returns after replacing the source while deliberately retaining the old generated… View comment
Issues resolved since last review (3)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.pyresolve_active_agent_for_registration() returns the non-string MISSING_INIT_OPTIONS_FILEView resolved comment
Medium severity src/​specify_cli/​presets/​_commands.py — Legacy registries store registered_skills as a flat list, but _normalize_registered_skills()View resolved comment
Medium severity src/​specify_cli/​presets/​_commands.py — Validate the installed manifest before setting enabled. As written, a missing or corrupt… View resolved comment
Suppressed comments (4)

src/specify_cli/presets/init.py:4566

  • In a legacy project without init-options.json, resolve_active_agent_for_registration() returns the truthy MISSING_INIT_OPTIONS_FILE sentinel. Passing active_agent or "" here lets _infer_legacy_skill_provenance() use that object as a dictionary key for any unmatched legacy skill. The resulting non-string key reaches registry.update() and json.dump(), which raises after the live preset has already been swapped and can leave .registry truncated. Normalize the sentinel to an empty string, as preset_enable() already does.
                registered_skills_before = self._infer_legacy_skill_provenance(
                    [name for name in raw_registered_skills_before if isinstance(name, str)],
                    target_id,
                    active_agent or "",
                )

src/specify_cli/presets/init.py:4445

  • This cleanup runs after the directory swap and registry update have committed. If removing the backup fails (for example, because a file is locked), update_from_directory raises and the CLI reports the preset as failed even though the new version is installed; the enabled path has the same problem in its final cleanup. Treat post-commit backup/staging cleanup as best-effort and emit an actionable warning while retaining the recovery directory instead of converting a successful update into a failure.
            remove_swap_path(backup_dir)

src/specify_cli/presets/init.py:4618

  • Changed commands and skills have already been written by _register_commands and _register_skills above, and these reconciliation calls write those same affected names again. That contradicts #4427's requirement that command/skill registration reconciliation run exactly once per update and creates an avoidable intermediate artifact state. Use the full-stack reconciliation pass as the sole writer and derive ownership metadata from its results.
            if reconcile_command_names:
                historical_agents = {

src/specify_cli/presets/_commands.py:730

  • This equality check happens before resolving a bundled preset's local manifest version. Consequently, an installed v2 preset is reported up to date when the catalog still says v2 even if the current Spec Kit package bundles v3; the bulk preflight has the same ordering. Resolve bundled availability first and compare the installed version against the local bundled version so newer bundled content is not skipped.
                if catalog_version == installed_version and effective_priority is None:
                    console.print(
                        f"[dim]• {safe_id}: Up to date, skipped "
                        f"(v{installed_version})[/dim]"
                    )
                    outcomes.append("skipped")
                    continue

Comment thread src/specify_cli/presets/_commands.py Outdated
Comment thread src/specify_cli/presets/_commands.py Outdated
- Snapshot the pre-enable registry entry and wrap the reconciliation
  body in try/except: any failure restores the registry to its prior
  disabled state instead of leaving the preset marked enabled with
  only partially refreshed artifacts.
- Reorder preset_enable to unregister/reconcile stale skills before
  stale commands, mirroring PresetManager.remove(). Native skill-only
  agents (claude, codex) track the same physical SKILL.md file under
  both registered_commands and registered_skills; unregistering
  commands first (no core-fallback there) could delete that file
  before the skills pass ever got a chance to restore/reconcile it.
- Filter stale commands already covered by the skills pass out of the
  _unregister_commands call for native skill-only agents, avoiding a
  double-delete of the same file.
- Pass _unregister_skills's returned affected directories through to
  _reconcile_skills, so a surviving lower-priority preset's override
  is reconciled back in instead of left showing core/extension
  content.
- Add _command_name_for_skill_name (inverse of
  _skill_names_for_command) and use it to derive command names for
  stale skills, so skill-only reconciliation still runs even when no
  commands went stale.

Adds regression tests for registry rollback on reconciliation failure
and for surviving lower-priority preset reconciliation across
historical and active agents.

Assisted-by: GitHub Copilot (model: Auto, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: ab5495b7-024f-486a-8abd-7cbfe54056d6
Copilot AI review requested due to automatic review settings September 5, 2026 14:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Moderate defects remain in bundled-version selection and command/skill reconciliation.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 2 Medium severity

New issues introduced by this change (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — This is not an inverse for namespaced commands: _skill_names_for_command(&quot;speckit.foo.bar&quot;)
Medium severity src/​specify_cli/​presets/​__init__.py — Removed commands are unregistered before their skill registrations are restored. For native skill…
Issues resolved since last review (4)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — This cleanup restores only extension/core content; it does not resolve a surviving lower-priority… View resolved comment
Medium severity src/​specify_cli/​presets/​_commands.py — The preset is persisted as enabled before the newly added unregister/register/reconcile sequence,… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — A disabled update returns after replacing the source while deliberately retaining the old generated… View resolved comment
High severity src/​specify_cli/​presets/​__init__.py — This lock does not serialize the update against other preset mutations. preset disable, enable,… View resolved comment
Suppressed comments (3)

src/specify_cli/presets/_commands.py:1412

  • Only the active agent is refreshed here; the reconciliation below targets historical agents only for names that became stale. If a preset was rendered for Claude, switched to Codex, then updated while disabled with the same command name but new content, enabling it refreshes Codex while Claude keeps the old body indefinitely. Reconcile all current command/skill names across the preset's recorded historical agents, not just removed names.
        elif isinstance(resolved_agent, str):
            manager.register_enabled_presets_for_agent(resolved_agent)

        reconcile_command_names = sorted(
            {name for names in stale_commands.values() for name in names}

src/specify_cli/presets/_commands.py:579

  • The bulk path decides a bundled preset is current before inspecting the bundled manifest. Thus, with installed v2, catalogue v2, and a locally shipped bundled v3, it skips v3 even though this command otherwise treats the newer local bundled version as authoritative. Resolve the bundled version first, then compare the effective available version with the installed version.
                catalog_version = pkg_version.Version(str(pack_info["version"]))
                if catalog_version <= installed_version and effective_priority is None:
                    console.print(
                        f"[dim]• {safe_id}: Up to date, skipped "
                        f"(v{installed_version})[/dim]"

src/specify_cli/presets/_commands.py:728

  • This early version skip also prevents single-item bundled updates from seeing a newer local bundled copy. For installed v2/catalogue v2/local bundled v3, the command reports “Up to date” and never reaches _bundled_update_source, despite the supported local-newer behavior. Compare against the bundled manifest's effective version before returning.
                if catalog_version == installed_version and effective_priority is None:
                    console.print(
                        f"[dim]• {safe_id}: Up to date, skipped "
                        f"(v{installed_version})[/dim]"
                    )

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py
_commands.py doesn't import typing.Dict/List; with `from __future__
import annotations` these are lazily-evaluated string annotations so
ruff's F821 caught the undefined names rather than a runtime failure.
Switch to builtin dict/list generics (PEP 585) instead of adding an
unused-elsewhere typing import.

Assisted-by: GitHub Copilot (model: Auto, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: ab5495b7-024f-486a-8abd-7cbfe54056d6
Copilot AI review requested due to automatic review settings September 5, 2026 14:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Moderate issues remain in bundled-version selection, registry handling, and command/skill reconciliation.

Review tier: Balanced
Findings: 2 Medium severity

Pre-existing issues (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — Removed commands are unregistered before their skill registrations are restored. For native skill… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — This is not an inverse for namespaced commands: _skill_names_for_command(&quot;speckit.foo.bar&quot;)View comment
Suppressed comments (7)

Previously missed (2) — in code that hasn't changed since the last review.

src/specify_cli/presets/_commands.py:676

  • A corrupt registry value that is not a mapping causes "version" not in metadata to raise TypeError, which is not caught by the single-update handler and leaks a traceback instead of the intended “registry entry is missing or corrupt” failure. The bulk preflight happens to catch this separately, so guard the type here explicitly.
    src/specify_cli/presets/_commands.py:1401
  • When registered_skills is a legacy flat list and none of its skills are stale, the earlier migration remains only in the local registered_skills variable. Here current_metadata["registered_skills"] is still a list, so the subsequent merged_skills.get(...) raises AttributeError and enabling an otherwise unchanged legacy preset fails. Seed the merge from the already inferred mapping when the persisted value is a list.

src/specify_cli/presets/init.py:2954

  • This is not an inverse for namespaced commands: _skill_names_for_command("speckit.fakeext.cmd") produces speckit-fakeext-cmd, but this maps it back to speckit.fakeext-cmd. When enable cleans a stale skill that has no registered_commands entry, historical skill directories rely on this recovered name; reconciliation therefore cannot find a surviving lower-priority provider of the original namespaced command. Match stale skill names against known manifest command identities instead of reversing this lossy dot-to-hyphen encoding.
        if skill_name.startswith("speckit-"):
            short_name = skill_name[len("speckit-"):]
        elif skill_name.startswith("speckit."):
            short_name = skill_name[len("speckit."):]
        else:

src/specify_cli/presets/init.py:4550

  • Removed commands are unregistered before their corresponding skills. For an active native */SKILL.md agent, both registry maps refer to the same file, so _unregister_commands deletes the skill directory; _unregister_skills then cannot restore it, and the later active-directory reconciliation sees no managed skill names after the registry entry is cleared. Updating an enabled preset that drops a core-overriding command therefore leaves the core skill missing. Mirror remove()/preset_enable: unregister skills first, capture affected directories, and exclude covered native-skill commands from this pass.
                if stale_commands:
                    self._unregister_commands(stale_commands)

src/specify_cli/presets/init.py:4553

  • This directly writes every changed template from the updated preset before _reconcile_composed_commands writes the winning layer again. When the updated preset is lower priority, the first write is known to be the wrong winner; every successful update performs duplicate writes, and a later reconciliation failure leaves that lower-priority content active. This also contradicts #4427's single-pass reconciliation requirement. Drive the active-agent write only through the winner-aware reconciliation pass and use its results to update registration metadata.
            registered_commands = self._register_commands(
                new_manifest, current_dir, command_names=primary_command_names
            )

src/specify_cli/presets/_commands.py:710

  • The single-preset path has the same premature catalog-version check: an installed v2 preset is skipped when the catalog remains at v2 even if the locally bundled manifest is v3. Determine the effective bundled version before deciding the installation is current, otherwise users cannot pick up newer bundled content after upgrading Spec Kit.
                if catalog_version < installed_version:

src/specify_cli/presets/_commands.py:576

  • Bulk updates compare the catalog version before resolving a bundled preset's actual local version. If the catalog says v2, the installed preset is already v2, and this Spec Kit release bundles v3, this branch reports “Up to date” and never reaches _bundled_update_source, despite the later logic intentionally preferring the newer bundled copy. Resolve bundled metadata first and compare installed_version against that effective version.
                catalog_version = pkg_version.Version(str(pack_info["version"]))
                if catalog_version <= installed_version and effective_priority is None:

…n preset enable

Copilot's review flagged that recovering a command name from a skill
directory name by naive reversal (`_command_name_for_skill_name`) is
mathematically lossy for namespaced commands: "speckit.git.feature"
and "speckit.git-feature" both encode to "speckit-git-feature", so
reversing the encoding can't tell them apart.

Replace the reversal with forward-matching: build the set of command
names this preset is known to use (current manifest + everything ever
recorded in its own registered_commands), and for each stale skill,
find which known command name's _skill_names_for_command(...) output
actually produces it. This is unambiguous because it goes in the
transform's defined (forward) direction, and is scoped to only this
preset's own command universe.

Removed the now-dead, buggy _command_name_for_skill_name helper.

Added a regression test (namespaced speckit.git.feature command, with
the affected agent in a historical/inactive-agent scenario) covering
this exact case end-to-end.

A related reordering change to update_from_directory (unregistering
stale skills before stale commands) was evaluated but reverted: traced
step-by-step, update_from_directory's own _reconcile_skills call
unconditionally rebuilds every affected command name's skill content
after both unregister calls run, regardless of their order, so no
reachable regression exists there -- reordering would have added
complexity without fixing anything.

Assisted-by: GitHub Copilot (model: auto, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ab5495b7-024f-486a-8abd-7cbfe54056d6

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Two moderate reconciliation and post-commit cleanup issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 3 Medium severity

New issues introduced by this change (1)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — For a command-backed integration in skills mode (for example Copilot), registered_commands is…
Pre-existing issues (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — Removed commands are unregistered before their skill registrations are restored. For native skill… View comment
Medium severity src/​specify_cli/​presets/​__init__.py — This is not an inverse for namespaced commands: _skill_names_for_command(&quot;speckit.foo.bar&quot;)View comment
Suppressed comments (1)

src/specify_cli/presets/init.py:4785

  • If deleting the backup or staging directory fails after the swap and registry update have succeeded, this finally block propagates OSError. The CLI then reports the preset as “failed,” although the new version is already installed and deliberately cannot be rolled back at this stage. Treat post-commit cleanup as best-effort and emit an actionable warning so the outcome accurately reports the successful update and the leftover recovery directory.
        finally:
            remove_swap_path(backup_dir)
            remove_swap_path(staging_dir)

Comment thread src/specify_cli/presets/_commands.py
For a command-backed integration running in explicit skills-opt-in mode
(e.g. Copilot with ai_skills on), _register_commands's guard never
populates registered_commands for it, and after a disabled update the
stale command is also absent from the preset's own current manifest.
The prior forward-match fix (8d2210c) only searched this preset's own
current manifest plus its own registered_commands, so it could not
recover the command name in this case: _reconcile_skills received an
empty command list and no-op'd, and the skill directory was deleted
outright (no core/extension fallback to restore).

Broaden the candidate search in preset_enable to also scan every other
installed preset's registered_commands and re-parsed preset.yml
(including aliases), since the surviving lower-priority preset that
should win the skill back always still lists the command in its own
current manifest.

Added a regression test that discriminates the fix using two
command-backed, skills-opt-in agents (one historical, one active) so
neither agent's registered_commands can mask the bug via the
active-agent full-rescaffold path. Confirmed the test fails (skill file
deleted) against the narrower fix and passes with this change.

Assisted-by: GitHub Copilot (model: auto, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ab5495b7-024f-486a-8abd-7cbfe54056d6

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Three unresolved moderate correctness issues must be addressed before approval.

Review tier: Balanced
Findings: 1 Medium severity

Pre-existing issues (1)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — Removed commands are unregistered before their skill registrations are restored. For native skill… View comment
Issues resolved since last review (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — For a command-backed integration in skills mode (for example Copilot), registered_commands is… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — This is not an inverse for namespaced commands: _skill_names_for_command(&quot;speckit.foo.bar&quot;)View resolved comment
Suppressed comments (3)

Previously missed (1) — in code that hasn't changed since the last review.

src/specify_cli/presets/_commands.py:784

  • In bulk dry-run mode, every call here leaves the manager and registry unchanged, so each preset's prospective constitution is calculated against the original stack rather than the cumulative stack that the real bulk update will produce. For example, if a higher-priority update removes its constitution layer and a lower-priority update changes its layer, the lower update is reported as constitution unchanged here even though the real sequential run reconciles it after the higher layer is removed. Accumulate the preflighted sources as resolver overrides (in execution order) when computing bulk dry-run status so the preview matches the final stack.

src/specify_cli/presets/_commands.py:1387

  • In a legacy project without init-options.json, enabling this preset registers only this preset across every detected agent. Because registration writes directly, enabling a lower-priority preset can overwrite an already-enabled higher-priority preset for every shared command/skill; the later reconciliation only covers stale (removed) names, not the current names just written. Reconcile current_command_names against the full enabled stack after this branch, or re-register all enabled presets in reverse priority order as the active-agent path does.
        if resolved_agent is MISSING_INIT_OPTIONS_FILE:
            # Legacy pre-init-options project: there is no single "active
            # agent" to target, so mirror install-time behaviour and
            # register/re-register this preset's commands and skills for
            # every agent directory actually present on disk, merging the
            # fresh result into the stored registry state.
            fresh_commands = manager._register_commands(manifest, pack_dir)

src/specify_cli/presets/_commands.py:1492

  • Forward matching is still ambiguous for the collision described above. If the installed stack contains both speckit.git.feature and speckit.git-feature, both map to speckit-git-feature; iterating a set and breaking on the first match chooses an arbitrary logical command, so enable can repopulate the stale skill with the unrelated command's content. The new regression test only includes one of the colliding names. Preserve a non-lossy command-to-skill identity in registry provenance (or reject such collisions) instead of recovering it from the encoded directory name.
                for command_name in all_known_command_names:
                    modern, legacy = manager._skill_names_for_command(command_name)
                    if skill_name in (modern, legacy):
                        stale_skill_command_names.add(command_name)
                        break

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants