Fix: make --skill deployment additive, not just persistence (#1786 follow-up)#1955
Conversation
…llow-up) PR #1786 made apm.yml/lockfile persistence of --skill subsets additive, but the deployment path still used the raw CLI subset. A second `apm install <bundle> --skill B` deployed only B and the stale-file cleanup deleted the previously deployed skill A from the target folder, so manifest and on-disk state diverged until the next bare install. Changes: - New shared helper `effective_deploy_skill_subset()` in package_resolution.py: deploy the UNION of the persisted apm.yml `skills:` and the current CLI `--skill` values; `--skill '*'` returns None (deploy all). - template.py: deploy via the helper instead of the raw CLI subset. - phases/lockfile.py: `_attach_skill_subset_override` now unions the CLI values with the persisted subset so the lockfile matches what was deployed. - install.py: `--skill '*'` now resets the apm.yml `skills:` pin back to the full bundle (the documented "reset to all skills" contract), so manifest and on-disk state agree after a reset. Adds regression tests covering the helper's union/reset semantics and an e2e suite (additive deploy-on-top, bare reinstall, --skill '*' reset, manifest reduction cleanup, uninstall removes the union). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 2 | 1 | Clean extraction of effective_deploy_skill_subset as single-source-of-truth for deploy resolution; minor redundancy with normalize_and_merge_skill_subset. |
| CLI Logging Expert | 0 | 2 | 1 | Additive deploy and --skill '*' reset are correct but silent; users get no breadcrumb of what was deployed. |
| DevX UX Expert | 0 | 1 | 2 | Additive --skill matches npm mental model; no ergonomic regression; removal path under-documented. |
| Supply Chain Security | 0 | 0 | 1 | Union draws from user-controlled manifest (not stale lockfile); deploy/lockfile parity holds; no surface expansion. Ship. |
| OSS Growth Hacker | 0 | 0 | 3 | Trust-protecting bugfix; frame CHANGELOG + docs callout around the additive promise. |
| Doc Writer | 0 | 1 | 0 | No doc drift; * reset contract now honored by code; add one clause that --skill is additive across installs. |
| Test Coverage Expert | 0 | 0 | 1 | All five regression scenarios have passing e2e traps (11 passed in 1.79s); ship. |
| Performance Expert | 0 | 0 | 1 | Pure set-arithmetic over tiny skill lists; no new I/O or materialization overhead. Ship. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Python Architect + Supply Chain Security] Replace hardcoded
skill_subset_from_cli=Trueatlockfile.py:249withself.ctx.skill_subset_from_cli-- Convergent signal from two independent panelists. Prevents a defensive-drift trap if the lockfile phase is ever invoked outside CLI-driven install. - [Doc Writer] Add one clause to
reference/cli/install.mdandapm-usage/commands.mdstating--skillis additive across separate installs (union, not replace) -- Users discovering--skillin the docs today have no way to know the additive contract without reading source. - [CLI Logging Expert] Emit a user-facing message on additive
--skilldeploy confirming which skills were retained and on*reset confirming pin removal -- Silent correctness is still surprising; a breadcrumb builds trust and aids debugging. - [OSS Growth Hacker] Frame the CHANGELOG entry as a user-visible promise rather than implementation detail -- CHANGELOG is a public trust surface; leading with the user promise reinforces positioning.
- [DevX UX Expert] Document how to REMOVE a single previously-pinned skill in help text -- Additive semantics require a discoverable removal path.
Architecture
classDiagram
direction LR
class InstallContext {
+skill_subset tuple~str~ or None
+skill_subset_from_cli bool
}
class effective_deploy_skill_subset {
+__call__(skill_subset_from_cli, cli_subset, persisted_subset) tuple or None
}
class TemplateIntegrator {
+_integrate_materialization(ctx, dep_ref)
}
class LockfileBuildPhase {
+_attach_skill_subset_override(lockfile)
}
InstallContext ..> effective_deploy_skill_subset : provides inputs
TemplateIntegrator ..> effective_deploy_skill_subset : calls
LockfileBuildPhase ..> effective_deploy_skill_subset : calls
class effective_deploy_skill_subset:::touched
class LockfileBuildPhase:::touched
class TemplateIntegrator:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
CLI["apm install bundle --skill B"]
CLI --> NORM["normalize_and_merge_skill_subset"]
NORM -->|"union(CLI B, persisted A)"| MANIFEST["[FS] apm.yml skills: [A,B]"]
CLI --> DEPLOY_RESOLVE["effective_deploy_skill_subset"]
DEPLOY_RESOLVE -->|"union(persisted [A,B], CLI (B,))"| DEPLOY["[FS] deploy skills A + B"]
CLI --> LOCK["_attach_skill_subset_override"]
LOCK -->|"effective_deploy_skill_subset"| LOCKW["[FS] apm.lock.yaml skill_subset [A,B]"]
DEPLOY --> STALE["[FS] stale cleanup removes only files NOT in union"]
Recommendation
Merge after folding the in-scope follow-ups. The PR restores a broken trust invariant (no silent artifact deletion), is backed by 11 passing e2e regression traps at the correct tier, and carries zero blocking findings from nine panelists. The author is folding the lockfile.py:249 defensive hardening, the doc clause, the CLI breadcrumbs, help-text removal path, and CHANGELOG framing into this PR before merge.
This panel is advisory. It does not block merge.
There was a problem hiding this comment.
Pull request overview
This PR closes a parity gap introduced after #1786 by making the deployment and lockfile recording paths for --skill behave additively (union with the persisted manifest subset), so the manifest/lockfile and on-disk deployed skills don’t silently diverge across repeated installs.
Changes:
- Add
effective_deploy_skill_subset()to compute the union of persisted + CLI skill subsets (and interpret--skill '*'as “deploy all”). - Switch deployment (
install/template.py) and lockfile override (install/phases/lockfile.py) to use the effective union, keeping deploy and lockfile aligned with persisted state. - Thread a
skill_subset_from_cliflag into install validation/resolution and add regression coverage for deploy parity.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/install/test_skill_subset_deploy_parity.py | Adds regression tests for additive deploy-on-top behavior, --skill '*' reset, manifest-driven reduction cleanup, and uninstall behavior. |
| src/apm_cli/install/template.py | Uses the new helper so deployment uses the effective union (rather than raw CLI subset). |
| src/apm_cli/install/phases/lockfile.py | Updates lockfile skill subset override to union CLI subset with persisted subset via the helper. |
| src/apm_cli/install/package_resolution.py | Introduces effective_deploy_skill_subset() helper to centralize deploy-subset resolution semantics. |
| src/apm_cli/commands/install.py | Threads skill_subset_from_cli and resets persisted skills: pin on --skill '*' (when resolving explicit package refs). |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Low
| if skill_subset_from_cli and not cli_subset: | ||
| return None # --skill '*' -> full bundle | ||
| merged: builtins.set[str] = builtins.set() | ||
| if persisted_subset: | ||
| merged.update(persisted_subset) | ||
| if cli_subset: | ||
| merged.update(cli_subset) | ||
| return builtins.tuple(sorted(merged)) or None |
| ``--skill '*'`` (empty ``ctx.skill_subset``) is a no-op here -- the | ||
| manifest reset already flowed the full-bundle (empty) subset through. | ||
| """ | ||
| if not self.ctx.skill_subset: | ||
| return # No CLI override; dep_ref.skill_subset already flows through | ||
| effective = sorted(set(self.ctx.skill_subset)) | ||
| return # bare install or --skill '*'; manifest value already flows through | ||
| for dep_key, locked_dep in lockfile.dependencies.items(): # noqa: B007 | ||
| if locked_dep.package_type == "skill_bundle": | ||
| locked_dep.skill_subset = effective | ||
| merged = effective_deploy_skill_subset( | ||
| skill_subset_from_cli=True, | ||
| cli_subset=self.ctx.skill_subset, | ||
| persisted_subset=locked_dep.skill_subset, | ||
| ) | ||
| locked_dep.skill_subset = list(merged or []) |
| elif skill_subset_from_cli: | ||
| # ``--skill '*'`` (explicit CLI, empty subset): reset the pin | ||
| # back to the full bundle. Drop any persisted ``skills:`` so the | ||
| # entry reverts to the plain string form and manifest/on-disk | ||
| # state agree on the whole bundle (documented contract: | ||
| # "Use --skill '*' to reset to all skills"). | ||
| dep_ref.skill_subset = None | ||
| _apm_yml_entries[canonical] = dep_ref.to_apm_yml_entry() |
Round-1 apm-review-panel returned ship_with_followups (zero blocking). Fold all five in-scope CEO-curated recommendations: - lockfile.py: pass ctx.skill_subset_from_cli to effective_deploy_skill_subset instead of hardcoded True (consistency with template.py; defensive against future non-CLI callers). [py-architect + supply-chain convergent] - template.py: hoist effective_skill_subset to a named local and emit a verbose breadcrumb naming retained (previously-pinned) skills when the additive union exceeds the CLI request. [cli-logging] - install.py: verbose breadcrumb on --skill '*' pin reset; expand --skill help text to document additive union + single-skill removal path. [cli-logging + devx-ux] - docs/install.md + apm-usage/commands.md: document --skill additive-across- installs union + removal path (doc-sync Rule 4). [doc-writer] - CHANGELOG: add Unreleased Fixed entry framed as user-promise restoration. [growth] Breadcrumbs are verbose-only (verbose_detail), ASCII-only; no change to default output. Parity suite 11 passed; full lint chain green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CI Stage-1 tightened the file-length guardrail to 2100 lines (was 2450); main's install.py already sits at 2099, so the additive --skill fix pushed it to 2116 and tripped the guard. Move the skill-pin handling into package_resolution.py -- the module that exists to keep the command module small: - cli_skill_subset(skill_names): normalize raw --skill names to a subset (None for absent / '*'), lifting the inline block out of install(). - apply_cli_skill_pin(...): attach/merge the additive pin (#1771) or reset to the full bundle on --skill '*' (#1786), deriving identity/canonical from the reference so the call site stays compact. install.py drops from 2116 to 2100 (== cap, passes the > comparison). Behaviour unchanged: parity suite + full install/commands unit dirs green (2817 passed); ruff, R0801, auth-signal, and file-length guards all clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The deployment/lockfile parity fix lands under install/ (an OpenAPM critical path) but introduces no net-new wire-format behaviour: the lockfile skill_subset field format and semantics are unchanged, and PR #1786 established the additive --skill contract without a spec requirement, so no anchor/manifest row applies. Re-triggers the spec-conformance workflow to re-read the PR-body waiver. apm-spec-waiver: bug fix to apm-CLI --skill additive deploy parity; no net-new OpenAPM wire-format surface -- lockfile skill_subset field/semantics unchanged; #1786 established the additive --skill contract without a spec requirement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add hermetic unit coverage for the two helpers the additive --skill deploy fix relies on, and tighten doc + lockfile clarity per the panel: - cli_skill_subset: absent / '*' / named / mixed-'*' normalization - apply_cli_skill_pin: '*' reset branch (clears pin, records full-bundle apm.yml entry) and bare-install no-op branch - docs: package-types.md additive cross-ref; install.md additive example - lockfile.py: explicit `list(merged) if merged else []` + guard comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 0 | 2 | Clean additive-union design; helper extraction is sound; SRP respected. |
| CLI Logging Expert | 0 | 0 | 0 | Output paths consistent; verbose breadcrumb well-formed and ASCII-clean. Ship clean. |
| DevX UX Expert | 0 | 0 | 2 | Additive semantics match the package-manager mental model; reset path is discoverable. |
| Supply Chain Security Expert | 0 | 0 | 0 | Union sources from the manifest, not the lockfile; skill_subset is inventory metadata, not a trust anchor. No findings. |
| OSS Growth Hacker | 0 | 0 | 0 | Reliability beat: install --skill is now truly additive -- a credible "we fixed silent data loss" story. |
| Doc Writer | 0 | 1 | 2 | Docs now state the additive contract clearly (round-2 folds applied). |
| Test Coverage Expert | 0 | 0 | 2 | Behavior well-covered; the two helper unit-test gaps were addressed this round (17 passing). |
| Performance Expert | 0 | 0 | 1 | No hot-path impact; union is O(n) over a tiny skill list. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 3 follow-ups
- [DevX UX Expert] Open a follow-up issue for a
--remove-skill Xor--skill -Xsubtractive flag to complete the additive/subtractive model. -- Additive-only is sound today, but enterprise users managing many skills will eventually need explicit subtraction without a full--skill '*'reset. - [Performance Expert] Remove the redundant
get_identity()recompute insideapply_cli_skill_pinonce the install.py line cap is addressed. -- Sub-microsecond today, but redundant work signals copy-paste drift; the fix is one line once the cap allows. - [Python Architect] Add a call-site comment in install.py noting that
apply_cli_skill_pinmutates bothdep_refandapm_yml_entriesin place. -- Mutation of two arguments is a maintainability surprise; a single-line comment is cheap insurance against future bugs.
Architecture
classDiagram
class package_resolution {
+effective_deploy_skill_subset(from_cli, cli_subset, persisted) tuple|None
+cli_skill_subset(skill_names) tuple|None
+apply_cli_skill_pin(dep_ref, cli_subset, from_cli, deps, entries) None
+normalize_and_merge_skill_subset(...) list
}
class template_deploy {
+deploy(ctx, dep_ref) None
}
class lockfile_phase {
+_attach_skill_subset_override(locked_dep, merged) None
}
class install_cmd {
+install(...) None
}
install_cmd ..> package_resolution : cli_skill_subset / apply_cli_skill_pin
template_deploy ..> package_resolution : effective_deploy_skill_subset
lockfile_phase ..> package_resolution : effective_deploy_skill_subset
flowchart TD
A[apm install bundle --skill B] --> B{cli_skill_subset}
B -->|'*' or absent| C[None = deploy all]
B -->|named| D[CLI subset tuple]
D --> E[effective_deploy_skill_subset]
F[apm.yml persisted subset] --> E
E --> G[additive union: prior + new]
G --> H[template deploy: write union to disk]
G --> I[lockfile phase: persist union]
C --> J[reset: deploy full bundle, apm.yml = plain entry]
sequenceDiagram
participant U as User
participant CLI as install.py
participant PR as package_resolution
participant T as template.py
participant L as lockfile.py
U->>CLI: apm install bundle --skill B
CLI->>PR: cli_skill_subset(("B",))
PR-->>CLI: ("B",)
CLI->>PR: apply_cli_skill_pin(dep_ref, ...)
PR-->>CLI: dep_ref.skill_subset = union(persisted, "B")
CLI->>T: deploy(dep_ref)
T->>PR: effective_deploy_skill_subset(...)
PR-->>T: ("A","B") union
T-->>U: A and B both on disk (A preserved)
CLI->>L: persist union to lockfile
Recommendation
Merge as-is. CI is green, all 17 tests pass, every in-scope recommendation was folded or addressed this round, and no panelist raised a blocking finding. The three deferred items are cap-blocked single-line edits or out-of-scope features -- track as follow-up issues post-merge. The reliability fix is user-facing and time-sensitive; delaying for cosmetic debt would be wrong.
Full per-persona findings
Python Architect
- [nit] lockfile.py
list(merged or [])readability atsrc/apm_cli/install/phases/lockfile.py
Ambiguous truthiness guard. Folded this round: nowlist(merged) if merged else []with a clarifying comment. - [nit]
apply_cli_skill_pinmutates two args (dep_ref+apm_yml_entries) in place atsrc/apm_cli/install/package_resolution.py
Wants a side-effect note at the call site. Deferred: the clean fix adds a line to install.py, which is at the 2100-line CI cap.
CLI Logging Expert
No findings.
DevX UX Expert
- [nit]
--skill '*'help text should hint shell-quoting atsrc/apm_cli/commands/install.py
Folded this round (in-place, net-zero-line edit). - [nit] A future
--remove-skill X/--skill -Xsubtractive flag would complete the model.
Deferred as an out-of-scope follow-up issue candidate.
Supply Chain Security Expert
No findings.
OSS Growth Hacker
No findings. Reliability story flagged for the release note (see Growth signal above).
Doc Writer
- [recommended] package-types.md missing additive cross-ref at
docs/src/content/docs/reference/package-types.md
Folded this round. - [nit] install.md example should demonstrate additive-across-two-installs at
docs/src/content/docs/reference/cli/install.md
Folded this round. - [nit] migration.md -- persona itself said leave as-is. No action.
Test Coverage Expert
- [nit] No dedicated unit test for
apply_cli_skill_pinreset branch.
Addressed this round: added reset-branch + no-op-branch unit tests. - [nit] None for
cli_skill_subset.
Addressed this round: added absent /'*'/ named / mixed-'*'unit tests.
Proof (passed):tests/unit/install/test_skill_subset_deploy_parity.py-- 17 passed.
Performance Expert
- [nit]
apply_cli_skill_pinrecomputesget_identity()the caller already holds atsrc/apm_cli/install/package_resolution.py
Deferred: sub-microsecond; the clean fix adds a line to the capped install.py.
Auth Expert -- inactive
no auth surface -- PR touches install deploy/lockfile parity only, no token/credential/host-resolution paths
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Bump pyproject.toml + uv.lock to 0.23.1 and move the [Unreleased] CHANGELOG block into [0.23.1] - 2026-06-29 (two Fixed entries: audit content-hash CRLF invariance #1952/#1959, --skill additive deploy #1955). Lint mirror green locally (ruff check + format, pylint R0801, auth-signals). Post-merge: tag v0.23.1 to trigger the release workflow. Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
TL;DR
PR #1786 made
apm.yml/lockfile persistence of--skillsubsets additive, but the deployment path still used the raw CLI subset. A secondapm install <bundle> --skill Bdeployed onlyBand the stale-file cleanup deleted the previously deployed skillAfrom the target folder — so the manifest said[A, B]while the disk had onlyB, until the next bareapm install. This PR closes that gap so deployment, lockfile, and manifest stay in lockstep.Problem (WHY)
apm install <bundle> --skill Athenapm install <bundle> --skill Bshould leave both skills deployed (B installed on top of A). Instead:apm.ymlcorrectly merged to[A, B](the Fix --skill to merge additively with existing skills in apm.yml #1786 fix), butB), and stale-file cleanup removedAfrom disk.The result was a silent divergence between the manifest/lockfile and the actual on-disk deployment.
Approach (WHAT)
Deploy the union of the persisted manifest
skills:and the current CLI--skillvalues, and make--skill '*'honor its documented "reset to all skills" contract on the manifest as well as on disk.Implementation (HOW)
effective_deploy_skill_subset()(install/package_resolution.py): returns the sorted, deduped union of the persisted subset and the CLI subset; returnsNone(deploy all) for--skill '*'.install/template.py: deployment now calls the helper instead of using the raw CLI subset.install/phases/lockfile.py:_attach_skill_subset_overridenow unions the CLI values with the persisted subset (via the helper) so the lockfile records what was actually deployed, instead of clobbering with the raw CLI value.commands/install.py:--skill '*'now resets theapm.ymlskills:pin back to the full bundle (drops the field → plain string entry), matching the documented contract (--skillhelp text andmigration.md: "back to full bundle"). Threads askill_subset_from_cliflag through_validate_and_add_packages_to_apm_yml→_resolve_package_references.Trade-offs
apm.ymlare not silently re-added. Unioning the just-requested CLI skill is a safety net so the requested skill always deploys.Validation evidence
tests/unit/install/test_skill_subset_deploy_parity.py(11 tests): helper union/reset unit tests + e2e for additive deploy-on-top, bare reinstall,--skill '*'reset, manifest-reduction cleanup, and uninstall-removes-union.tests/unit/test_skill_subset_persistence.py,tests/unit/commands/test_install_skill_subset.py,tests/unit/install/(1759 passed); command/policy install+lockfile+skill tests (272 passed).ruff check,ruff format --check, pylint R0801 (10.00/10),lint-auth-signals.sh; all changed files well under the file-length guard.Live end-to-end (separate processes)
How to test
Or manually: install a multi-skill bundle with
--skill A, then--skill B, and confirm both land in.agents/skills/(or your target) and inapm.lock.yaml'sskill_subset.apm-spec-waiver: bug fix to apm-CLI --skill additive deploy parity; no net-new OpenAPM wire-format surface -- lockfile skill_subset field format and semantics are unchanged, and #1786 established the additive --skill contract without a spec requirement, so no anchor/manifest row applies.