Skip to content

fix(hooks): type untyped flat command handlers for Claude - #3153

Draft
parth (Parth-Vasave) wants to merge 2 commits into
microsoft:mainfrom
Parth-Vasave:fix/3130-claude-hook-handler-type
Draft

parth (Parth-Vasave) wants to merge 2 commits into
microsoft:mainfrom
Parth-Vasave:fix/3130-claude-hook-handler-type

Conversation

@Parth-Vasave

@Parth-Vasave parth (Parth-Vasave) commented Oct 2, 2026 •

Copy link
Copy Markdown

Description

Claude-native rendering now supplies "type": "command" for flat hook command entries that omit type, so .claude/settings.json receives schema-valid handlers. Explicit handler types, nested Claude groups, non-command entries and every other target are unchanged.

TL;DR

Flat hook entries without type are valid in Cursor (where type defaults to command), but the Claude render wrapped them into { "matcher", "hooks": [...] } groups and left the handler without the type field Claude's hook schema requires. The Claude renderer now adds it for recognized command entries only. A 6-line companion change keeps reinstall cleanup recognizing entries written before this fix.

Note

Open question for the review contact (asked on the issue, still unanswered): should an untyped handler that is already inside a nested Claude group also get type: "command"? The scope line says "recognized command handlers"; Done-when names flat input. This PR covers flat input only and pins that with a test, so extending it is a one-condition change if you prefer.

Problem (WHY)

  • The issue's exact repro (preToolUse with command, matcher, timeout, no type) renders a Claude handler with no type. As the report puts it: "The nesting happens, but the handler is incomplete."
  • The same happens when the flat file sits next to a Claude-shaped hook file in the same package: its group is merged into the same PreToolUse list, still untyped.
  • [!] Typing the output changes the content key that reinstall cleanup matches on. Without a companion change, a project's own root .apm/hooks entries carrying a stale source marker (directory rename, worktree, apm.yml name change) would be left behind as an untyped duplicate instead of healed, regressing the #1329 / #1392 contract.

The approved Done-when requires that "Applicable untyped flat command input produces schema-valid Claude command handlers" and that ownership and other targets "remain unchanged". Per the maintainer's note, this PR claims schema validity only, not a demonstrated Claude Code runtime rejection.

Approach (WHAT)

# Fix
1 In Claude rendering only, default type: "command" for a flat entry with no type that the neutral hook grammar reads as a command (command, or bash/powershell/windows normalized to command).
2 Leave explicit types (command, prompt, ...), nested Claude groups, and untyped entries without a command exactly as authored; no handler type is guessed.
3 Leave the shared hook IR (hook_contract.py, hook_ir.py) and the Codex, Gemini, Cursor and Antigravity renderers untouched, so no default reaches other targets.
4 Let reinstall cleanup also accept the pre-fix untyped render as a legacy content key, mirroring the existing Codex legacy-key path in the same loop.

Implementation (HOW)

File Change
src/apm_cli/integration/hook_native_formats.py New _with_claude_default_handler_type(), applied in _to_claude_hook_entries(), which gains a keyword-only default_handler_type=True switch. The command test reuses _handler_to_ir() from the neutral-grammar owner rather than defining "command handler" a second time.
src/apm_cli/integration/hook_integrator.py +6 lines: the Claude branch adds legacy_content_keys from _to_claude_hook_entries(entries, default_handler_type=False), like the Codex branch below it. Cleanup only recognizes one more content form; it removes nothing main would not have removed. File stays at 2,090 of 2,100 lines.
tests/unit/integration/test_hook_integrator_issue3130.py New component module: 11 cases through HookIntegrator on a temporary project (see Scenario Evidence).
docs/src/content/docs/producer/author-primitives/hooks-and-commands.md States the Claude input/output contract next to the passage the issue quotes.
packages/apm-guide/.apm/skills/apm-usage/package-authoring.md Same contract in the packaged usage guide.
CHANGELOG.md One Fixed entry under [Unreleased].

Trade-offs

  • Flat input only. Chose the narrower reading (matches Done-when and the plan posted on the issue); typing handlers inside nested groups waits for the review contact's answer.
  • Default in the Claude renderer, not the shared IR. A default on the shared representation is explicitly out of scope and would change every target.
  • Keep the 6-line cleanup change. Dropping it would shrink the diff but reintroduce stale-root duplicates for projects that installed untyped flat hooks with an earlier release.
  • Spec waiver rather than spec edits. OpenAPM v0.1 has no normative text for target-native hook handler fields, and the portable hook subset belongs to #2111, which the approval excludes.
  • No control-flow diagram: the change is one renderer helper plus one cleanup key, described above.

Benefits

  1. The issue's repro now writes {"type": "command", "command": "...", "timeout": 10}; before, the handler had no type.
  2. A flat file merged next to a Claude-shaped file yields typed handlers in both settings.json and the apm-hooks.json sidecar.
  3. Explicit command / prompt handlers and non-command entries render exactly as before.
  4. Codex and Cursor output for the same input is unchanged (asserted).
  5. Reinstalling over pre-fix settings converges to one typed group, including stale root-package entries.
Out-of-scope observations (not changed in this PR; happy to file issues)
  • Running the suites locally writes into the checkout: apm.lock.yaml (from tests/unit/install/test_install_target_copilot_app_e2e.py::TestCopilotAppParserE2E::test_project_scope_now_supported, no chdir), .github/mcp.json (from TestRunMcpInstallSelfDefined::test_self_defined_dep_separated_correctly, duplicated in two files, no assertion), plus .codex/config.toml, apm_modules/, .local/state/gh/ and build/lib/. CI's fresh checkouts hide this; locally it is easy to commit by accident.
  • uv run pytest (documented as the full suite) skips every test: tests/perf/conftest.py applies its opt-in skip in pytest_collection_modifyitems, which receives all session items.
  • The v0.33.0 release commit's uv.lock points about 100 packages at packagefeedproxy.microsoft.io instead of PyPI.
  • Root-level tests/test_*.py and tests/fixtures/policy/test_fixtures_load.py contain stale assertions that also fail on main; CI's unit lanes do not run them.

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 into an existing Claude-shaped group), preserved explicit and non-command types, regression coverage for both cases, and the related guidance. It does not change the shared hook representation, other targets, Codex event-name mapping, ownership or consent behavior.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Testing

  • Tested locally
  • All existing tests pass
  • Added tests for new functionality (if applicable)

Validated at cecf16be on main 18c4c43c (v0.33.0); the follow-up commit only adds this PR's number to the CHANGELOG entry. "All existing tests pass" is left unchecked because some local-only tests fail on this macOS host, identically on main (listed below), and remote CI has not run yet.

Validation evidence

Issue repro, Claude handler written to .claude/settings.json, before (pre-fix renderer) and after:

before: {"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/demo-pkg/.apm/hooks/scripts/gate.mjs\"", "timeout": 10}
after:  {"type": "command", "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/demo-pkg/.apm/hooks/scripts/gate.mjs\"", "timeout": 10}
Check (CI mirror) Result
Lint: ruff check and format, pylint R0801, YAML / relative-path / 2100-line guards, lint-auth-signals.sh, lint-architecture-boundaries.sh All passed
New tests 11 passed; 4 failed before the renderer fix and 1 before the cleanup fix
Unit suite (tests/unit tests/test_console.py -n auto) 23,584 passed, 2 failed; both pass alone and as whole files (load-sensitive installer output and target-provenance log line, unrelated to hooks)
Lifecycle Smoke (exact CI command) 228 passed, 1 failed: test_claude_project_hook_runs_from_external_cwd needs pwsh, absent on this host; fails identically on main
apm audit --ci (APM Self-Check) All 10 checks passed
NOTICE drift Clean
Docs job (npm ci, test:links, build, CLI docs contract, schema $ids) All passed (1,070 relative links, 34/34 CLI pages); run before rebasing onto 18c4c43c, which changes no docs
Windows Compatibility Gate Not run (macOS host); the new tests carry no windows_compat marker
New test run (verbatim)
tests/unit/integration/test_hook_integrator_issue3130.py::test_standalone_flat_untyped_entry_gets_command_type PASSED
tests/unit/integration/test_hook_integrator_issue3130.py::test_flat_untyped_entry_merged_next_to_claude_shaped_file PASSED
tests/unit/integration/test_hook_integrator_issue3130.py::test_flat_handler_types_are_defaulted_only_for_commands[untyped-bash-command] PASSED
tests/unit/integration/test_hook_integrator_issue3130.py::test_flat_handler_types_are_defaulted_only_for_commands[explicit-command] PASSED
tests/unit/integration/test_hook_integrator_issue3130.py::test_flat_handler_types_are_defaulted_only_for_commands[explicit-prompt] PASSED
tests/unit/integration/test_hook_integrator_issue3130.py::test_flat_handler_types_are_defaulted_only_for_commands[untyped-without-command] PASSED
tests/unit/integration/test_hook_integrator_issue3130.py::test_untyped_handler_inside_nested_claude_group_is_unchanged PASSED
tests/unit/integration/test_hook_integrator_issue3130.py::test_claude_handler_default_does_not_reach_other_targets[codex] PASSED
tests/unit/integration/test_hook_integrator_issue3130.py::test_claude_handler_default_does_not_reach_other_targets[cursor] PASSED
tests/unit/integration/test_hook_integrator_issue3130.py::test_reinstall_replaces_untyped_group_written_before_fix PASSED
tests/unit/integration/test_hook_integrator_issue3130.py::test_stale_root_source_untyped_group_is_still_healed PASSED
============================== 11 passed in 0.33s ==============================

Scenario Evidence

# Scenario (user promise) Principle(s) Test(s) proving it Type
1 A package ships a flat untyped hook; after apm install --target claude, the handler in .claude/settings.json has type: "command" (regression trap for #3130). Multi-harness support, Portability by manifest tests/unit/integration/test_hook_integrator_issue3130.py::test_standalone_flat_untyped_entry_gets_command_type component
2 The same flat file next to a Claude-shaped hook file in one package: both groups land typed and the sidecar matches. Multi-harness support ::test_flat_untyped_entry_merged_next_to_claude_shaped_file component
3 Authors who set type explicitly (command, prompt) or ship non-command entries see their handlers unchanged. Portability by manifest ::test_flat_handler_types_are_defaulted_only_for_commands (4 cases), ::test_untyped_handler_inside_nested_claude_group_is_unchanged component
4 Codex and Cursor users installing the same package see no change. Multi-harness support, Vendor-neutral ::test_claude_handler_default_does_not_reach_other_targets (2 cases) component
5 Re-running apm install over settings written by an older APM leaves one typed group, including stale root-package entries. DevX (pragmatic as npm), Governed by policy ::test_reinstall_replaces_untyped_group_written_before_fix, ::test_stale_root_source_untyped_group_is_still_healed component

How to test

  • Run uv run --frozen --extra dev pytest -q tests/unit/integration/test_hook_integrator_issue3130.py; expect 11 passed.
  • Recreate the issue's two packages (flat preToolUse entry without type) and run apm install --target claude; the PreToolUse handler in .claude/settings.json starts with "type": "command".
  • Add a Claude-shaped hook file to the same package and reinstall; both groups are typed and .claude/apm-hooks.json mirrors them.
  • Run apm install --target codex,cursor with the same package; .codex/hooks.json and .cursor/hooks.json match main.

Spec conformance (OpenAPM v0.1)

If this PR changes behaviour that an OpenAPM v0.1 req-XXX covers,
confirm the three-step ritual in the
development guide:

  • Spec edit: docs/src/content/docs/specs/openapm-v0.1.md updated
    (new/changed <a id="req-XXX"></a> anchor + prose + Appendix C
    row).
  • Manifest edit: docs/src/content/docs/specs/manifests/openapm-v0.1.requirements.yml
    updated.
  • Test edit: a @pytest.mark.req("req-XXX") test under
    tests/spec_conformance/ added or extended.
  • CONFORMANCE.{md,json} regenerated via
    uv run --extra dev python -m tests.spec_conformance.gen_statement
    and committed.
  • N/A -- this PR does not change OpenAPM-observable behaviour.

src/apm_cli/integration/ is a Mode B critical path and this diff has exactly 20 substantive lines there. No req-XXX covers target-native hook handler fields, and the req-lk-021 ownership 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

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.
@Parth-Vasave

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Flat hook entries without type reach .claude/settings.json without the required type: command

1 participant