Skip to content

feat: add preset update command - #4441

Draft
digimangos wants to merge 39 commits into
github:mainfrom
digimangos:digimangos-preset-update-command-869
Draft

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

Conversation

@digimangos

@digimangos digimangos commented Sep 4, 2026

Copy link
Copy Markdown

Summary

Implements specify preset update for single and bulk preset updates.

  • Adds catalogue, --from, and --dev update sources.
  • 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.

Closes #4427.

digimangos and others added 17 commits September 4, 2026 11:51
Implement staged atomic preset updates with bulk and dry-run support, preserving registry state and reconciling manifests and constitutions safely.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep the installed preset untouched and clean up partial staging when update validation or staging fails, and make missing preset errors direct users to preset add.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add the --all alias for bulk updates while preserving source-option validation and single-update semantics.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Scope command and skill registration to added, removed, and changed manifest identities, preserve unaffected registry tracking, and report constitution status from the resolved content hash.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Detect referenced template content changes in update diffs and isolate malformed catalog versions and archive cleanup. Add regression coverage for content-only changes.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add regression coverage for manifest rejection, type changes, rollback, missing IDs, and single-update dry runs.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Revalidate the copied staging directory before the atomic swap and cover rollback when staged validation fails.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reconcile historical command and skill targets during updates and reuse release-asset download handling for explicit sources.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Use the installed bundled preset directory when catalogue metadata has no download URL. Preserve archive handling for externally downloaded presets.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Preserve recorded skill ownership until removed command skills are unregistered during preset reconciliation.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064

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(&quot;new&quot;) or item.get(&quot;old&quot;) 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
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
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
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(&quot;new&quot;) or item.get(&quot;old&quot;) 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
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
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

Four moderate correctness and concurrency 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 — Archive classification no longer honors the response's final URL. The previous preset add --from
Pre-existing issues (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — This discards each historical agent's recorded names and passes every affected skill name… View comment
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
Suppressed comments (3)

src/specify_cli/presets/init.py:4376

  • Current aliases are added to the reconciliation set, but PresetResolver._manifest_declared_template() only matches a template's primary name (src/specify_cli/presets/__init__.py:5979-5988). If an alias shares a name with a lower preset, extension, or core command, _register_commands() first writes the updated alias and this reconciliation then resolves and overwrites it with the lower provider. Make aliases resolver-visible, or distinguish live aliases from removed aliases so only removed aliases fall through to a lower layer.
                        alias
                        for alias in template.get("aliases", [])

src/specify_cli/presets/init.py:4314

  • The directory renames and registry write are not serialized. Two updaters can both validate the same old installation, then one can move the other's newly installed directory before either finishes registry update/reconciliation; both may report success while generated artifacts reflect interleaved diffs. The FileNotFoundError branch catches only the brief missing-directory window. Hold a per-project or per-preset transaction lock and recheck the installed version/hash under it before this rename; _workflow_install_transaction in src/specify_cli/workflows/_commands.py:685-718 provides an existing cross-platform pattern.
                os.replace(current_dir, backup_dir)

src/specify_cli/presets/_commands.py:758

  • The dry-run constitution status does not apply the conditions used by the real reconciliation. A command-only preset with --priority is reported as "constitution reconciliation pending" even though update_from_directory() never calls it, while a changed constitution template is reported as planned even when _constitution_is_generated() will preserve a hand-edited file. Compute the planned outcome using the same constitution-layer and hand-edit guards so dry-run output reflects the writes an actual update would perform.
            constitution_status = (
                "constitution reconciliation pending"
                if dry_run and priority is not None and not constitution_diff
                else "constitution change planned"
                if dry_run and constitution_diff

Comment thread src/specify_cli/presets/_commands.py Outdated
digimangos and others added 2 commits September 4, 2026 17:43
Preserve the final URL returned after redirects and classify downloaded
archives from their detected bytes using the canonical suffix. This
prevents a .zip request that redirects to a .tgz asset from being
validated with the wrong filename format.

Add a regression test covering the redirected archive case.

Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Serialize preset updates with the existing project transaction lock,
make resolver lookups alias-aware, preserve per-agent historical skill
ownership during reconciliation, and make dry-run constitution output
respect the real sync and hand-edit guards.

Add regression coverage for alias resolution and command-only priority
changes during dry-run updates.

Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Copilot AI review requested due to automatic review settings September 4, 2026 17:02
@digimangos

Copy link
Copy Markdown
Author

Review round update, posted on behalf of @digimangos by GitHub Copilot (model: gpt-5.6-luna).

Addressed the remaining findings:

  • 6cec91f1 fixes redirected archive classification by preserving the final URL and using the detected archive format's canonical suffix. Added regression coverage for .zip redirecting to .tgz.
  • f7cb866f preserves per-agent historical skill ownership, makes preset manifest resolver lookups alias-aware, serializes preset update swaps and registry writes with the project transaction lock, and makes dry-run constitution status honour the actual sync and hand-edit guards.
  • 981b5f0d added the missing bulk update coverage, including post-confirmation failure isolation, one-time confirmation, catalogue filtering, disabled-state preservation, skip handling, source re-resolution failures, and --all.

Validation: 7,731 tests passed, 16 skipped, and uv build succeeded.

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, skill reconciliation, dry-run reporting, and priority-only updates.

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 (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — Aliases now participate in preset resolution, but this validation does not reject an alias that…
Medium severity src/​specify_cli/​presets/​_commands.py — This current-version check also bypasses an explicitly requested single-preset --priority. Thus…
Pre-existing issues (1)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — This discards each historical agent's recorded names and passes every affected skill name… View comment
Issues resolved since last review (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — Archive classification no longer honors the response's final URL. The previous preset add --fromView resolved comment
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 resolved comment
Suppressed comments (2)

src/specify_cli/presets/init.py:4575

  • Historical skill directories are collected only from the preset being updated. If another preset currently owns an affected skill under an inactive agent, and this update changes priority so the updated preset becomes the winner, _reconcile_skills() updates only the active directory and leaves that inactive agent on the old winner. The command path above already scans every other registry entry for this case; perform the equivalent stack-wide registered_skills scan and pass those agent directories and affected names here.
                extra_skill_dirs = {}
                if isinstance(registered_skills_before, dict):
                    for agent, names in registered_skills_before.items():
                        if agent == active_agent:
                            continue

src/specify_cli/presets/_commands.py:769

  • This dry-run status reports a planned constitution change whenever this preset's constitution bytes differ, even if that layer is disabled or remains below the current winner. The real reconciliation resolves the final enabled priority stack and _materialize_constitution_template() then skips an identical result, so the same update is reported as “change planned” in dry-run and “unchanged” when executed. Compute the prospective winning composition (including the proposed priority and enabled state) and compare its bytes with the current constitution before reporting the status.
                constitution_status = (
                    "constitution change planned"
                    if constitution_can_reconcile
                    and (constitution_diff or priority is not None)
                    else "constitution unchanged"

Comment thread src/specify_cli/presets/__init__.py
Comment thread src/specify_cli/presets/_commands.py Outdated
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
Copilot AI review requested due to automatic review settings September 4, 2026 17:18
@digimangos

Copy link
Copy Markdown
Author

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

Addressed the two remaining review findings in commit e63f25f7: preset manifest validation now rejects duplicate primary/alias identities by (name, type), and a single preset update <id> --priority N remains actionable when the catalogue version equals the installed version. Added regression tests for both cases.

Validation: full repository suite passed, 7,749 tests collected with no failures; full preset suite passed, 709 tests.

Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064

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

Five unresolved moderate issues affect dry-run immutability, reconciliation correctness, alias ownership, and bulk-update skipping.

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 (1)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — Bulk mode deliberately sets effective_priority to None, but this predicate checks the raw…
Pre-existing issues (1)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — This discards each historical agent's recorded names and passes every affected skill name… View comment
Issues resolved since last review (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — This current-version check also bypasses an explicitly requested single-preset --priority. Thus… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — Aliases now participate in preset resolution, but this validation does not reject an alias that… View resolved comment
Suppressed comments (6)

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

src/specify_cli/presets/init.py:4248

  • Every dry run enters _workflow_install_transaction, whose os.open(..., O_CREAT) creates .specify/.workflow-install.lock when absent. Thus --dry-run changes the project despite the documented “without changing anything” behavior. Route the dry-run validation through a read-only path before acquiring this persistent lock, or use locking that does not leave a project artifact.

This issue also appears on line 4248 of the same file.
src/specify_cli/presets/init.py:4576

  • Priority changes collect historical command agents stack-wide above, but skill reconciliation only includes directories recorded by the preset being updated. If another preset owns an affected skill in a now-inactive agent and this update changes the winner, that agent's skill remains stale. Build extra_skill_dirs from affected registered_skills across the preset stack, analogous to historical_agents for commands.

src/specify_cli/presets/init.py:4248

  • PresetManager loads its registry snapshot in __init__, before this lock is acquired. If two update processes construct managers concurrently, the second can wait for the first and then save its stale registry snapshot, reverting the first update's metadata while its new directory remains installed. Reload PresetRegistry after acquiring the lock so validation and every subsequent save use the serialized state.
        with _workflow_install_transaction(self.project_root):

src/specify_cli/presets/init.py:4465

  • This directly registers each changed template before _reconcile_composed_commands performs full-stack resolution, so an updated lower-priority preset temporarily overwrites the actual winner and can remain visible if reconciliation fails. It also contradicts #4427's required single reconciliation pass. Make the full-stack reconciliation the sole writer and derive registry tracking from its result instead of pre-registering the incoming manifest.
            registered_commands = self._register_commands(
                new_manifest, current_dir, command_names=primary_command_names
            )

src/specify_cli/presets/init.py:6029

  • Alias-aware resolution is incomplete: _reconcile_composed_commands still identifies preset owners at lines 2187 and 2300 using primary-name equality only. Resolving an alias therefore falls through to generic path registration and does not merge the write into the owning preset's registry metadata, which can orphan restored historical-agent output. Update those owner lookups to match the primary name or declared aliases.
                tmpl.get("type") == template_type
                and (
                    tmpl.get("name") == template_name
                    or (
                        isinstance(aliases, list)
                        and template_name in aliases
                    )

src/specify_cli/presets/_commands.py:689

  • The execution path repeats the raw priority check, so an equal-version preset reaching a bulk run with --priority is reinstalled even though bulk priority is ignored. Check effective_priority here so only a single-preset explicit reprioritization can bypass the equal-version skip.
                if catalog_version < installed_version or (
                    catalog_version == installed_version and priority is None
                ):

Comment thread src/specify_cli/presets/_commands.py Outdated
Copilot AI review requested due to automatic review settings September 4, 2026 17:22
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
@digimangos

Copy link
Copy Markdown
Author

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

Addressed the latest bulk-update finding in commit 12f7219b. Both catalogue preflight and execution now use effective_priority, so bulk --priority remains ignored and equal-version presets are skipped as intended. Added a regression test covering preset update --all --priority N.

Validation: CI-equivalent Ruff passed across src and tests; full preset suite passed, 710 tests.

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 critical concurrency defects and four moderate dry-run, reconciliation, priority, and validation issues remain unresolved.

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

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

New issues introduced by this change (3)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py — The update lock is acquired after PresetManager.__init__ has already cached the registry. If two…
High severity src/​specify_cli/​presets/​_commands.py — The catalogue-version decision is made before update_from_directory() acquires its transaction…
Medium severity src/​specify_cli/​presets/​__init__.pydry_run=True still enters _workflow_install_transaction, whose O_CREAT path creates and…
Issues resolved since last review (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​_commands.py — Bulk mode deliberately sets effective_priority to None, but this predicate checks the raw… View resolved comment
Medium severity src/​specify_cli/​presets/​__init__.py — This discards each historical agent's recorded names and passes every affected skill name… View resolved comment
Suppressed comments (4)

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

src/specify_cli/presets/_commands.py:560

  • Bulk --priority is documented and reported as ignored, but this preflight check uses the raw option. Supplying it makes a preset at the catalogue version actionable, so an already-current preset is downloaded and reinstalled instead of skipped.

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

  • The metadata guard assumes every registry value is iterable. For a corrupt scalar entry such as an integer, single-item update raises a raw TypeError at "version" not in metadata, bypassing the command's handled preset errors. Validate that the entry is a dictionary first, consistent with the existing enable/disable commands.

src/specify_cli/presets/init.py:4465

  • Changed commands are written here directly and then the same names are written again by _reconcile_composed_commands below; skills follow the same duplicate pattern. For a changed lower-priority preset, the first pass temporarily installs losing content, and if the later best-effort reconciliation fails that incorrect content remains. The linked #4427 acceptance criteria require one full-stack reconciliation pass per update; route the diff union through that pass once and derive registry ownership from its result.
            registered_commands = self._register_commands(
                new_manifest, current_dir, command_names=primary_command_names
            )

src/specify_cli/presets/_commands.py:689

  • The execution pass also uses the raw priority option, so bulk --priority can bypass the equal-version skip after preflight (including when the installed version changes between the two passes). Use the effective value, which is intentionally None for bulk updates, while preserving single-preset reprioritization.
                if catalog_version < installed_version or (
                    catalog_version == installed_version and effective_priority is None
                ):

Comment thread src/specify_cli/presets/__init__.py
Comment on lines +714 to +715
if source_kind == "dev" or source_path is not None:
manifest, diff = manager.update_from_directory(
Comment thread src/specify_cli/presets/__init__.py
Copilot AI review requested due to automatic review settings September 4, 2026 17:28
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064

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 stale registry race and reconciliation/reporting defects must be fixed before approval.

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

Review tier: Balanced
Findings: 2 High severity

New issues introduced by this change (1)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py — The transaction lock does not refresh the registry snapshot that PresetManager loaded in its…
Pre-existing issues (1)
Severity Finding
High severity src/​specify_cli/​presets/​_commands.py — The catalogue-version decision is made before update_from_directory() acquires its transaction… View comment
Issues resolved since last review (2)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.pydry_run=True still enters _workflow_install_transaction, whose O_CREAT path creates and… View resolved comment
High severity src/​specify_cli/​presets/​__init__.py — The update lock is acquired after PresetManager.__init__ has already cached the registry. If two… View resolved comment
Suppressed comments (3)

src/specify_cli/presets/init.py:6029

  • Alias-aware resolution is not matched by alias-aware reconciliation. _reconcile_composed_commands identifies preset owners only with tmpl["name"] == cmd_name (lines 2188 and 2300). A wrap/prepend/append command whose alias has no lower layer therefore resolves as a lone composing alias, resolve_content(alias) returns None, and reconciliation unregisters the alias that registration just wrote; both install and update now feed aliases into that path. Canonicalize aliases to their primary identity during reconciliation, or make the owner/composition logic consistently alias-aware.
        callers must NOT fall back to the convention lookup — that would mask a
        typo or pick up an undeclared file. Shared by ``resolve()`` and
        ``collect_all_layers()`` so their manifest-first resolution cannot
        silently diverge again (the divergence this fix addressed).
        """

src/specify_cli/presets/init.py:176

  • This introduces a third successful return value, "unchanged", but the function docstring still documents only "copied", "composed", and None. Document the new value so callers do not treat it as the composed case by default.
    provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
    if memory_constitution.exists() and memory_constitution.read_bytes() == content:
        # An unchanged constitution must produce zero file changes, so no
        # provenance sidecar is written here either. Provenance is recorded
        # only when constitution content is actually materialized below.
        return "unchanged"

src/specify_cli/presets/_commands.py:771

  • This predicts a constitution change from any manifest delta or --priority, but it never evaluates the hypothetical post-update stack. Updating a disabled or non-winning constitution provider—or setting an effect-neutral priority—leaves the resolved bytes unchanged, so dry-run reports “constitution change planned” while the real update reports “constitution unchanged.” Compare the live bytes with content resolved from the staged manifest/priority, or describe this only as a possible reconciliation rather than a change.
                constitution_status = (
                    "constitution change planned"
                    if constitution_can_reconcile
                    and (constitution_diff or priority is not None)
                    else "constitution unchanged"

Comment thread src/specify_cli/presets/__init__.py
Copilot AI review requested due to automatic review settings September 4, 2026 17:36
Assisted-by: GitHub Copilot (model: gpt-5.6-luna, autonomous)

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

Copilot-Session: 77a8e35d-943a-45f9-8afd-b94968b66064
@digimangos

Copy link
Copy Markdown
Author

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

Addressed the catalogue-version race in commit e8a7950d. After acquiring the transaction lock, preset updates now refresh registry data and re-check the installed version against the observed catalogue version before staging or swapping. Added a regression simulating a concurrent v3 install while a v2 update is pending; the stale update is rejected without touching installed files.

Validation: CI-equivalent Ruff passed; full preset suite passed, 712 tests.

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

Update locking, skill reconciliation, alias handling, dry-run reporting, and CLI help require correction.

Review tier: Balanced
Findings: 1 High severity

Pre-existing issues (1)
Severity Finding
High severity src/​specify_cli/​presets/​_commands.py — The catalogue-version decision is made before update_from_directory() acquires its transaction… View comment
Issues resolved since last review (1)
Severity Finding
High severity src/​specify_cli/​presets/​__init__.py — The transaction lock does not refresh the registry snapshot that PresetManager loaded in its… View resolved comment
Suppressed comments (5)

src/specify_cli/presets/init.py:4261

  • This lock does not serialize updates against the existing preset mutation paths: preset_add calls install_from_directory() and preset_remove calls remove() without acquiring _workflow_install_transaction. If a remove runs after this update has renamed the live directory to its backup, it can delete the registry entry, after which this update writes its stale in-memory registry snapshot and resurrects the preset. Use the same transaction lock for every preset add/remove/update mutation (or move locking into shared manager entry points) so the directory swap and registry save cannot interleave.
        with _workflow_install_transaction(self.project_root):
            # Refresh after waiting for the lock so concurrent updates cannot
            # overwrite newer registry metadata with this manager's snapshot.
            self.registry.data = PresetRegistry(self.presets_dir).data

src/specify_cli/presets/init.py:4587

  • extra_skill_dirs is built only from the updated preset's own registered_skills, unlike the stack-wide historical-agent scan used for commands above. When --priority promotes this preset over another preset that owns the same skill in an inactive agent directory, that directory is never reconciled and continues serving the old winner. Scan other registry entries for affected skill names and add their tracked agent directories here, mirroring historical_agents.
                    include_disabled=True
                ):
                    if other_id == target_id or not isinstance(other_meta, dict):

src/specify_cli/presets/init.py:6042

  • Alias resolution is not propagated to _FilteredManifest.templates, which still retains templates only when their primary name matches. During update reconciliation, aliases are included in reconcile_command_names; if a lower-priority preset is updated, its alias skill is written first, but _reconcile_skills filters out the higher-priority winner whose alias matched here, leaving the lower-priority SKILL.md active. Make the reconciliation filters and preset-owner lookups alias-aware as well.
          empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.

        The manifest is authoritative: when it declares a template (``entry`` is
        not ``None``) but the file is unusable (``candidate`` is ``None``),
        callers must NOT fall back to the convention lookup — that would mask a

src/specify_cli/presets/_commands.py:760

  • A dry run reports constitution unchanged whenever the file is absent, but the real update calls reconcile_constitution(..., create_if_missing=True) and will create it when constitution-sync is enabled. Treat a missing constitution as materialization pending so the dry-run output accurately describes the write that a real run will perform.
                    and constitution_path.exists()

src/specify_cli/presets/_commands.py:475

  • The help text implies that the positional value all selects bulk mode, but bulk mode is only activated by omitting the ID or passing --all; specify preset update all instead tries to update a preset literally named all. Remove this claim or implement the positional spelling.
    preset_id: str = typer.Argument(None, help="Preset ID to update (or all)"),

Copilot AI review requested due to automatic review settings September 4, 2026 17:46

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

Four moderate correctness and concurrency issues must be addressed before approval.

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

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

New issues introduced by this change (1)
Severity Finding
Medium severity src/​specify_cli/​presets/​__init__.py — This lock does not serialize against the existing preset mutators. preset set-priority, enable,…
Pre-existing issues (1)
Severity Finding
High severity src/​specify_cli/​presets/​_commands.py — The catalogue-version decision is made before update_from_directory() acquires its transaction… View comment
Suppressed comments (3)

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

src/specify_cli/presets/init.py:4523

  • Legacy registries store registered_skills as a flat list, but this call omits the required fallback_agent argument. Every update of such a preset therefore raises TypeError after the directory/registry swap; the outer handler only warns and skips the remaining skill and constitution reconciliation. Pass the already-resolved active agent as the fallback, matching the existing migration call sites.

src/specify_cli/presets/init.py:4443

  • This update path has the same alias/composition failure as installation: current aliases are added to reconcile_command_names and resolved as standalone commands, although they must share the primary command's composed output. A wrapped primary can therefore be updated successfully while its alias is immediately removed as “uncomposable.” Keep live aliases grouped with their primary registration, and only reconcile aliases that were actually removed so a lower-priority provider can be restored; add an update regression test using a wrapped command with an alias.
                    command_names.update(
                        alias
                        for alias in template.get("aliases", [])
                        if isinstance(alias, str)
                    )

src/specify_cli/presets/init.py:4495

  • This writes each affected command through _register_commands, and the later full-stack reconciliation writes the same command/skill set again. That violates #4427's requirement for one reconciliation pass over the added/removed/changed union, causes duplicate filesystem writes, and temporarily materializes the incoming preset rather than the winning stack. Consolidate registration and ownership tracking into the single full-stack reconciliation pass.
            registered_commands = self._register_commands(
                new_manifest, current_dir, command_names=primary_command_names
            )

Comment on lines +4258 to +4261
with _workflow_install_transaction(self.project_root):
# Refresh after waiting for the lock so concurrent updates cannot
# overwrite newer registry metadata with this manager's snapshot.
self.registry.data = PresetRegistry(self.presets_dir).data
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