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 campaignport hooks to a new harnesscopilot hook timeout regressionverify the copilot runtime contractnew 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*.pyandbuild/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
approveto Copilotallow. - Translate Claude
denyto Copilotdeny. - Translate Claude
askto 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 SubagentStopdecision: blockwith a string reason; or nested PreToolUsepermissionDecision: denywith 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: six events, eight registrations. - Vendored
.claude/hooks/hooks.json: two events, four 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
- Official sidecar and probe evidence were read.
- Each hook has an event, payload, matcher, output, failure, and cloud classification.
- PermissionRequest ask produces empty stdout.
- No multi-producer structured output is concatenated.
- PreCompact remains available in
eventRemap, even with no active vendored registration. - Canonical and generated artifacts changed together.
- Targeted generator and runtime tests pass.
- Real CLI ran for runtime-only changes.
- Both project-toolkit plugin manifests are still version-free (
validate_plugin_version_bump.pyexits 0). - No stale contract claim remains in requirements, audits, memories, or mirrors.
Provenance and Maintenance
Verified 2026-08-14 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.