Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Hook-only Git-path packages using `hooks/*.json` or `.apm/hooks/*.json` install without a placeholder manifest or skill, and invalid updates preserve the installed source, lockfile and deployment ownership. (#3282)
- Codex MCP entries no longer emit the unsupported `id` setting; reinstalling repairs recorded APM-owned entries while preserving registry identity, user settings, and ownership safeguards. -- by @Pybsama (#3157)
- Git marketplace installs accept versioned plugin directories while rejecting file, symlink, and submodule responses during directory validation. -- by @Pybsama (#3156)
- `apm install` and `apm update` no longer fail with `Object of type mappingproxy is not JSON serializable` when a hook carries nested metadata such as `env`; Claude, Codex, Gemini and Antigravity hook files keep that metadata as plain JSON. (by @abhishekacharya200, with regression coverage from @MohammedAlkindi in #3199, closes #3167) (#3211)
Expand Down
10 changes: 9 additions & 1 deletion CONFORMANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -1161,8 +1161,16 @@
"keyword": "MUST",
"section": "7.7",
"status": "active",
"test_count": 1,
"test_count": 9,
"tests": [
"tests/spec_conformance/test_hook_package_selection_reqs.py::test_selected_hook_update_preserves_unrelated_pins_and_bytes[direct-.apm/hooks]",
"tests/spec_conformance/test_hook_package_selection_reqs.py::test_selected_hook_update_preserves_unrelated_pins_and_bytes[direct-hooks]",
"tests/spec_conformance/test_hook_package_selection_reqs.py::test_selected_hook_update_preserves_unrelated_pins_and_bytes[parent-.apm/hooks]",
"tests/spec_conformance/test_hook_package_selection_reqs.py::test_selected_hook_update_preserves_unrelated_pins_and_bytes[parent-hooks]",
"tests/spec_conformance/test_hook_package_selection_reqs.py::test_selected_hook_update_preserves_unrelated_pins_and_bytes[refresh-.apm/hooks]",
"tests/spec_conformance/test_hook_package_selection_reqs.py::test_selected_hook_update_preserves_unrelated_pins_and_bytes[refresh-hooks]",
"tests/spec_conformance/test_hook_package_selection_reqs.py::test_selected_hook_update_preserves_unrelated_pins_and_bytes[shared-child-.apm/hooks]",
"tests/spec_conformance/test_hook_package_selection_reqs.py::test_selected_hook_update_preserves_unrelated_pins_and_bytes[shared-child-hooks]",
"tests/spec_conformance/test_resolution_reqs.py::test_resolver_records_resolved_ref_in_lockfile"
]
},
Expand Down
2 changes: 1 addition & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ APM claims the proposed deployed-prompt-audit capability (req-pl-019, req-pl-020
| [req-rs-009](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-009) | MUST | 7.5.1 | consumer | active | 1 | - |
| [req-rs-010](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-010) | MUST | 7.2 | consumer | active | 3 | - |
| [req-rs-011](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-011) | MUST | 7.7 | consumer | active | 4 | - |
| [req-rs-012](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-012) | MUST | 7.7 | consumer | active | 1 | - |
| [req-rs-012](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-012) | MUST | 7.7 | consumer | active | 9 | - |
| [req-rs-013](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-013) | MUST | 7.2 | consumer | active | 1 | - |
| [req-rs-014](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-014) | MUST | 7.3.1 | consumer | active | 1 | - |
| [req-rs-015](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-015) | MUST | 7.5 | consumer | active | 5 | - |
Expand Down
45 changes: 31 additions & 14 deletions docs/src/content/docs/reference/package-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Pick the form that matches the author's intent -- APM preserves it.
| `.apm/` (with or without apm.yml) | "I have N independent primitives" | Hoist each primitive into the target's runtime dirs |
| `SKILL.md` (alone or with apm.yml -- HYBRID) | "I am one skill bundle" | Copy the whole bundle to `<target>/skills/<name>/` |
| `skills/<name>/SKILL.md` (nested) | "I ship many skills in one repo" | Promote each nested skill to `<target>/skills/<name>/` |
| `hooks/*.json` only (no apm.yml or SKILL.md) | "I ship a set of harness hooks" | Deploy each hook to the target's `hooks/` directory |
| `hooks/*.json` or `.apm/hooks/*.json` only (no apm.yml or SKILL.md) | "I ship a set of harness hooks" | Register hooks and referenced resources for the selected targets |
| `plugin.json` (no `$schema`, or unrecognized `$schema`) / `.claude-plugin/` | Claude plugin collection | Dissect via plugin artifact mapping |
| `plugin.json` with the recognized Agent Plugins `$schema` | Portable Agent Plugin | Installed whole and registered when the effective targets include Copilot |
| Marketplace entry with inline `lspServers` or `mcpServers` | Catalog owns server metadata | Synthesize and validate `apm.yml`, then deploy servers |
Expand Down Expand Up @@ -180,23 +180,40 @@ primitives.
in one repository (e.g. all Azure skills, all Firebase skills). Consumers
can install the full set or cherry-pick with `--skill`.

## Hook package (`hooks/*.json` only)
## Hook package (`hooks/*.json` or `.apm/hooks/*.json`)

A package whose root contains `hooks/*.json` files but no `apm.yml`,
`SKILL.md`, or `plugin.json`. APM treats the whole directory as a hook
bundle: each hook JSON is deployed to the target's runtime hooks
directory.
A package containing `hooks/*.json` or `.apm/hooks/*.json` but no
`apm.yml`, `SKILL.md`, or `plugin.json` needs no generated manifest or
placeholder skill. Use a Git dependency's `path` to select the package
directory, not its inner hooks directory:

```
my-hooks/
+-- hooks/
+-- pre-commit.json
+-- post-merge.json
```yaml
dependencies:
apm:
- git: acme/agent-packages
path: packages/my-hooks
ref: v1.0.0
```

**What gets installed:** every file under `hooks/` is deployed to the
target's hooks runtime path (e.g. `.github/hooks/` for Copilot,
`.claude/hooks/` for Claude).
Here, `packages/my-hooks/hooks/lint.json` or
`packages/my-hooks/.apm/hooks/lint.json` declares the hooks.
Discovery is non-recursive; arbitrary directories are not packages.

**What gets installed:** hook registrations and referenced script bundles
follow the selected targets and existing executable approval rules. Claude
and Codex use their native configuration files; Copilot uses `.github/hooks/`.
Installation does not execute the hooks. See
[hook authoring](../../producer/author-primitives/hooks-and-commands/)
for command paths, resource selection, and target restrictions.

`apm install` restores the locked revision; `apm update` refreshes it.
`apm update <package>` and `apm install <package> --refresh` preserve unrelated
installed hook packages, despite invalid upstream descriptors.
Malformed hook JSON or an invalid replacement package fails before activation,
leaving the previous installation, lockfile, and deployment ownership intact.
This also applies to `apm install plugin@marketplace --refresh` when a catalog
selects a versioned hook-package directory, such as `plugins/hooks-1.2.3`.
`apm uninstall` removes the package's managed hooks and resources.

**When to choose:** you ship a set of harness hooks with no other
primitives. If you also ship skills or instructions, prefer the `.apm/`
Expand Down
15 changes: 14 additions & 1 deletion packages/apm-guide/.apm/skills/apm-usage/package-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ how to install it:
| `.apm/` (with or without apm.yml) | Multiple independent primitives | Hoist each primitive into the consumer runtime dirs |
| `SKILL.md` (alone, or with apm.yml = HYBRID) | One skill bundle | Copy whole tree to `<target>/skills/<name>/` |
| `skills/<name>/SKILL.md` | Many skills in one repo | Promote each nested skill to `<target>/skills/<name>/` |
| `hooks/*.json` only | Harness hook package | Deploy hooks to the target's hooks directory |
| `hooks/*.json` or `.apm/hooks/*.json` only | Harness hook package | Add hook commands to each target's configuration and copy referenced files |
| `plugin.json` (no `$schema`, or unrecognized `$schema`) / `.claude-plugin/` | Claude plugin collection | Dissect via plugin artifact mapping |
| `plugin.json` with the recognized Agent Plugins `$schema` | Portable Agent Plugin | Acquired and locked as one opaque unit; registered when effective targets include Copilot and admission gates pass. Excluded targets create no native registration or loose primitive projection. APM does not require the runtime during lifecycle operations; loading requires supported Copilot CLI 1.0.81 or newer |

Expand Down Expand Up @@ -129,6 +129,19 @@ Packages can ship hooks (pre/post tool-use scripts) by placing JSON
files under `hooks/` or `.apm/hooks/`. Filename-based hook routing
(`*-<harness>-hooks.json` and `hooks-<harness>.json`) is deprecated.

Hook-only Git dependencies need neither `apm.yml` nor `SKILL.md`. Set
`path` to the directory containing `hooks/` or `.apm/hooks/`, not the
inner hooks directory. Discovery is non-recursive. `apm install` restores
locked revisions; `apm update` refreshes them; `apm uninstall` removes
managed hooks and resources. Malformed hook descriptors or invalid
replacement packages leave the prior installation, lock, and ownership
intact. Hook admission neither grants executable approval nor runs scripts.
`apm update <package>` and `apm install <package> --refresh` preserve unrelated
installed hook packages.
For marketplace-selected versioned hook directories, use
`apm install plugin@marketplace --refresh`; a rejected replacement also preserves
the installed source bytes, lock, and deployment ownership.

APM computes hook reach with one restriction-only intersection:

`effective hook targets = project active targets INTERSECT consumer
Expand Down
23 changes: 22 additions & 1 deletion scripts/architecture_linter/checks/install_dry_run_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ def check_prospective_dry_run_plan(provider: FactsProvider) -> tuple[Violation,
command, command_fail = _facts_for(provider, _COMMAND, rule_id)
renderer, renderer_fail = _facts_for(provider, _RENDERER, rule_id)
selection, selection_fail = _facts_for(provider, _SELECTION_OWNER, rule_id)
failures = list(owner_fail) + list(command_fail) + list(renderer_fail) + list(selection_fail)
resolve, resolve_fail = _facts_for(provider, _RESOLVE, rule_id)
failures = (
list(owner_fail)
+ list(command_fail)
+ list(renderer_fail)
+ list(selection_fail)
+ list(resolve_fail)
)
if failures:
return tuple(failures)

Expand Down Expand Up @@ -86,6 +93,20 @@ def check_prospective_dry_run_plan(provider: FactsProvider) -> tuple[Violation,
_count_re(owner, class_pattern) == 1
and _count_re(selection, identity_pattern) == 1
and _selection_uses_interpreted_identity(provider)
and _present(selection, "for dependency in dependencies:")
and _present(selection, "if dependency.to_canonical() == package:")
and _present(
selection,
"return _selected_dependency_reference(package, dependencies).get_identity()",
)
and _present(
selection,
"_selected_dependency_reference(spec, dependencies).get_unique_key()",
)
and _present(
resolve,
"selected_dependency_keys(ctx.only_packages, ctx.all_apm_deps)",
)
and _present(owner, "def from_apm_package(")
and _present(owner, "def with_allowed_lsp_dependencies(")
and _present(owner, "selected_apm_dependencies=selected_apm_dependencies")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,94 @@ def _check_package_format_precedence(provider: FactsProvider) -> tuple[Violation
"Agent Plugin ingress must delegate package-format precedence to the planner",
)
)
findings.extend(
_require_subs(
provider,
inv,
_RID_FORMAT_PRECEDENCE,
"src/apm_cli/deps/apm_resolver.py",
(
"package_type, _ = detect_package_type(install_path)",
"PackageType.HOOK_PACKAGE,",
"validation = validate_apm_package(",
"update_keys=self._update_keys,",
"self._update_keys.add(sub_dep.get_unique_key())",
"and dep_ref.get_unique_key() not in self._scoped_rechecked",
),
"Resolver hook admission must use canonical package classification and validation",
)
)
findings.extend(
_require_subs(
provider,
inv,
_RID_FORMAT_PRECEDENCE,
_VALIDATION,
(
"hook_files = HookIntegrator().find_hook_files(package_path)",
"parse_hook_source(json.load(handle))",
"_validate_hook_descriptors(package_path, result)",
),
"Hook-only validation must reuse hook discovery and the neutral source grammar",
)
)
findings.extend(
_require_subs(
provider,
inv,
_RID_FORMAT_PRECEDENCE,
"src/apm_cli/drift.py",
(
"package_type, _ = detect_package_type(package_path)",
"if package_type is PackageType.HOOK_PACKAGE:",
"if update_keys is not None and dep_ref.get_unique_key() not in update_keys:",
),
"Hook update rechecks must use canonical package classification",
)
)
for path, snippets in (
(
"src/apm_cli/install/phases/resolve.py",
(
"update_keys=update_keys,",
"content_hash = compute_package_hash(candidate_path)",
"selected_dependency_keys(ctx.only_packages, ctx.all_apm_deps)",
),
),
(
"src/apm_cli/install/package_selection.py",
("_selected_dependency_reference(spec, dependencies).get_unique_key()",),
),
(
"src/apm_cli/install/phases/integrate.py",
(
"receipt[0] == install_path.resolve()",
"if verify_package_hash(install_path, receipt[1]):",
"and (update_refs or ctx.refresh)",
),
),
):
findings.extend(
_require_subs(
provider,
inv,
_RID_FORMAT_PRECEDENCE,
path,
snippets,
"Hook replacement reuse must preserve selected scope and validated path/hash evidence",
)
)
findings.extend(
_forbid_scan(
provider,
inv,
_RID_FORMAT_PRECEDENCE,
("src/apm_cli/deps/apm_resolver.py",),
re.compile(r"\.(?:rglob|glob)\("),
"Resolver must not discover hook layouts independently of package classification",
exempt=False,
)
)
return tuple(findings)


Expand Down
33 changes: 31 additions & 2 deletions src/apm_cli/deps/apm_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ def __init__(
cache_validation_callback: Callable[[Path, str], Path | None] | None = None,
reference_resolver: ReferenceResolver | None = None,
frozen: bool = False,
update_keys: set[str] | None = None,
):
"""Initialize the resolver with maximum recursion depth.

Expand All @@ -130,6 +131,8 @@ def __init__(
install path already exists, at any depth -- see that
method's docstring for why this can't be scoped to direct
deps only.
update_keys: Shared selected-package keys for scoped updates.
Selected descendants are added before download callbacks run.
existing_lockfile: Existing resolved dependency state used by the
canonical ref-drift owner to decide whether a plain install
must re-enter the download callback.
Expand All @@ -145,6 +148,8 @@ def __init__(
self._activation_callback = activation_callback
self._cache_validation_callback = cache_validation_callback
self._update_refs = update_refs
self._update_keys = update_keys
self._scoped_rechecked: set[str] = set()
self._existing_lockfile = existing_lockfile
self._reference_resolver = reference_resolver
self._frozen = frozen
Expand Down Expand Up @@ -771,6 +776,12 @@ def build_dependency_tree(
child is not existing_node for child in parent_node.children
):
parent_node.children.append(existing_node)
if (
self._update_keys is not None
and dep_ref.get_unique_key() in self._update_keys
and dep_ref.get_unique_key() not in self._scoped_rechecked
):
work_items.append((existing_node, dep_ref, parent_node, is_dev))
continue

# Create a new node for this dependency
Expand Down Expand Up @@ -917,6 +928,11 @@ def build_dependency_tree(
continue
# Avoid infinite recursion by checking if we're already processing this dep
# Use O(1) set lookup instead of O(n) list comprehension
if (
self._update_keys is not None
and node.dependency_ref.get_unique_key() in self._update_keys
):
self._update_keys.add(sub_dep.get_unique_key())
queue_key = sub_dep.get_resolution_key()
if queue_key not in queued_keys:
processing_queue.append((sub_dep, node.depth + 1, node, is_dev))
Expand Down Expand Up @@ -1045,6 +1061,9 @@ def _load_work_item(self, item):
loop-iteration variable.
"""
node, dep_ref, parent_node, _is_dev = item
if self._update_keys is not None and dep_ref.get_unique_key() in self._update_keys:
with self._download_lock:
self._scoped_rechecked.add(dep_ref.get_unique_key())
# Compute breadcrumb chain from this node's ancestry so download
# errors can report "root > mid > failing-dep" context.
parent_chain = node.get_ancestor_chain()
Expand Down Expand Up @@ -1085,6 +1104,12 @@ def _should_force_recheck(self, dep_ref: DependencyReference) -> bool:
dep_ref,
locked_dep,
update_refs=self._update_refs,
update_keys=self._update_keys,
package_path=(
dep_ref.get_install_path(self._apm_modules_dir)
if self._apm_modules_dir is not None
else None
),
)

def _try_load_dependency_package(
Expand Down Expand Up @@ -1265,7 +1290,11 @@ def _try_load_dependency_package(
)

package_type, _ = detect_package_type(install_path)
if package_type in (PackageType.MARKETPLACE_PLUGIN, PackageType.SKILL_BUNDLE):
if package_type in (
PackageType.MARKETPLACE_PLUGIN,
PackageType.SKILL_BUNDLE,
PackageType.HOOK_PACKAGE,
):
validation = validate_apm_package(
install_path,
source_path=dep_source_path,
Expand All @@ -1281,7 +1310,7 @@ def _try_load_dependency_package(
self._raise_downloaded_package_error(
downloaded_candidate,
dep_ref,
f"Marketplace Plugin validation produced no package metadata: {install_path}",
f"Package validation produced no package metadata: {install_path}",
had_existing_install,
)
if not validation.package.source:
Expand Down
Loading
Loading