Repository navigation
fix(hooks): type untyped flat command handlers for Claude - #3153
parth (Parth-Vasave) wants to merge 7 commits into
Conversation
Flat hook command entries that omit `type` (valid in Cursor, where it defaults to `command`) were wrapped into Claude's matcher groups without the handler `type` field that Claude's hook schema requires. The Claude renderer now supplies `"type": "command"` for flat entries that the neutral hook grammar reads as command handlers. Explicit handler types, untyped handlers already inside nested Claude groups, non-command entries, and all other targets are unchanged. Reinstall cleanup also matches the pre-fix untyped render, so stale root-package entries keep healing instead of duplicating (the microsoft#1329 / microsoft#1392 contract), mirroring the existing Codex legacy-content-key path. Refs microsoft#3130 apm-spec-waiver: Claude-native handler type default for microsoft#3130; OpenAPM v0.1 defines no target-native hook handler fields, the portable hook subset is deferred to microsoft#2111 (out of approved scope), and req-lk-021 ownership reconciliation is preserved.
|
@microsoft-github-policy-service agree |
|
Hi Daniel Meppiel (@danielmeppiel), one scope question carried over from #3130: This PR adds type: "command" only to flat untyped command entries, both standalone and merged into The approved scope says "recognized command handlers", which could cover these too. Done-when only I'm also happy to mark this ready for review whenever it suits you, so CI can run. |
Mohammed Alkindi (MohammedAlkindi)
left a comment
There was a problem hiding this comment.
The codex case in test_claude_handler_default_does_not_reach_other_targets asserts '"type"' not in rendered, but Codex needs that field too: MatcherGroup.hooks is Vec<HookHandlerConfig>, an enum with #[serde(tag = "type")] (codex-rs/config/src/hook_config.rs in openai/codex), so an untyped handler fails to deserialize. Leaving Codex alone fits the approved scope, but this assertion pins the invalid output. Limiting the parametrization to cursor keeps the scope check without blocking a Codex follow-up.
Preserve current main changes while validating the bounded microsoft#3130 fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f79e464c-180e-4be3-9056-0773aa679a1a
APM Review Panel: prior
|
Fold panel coverage gaps for microsoft#3130: verify ownership sidecars, actual CLI install/reinstall and legacy root healing without executing hooks. Preserve non-target behavior without pinning invalid Codex output; add canonical-edge and explicit-type traps and clarify flat-only guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f79e464c-180e-4be3-9056-0773aa679a1a
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f79e464c-180e-4be3-9056-0773aa679a1a
Keep the integrator below the canonical module limit by consolidating the duplicate legacy-key selection through the existing native renderer owner. Preserve Codex flat legacy matching and Claude untyped upgrade healing. Add a static boundary assertion and mutation-proven functional guardrail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f79e464c-180e-4be3-9056-0773aa679a1a
|
Thanks Daniel Meppiel (@danielmeppiel) for the panel pass, and Mohammed Alkindi (@MohammedAlkindi) for the Codex catch. I'll take the follow-ups myself in this PR:
Per the panel, nested-group defaults stay out of scope, so the PR remains flat-input only. I'll also rebase onto current |
|
Thanks Daniel Meppiel (@danielmeppiel), your convergence pass covered all three follow-ups, including a cleaner fix for Mohammed Alkindi (@MohammedAlkindi)'s Codex point, so I've dropped my local version and won't push over it. Happy to help with anything else before this comes out of draft. |
Preserve the reviewed flat-only Claude repair and all landed main changes. The automatic merge retains both changelog entries and independent hook architecture guard extensions without changing regression tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f79e464c-180e-4be3-9056-0773aa679a1a
fix(hooks): type untyped flat command handlers for Claude
Description
Claude-native rendering now supplies
"type": "command"for flat hook command entries that omittype, so.claude/settings.jsonreceives schema-valid handlers. Explicit handler types, nested Claude groups, non-command entries and every other target are unchanged.Warning
Readiness blocked after main integration. At the published head,
tests/integration/test_architecture_owner_rule_mutations.py::test_matrix_covers_every_registry_guard_exactly_oncefails because main registersregistry-delegation-build-id-verificationwithout a matrix case. The matrix and all owner registry files match mainb71cb47cexactly. Passing scoped tests and remote CI do not override this failure. No shared-main repair is authorized during release wind-down; this PR remains open and draft.TL;DR
Flat hook entries without
typeare valid in Cursor (wheretypedefaults tocommand), but the Claude render wrapped them into{ "matcher", "hooks": [...] }groups and left the handler without thetypefield Claude's hook schema requires. The Claude renderer now adds it for recognized command entries only. A shared legacy-native selector keeps reinstall cleanup recognizing entries written before this fix without changing ownership or consent policy.Note
Flat input only. Nested Claude handlers remain as authored. The contributor accepted the completed folds; no author rebase or additional push is requested. This PR remains draft.
Problem (WHY)
preToolUsewithcommand,matcher,timeout, notype) rendered a Claude handler with notype. As the report puts it: "The nesting happens, but the handler is incomplete."PreToolUselist, still untyped..apm/hooksentries carrying a stale source marker would be left behind as an untyped duplicate instead of healed, regressing the#1329/#1392contract.The approved Done-when requires schema-valid Claude command handlers from applicable untyped flat input while preserving ownership and other targets. This PR claims schema validity, not a demonstrated Claude Code runtime rejection. The regression and mutation evidence below makes that boundary checkable, following Agent Skills' validation pattern: "do the work, run a validator (a script, a reference checklist, or a self-check), fix any issues, and repeat until validation passes."
Approach (WHAT)
type: "command"for a flat entry with notypethat the neutral hook grammar reads as a command (command, orbash/powershell/windowsnormalized tocommand).promptwith acommandfield), nested Claude groups, and untyped non-command entries as authored.hook_contract.py,hook_ir.py) and other targets' rendering behavior unchanged; Codex's existing typing and event mapping are not overridden._legacy_hook_entries(): pre-default Claude groups, existing flat Codex entries, and no legacy entries for other targets.The reviewed fix was carried forward by a normal merge of main
b71cb47cd99e665d285a497ba6d05e810ae42ea4intobbd89bf3e4ea5ff9ad59ea86f4e34a9203285b77, preserving both parents and contributor commits. Git's local merge was clean despite the earlier GitHubDIRTYreport: both changelog entries and independent hook architecture guards were retained. The PR's production helpers and regression tests are unchanged; the actual prior panel is carried forward, not represented as a new review.Implementation (HOW)
src/apm_cli/integration/hook_native_formats.py_with_claude_default_handler_type()reuses_handler_to_ir()for command recognition._to_claude_hook_entries(..., default_handler_type=False)reproduces pre-fix output;_legacy_hook_entries()owns Claude/Codex legacy-shape selection.src/apm_cli/integration/hook_integrator.pylegacy_content_keys, replacing the inline Codex-only branch. Existing ownership removal policy is unchanged. The file is 2,099 of 2,100 lines at this head.scripts/architecture_linter/checks/mutation_hook_contract.py_nhc_legacy_native_shapesundermutation_writes.neutral_hook_contract, requiring cleanup to call the native-shape owner.tests/unit/integration/test_hook_integrator_issue3130.pytests/integration/test_architecture_contract_guards.pytests/integration/test_required_lifecycle_state_machine.pydocs/src/content/docs/producer/author-primitives/hooks-and-commands.mdpackages/apm-guide/.apm/skills/apm-usage/package-authoring.mdCHANGELOG.mdFixedentry under[Unreleased], crediting parth (@Parth-Vasave) and linking #3153 / #3130.Diagrams
Legend: dashed nodes are new native-renderer helpers; legacy and current Claude forms meet at existing ownership reconciliation before settings and sidecar writes.
flowchart LR subgraph Source[Source] A["Flat Claude command entries"] end subgraph Render[Native shapes] L["_legacy_hook_entries: pre-fix form"] N["_with_claude_default_handler_type: recognized untyped flat entries"] C["_to_claude_hook_entries: nested groups"] end subgraph Reconcile[Ownership reconciliation] K["_hook_entry_content_key: compare old and current forms"] W["Write settings.json and apm-hooks.json"] end A --> L L --> K A --> N N --> C C --> K K --> W classDef new stroke-dasharray: 5 5; class L,N new;Trade-offs
Benefits
{"type": "command", "command": "...", "timeout": 10}instead of an untyped handler.settings.jsonandapm-hooks.json.command/prompttypes, nested handlers, and non-command entries retain their existing behavior.Historical contributor observations (not current-head assertions)
The original write-up recorded these observations around
cecf16be/18c4c43c(v0.33.0). They were not revalidated as part of this body correction and are not evidence of failures at the reviewed head:apm.lock.yaml,.github/mcp.json,.codex/config.toml,apm_modules/,.local/state/gh/andbuild/lib/into the checkout.uv run pytestwas reported to skip the suite through the performance opt-in collection hook; the v0.33.0 lockfile was reported to reference the Microsoft package feed.pwshfailure.Issue and approved scope
Issue: Fixes #3130
Human scope-approval comment: #3130 (comment)
This PR completes the bounded issue scope: Claude-native typing of untyped flat command handlers (standalone and merged alongside an existing Claude-shaped hook file), preserved explicit and non-command types, regression coverage for both cases, and related guidance. It does not change shared hook representation, other targets' behavior, Codex event-name mapping, ownership or consent behavior. Contributor coordination is resolved by acceptance of the folds; the existing advisory records the prior
ship_nowpanel atbbd89bf3and the current shared-main readiness blocker; it is not an instruction to publish or merge.Type of change
Testing
Evidence is bound to
71fa67b6f5f6927fb49edd8e0099f60120a7c425, with tested baseb71cb47cd99e665d285a497ba6d05e810ae42ea4. "All existing tests pass" remains unchecked because the named shared-main matrix gate fails; no full local suite was run at this head. The six reviewed-head workflows below completed successfully, but do not establish readiness over that failure. Latermainchanges are not covered by this evidence.Validation evidence
uv run --frozen --extra dev pytest -q tests/integration/test_architecture_owner_rule_mutations.py::test_matrix_covers_every_registry_guard_exactly_onceregistry-delegation-build-id-verificationmatrix coverage.conflict-b71/shared-baseline.logand.xml. No fix attempted.bash .worker-evidence/lint.shconflict-b71/lint.lognames the exact merged head.bbd89bf3)bbd89bf3,quality-final.log)cd docs && npm run test:links(bbd89bf3)conflict-b71/functional.xml)mode-b-final.log, unchanged PR delta)Remote workflows at the reviewed head: CI, Merge Gate, CodeQL, Deploy Docs, Spec conformance, and NOTICE Drift Check all completed success.
gh pr checks 3153 --repo microsoft/apm --watch --interval 30completed with exit 0 after one observer connection-reset retry (no workflow rerun), including CI's unit shards, lifecycle and Windows gates.Exact focused/conformance commands and retained output
Run from the worktree root. The evidence directory and its
lint.share retained worker artifacts, not committed project files. The lint script runs the canonical commands with--frozen, includingscripts/windows_native_symlink_probe_entry.py; its macOS-compatible Python checks enforce the CI YAML, relative-path and line-count guards.Retained prior-head (
bbd89bf3) conformance commands/output; not rerun locally after this merge:That prior run's final
git diff --exit-codepassed with no statement drift.bash .worker-evidence/lint.shexited 0 and ended with:Mutation evidence is retained from five deliberately broken variants at reviewed
bbd89bf3, all restored there. The faithful merge changed no PR regression trap or guard, so no new mutation program was needed: disabling defaulting caused 10 failures; removing legacy keys caused 3; overwriting explicit types caused 1; removing the existing rewrite-scope static check caused 1; removing both Claude legacy-owner routing and its new static check caused 4. These are failed-test counts, not counts of separate mutation programs.Scenario Evidence
tests/unit/integration/test_hook_integrator_issue3130.py::test_standalone_flat_untyped_entry_gets_command_type(regression-trap for #3130)tests/unit/integration/test_hook_integrator_issue3130.py::test_flat_untyped_entry_merged_next_to_claude_shaped_filetests/unit/integration/test_hook_integrator_issue3130.py::test_flat_handler_types_are_defaulted_only_for_commands(7 cases);tests/unit/integration/test_hook_integrator_issue3130.py::test_untyped_handler_inside_nested_claude_group_is_unchangedtests/unit/integration/test_hook_integrator_issue3130.py::test_claude_handler_default_does_not_reach_other_targets(2 cases; helper-isolation check)tests/unit/integration/test_hook_integrator_issue3130.py::test_reinstall_replaces_untyped_group_written_before_fix;tests/unit/integration/test_hook_integrator_issue3130.py::test_stale_root_source_untyped_group_is_still_healedtests/integration/test_required_lifecycle_state_machine.py::test_required_claude_untyped_hook_upgrade_preserves_ownership_and_converges[standalone]and[merged]The registered owner guard is separately proven by
tests/integration/test_architecture_contract_guards.py::test_legacy_hook_shapes_boundary_rejects_cleanup_bypass; it and both CLI mappings executed successfully at the merged head within the 260-test run.How to test
uv run --frozen --extra dev pytest -q tests/unit/integration/test_hook_integrator_issue3130.py; expect 14 passed..venv/bin/apm; expect both standalone/merged CLI upgrade scenarios and owner-boundary tests to pass.preToolUsehook withouttypeusingapm install --target claude; confirmPreToolUsein.claude/settings.jsonhas"type": "command"and.claude/apm-hooks.jsonrecords the owned group.uv run --frozen --extra dev pytest -q tests/unit/integration/test_hook_integrator_issue3130.py -k 'other_targets or nested or defaulted_only'; confirm other-target isolation and explicit/nested/non-command preservation.Spec conformance (OpenAPM v0.1)
If this PR changes behaviour that an OpenAPM v0.1
req-XXXcovers,confirm the three-step ritual in the
development guide:
docs/src/content/docs/specs/openapm-v0.1.mdupdated(new/changed
<a id="req-XXX"></a>anchor + prose + Appendix Crow).
docs/src/content/docs/specs/manifests/openapm-v0.1.requirements.ymlupdated.
@pytest.mark.req("req-XXX")test undertests/spec_conformance/added or extended.CONFORMANCE.{md,json}regenerated viauv run --extra dev python -m tests.spec_conformance.gen_statementand committed.
src/apm_cli/integration/is a Mode B critical path; the reviewed diff has 25 substantive added lines there. The unmodified Mode B script accepted the existing contributor waiver. Noreq-XXXcovers target-native hook handler fields, andreq-lk-021ownership reconciliation is preserved, not changed.apm-spec-waiver: Claude-native hook handler type default; OpenAPM v0.1 defines no target-native hook handler fields and req-lk-021 ownership reconciliation is preserved
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com