| name | ai-agents-portability-campaign |
| description | Execute cross-harness hook changes for Claude Code and GitHub Copilot CLI using the settled official contract, versioned probes, generators, and runtime tests. Use for `run the portability campaign`, `port hooks to a new harness`, `copilot hook timeout regression`, or `new copilot cli release, recheck the contract`. Do NOT use for fact lookup alone (use `agent-harness-reference`) or release generation ownership (use `ai-agents-generation-and-release`). |
| version | 1.1.0 |
| license | MIT |
ai-agents Portability Campaign
This skill changes hooks. agent-harness-reference owns hook facts.
ai-agents-empirical-probe-toolkit owns probe design.
ai-agents-generation-and-release owns mirror generation and the release path.
Triggers
run the portability campaign
port hooks to a new harness
copilot hook timeout regression
verify the copilot runtime contract
new copilot cli release, recheck the contract
Process
Phase 0: Load the Settled Contract
Read:
.claude/skills/agent-harness-reference/SKILL.md
.claude/skills/agent-harness-reference/references/official-hook-contracts.md
.claude/skills/agent-harness-reference/references/probe-evidence.md
.agents/architecture/ADR-068-consolidated-hook-dispatcher.md
.agents/architecture/ADR-071-plugin-hook-runtime-contract-verification.md
.claude/rules/generated-artifacts.md
Do not run open-ended documentation research. Re-search or re-probe only when
the reference skill's refresh rules apply.
Current load-bearing facts:
| Dimension | Settled contract |
|---|
| Event casing | PascalCase compatibility events deliver snake_case payloads |
| Stop | Stop is per-turn completion; SessionEnd is process lifecycle |
| PreCompact | Supported locally and in cloud automatic compaction |
| Matcher | Supported; PascalCase tool events use Claude-compatible tool names |
| PreToolUse deny | Top-level permissionDecision; nonzero denies; timeout fails open |
| PermissionRequest | behavior accepts only allow or deny; translated ask emits nothing |
| Observer output | Active PostToolUse text merges into one additionalContext; dormant SessionStart, PreCompact, and UserPromptSubmit adapters discard output; unclassified events stay direct |
| Cloud reach | Only default-branch .github/hooks/*.json loads |
| Plugin paths | Anchor through plugin-root variables; do not depend on cwd |
Phase 1: Classify the Source Hook
For every source registration, record:
- Claude event and Copilot event.
- Input payload fields in each harness.
- Matcher target and matcher syntax.
- Output schema.
- Exit, crash, timeout, and malformed-output behavior.
- Local CLI and cloud reach.
- Whether stdout is diagnostic text or documented structured host output.
Use this repository mapping:
| Claude source | Copilot target | Generated form |
|---|
| PreToolUse | PreToolUse | Active gate dispatcher, one source shim |
| PostToolUse | PostToolUse | Active observe dispatcher, one source shim, text to additionalContext |
| PermissionRequest | PermissionRequest | Supported translation path; no current producer |
| SessionStart | SessionStart | No current vendored source; observe/discard adapter if added |
| UserPromptSubmit | UserPromptSubmit | No current vendored source; observe/discard adapter if added |
| PreCompact | PreCompact | No current vendored source; observe/discard adapter if added |
| Stop | Stop | No current vendored source; separate direct registrations if added |
| SubagentStop | SubagentStop | Separate direct registrations if added |
| SessionEnd | SessionEnd | No current source registration; stays direct if added until reviewed |
| PostToolUseFailure | PostToolUseFailure | Direct registration preserves exit-2 recovery context |
Why Stop stays direct: Copilot parses one final JSON document from each command
hook. Concatenating two decision objects makes invalid JSON and discards both.
The same parser boundary applies to additionalContext, modifiedResult, and
other structured outputs. The active PostToolUse merger treats successful
observer stdout as ordered, flat context text and emits one additionalContext
object. Failed-observer partial output is discarded. It does not merge
modifiedResult or pre-structured JSON. Dormant SessionStart and PreCompact
adapters discard stdout and stderr. Their direct rollback tests preserve side
effects but suppress both channels. PostToolUseFailure stays direct because
exit-2 stdout becomes recovery
context. Copilot documents no output field for UserPromptSubmit, so its stdout is
redirected to stderr in dispatcher and direct rollback modes. Stderr is not a
documented model-context path. Every unclassified event stays direct until its
host contract is reviewed.
Phase 2: Change Canonical Sources
Edit the smallest canonical surface:
- Hook script:
.claude/hooks/**
- Hook registrations:
.claude/settings.json
- Event mapping:
templates/platforms/copilot-cli.yaml
- Copilot generation:
build/scripts/generate_hooks*.py and
build/scripts/generate_dispatcher.py
- Runtime adapter:
.claude/lib/hook_dispatch.py
Do not hand-edit src/copilot-cli/**.
Rules:
- Keep PascalCase target events when source hooks expect snake_case payloads.
- Keep script-side matcher checks as defense in depth.
- Keep canonical Claude PermissionRequest output unchanged.
- Translate only in the generated Copilot dispatcher.
- Translate Claude
approve to Copilot allow.
- Translate Claude
deny to Copilot deny.
- Translate Claude
ask to empty stdout.
- Never map Stop to SessionEnd.
- Never consolidate structured host output without a tested, event-specific
merger.
- Merge PostToolUse context text into one
additionalContext.
- Capture SessionStart and PreCompact Python stdout and stderr, direct writes
to file descriptors 1 and 2, and inherited child-process output on both
channels. Discard the content unless a reviewed trust boundary makes it safe
model context.
- Reuse the same process-level capture in Claude grouped dispatch. Python-only
stream capture does not protect the single-document protocol.
- Treat unknown Claude JSON as context and continue later gates.
- Treat only event-valid Claude blocking shapes as terminal:
continue: false;
Stop or SubagentStop decision: block with a string reason; or nested
PreToolUse permissionDecision: deny with a string reason. Fail closed on
malformed object-shaped JSON, allow-shaped decisions, unsupported fields, and
invalid field types.
- Reject empty, malformed, event/mode-mismatched, duplicate, or path-escaping
Claude dispatch groups before any shim runs.
- Keep direct rollback commands for SessionStart and PreCompact silent on both
stdout and stderr while preserving producer side effects.
- Keep PostToolUseFailure direct unless an event-specific merger preserves
exit-2 recovery context.
- Discard UserPromptSubmit stdout and stderr. Do not invent fields or treat
undocumented stderr as model context.
- Keep unclassified events direct. Do not assign observe mode by default.
Phase 3: Test Before Generation
Run the smallest suites that cover the changed contract:
uv run pytest \
tests/build_scripts/test_generate_dispatcher.py \
tests/build_scripts/test_generate_hooks.py \
tests/test_hook_dispatch.py -q
For a launcher or cwd change, also run:
uv run pytest \
tests/build_scripts/test_generate_hooks_runtime_contract.py \
tests/build_scripts/test_generate_hooks_plugin_root.py -q
Every runtime-contract test needs a negative control. A test that compares the
generator with its own expected string is one closed loop.
Phase 4: Regenerate All Mirrors
Use ai-agents-generation-and-release. The required outcome is one coherent
change containing canonical sources and every generated artifact.
uv run python build/scripts/build_all.py
Current inventories:
- Local
.claude/settings.json: four events, five registrations.
- Vendored
.claude/hooks/hooks.json: two events, two registrations.
- Generated
src/copilot-cli/hooks/hooks.json: two events, two dispatcher
registrations.
Expected generated Copilot hook shape:
| Event | Entries |
|---|
| PostToolUse | 1 dispatcher |
| PreToolUse | 1 dispatcher |
No PermissionRequest policy hook is registered. Generic advise translation
remains covered by generator and dispatcher tests for a future reviewed policy.
Only the generated PreToolUse and PostToolUse event directories may remain.
Inactive event directories must be removed through ownership-proven generation.
Phase 5: Test Shipped Artifacts
uv run pytest \
tests/build_scripts/test_copilot_dispatcher_artifact.py \
tests/build_scripts/test_hook_contract_knowledge.py \
tests/e2e/test_cli_hook_e2e.py -q
Run the isolated real-CLI smoke when hook paths, event names, matchers,
decisions, exit handling, or timeout behavior changed:
uv run pytest tests/e2e/test_plugin_load_smoke.py -q
The smoke uses --plugin-dir; it must not install or mutate the user's default
plugin.
Phase 6: Refresh Knowledge Only When Needed
If the runtime matches the settled contract, do not create a new research
artifact.
If the runtime differs:
- Pin the CLI version.
- State the expected result before probing.
- Use an isolated plugin and foreign cwd.
- Capture raw stdin, cwd, env, stdout, stderr, and exit.
- Run a negative control.
- Compare the previous pinned version when possible.
- Update the official sidecar, probe evidence, affected ADRs, tests, generated
mirrors, and Serena memory together.
Do not replace an official statement with a single probe. Record the conflict
and scope the observation to the measured version.
Anti-Patterns
| Wrong path | Why it fails |
|---|
Map Stop to SessionEnd | Converts per-turn validation into process cleanup |
Copilot nested hookSpecificOutput.permissionDecision | Copilot PreToolUse expects top-level fields |
Copilot top-level decision: "deny" for PreToolUse | decision belongs to Stop events |
Copilot PermissionRequest behavior: "ask" | Official schema permits only allow or deny |
| JSON-only denial with exit 0 after a hook crash | PreToolUse nonzero is the documented fail-closed path |
| Raising timeout to obtain fail-closed behavior | Copilot timeouts always fail open |
| One observe dispatcher for multiple Stop decisions | Concatenates JSON and loses every decision |
| Assuming matchers are ignored | Current docs and 1.0.72-1 prove selective firing |
| Depending on general-purpose subagent events | Current docs say that agent emits neither event |
Hand-editing src/copilot-cli/** | Regeneration overwrites it and drift gates fail |
| Repeating settled web research | Wastes tokens and risks replacing pinned facts with weaker sources |
Extension Points
When a harness adds an event or output field, update
agent-harness-reference first. Then extend this campaign's mapping, generator
tests, shipped-artifact tests, real CLI probe, and generated mirrors together.
Verification
Provenance and Maintenance
Verified 2026-07-22 against .claude/settings.json,
.claude/hooks/hooks.json, generated Copilot manifests, ADR-068, ADR-071,
ADR-085, agent-harness-reference, and the hook contract knowledge tests.
Refresh this campaign after a host CLI version change, hook inventory change,
dispatcher design change, or vendor documentation update. First refresh
agent-harness-reference and its official and empirical sidecars. Then amend
affected ADRs, review them through adr-review, regenerate every mirror, and
rerun this skill's verification commands. Do not preserve dated inventory
counts as current operational guidance.