fix: emit required "version": 1 in .cursor/hooks.json#1840
Conversation
Cursor requires a top-level "version" field in .cursor/hooks.json to
load any project hooks. Previously, _integrate_merged_hooks() seeded
only the "hooks" container and never emitted target-specific top-level
keys, so Cursor silently dropped all APM-installed hooks.
Fix:
- Add top_level_defaults: dict field to _MergeHookConfig (frozen
dataclass, uses field(default_factory=dict) to stay idiomatic).
- Populate top_level_defaults={"version": 1} for the cursor entry in
_MERGE_HOOK_TARGETS.
- In _integrate_merged_hooks(), after seeding the event container key,
inject any missing top_level_defaults keys. The "if key not in
json_config" guard preserves any value the user has set manually, so
reinstalling over a file that already has "version" is a no-op.
Other targets (claude, codex, gemini, windsurf) do not require a
schema version field and are unaffected.
Tests added:
- test_cursor_version_emitted_on_fresh_install
- test_cursor_existing_version_preserved
- assert config["version"] == 1 added to test_integrate_hookify_cursor
Fixes #1823
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Fixes Cursor hook integration by ensuring .cursor/hooks.json always includes the required top-level "version": 1 field (without overwriting an existing user-provided version), so Cursor will actually load APM-installed project hooks.
Changes:
- Add a declarative
top_level_defaultsto_MergeHookConfigand apply it during merged hook integration. - Configure the Cursor target to inject
{"version": 1}when absent. - Extend unit tests to assert version emission on fresh install and preservation on reinstall.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/apm_cli/integration/hook_integrator.py | Adds target-specific top-level defaults (Cursor "version": 1) and injects them during merge. |
| tests/unit/integration/test_hook_integrator.py | Adds/extends Cursor integration tests to validate emitted and preserved version. |
- Update module docstring Cursor example to show "version": 1, matching what APM now emits for the cursor target. - Type top_level_defaults as dict[str, Any] instead of bare dict, consistent with the parameterized dict types used elsewhere in this module. Addresses copilot-pull-request-reviewer feedback on PR #1840. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 0 | 2 | Clean, minimal fix; one nit on type annotation precision. Architecture is sound. |
| CLI Logging Expert | 0 | 1 | 1 | Silent injection is consistent with the existing container-seeding pattern. One recommended debug-log addition for verbose/agent traceability. |
| DevX UX Expert | 0 | 0 | 3 | Clean, idiomatic fix. Declarative top_level_defaults is the right pattern. |
| Supply Chain Security Expert | 0 | 0 | 0 | No supply-chain or threat-model concerns. top_level_defaults is hardcoded and package-unreachable. |
| OSS Growth Hacker | 0 | 2 | 1 | Silent-failure bug in a headline integration target for 6+ versions is trust-denting. Fix is clean. Critical gap: no CHANGELOG entry. |
| Doc Writer | 0 | 2 | 1 | PR body is accurate and complete. Two recommended gaps: missing CHANGELOG entry and troubleshooting breadcrumb. |
| Test Coverage Expert | 0 | 1 | 0 | Bug-fix regression traps present at unit tier. Integration-tier gap: version field not asserted in existing integration test. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Doc Writer] Add CHANGELOG entry under [Unreleased] ### Fixed with affected version range (0.14.1-0.20.0), symptom, and repair instruction -- CHANGELOG is the canonical upgrade signal. Without an entry, users on affected versions have no discoverable reason to upgrade or re-run apm install. The oss-growth-hacker independently flagged the same gap -- this is a trust-repair moment that should not ship silent.
- [Test Coverage Expert] Add
assert data.get('version') == 1to the existing integration testtest_integrates_when_cursor_dir_exists-- Evidence outcome:missing on a multi-harness-support surface. One-line addition closes the tier gap and prevents future regressions at the integration level. - [Doc Writer] Add troubleshooting entry in common-errors.md mapping Cursor 'Config version must be a number' to
apm install --target cursor-- Users on v0.14.1-v0.20.0 whose Cursor hooks silently fail will search the docs. No breadcrumb currently exists. - [Python Architect] Annotate
top_level_defaultsasdict[str, Any]instead of baredict-- Every other typed field on_MergeHookConfiguses precise annotations. Trivial fix, consistent with existingAnyimport. - [CLI Logging Expert] Add debug-level logging to the entire
_integrate_merged_hooksflow (both container seeding and top_level_defaults injection) -- Both paths are currently silent. A follow-up covering the whole function gives--verboseusers full traceability.
Architecture
classDiagram
direction LR
class HookIntegrator {
<<BaseIntegrator>>
+integrate_package_hooks_cursor(pkg_info, root)
-_integrate_merged_hooks(config, hook_files, ...)
}
class _MergeHookConfig {
<<ValueObject / frozen>>
+config_filename str
+target_key str
+require_dir bool
+schema_strict bool
+event_container_key str
+top_level_defaults dict
}
class _MERGE_HOOK_TARGETS {
<<Registry / module-level dict>>
+cursor _MergeHookConfig
+claude _MergeHookConfig
+codex _MergeHookConfig
+gemini _MergeHookConfig
+antigravity _MergeHookConfig
+windsurf _MergeHookConfig
}
class BaseIntegrator {
<<Abstract>>
+files_integrated list
}
BaseIntegrator <|-- HookIntegrator : inherits
HookIntegrator ..> _MERGE_HOOK_TARGETS : looks up config
HookIntegrator ..> _MergeHookConfig : reads
_MERGE_HOOK_TARGETS *-- _MergeHookConfig : contains
flowchart TD
A["CLI: apm install --target cursor"] --> B["HookIntegrator.integrate_package_hooks_cursor()"]
B --> C["Look up _MERGE_HOOK_TARGETS['cursor']"]
C --> D["_integrate_merged_hooks(config, hook_files, ...)"]
D --> E{"Config file exists?"}
E -->|Yes| F["Read existing hooks.json"]
E -->|No| G["json_config = {}"]
F --> H["Ensure container key: json_config['hooks'] = {}"]
G --> H
H --> I["NEW: Inject top_level_defaults\nif 'version' not in json_config:\n json_config['version'] = 1"]
I --> J["For each hook_file: parse, merge into json_config['hooks']"]
J --> K["Write hooks.json to .cursor/hooks.json"]
style I fill:#fff3b0,stroke:#d47600
Recommendation
Ship now. Zero blocking findings across nine panelists. The fix is architecturally sound, security-neutral, and correctly idempotent. The CHANGELOG entry is the single highest-priority follow-up -- it should ideally land in this PR before merge to ensure the trust-repair narrative reaches users via the canonical upgrade channel. The integration-test assertion is a one-liner that would strengthen the regression trap. Both are small enough to fold into this PR without re-review, but neither blocks the correctness of the fix itself. Every Cursor hook user on v0.14.1-v0.20.0 is currently broken; shipping sooner is better than shipping perfect.
Full per-persona findings
Python Architect
-
[nit] Use
dict[str, Any]instead of baredictfortop_level_defaultsatsrc/apm_cli/integration/hook_integrator.py:104
Every other typed field on_MergeHookConfiguses precise annotations. Baredictloses key/value type information and triggers mypy --strict warnings.dict[str, Any]is consistent with the existingAnyimport at line 51.
Suggested:top_level_defaults: dict[str, Any] = field(default_factory=dict) -
[nit] Injection loop placement is correct but could benefit from a one-line comment linking it to the container init above at
src/apm_cli/integration/hook_integrator.py:1406
A single inline comment at the loop site would make the two-phase init (container + defaults) scannable without scrolling 1300 lines to the dataclass docstring.
CLI Logging Expert
-
[recommended] Add
_log.debugwhen top_level_defaults keys are injected atsrc/apm_cli/integration/hook_integrator.py:1413
The injection loop silently adds missing keys, consistent with the existing container-seeding pattern. However, adding a debug log inside theif key not in json_configguard would give agents and --verbose users traceability that a field was auto-added. Zero noise for default users; helpful for debugging when a target schema changes. -
[nit] No warning emitted for unexpected existing version type at
src/apm_cli/integration/hook_integrator.py
The guard correctly skips injection when the key is already present, but emits no warning if the existing value has an unexpected type. A follow-up could add a warning for unexpected types, but this is out of scope for this fix PR.
DevX UX Expert
-
[nit] Consider a DEBUG-level log line when injecting a missing top-level default at
src/apm_cli/integration/hook_integrator.py
The fix is deliberately silent on the happy path, which is correct. But when a user runsapm install --verboseto debug why hooks are not loading, a single DEBUG line would make the injection discoverable without adding noise. -
[nit] Silent failure window (0.14.1-0.20.0) suggests a missing post-write validation opportunity
Six minor versions shipped hooks that were dead on arrival with zero APM-side feedback. Filing a follow-up: after writing hooks.json, validate that all target-required top-level fields are present and warn if not. -
[nit] The idempotency guard correctly matches reinstall expectations -- no action needed
Theif key not in json_configpattern mirrors how npm handles package.json defaults. The test_cursor_existing_version_preserved test nails this contract.
Supply Chain Security Expert
No findings.
OSS Growth Hacker
-
[recommended] Missing CHANGELOG entry with affected version range (0.14.1-0.20.0) at
CHANGELOG.md
Users upgrading need a story-shaped entry naming the affected range, the symptom, and a repair instruction. This converts a trust-repair moment into a positive signal. -
[recommended] Call out affected version range explicitly in release notes at
CHANGELOG.md
Users who hit this during v0.14.1-v0.20.0 likely concluded 'APM hooks do not work in Cursor' and stopped trying. Silent failures cause permanent churn unless APM proactively names the range. -
[nit] Consider a diagnostic log line when top_level_defaults are injected on a repair path at
src/apm_cli/integration/hook_integrator.py
Turning a silent repair into a trust-building moment.
Auth Expert -- inactive
PR touches only hook_integrator.py and its tests -- no auth, token, credential, or host-classification code is affected.
Doc Writer
-
[recommended] No CHANGELOG entry for a 6-version blast-radius bug fix at
CHANGELOG.md
CHANGELOG.md has no entry under [Unreleased] ### Fixed. This fix affects every Cursor user who installed a hook-bearing package with APM 0.14.1 through 0.20.0. Without an entry, users on affected versions have no discoverable signal to upgrade.
Suggested: Add: 'apm install --target cursornow emits the required top-levelversion: 1in.cursor/hooks.json. Previously (APM 0.14.1-0.20.0) the field was silently omitted, causing Cursor to reject the entire hooks config. Existingversionvalues are preserved on reinstall. (closes [BUG] Cursor hook integration omits required top-level "version" in .cursor/hooks.json #1823) (fix: emit required "version": 1 in .cursor/hooks.json #1840)' -
[recommended] No troubleshooting entry for silent Cursor hook failure symptom at
docs/src/content/docs/troubleshooting/common-errors.md
A user on APM 0.14.1-0.20.0 whose Cursor hooks are not firing will search the docs.common-errors.mdhas no entry mapping Cursor's error message toapm install --target cursoras the fix. -
[nit] Pitfalls section in hooks-and-commands.md omits the now-managed Cursor version field at
docs/src/content/docs/producer/author-primitives/hooks-and-commands.md
A one-sentence note would confirm APM now injectsversion: 1automatically and authors need not add it manually.
Test Coverage Expert
- [recommended] Integration test for cursor hooks does not assert version field at
tests/integration/test_integrators_hooks_execution.py
TestIntegratePackageHooksCursor::test_integrates_when_cursor_dir_existsreads the generated.cursor/hooks.jsonand assertshooks in databut never checksdata['version'] == 1. The tier floor for hook execution is integration-with-fixtures. One-line addition closes the tier gap.
Suggested: Addassert data.get('version') == 1after the existingassert 'hooks' in dataassertion.
Proof (missing at integration-with-fixtures):tests/integration/test_integrators_hooks_execution.py::TestIntegratePackageHooksCursor::test_integrates_when_cursor_dir_exists-- proves: Integration-tier proof that cursor hooks.json includes required version field after apm install [multi-harness-support, devx]
Performance Expert -- inactive
PR touches only hook_integrator.py and its unit tests. No cache, deps, install pipeline, transport, or materialization paths are in the diff. O(1) dict iteration on file-write path is negligible.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
- CHANGELOG: add Fixed entry for Cursor "version": 1 regression (closes #1823). - Integration test: assert data["version"] == 1 in test_integrates_when_cursor_dir_exists to close the integration-tier regression trap. - Docs: add docs/src/content/docs/reference/common-errors.md with a Cursor "Config version must be a number" troubleshooting entry; link from the reference index. - Type annotation: top_level_defaults already typed as dict[str, Any] (addressed in previous commit); no change needed. - Debug logging: add _log.debug() after container seed and after top_level_defaults injection in _integrate_merged_hooks() so --verbose runs show which keys were injected and for which file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 1 | 1 | Clean minimal fix: top_level_defaults on frozen dataclass correctly injects cursor version:1 on install; sync path silently leaves {version:1} after full uninstall -- document or cover with a test. |
| CLI Logging Expert | 0 | 1 | 2 | Correct idempotent fix; inject loop (line 1416) is fully silent -- a _log.debug() on inject + preserve branches is the only logging gap. |
| DevX UX Expert | 0 | 2 | 1 | Correctness fix is solid and idempotent; two UX gaps: no CHANGELOG entry for a 6-version regression, and no in-terminal signal when the repair is applied on reinstall. |
| Supply Chain Security Expert | 0 | 1 | 1 | top_level_defaults is compile-time-only with no user/package/network input path; injection guard is correct; no prototype-pollution risk in Python. Two quality nits. |
| OSS Growth Hacker | 0 | 2 | 1 | High-impact silent regression fix for Cursor (fastest-growing AI editor). Missing CHANGELOG entry leaves 7-version user-trust story unnarrated; fix unlocks a positive 'hooks now work' story. |
| Doc Writer | 0 | 1 | 1 | CHANGELOG.md [Unreleased] has no Fixed entry for the cursor hooks.json version:1 regression; hooks-and-commands.md is otherwise accurate. |
| Test Coverage Expert | 0 | 1 | 1 | Unit tests cover version:1 regression at unit tier; existing integration-tier cursor test not updated to assert version, leaving floor-tier gap (one-line fix). |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Test Coverage Expert] Add
assert data.get('version') == 1inTestIntegratePackageHooksCursor::test_integrates_when_cursor_dir_exists(tests/integration/test_integrators_hooks_execution.py) -- Missing integration-tier assertion on the hook-execution promise is the highest-signal gap per evidence rules; the unit tests prove the injector is correct but the integration floor would not catch a future regression at the wiring layer. One line closes the trap. - [DevX UX Expert] Add
CHANGELOG.md[Unreleased] ### Fixedentry for the cursor hooks.json version:1 regression with a reinstall call-to-action -- Three panelists (devx-ux-expert, doc-writer, oss-growth-hacker) converge on this gap; without a CHANGELOG line, users upgrading from 0.14.1-0.20.0 have no signal that re-runningapm installis required to repair existing deployments. Must land before next release cut. - [Supply Chain Security Expert] Narrow
top_level_defaultsfield type todict[str, int | str | bool]and add a__post_init__validator rejecting non-scalar values -- Prevents silent write-surface expansion by future contributors adding nested structures; cheap static guardrail with no runtime overhead on the install hot path. Resolves the type-annotation dissent between security and architect personas in favor of the narrower bound. - [CLI Logging Expert] Add
_log.debug('target %s: injected missing top-level key %r=%r', config.target_key, key, value)inside theif key not in json_configbranch (line ~1416) -- Two panelists (cli-logging-expert, devx-ux-expert) converge; the original breakage was silent and the repair is also silent. A verbose-mode user debugging still-broken hooks cannot confirm the fix fired without a trace line. - [Python Architect] Document or test the sync-path asymmetry: version:1 is injected on install but not reclaimed by
_clean_apm_entries_from_jsonon full uninstall, leaving{"version":1}residual in.cursor/hooks.json-- Not a correctness regression (Cursor parses the residual as a valid empty config) but the asymmetry is unintentional-looking to a user runninggit statusafterapm uninstall. A comment in_clean_apm_entries_from_jsonor a test asserting the expected residual state makes the behavior deliberate rather than accidental.
Architecture
classDiagram
direction LR
class _MergeHookConfig:::touched {
<<ValueObject>>
+config_filename: str
+target_key: str
+require_dir: bool
+schema_strict: bool
+event_container_key: str
+top_level_defaults: dict
}
class _MERGE_HOOK_TARGETS {
<<Registry>>
+cursor _MergeHookConfig
+claude _MergeHookConfig
+codex _MergeHookConfig
+gemini _MergeHookConfig
+windsurf _MergeHookConfig
+antigravity _MergeHookConfig
}
class HookIntegrator {
<<Service>>
+integrate_package_hooks_cursor()
+integrate_hooks_for_target()
+sync_integration()
-_integrate_merged_hooks()
-_clean_apm_entries_from_json()
}
class BaseIntegrator {
<<AbstractBase>>
+check_collision()
+try_adopt_identical()
+cleanup_empty_parents()
}
class HookIntegrationResult {
<<ValueObject>>
+files_integrated: int
+scripts_copied: int
}
class IntegrationResult {
<<ValueObject>>
+files_integrated: int
+files_updated: int
+target_paths: list
}
BaseIntegrator <|-- HookIntegrator
IntegrationResult <|-- HookIntegrationResult
HookIntegrator ..> _MERGE_HOOK_TARGETS : lookups config by target key
_MERGE_HOOK_TARGETS o-- _MergeHookConfig : contains
HookIntegrator ..> HookIntegrationResult : returns
note for _MergeHookConfig "top_level_defaults: injects target-required schema fields absent from file; e.g. cursor version=1"
classDef touched fill:#fff3b0,stroke:#d47600
class _MergeHookConfig:::touched
flowchart TD
A["integrate_package_hooks_cursor"] --> B
B["_integrate_merged_hooks"] --> C
C["find_hook_files + filter for cursor target"] --> D
D{"hook_files empty?"}
D -- yes --> E["return HookIntegrationResult files_integrated=0"]
D -- no --> F["read .cursor/hooks.json\njson_config={} on miss or decode-error"]
F --> G["seed event container:\nif hooks not in json_config: json_config[hooks]={}"]
G --> H["NEW: inject top_level_defaults\nfor k,v in config.top_level_defaults.items()\nif k not in json_config: json_config[k]=v\ncursor: sets version=1 when absent"]
H --> I["for each hook_file:\nparse + rewrite hooks data\nmerge entries into json_config[hooks]"]
I --> J["json.dump json_config to .cursor/hooks.json"]
J --> K["return HookIntegrationResult"]
Recommendation
The fix is correct, idempotent, and well-covered at unit tier; the panel is unanimous on the core implementation and no panelist raised a blocking-severity finding. Ship the merge and track five follow-up items as a post-merge batch: the integration-tier assertion (one line, closes the regression trap), the CHANGELOG entry (must land before next release cut to drive upgrades for the Cursor user base), the type-bound narrowing, the _log.debug trace, and the sync-path asymmetry note. None of these warrant holding a regression fix that unblocks all Cursor hook users on six affected versions.
Full per-persona findings
Python Architect
- [recommended] Sync path has no knowledge of top_level_defaults; full Cursor uninstall leaves {"version":1} ghost in hooks.json at
src/apm_cli/integration/hook_integrator.py:1970
_clean_apm_entries_from_json removes APM-tagged hook entries and deletes the event container key when empty, but never touches keys injected via top_level_defaults. After a full install+uninstall cycle, version:1 is abandoned in .cursor/hooks.json. Cursor parses this without error, so there is no correctness regression, but APM is asymmetric: it injects on install and never reclaims on uninstall.
Suggested: Option A: add a comment in _clean_apm_entries_from_json noting that top_level_defaults-injected keys are deliberately left in place (schema scaffolding, not APM content markers). Option B: add test_cursor_version_after_full_uninstall asserting the expected residual state. - [nit] top_level_defaults uses bare dict annotation; dict[str, Any] is more precise at
src/apm_cli/integration/hook_integrator.py:107
Any is already imported at L52. The bare dict annotation conveys nothing about the expected key/value types.
Suggested:top_level_defaults: dict[str, Any] = field(default_factory=dict)
CLI Logging Expert
- [recommended] Inject loop emits no trace in any log level -- verbose agents cannot confirm version:1 was written at
src/apm_cli/integration/hook_integrator.py:1416
Lines 1416-1418 silently mutate json_config with no _log.debug() call. An agent runningapm install --verboseon a fresh Cursor project has no way to observe that the top-level defaults injection fired. This matters especially for debugging future schema regressions.
Suggested:_log.debug('target %s: injected missing top-level key %r=%r', config.target_key, key, value)inside theif key not in json_configbranch. - [nit] Type annotation uses bare dict instead of dict[str, Any] at
src/apm_cli/integration/hook_integrator.py:107
The file already imports Any from typing and uses dict[str, Any] elsewhere.
Suggested:top_level_defaults: dict[str, Any] = field(default_factory=dict) - [nit] No migration hint for pre-existing hooks.json files missing version:1 at
src/apm_cli/integration/hook_integrator.py:1374
Users who installed Cursor hooks before this fix have a broken .cursor/hooks.json on disk. The fix only applies on next install/reinstall with no APM-visible signal.
Suggested: After reading an existing json_config, check if any top_level_defaults key is absent and emit_rich_warningwith a reinstall recommendation.
DevX UX Expert
- [recommended] No CHANGELOG entry for a 6-version regression affecting all cursor hook users at
CHANGELOG.md
All cursor users with hook-based packages had silently dead hooks from 0.14.1 through 0.20.0. Without a CHANGELOG line, users cannot discover the fix and do not know they need to reinstall.
Suggested: Add under[Unreleased] ### Fixed: "apm install --target cursornow emits the required top-level"version": 1in.cursor/hooks.json; re-runapm installto repair existing deployments." - [recommended] Silent repair: no user-facing output when version:1 is injected into an existing file at
src/apm_cli/integration/hook_integrator.py
The original breakage was silent and the fix is also silent. A user who suspects their hooks were broken and runsapm installto repair gets zero in-terminal confirmation that anything changed.
Suggested: Emit a_rich_infoline when top_level_defaults are actually injected (gate onif key not in json_configso it only fires on repair, not on idempotent reinstalls). - [nit] No _log.debug trace when top_level_defaults are injected, hampering verbose-mode diagnosis at
src/apm_cli/integration/hook_integrator.py
Suggested:_log.debug("target %s: injected top-level default %r = %r", config.target_key, key, value)
Supply Chain Security Expert
- [recommended] Unparameterized dict type for top_level_defaults provides no static guardrail against future callers injecting complex/mutable values at
src/apm_cli/integration/hook_integrator.py
A future contributor could add a nested list or dict (e.g.top_level_defaults={'hooks': [...]}) without a type-checker warning. The pattern is being established as a reusable field; bounding the type now is cheap and prevents silent expansion of the write surface.
Suggested: Change todict[str, int | str | bool]to bound injectable scalar types; add a__post_init__validator rejecting non-scalar values. - [nit] frozen=True does not protect the dict contents; top_level_defaults is mutable at runtime despite the frozen dataclass at
src/apm_cli/integration/hook_integrator.py
_MERGE_HOOK_TARGETSobjects are shared module-level singletons; any code with a reference can mutate top_level_defaults mid-run without raising an error.
Suggested: Wrap the dict intypes.MappingProxyTypeat construction time.
OSS Growth Hacker
- [recommended] No CHANGELOG entry in [Unreleased] -- the 7-version Cursor regression story goes untold
Cursor is prominently listed in the README hero line. Without a CHANGELOG entry, the regression never becomes a 'now fixed' story and existing users have no upgrade signal.
Suggested: Add toCHANGELOG.md [Unreleased] ### Fixedwith version range (0.14.1-0.20.0) and reinstall CTA. - [recommended] Release notes for the next version should carry an explicit 'Cursor hooks now work -- upgrade' callout
Cursor users who gave up on APM hooks after seeing them silently fail will not re-test unless there is a deliberate signal in the release post naming the exact symptom and the fixing version.
Suggested: Lead the Fixed section with a Cursor-specific callout: 'Cursor hooks: if you installed APM hook packages in 0.14.1-0.20.0 and never saw them run, upgrade now.' - [nit] The top_level_defaults extensibility story is buried in a PR trade-offs table -- surface it for contributors
Adding hook support for a new AI editor that needs a schema version field is now a one-liner in_MERGE_HOOK_TARGETS. That lowers the bar for community PRs.
Suggested: Add a one-liner in CONTRIBUTING.md or docs: 'To add top-level schema fields for a new hook target, settop_level_defaultsin_MERGE_HOOK_TARGETS-- no code changes required.'
Doc Writer
- [recommended] CHANGELOG.md [Unreleased] is missing a Fixed entry for the cursor hooks.json version:1 regression at
CHANGELOG.md
Users upgrading from 0.14.1-0.20.0 need the changelog to tell them what changed and that re-runningapm installis required to fix deployed configs.
Suggested: Under[Unreleased] ### Fixed: "apm install --target cursornow emits the required top-level"version": 1in.cursor/hooks.json; the field was absent since 0.14.1, causing Cursor to silently reject every APM-installed hook. Re-runapm installto repair existing deployments. (closes [BUG] Cursor hook integration omits required top-level "version" in .cursor/hooks.json #1823) (fix: emit required "version": 1 in .cursor/hooks.json #1840)" - [nit] hooks-and-commands.md Pitfalls section could note that Cursor requires top-level version:1 and that APM injects it at
docs/src/content/docs/producer/author-primitives/hooks-and-commands.md
A package author auditing hooks.json by hand could remove the version field, re-introducing the bug.
Suggested: In Pitfalls: 'Cursor hooks.json schema: Cursor requires a top-level"version": 1in.cursor/hooks.json. APM injects it automatically; do not add or remove it by hand.'
Test Coverage Expert
- [recommended] Integration-tier cursor hook test not updated to assert version:1; hook-execution promise uncertified at floor tier at
tests/integration/test_integrators_hooks_execution.py
TestIntegratePackageHooksCursor::test_integrates_when_cursor_dir_existsassertsresult.hooks_integrated >= 1and'hooks' in databut lacksassert data.get('version') == 1. This is the integration floor for the hook-execution surface.
Suggested: Addassert data.get('version') == 1immediately afterassert 'hooks' in data.
Proof (missing at integration-with-fixtures):tests/integration/test_integrators_hooks_execution.py::TestIntegratePackageHooksCursor::test_integrates_when_cursor_dir_exists-- proves: apm install on a project with an existing .cursor dir writes version:1 to .cursor/hooks.json so Cursor loads the hooks [devx, portability-by-manifest]
assert data.get("version") == 1 -- absent; test currently ends at: assert "hooks" in data - [nit] Unit-tier coverage: three cursor tests correctly assert version:1 and idempotency guard at
tests/unit/integration/test_hook_integrator.py
test_cursor_version_emitted_on_fresh_install,test_cursor_existing_version_preserved(new) and the amendedtest_integrate_hookify_cursorcorrectly cover the fix at unit tier. Unit evidence is necessary but not sufficient for the hook-execution surface.
Proof (passed at unit):tests/unit/integration/test_hook_integrator.py::test_cursor_version_emitted_on_fresh_install-- proves: HookIntegrator.integrate_package_hooks_cursor() injects version:1 into a fresh .cursor/hooks.json and preserves any pre-existing version value
assert config.get("version") == 1
Auth Expert -- inactive
PR #1840 only modifies src/apm_cli/integration/hook_integrator.py and tests/unit/integration/test_hook_integrator.py (hook config generation), which touch no authentication, token management, credential resolution, host classification, or remote-host fallback surfaces.
Performance Expert -- inactive
Both changed files are in the hook-integration layer, not on any download, materialization, cache, or resolve hot path; the only runtime delta is a single dict iteration over a one-entry top_level_defaults dict per hook-integration call.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Generated by PR Review Panel for issue #1840 · sonnet46 11.5M · ◷
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve strangler-fig conflicts in install.py, hook_integrator.py, and builder.py: keep HEAD's relocated structure and fold main's behavioural deltas into the sibling helper modules that now own the code. - #1816 registry-routing: port default_registry param + probe-skip bypass into install/pkg_resolution.py (RULE B seam for update_existing_dependency_entry_if_needed re-exported from commands/install.py). - #1840 cursor hooks "version": 1 top-level default: port into integration/hook_transforms.py + injection in hook_integrator.py. - #1841 pack {name} placeholder fix: port name=entry.name into marketplace/_builder_resolve.py. Fix merge-introduced ruff complexity regressions in #1816 code: extract _registry_manifest_range_or_row (outdated.py PLR0911), _resolve_package_accessibility + _intercept_marketplace_ref (pkg_resolution.py C901/PLR0915). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
TL;DR
apm install(cursor target) was writing.cursor/hooks.jsonwithout thetop-level
"version": 1field that Cursor requires to load project hooks.Every APM-installed hook was silently dead on arrival. This PR fixes it.
Problem (WHY)
Cursor's hook loader validates the project config against a schema that
requires a top-level
"version"field. Without it the entire file isrejected:
Root cause in
_integrate_merged_hooks():Package templates that carry
"version": 1have it stripped at parse timebecause
_integrate_merged_hooks()only reads thehooksmap from eachsource file. This affected every cursor user with hook-based packages
(APM 0.14.1 -- 0.20.0).
Approach (WHAT)
Add a declarative
top_level_defaultsfield to_MergeHookConfigthatspecifies target-specific top-level keys to inject when absent. Set
{"version": 1}for cursor. Inject missing defaults after seeding theevent container.
Implementation (HOW)
src/apm_cli/integration/hook_integrator.pyfieldto thedataclassesimport.top_level_defaults: dict = field(default_factory=dict)to the_MergeHookConfigfrozen dataclass.top_level_defaults={"version": 1}in thecursorentry of_MERGE_HOOK_TARGETS._integrate_merged_hooks(), after seeding the event container:The
if key not in json_configguard means reinstalling over a filethat already carries
"version"is a no-op -- the user's value ispreserved.
tests/unit/integration/test_hook_integrator.pytest_integrate_hookify_cursor: addedassert config["version"] == 1test_cursor_version_emitted_on_fresh_install(new): fresh.cursor/dir produces
"version": 1test_cursor_existing_version_preserved(new): pre-existing"version": 2survives a reinstallArchitecture
flowchart LR A["_MergeHookConfig\ntop_level_defaults={'version':1}"] --> B["_integrate_merged_hooks()"] B --> C{key in json_config?} C -- No --> D["json_config[key] = value"] C -- Yes --> E["preserve existing value"] D --> F["json.dump to .cursor/hooks.json"] E --> FTrade-offs
top_level_defaults: dictfield on_MergeHookConfigif key not in json_configguard{"version": 1}Validation evidence
tests/unit/integration/test_hook_integrator.py-- all passruff check-- no diagnosticspylint R0801-- 10.00/10lint-auth-signals.sh-- cleanFixes #1823