Skip to content
1 change: 1 addition & 0 deletions .apm/instructions/architecture.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ semicolon-delimited, and specific to the file(s) that own the fact.
| Frozen install mutation eligibility | install/service.py (InstallService) | `src/apm_cli/install/service.py` |
| Install invocation option defaults | install/request.py (InstallRequest) | `src/apm_cli/install/request.py`; `src/apm_cli/commands/install.py` |
| Neutral hook source grammar and shape -> per-target native | hook_contract.py (HOOK_COMMAND_KEYS, parse_hook_source, _entries_to_ir) + per-target renderers | `src/apm_cli/hook_contract.py`; `src/apm_cli/integration/hook_ir.py`; `src/apm_cli/integration/hook_native_formats.py`; `src/apm_cli/integration/hook_integrator.py`; `src/apm_cli/integration/hook_ownership.py` |
| Plugin-root hook command vocabulary + parsing | integration/hook_command_paths.py | `src/apm_cli/integration/hook_command_paths.py` |
| File-level deploy / sync / cleanup | BaseIntegrator (see integrators.instructions.md) | `src/apm_cli/integration/base_integrator.py` |
| Windows stable executable path | install.ps1 ($currentDir / $currentExe) | `install.ps1` |
| Git repository cache-key normalization | cache/url_normalize.py (normalize_repo_url / cache_shard_key) | `src/apm_cli/cache/url_normalize.py` |
Expand Down
1 change: 1 addition & 0 deletions .github/instructions/architecture.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ semicolon-delimited, and specific to the file(s) that own the fact.
| Frozen install mutation eligibility | install/service.py (InstallService) | `src/apm_cli/install/service.py` |
| Install invocation option defaults | install/request.py (InstallRequest) | `src/apm_cli/install/request.py`; `src/apm_cli/commands/install.py` |
| Neutral hook source grammar and shape -> per-target native | hook_contract.py (HOOK_COMMAND_KEYS, parse_hook_source, _entries_to_ir) + per-target renderers | `src/apm_cli/hook_contract.py`; `src/apm_cli/integration/hook_ir.py`; `src/apm_cli/integration/hook_native_formats.py`; `src/apm_cli/integration/hook_integrator.py`; `src/apm_cli/integration/hook_ownership.py` |
| Plugin-root hook command vocabulary + parsing | integration/hook_command_paths.py | `src/apm_cli/integration/hook_command_paths.py` |
| File-level deploy / sync / cleanup | BaseIntegrator (see integrators.instructions.md) | `src/apm_cli/integration/base_integrator.py` |
| Windows stable executable path | install.ps1 ($currentDir / $currentExe) | `install.ps1` |
| Git repository cache-key normalization | cache/url_normalize.py (normalize_repo_url / cache_shard_key) | `src/apm_cli/cache/url_normalize.py` |
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Hook commands such as `"${CLAUDE_PLUGIN_ROOT}"/hooks/probe.py` now rewrite to
`"${CLAUDE_PLUGIN_ROOT}/hooks/probe.py"` and warn when a supported plugin-root
placeholder remains unresolved instead of silently deploying a dead hook.
OpenAPM v0.1 (`docs/src/content/docs/specs/openapm-v0.1.md#req-tg-012`) binds
the behavior.
(by @MohammedAlkindi; closes #2639) (#2645)
- `apm uninstall --global` now cleans removed-only target files before deleting their ownership state, while preserving files owned by surviving packages. (#2658)
- Windows binary is now Authenticode-signed in the release workflow, eliminating
the `Trojan:Script/Wacatac.H!ml` Windows Defender false positive on unsigned
Expand Down
15 changes: 13 additions & 2 deletions CONFORMANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -1286,12 +1286,23 @@
"tests/spec_conformance/test_manifest_reqs.py::test_agent_plugin_deployment_boundary_precedes_all_mutation",
"tests/spec_conformance/test_manifest_reqs.py::test_agent_plugin_undeployable_without_native_lifecycle"
]
},
{
"conformance_class": "consumer",
"id": "req-tg-012",
"keyword": "MUST",
"section": "8.5.6",
"status": "active",
"test_count": 1,
"tests": [
"tests/spec_conformance/test_manifest_reqs.py::test_plugin_root_hook_resolution_preserves_quoting_and_warns"
]
}
],
"spec_version": "v0.1.1",
"summary_by_class": {
"consumer": {
"active": 83,
"active": 84,
"skipped": 1,
"unbound": 0,
"xfail": 0
Expand All @@ -1315,5 +1326,5 @@
"xfail": 0
}
},
"total_requirements": 113
"total_requirements": 114
}
3 changes: 2 additions & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac
| Class | Active | Skipped | Xfail | Unbound |
|-------|-------:|--------:|------:|--------:|
| Producer | 12 | 0 | 0 | 0 |
| Consumer | 83 | 1 | 0 | 0 |
| Consumer | 84 | 1 | 0 | 0 |
| Registry | 1 | 0 | 0 | 0 |
| Governance | 16 | 0 | 0 | 0 |

Expand Down Expand Up @@ -140,6 +140,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac
| [req-tg-009](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-009) | MUST | 8.5.1 | consumer | active | 1 |
| [req-tg-010](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-010) | MUST | 8.5.4 | consumer | active | 1 |
| [req-tg-011](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-011) | MUST | 8.5.5 | consumer | active | 2 |
| [req-tg-012](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-012) | MUST | 8.5.6 | consumer | active | 1 |

## Waivers

Expand Down
4 changes: 2 additions & 2 deletions apm.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2783,7 +2783,7 @@ deployments:
owners:
- .
active_owner: .
content_hash: sha256:fd324760a724c6ccb7b3c6afd034a8e81c2fadaa123dbc85fa229e9dcfb7b104
content_hash: sha256:75b34bc8b2c22b88cf694ba5ec46484f304afc611baa153671c5f06be1d7ea65
- kind: project-relative
target: copilot
value: .github/instructions/changelog.instructions.md
Expand Down Expand Up @@ -3290,7 +3290,7 @@ local_deployed_file_hashes:
.github/agents/spec-tag-architect.agent.md: sha256:82907265c5e7cf1ac61ad96866fa7c5683b69c8f09b7a4c5f3cc241acc9568ca
.github/agents/supply-chain-security-expert.agent.md: sha256:8fb8cc426d6af17ba084a28b3f026c2b475b62e3ca63ed2f88b83bd823f877af
.github/agents/test-coverage-expert.agent.md: sha256:48c2172d1f18a394fa83ef9dc2be0b9b921a4e51e976498165250fed66369711
.github/instructions/architecture.instructions.md: sha256:fd324760a724c6ccb7b3c6afd034a8e81c2fadaa123dbc85fa229e9dcfb7b104
.github/instructions/architecture.instructions.md: sha256:75b34bc8b2c22b88cf694ba5ec46484f304afc611baa153671c5f06be1d7ea65
.github/instructions/changelog.instructions.md: sha256:1e51ec4c74e847967962bd279dc4c6e582c5d3578490b3c28d5f3acd3e05f73e
.github/instructions/cicd.instructions.md: sha256:33201cb88ea2f34b4950a9b52f87dc8dfb682796aaf53068ba7ae406c0c5e2c2
.github/instructions/cli.instructions.md: sha256:8e39e8d5047ce88575cb02f87c2bcede584dfef258bd86f7466c7badf136541a
Expand Down
5 changes: 5 additions & 0 deletions docs/public/specs/manifests/openapm-v0.1.requirements.yml
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,11 @@ requirements:
section: "8.5.5"
conformance_class: consumer
notes: "a schema-bearing Agent Plugins v1 dependency is undeployable until a native lifecycle is machine-verifiable; the consumer fails closed with one diagnostic and an unchanged tree, uniformly across solo, mixed-batch, and dry-run installs"
- id: req-tg-012
keyword: MUST
section: "8.5.6"
conformance_class: consumer
notes: "implementation-defined plugin-root placeholders preserve balanced expandable double quoting across split-quote forward- or backslash path spelling and unresolved placeholders emit a default-visible diagnostic"
- id: req-sc-001
keyword: MUST
section: "10.4"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ file lives in `hooks/` or `.apm/hooks/`, a path like
`./hooks/run-hook.sh` resolves from the package root so the deployed
path is not doubled.

Quote the complete path when it may contain spaces:
`"${PLUGIN_ROOT}/scripts/my hook.sh"`. A split-quoted path such as
`"${PLUGIN_ROOT}"/scripts/my\ hook.sh` is also accepted. If a supported
plugin-root token cannot be resolved, install names the package and explains
whether to balance the quotes, add a relative path, or keep the path inside the
package. Fix the package hook command, then run `apm install` again.

When a hook command points at a script inside a package hook directory,
APM deploys the hook source bundle so sibling helper modules stay
available at runtime:
Expand Down Expand Up @@ -275,8 +282,8 @@ agent a procedure" fits a skill -- and reaches every harness.
command transformer today, so any prompt-only metadata is dropped
with a diagnostic. Keep Cursor commands to the preserved key set.
- **Script paths.** Use `${PLUGIN_ROOT}` (or the harness-specific
alias) for scripts that ship inside the package. Plain absolute
paths break on consumers' machines.
alias) for scripts that ship inside the package, using the quoting forms
described above. Plain absolute paths break on consumers' machines.
- **Hook script path resolution.** `apm install -g` (user-scope)
rewrites `${PLUGIN_ROOT}` and relative `./` references to absolute
paths so Claude Code and Copilot CLI can execute scripts regardless
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ in `apm.yml`, then run `apm install` again.
- **Enterprise marketplace gate.** When installing from a `*.ghe.com` marketplace, bare cross-repo `repo:` fields (e.g. `repo: owner/repo`) are refused before any network request runs, preventing dependency-confusion attacks. Host-qualify the field to proceed: `repo: corp.ghe.com/owner/repo` for an enterprise dep, or `repo: github.com/owner/repo` for a declared cross-host dep.
- **Security scan.** Source files are scanned for hidden Unicode and other tag-character / bidi-override patterns before deployment. Critical findings block the package; the install exits `1`. Use `--force` to deploy anyway, or run `apm audit --strip` first to remediate.
- **Diagnostic summary.** Output is grouped at the end (collisions, replacements, warnings, errors) instead of inline. Use `--verbose` to expand individual file paths.
- **Unresolved hook roots.** A hook command that leaves a supported `${PLUGIN_ROOT}` alias unresolved emits a warning naming the package and a concrete repair. Balance quotes around the complete package-relative path, keep it inside the package, then run `apm install` again. See [Hooks and commands](../../../producer/author-primitives/hooks-and-commands/#hooks) for accepted quoting forms.
- **Declared plugin components.** Every path explicitly listed under a recognized plugin manifest's `agents`, `skills`, `commands`, or `hooks` field must resolve inside that plugin root. A missing or escaping path fails before deployment and lockfile commit; remove the declaration or add the component, then reinstall. Omitted fields and empty lists remain valid.
- **Default registry routing.** When a default registry is configured (project `registries.default` in `apm.yml` or `registry.<name>.default true` in `~/.apm/config.json`), unscoped `owner/repo#ref` shorthand deps passed to `apm install` route to the registry instead of GitHub. A `#<version>` selector is required; omitting it exits `1`. The selector may be a semver range (`^1.0.0`), an exact version (`1.2.3`), or a non-semver label (`main`, `stable`, `v1.4.2`) -- the registry exact-matches non-semver selectors against its published version list. GitHub probe is skipped for these deps; use the `git:` URL form in `apm.yml` to force the GitHub path (e.g., `- git: https://github.com/owner/repo.git`).

Expand Down
33 changes: 29 additions & 4 deletions docs/src/content/docs/specs/openapm-v0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ between the companion corpus and the implementation.

### 1.3 Document conventions

- OpenAPM v0.1 carries **110 normative statements** indexed in
- OpenAPM v0.1 carries **114 normative statements** indexed in
[Appendix C](#appendix-c-index-of-normative-statements).
- All on-disk files defined by this specification are **YAML 1.2**
parsed under the safe subset defined in
Expand Down Expand Up @@ -2493,6 +2493,27 @@ closed rather than partially projecting the package.
> future revision once a consumer implementation demonstrates a
> qualified, machine-verifiable binary lifecycle.

#### 8.5.6 Plugin-root hook command resolution

<a id="req-tg-012"></a>
**[req-tg-012]** When a conforming **consumer** implementation resolves an
implementation-defined plugin-root placeholder in a hook command, it MUST treat
a placeholder enclosed in matching quotation marks and followed by a forward
slash (`/`) or backslash (`\`) path separator outside the closing quote as
equivalent to the placeholder and path enclosed together in one double-quoted
span, regardless of the source quote character. The generated command MUST
preserve balanced quoting, and any environment-variable expression retained in
that command MUST remain live for target expansion. If any
implementation-defined plugin-root placeholder remains unresolved, the consumer
MUST emit a default-visible diagnostic before the operation returns; it MUST
NOT silently deploy the unresolved command.

> **Editorial note.** A plugin-root placeholder has the form `${NAME}`; each
> consumer documents the fixed set of names it recognizes for its targets.
> `${PLUGIN_ROOT}` is an illustrative spelling. For example,
> `"${PLUGIN_ROOT}"/hooks/probe.py` normalizes to
> `"${PLUGIN_ROOT}/hooks/probe.py"`.

### 8.6 Per-target primitive support (informational)

The matrix of which primitive types each target supports is
Expand All @@ -2508,7 +2529,8 @@ without a spec revision. The current matrix is in the companion
[req-tg-004](#req-tg-004), [req-tg-005](#req-tg-005),
[req-tg-006](#req-tg-006), [req-tg-007](#req-tg-007),
[req-tg-008](#req-tg-008), [req-tg-009](#req-tg-009),
[req-tg-010](#req-tg-010), [req-tg-011](#req-tg-011).
[req-tg-010](#req-tg-010), [req-tg-011](#req-tg-011),
[req-tg-012](#req-tg-012).

---

Expand Down Expand Up @@ -3070,7 +3092,8 @@ conformance statement identifying:
[req-tg-004](#req-tg-004), [req-tg-005](#req-tg-005),
[req-tg-006](#req-tg-006), [req-tg-007](#req-tg-007),
[req-tg-008](#req-tg-008), [req-tg-009](#req-tg-009),
[req-tg-010](#req-tg-010), [req-sc-001](#req-sc-001),
[req-tg-010](#req-tg-010), [req-tg-011](#req-tg-011),
[req-tg-012](#req-tg-012), [req-sc-001](#req-sc-001),
[req-sc-002](#req-sc-002), [req-sc-003](#req-sc-003),
[req-sc-004](#req-sc-004), [req-sc-005](#req-sc-005),
[req-sc-006](#req-sc-006), [req-sc-007](#req-sc-007),
Expand Down Expand Up @@ -3504,6 +3527,7 @@ renumbering of conformance classes.
| [req-tg-009](#req-tg-009) | MUST | 8.5.1 | consumer |
| [req-tg-010](#req-tg-010) | MUST | 8.5.4 | consumer |
| [req-tg-011](#req-tg-011) | MUST | 8.5.5 | consumer |
| [req-tg-012](#req-tg-012) | MUST | 8.5.6 | consumer |
| [req-sc-001](#req-sc-001) | MUST | 10.4 | consumer |
| [req-sc-002](#req-sc-002) | MUST | 10.9 | consumer |
| [req-sc-003](#req-sc-003) | MUST | 10.3 | consumer |
Expand All @@ -3522,7 +3546,7 @@ renumbering of conformance classes.
| [req-cf-001](#req-cf-001) | MUST | 12.5 | consumer |
| [req-cf-002](#req-cf-002) | MUST | 12.3 | consumer |

**Total normative statements: 113** (108 MUST, 5 SHOULD).
**Total normative statements: 114** (109 MUST, 5 SHOULD).

---

Expand Down Expand Up @@ -3561,6 +3585,7 @@ renumbering of conformance classes.
| 0.1.27 | 2026-08-03 | Spec-citation fold for object-form registry identity preservation on CLI-driven manifest updates (closes the PR #2166 Mode-B silent-extension gate). Added [req-mf-024] (Section 4.3.2, consumer MUST): a consumer MUST NOT silently rewrite an existing `id:`-form (registry-sourced) manifest entry into a `git:`-form entry when persisting a subsequent CLI-driven update (e.g. an additive `--skill` pin) for the same dependency identity; when a CLI-parsed reference is ambiguous about its source but an existing manifest entry for the same identity already resolves to the `registry` source, the existing entry's source MUST be honored, and an update that would otherwise replace a registry-sourced entry with a non-registry-shaped entry MUST be rejected with a diagnostic naming the identity. Section 4.9 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 110 -> 111 (106 MUST, 5 SHOULD). |
| 0.1.28 | 2026-08-06 | Spec-citation fold for per-invocation executable consent in non-interactive contexts (closes #1620 Mode-B silent-extension gate). Added [req-sc-014] (Section 10.15, consumer MUST): a consumer that supports a per-invocation consent flag for bin/ executable deployment MUST deny deployment by default when stdout is not a TTY, unless the operator has explicitly opted in for that invocation; an explicit opt-in overrides the non-interactive default and permits deployment; an explicit opt-out overrides the default and denies deployment even in a terminal; the allowExecutables policy gate [req-sc-009] is evaluated first and always takes precedence. Added row 19 to the Section 10.11 summary table. Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 111 -> 112 (107 MUST, 5 SHOULD). |
| 0.1.29 | 2026-08-22 | Spec-citation fold for the Agent Plugins v1 native-lifecycle deployment boundary (closes #2522 Mode-B silent-extension gate). Added [req-tg-011] (Section 8.5.5, consumer MUST): a consumer MUST treat a schema-bearing Agent Plugins v1 dependency as undeployable until it exposes a machine-verifiable native lifecycle for that dependency; before any target handler or primitive integrator runs, the consumer MUST refuse deployment with one actionable diagnostic, MUST leave the project tree unchanged, MUST NOT fall back to legacy primitive projection, and MUST reach the identical single-diagnostic outcome whether the dependency is materialized alone, mixed with ordinary dependencies in the same install, or under `--dry-run`. Section 8.7 and Appendix C updated. Statement count: 112 -> 113 (108 MUST, 5 SHOULD). |
| 0.1.30 | 2026-08-23 | Spec-citation fold for plugin-root hook command resolution (closes #2639 Mode-B silent-extension gate). Added [req-tg-012] (Section 8.5.6, consumer MUST): a consumer that resolves plugin-root placeholders treats a matching quoted placeholder followed by an outside path separator equivalently to the fully quoted path, preserves balanced expandable quoting, and emits a default-visible diagnostic instead of silently deploying any supported placeholder that remains unresolved. Section 8.7, Section 11.3.2, and Appendix C updated. Statement count: 113 -> 114 (109 MUST, 5 SHOULD). |

Errata (none at publication).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ When a hook command references a script inside `hooks/` or `.apm/hooks/`,
APM deploys that hook source bundle so sibling helper files resolve at
runtime. Claude-family merged targets (Claude, Cursor, Codex, Gemini,
Antigravity, and Windsurf), Copilot, and Kiro receive the same bundle.

For portable hook scripts, quote the complete package-relative path:
`"${PLUGIN_ROOT}/scripts/my hook.sh"`. A split-quoted path such as
`"${PLUGIN_ROOT}"/scripts/my\ hook.sh` is also accepted. If install reports an
unresolved plugin-root token, follow the package-specific repair: balance the
quotes, add a relative path, or keep the path inside the package. Then run
`apm install` again.

Root hook JSON descriptors, symlinks, and `.apm-pin` markers are not
deployed. JavaScript and TypeScript hook bundles get a minimal
`package.json` sidecar with the source package's Node `type` (defaulting
Expand Down
11 changes: 11 additions & 0 deletions scripts/lint-architecture-boundaries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,17 @@ if ! grep -q '^def dependency_hook_source_marker(' "$hook_ownership_owner" \
violations=$((violations + 1))
fi

echo "[*] AC15d: plugin-root hook command parsing authority"
plugin_root_owner="src/apm_cli/integration/hook_command_paths.py"
plugin_root_consumer="src/apm_cli/integration/hook_integrator.py"
if ! grep -q '^PLUGIN_ROOT_NAMES = (' "$plugin_root_owner" \
|| grep -Eq 'CLAUDE_PLUGIN_ROOT|CURSOR_PLUGIN_ROOT|KIRO_PLUGIN_ROOT' \
"$plugin_root_consumer" \
|| grep -Fq '"PLUGIN_ROOT"' "$plugin_root_consumer"; then
echo "[x] Plugin-root hook command parsing must route through hook_command_paths.py"
violations=$((violations + 1))
fi

echo "[*] AC16: post-uninstall reachability owner authority"
if ! grep -Eq 'reachability\.compute_forward_reachable_keys|from \.\.\.deps\.reachability import|from apm_cli\.deps\.reachability import' \
src/apm_cli/commands/uninstall/engine.py; then
Expand Down
Loading
Loading