@jaguilar87/gaia-ops 4.4.0 → 4.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +12 -3
- package/ARCHITECTURE.md +9 -8
- package/CHANGELOG.md +34 -0
- package/README.md +43 -11
- package/agents/terraform-architect.md +1 -1
- package/bin/README.md +2 -2
- package/bin/gaia-doctor.js +18 -5
- package/bin/gaia-history.js +0 -1
- package/bin/gaia-metrics.js +2 -2
- package/bin/gaia-scan.py +23 -1
- package/bin/gaia-update.js +346 -54
- package/bin/pre-publish-validate.js +33 -10
- package/commands/gaia.md +37 -0
- package/config/README.md +3 -9
- package/config/context-contracts.json +47 -15
- package/config/surface-routing.json +9 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +22 -0
- package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
- package/dist/gaia-ops/agents/devops-developer.md +57 -0
- package/dist/gaia-ops/agents/gaia-system.md +58 -0
- package/dist/gaia-ops/agents/gitops-operator.md +60 -0
- package/dist/gaia-ops/agents/speckit-planner.md +71 -0
- package/dist/gaia-ops/agents/terraform-architect.md +60 -0
- package/dist/gaia-ops/commands/gaia.md +37 -0
- package/dist/gaia-ops/config/README.md +58 -0
- package/dist/gaia-ops/config/cloud/aws.json +140 -0
- package/dist/gaia-ops/config/cloud/gcp.json +145 -0
- package/dist/gaia-ops/config/context-contracts.json +131 -0
- package/dist/gaia-ops/config/git_standards.json +72 -0
- package/dist/gaia-ops/config/surface-routing.json +197 -0
- package/dist/gaia-ops/config/universal-rules.json +10 -0
- package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
- package/dist/gaia-ops/hooks/adapters/base.py +219 -0
- package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
- package/dist/gaia-ops/hooks/adapters/claude_code.py +1477 -0
- package/dist/gaia-ops/hooks/adapters/types.py +194 -0
- package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
- package/dist/gaia-ops/hooks/hooks.json +126 -0
- package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
- package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
- package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
- package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
- package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +124 -0
- package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
- package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
- package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
- package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
- package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
- package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +576 -0
- package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
- package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
- package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +215 -0
- package/dist/gaia-ops/hooks/modules/context/context_cache.py +129 -0
- package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +427 -0
- package/dist/gaia-ops/hooks/modules/context/context_writer.py +518 -0
- package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
- package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
- package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
- package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
- package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
- package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +558 -0
- package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
- package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
- package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
- package/dist/gaia-ops/hooks/modules/identity/__init__.py +0 -0
- package/dist/gaia-ops/hooks/modules/identity/identity_provider.py +21 -0
- package/dist/gaia-ops/hooks/modules/identity/ops_identity.py +34 -0
- package/dist/gaia-ops/hooks/modules/identity/security_identity.py +10 -0
- package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +227 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
- package/dist/gaia-ops/hooks/modules/security/__init__.py +89 -0
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
- package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +912 -0
- package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +153 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +584 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +86 -0
- package/dist/gaia-ops/hooks/modules/security/command_semantics.py +130 -0
- package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +850 -0
- package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
- package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
- package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
- package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +158 -0
- package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-ops/hooks/modules/tools/__init__.py +25 -0
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +708 -0
- package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +181 -0
- package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
- package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
- package/dist/gaia-ops/hooks/modules/tools/task_validator.py +283 -0
- package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
- package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
- package/dist/gaia-ops/hooks/post_compact.py +43 -0
- package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
- package/dist/gaia-ops/hooks/pre_tool_use.py +383 -0
- package/dist/gaia-ops/hooks/session_start.py +69 -0
- package/dist/gaia-ops/hooks/stop_hook.py +69 -0
- package/dist/gaia-ops/hooks/subagent_start.py +71 -0
- package/dist/gaia-ops/hooks/subagent_stop.py +288 -0
- package/dist/gaia-ops/hooks/task_completed.py +70 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +177 -0
- package/dist/gaia-ops/settings.json +72 -0
- package/dist/gaia-ops/skills/README.md +109 -0
- package/dist/gaia-ops/skills/agent-protocol/SKILL.md +105 -0
- package/dist/gaia-ops/skills/agent-protocol/examples.md +170 -0
- package/dist/gaia-ops/skills/agent-response/SKILL.md +53 -0
- package/dist/gaia-ops/skills/approval/SKILL.md +85 -0
- package/dist/gaia-ops/skills/approval/examples.md +140 -0
- package/dist/gaia-ops/skills/approval/reference.md +57 -0
- package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
- package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
- package/dist/gaia-ops/skills/context-updater/SKILL.md +76 -0
- package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
- package/dist/gaia-ops/skills/developer-patterns/SKILL.md +93 -0
- package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
- package/dist/gaia-ops/skills/execution/SKILL.md +66 -0
- package/dist/gaia-ops/skills/fast-queries/SKILL.md +47 -0
- package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +92 -0
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +22 -0
- package/dist/gaia-ops/skills/git-conventions/SKILL.md +48 -0
- package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +73 -0
- package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
- package/dist/gaia-ops/skills/investigation/SKILL.md +77 -0
- package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +64 -0
- package/dist/gaia-ops/skills/reference.md +134 -0
- package/dist/gaia-ops/skills/security-tiers/SKILL.md +61 -0
- package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
- package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
- package/dist/gaia-ops/skills/skill-creation/SKILL.md +119 -0
- package/dist/gaia-ops/skills/specification/SKILL.md +186 -0
- package/dist/gaia-ops/skills/speckit-workflow/SKILL.md +165 -0
- package/dist/gaia-ops/skills/speckit-workflow/reference.md +117 -0
- package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +63 -0
- package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
- package/dist/gaia-ops/speckit/README.md +516 -0
- package/dist/gaia-ops/speckit/scripts/.gitkeep +0 -0
- package/dist/gaia-ops/speckit/templates/adr-template.md +118 -0
- package/dist/gaia-ops/speckit/templates/agent-file-template.md +23 -0
- package/dist/gaia-ops/speckit/templates/plan-template.md +227 -0
- package/dist/gaia-ops/speckit/templates/spec-template.md +140 -0
- package/dist/gaia-ops/speckit/templates/tasks-template.md +257 -0
- package/dist/gaia-ops/tools/context/README.md +132 -0
- package/dist/gaia-ops/tools/context/__init__.py +42 -0
- package/dist/gaia-ops/tools/context/_paths.py +20 -0
- package/dist/gaia-ops/tools/context/context_provider.py +476 -0
- package/dist/gaia-ops/tools/context/context_section_reader.py +330 -0
- package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
- package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
- package/dist/gaia-ops/tools/context/surface_router.py +278 -0
- package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
- package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
- package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
- package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
- package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
- package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
- package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
- package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
- package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
- package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
- package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
- package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
- package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
- package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
- package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +262 -0
- package/dist/gaia-ops/tools/memory/README.md +0 -0
- package/dist/gaia-ops/tools/memory/__init__.py +20 -0
- package/dist/gaia-ops/tools/memory/episodic.py +1196 -0
- package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
- package/dist/gaia-ops/tools/review/__init__.py +1 -0
- package/dist/gaia-ops/tools/review/review_engine.py +157 -0
- package/dist/gaia-ops/tools/scan/__init__.py +35 -0
- package/dist/gaia-ops/tools/scan/config.py +247 -0
- package/dist/gaia-ops/tools/scan/merge.py +212 -0
- package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
- package/dist/gaia-ops/tools/scan/registry.py +127 -0
- package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
- package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
- package/dist/gaia-ops/tools/scan/scanners/environment.py +324 -0
- package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
- package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
- package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
- package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
- package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
- package/dist/gaia-ops/tools/scan/setup.py +753 -0
- package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
- package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
- package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
- package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
- package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
- package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
- package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
- package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
- package/dist/gaia-ops/tools/scan/ui.py +624 -0
- package/dist/gaia-ops/tools/scan/verify.py +266 -0
- package/dist/gaia-ops/tools/scan/walk.py +118 -0
- package/dist/gaia-ops/tools/scan/workspace.py +85 -0
- package/dist/gaia-ops/tools/validation/README.md +244 -0
- package/dist/gaia-ops/tools/validation/__init__.py +17 -0
- package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
- package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
- package/dist/gaia-security/.claude-plugin/plugin.json +22 -0
- package/dist/gaia-security/config/universal-rules.json +10 -0
- package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
- package/dist/gaia-security/hooks/adapters/base.py +219 -0
- package/dist/gaia-security/hooks/adapters/channel.py +17 -0
- package/dist/gaia-security/hooks/adapters/claude_code.py +1477 -0
- package/dist/gaia-security/hooks/adapters/types.py +194 -0
- package/dist/gaia-security/hooks/adapters/utils.py +25 -0
- package/dist/gaia-security/hooks/hooks.json +57 -0
- package/dist/gaia-security/hooks/modules/__init__.py +15 -0
- package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
- package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
- package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
- package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +124 -0
- package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
- package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
- package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
- package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
- package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
- package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +576 -0
- package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
- package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
- package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +215 -0
- package/dist/gaia-security/hooks/modules/context/context_cache.py +129 -0
- package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-security/hooks/modules/context/context_injector.py +427 -0
- package/dist/gaia-security/hooks/modules/context/context_writer.py +518 -0
- package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
- package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
- package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
- package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
- package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
- package/dist/gaia-security/hooks/modules/core/plugin_setup.py +558 -0
- package/dist/gaia-security/hooks/modules/core/state.py +179 -0
- package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
- package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
- package/dist/gaia-security/hooks/modules/identity/__init__.py +0 -0
- package/dist/gaia-security/hooks/modules/identity/identity_provider.py +21 -0
- package/dist/gaia-security/hooks/modules/identity/ops_identity.py +34 -0
- package/dist/gaia-security/hooks/modules/identity/security_identity.py +10 -0
- package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/memory/episode_writer.py +227 -0
- package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
- package/dist/gaia-security/hooks/modules/security/__init__.py +89 -0
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
- package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +912 -0
- package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-security/hooks/modules/security/approval_scopes.py +153 -0
- package/dist/gaia-security/hooks/modules/security/blocked_commands.py +584 -0
- package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +86 -0
- package/dist/gaia-security/hooks/modules/security/command_semantics.py +130 -0
- package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +850 -0
- package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
- package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
- package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
- package/dist/gaia-security/hooks/modules/session/session_event_injector.py +158 -0
- package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-security/hooks/modules/tools/__init__.py +25 -0
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +708 -0
- package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +181 -0
- package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
- package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
- package/dist/gaia-security/hooks/modules/tools/task_validator.py +283 -0
- package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
- package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
- package/dist/gaia-security/hooks/post_tool_use.py +54 -0
- package/dist/gaia-security/hooks/pre_tool_use.py +383 -0
- package/dist/gaia-security/hooks/session_start.py +69 -0
- package/dist/gaia-security/hooks/stop_hook.py +69 -0
- package/dist/gaia-security/hooks/user_prompt_submit.py +177 -0
- package/dist/gaia-security/settings.json +58 -0
- package/git-hooks/commit-msg +41 -0
- package/hooks/README.md +8 -6
- package/hooks/adapters/channel.py +0 -25
- package/hooks/adapters/claude_code.py +364 -125
- package/hooks/elicitation_result.py +132 -0
- package/hooks/hooks.json +10 -1
- package/hooks/modules/README.md +3 -2
- package/hooks/modules/agents/contract_validator.py +3 -51
- package/hooks/modules/agents/response_contract.py +4 -8
- package/hooks/modules/agents/transcript_reader.py +4 -5
- package/hooks/modules/audit/__init__.py +4 -6
- package/hooks/modules/audit/event_detector.py +0 -2
- package/hooks/modules/audit/metrics.py +108 -187
- package/hooks/modules/audit/workflow_auditor.py +0 -4
- package/hooks/modules/audit/workflow_recorder.py +0 -5
- package/hooks/modules/context/compact_context_builder.py +1 -0
- package/hooks/modules/context/context_cache.py +129 -0
- package/hooks/modules/context/context_injector.py +18 -40
- package/hooks/modules/context/context_writer.py +1 -25
- package/hooks/modules/context/contracts_loader.py +7 -10
- package/hooks/modules/core/hook_entry.py +1 -0
- package/hooks/modules/core/paths.py +12 -13
- package/hooks/modules/core/plugin_mode.py +74 -4
- package/hooks/modules/core/plugin_setup.py +395 -23
- package/hooks/modules/events/__init__.py +1 -0
- package/hooks/modules/events/event_writer.py +210 -0
- package/hooks/modules/identity/ops_identity.py +18 -27
- package/hooks/modules/memory/episode_writer.py +1 -6
- package/hooks/modules/orchestrator/__init__.py +1 -0
- package/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/hooks/modules/security/__init__.py +2 -4
- package/hooks/modules/security/approval_constants.py +5 -1
- package/hooks/modules/security/approval_grants.py +189 -6
- package/hooks/modules/security/approval_messages.py +9 -21
- package/hooks/modules/security/blocked_commands.py +98 -34
- package/hooks/modules/security/command_semantics.py +0 -4
- package/hooks/modules/security/gitops_validator.py +1 -11
- package/hooks/modules/security/mutative_verbs.py +179 -38
- package/hooks/modules/security/tiers.py +1 -19
- package/hooks/modules/session/session_event_injector.py +1 -25
- package/hooks/modules/tools/bash_validator.py +310 -94
- package/hooks/modules/tools/shell_parser.py +0 -1
- package/hooks/modules/tools/task_validator.py +9 -29
- package/hooks/post_tool_use.py +0 -72
- package/hooks/pre_tool_use.py +42 -102
- package/hooks/session_start.py +4 -2
- package/hooks/subagent_start.py +6 -2
- package/hooks/subagent_stop.py +1 -13
- package/hooks/user_prompt_submit.py +119 -37
- package/index.js +1 -1
- package/package.json +5 -3
- package/skills/README.md +3 -5
- package/skills/agent-protocol/SKILL.md +17 -16
- package/skills/agent-protocol/examples.md +6 -6
- package/skills/agent-response/SKILL.md +11 -14
- package/skills/approval/SKILL.md +28 -13
- package/skills/approval/reference.md +2 -2
- package/skills/execution/SKILL.md +1 -1
- package/skills/gaia-patterns/SKILL.md +2 -3
- package/skills/orchestrator-approval/SKILL.md +22 -50
- package/skills/security-tiers/SKILL.md +1 -1
- package/templates/README.md +9 -9
- package/templates/managed-settings.template.json +43 -0
- package/tools/gaia_simulator/runner.py +34 -1
- package/tools/scan/orchestrator.py +13 -0
- package/tools/scan/scanners/base.py +8 -0
- package/tools/scan/scanners/git.py +78 -0
- package/tools/scan/scanners/infrastructure.py +65 -0
- package/tools/scan/scanners/stack.py +110 -0
- package/tools/scan/setup.py +120 -13
- package/tools/scan/workspace.py +85 -0
- package/config/context-contracts.aws.json +0 -42
- package/config/context-contracts.gcp.json +0 -39
- package/skills/project-dispatch/SKILL.md +0 -34
- package/templates/settings.template.json +0 -226
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hook executor for gaia-ops replay testing.
|
|
3
|
+
|
|
4
|
+
Runs hooks as subprocesses with ReplayEvent payloads and compares results
|
|
5
|
+
against expected outcomes. Completely decoupled from log parsing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
from gaia_simulator.extractor import ReplayEvent
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ReplayResult:
|
|
25
|
+
"""Result of replaying a single event against the current hooks."""
|
|
26
|
+
|
|
27
|
+
event: ReplayEvent
|
|
28
|
+
actual_exit_code: int
|
|
29
|
+
actual_stdout: str
|
|
30
|
+
actual_stderr: str
|
|
31
|
+
actual_decision: str # "ALLOW", "BLOCK", "DENY", "ERROR"
|
|
32
|
+
actual_tier: str # parsed from stdout if available
|
|
33
|
+
matched: bool # expected_decision == actual_decision
|
|
34
|
+
regression_type: Optional[str] # None, "allow_to_block", "block_to_allow", "tier_change", "exit_code_change"
|
|
35
|
+
actual_metadata: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_RE_TIER = re.compile(r"\bT[0-3]\b")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_decision_from_output(
|
|
42
|
+
exit_code: int, stdout: str
|
|
43
|
+
) -> tuple[str, str]:
|
|
44
|
+
"""Parse the hook decision and tier from stdout/exit_code.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
(decision, tier) tuple.
|
|
48
|
+
"""
|
|
49
|
+
decision = "ALLOW"
|
|
50
|
+
tier = ""
|
|
51
|
+
|
|
52
|
+
if exit_code == 2:
|
|
53
|
+
decision = "BLOCK"
|
|
54
|
+
elif exit_code != 0:
|
|
55
|
+
decision = "ERROR"
|
|
56
|
+
|
|
57
|
+
# Try to parse structured JSON from stdout
|
|
58
|
+
stdout_stripped = stdout.strip()
|
|
59
|
+
if stdout_stripped:
|
|
60
|
+
# Hook output may have multiple lines; find the last JSON line
|
|
61
|
+
for line in reversed(stdout_stripped.splitlines()):
|
|
62
|
+
line = line.strip()
|
|
63
|
+
if not line.startswith("{"):
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(line)
|
|
67
|
+
# Check for deny via hookSpecificOutput
|
|
68
|
+
hook_output = data.get("hookSpecificOutput", {})
|
|
69
|
+
perm_decision = hook_output.get("permissionDecision", "")
|
|
70
|
+
if perm_decision == "deny":
|
|
71
|
+
decision = "DENY"
|
|
72
|
+
break
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
return decision, tier
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _extract_tier_from_text(*texts: str) -> str:
|
|
80
|
+
"""Return the first security tier found in the provided texts."""
|
|
81
|
+
for text in texts:
|
|
82
|
+
if not text:
|
|
83
|
+
continue
|
|
84
|
+
match = _RE_TIER.search(text)
|
|
85
|
+
if match:
|
|
86
|
+
return match.group(0)
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_last_json_line(stdout: str) -> Optional[dict[str, Any]]:
|
|
91
|
+
"""Parse the last JSON object emitted on stdout, if any."""
|
|
92
|
+
for line in reversed(stdout.strip().splitlines()):
|
|
93
|
+
stripped = line.strip()
|
|
94
|
+
if not stripped.startswith("{"):
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
return json.loads(stripped)
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
continue
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _classify_regression(
|
|
104
|
+
expected_decision: str,
|
|
105
|
+
actual_decision: str,
|
|
106
|
+
expected_exit_code: int,
|
|
107
|
+
actual_exit_code: int,
|
|
108
|
+
expected_tier: str,
|
|
109
|
+
actual_tier: str,
|
|
110
|
+
expected_metadata: Optional[dict[str, Any]] = None,
|
|
111
|
+
actual_metadata: Optional[dict[str, Any]] = None,
|
|
112
|
+
compare_tier: bool = True,
|
|
113
|
+
) -> Optional[str]:
|
|
114
|
+
"""Classify the type of regression, if any.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
None if no regression, or a string describing the regression type.
|
|
118
|
+
"""
|
|
119
|
+
expected_metadata = expected_metadata or {}
|
|
120
|
+
actual_metadata = actual_metadata or {}
|
|
121
|
+
|
|
122
|
+
if expected_decision == actual_decision and expected_exit_code == actual_exit_code:
|
|
123
|
+
if compare_tier and expected_tier and actual_tier and expected_tier != actual_tier:
|
|
124
|
+
return "tier_change"
|
|
125
|
+
for key, expected_value in expected_metadata.items():
|
|
126
|
+
if key not in actual_metadata:
|
|
127
|
+
return f"{key}_missing"
|
|
128
|
+
if actual_metadata[key] != expected_value:
|
|
129
|
+
return f"{key}_change"
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
if expected_decision == "ALLOW" and actual_decision == "BLOCK":
|
|
133
|
+
return "allow_to_block"
|
|
134
|
+
if expected_decision == "ALLOW" and actual_decision == "DENY":
|
|
135
|
+
return "allow_to_t3"
|
|
136
|
+
if expected_decision == "BLOCK" and actual_decision == "ALLOW":
|
|
137
|
+
return "block_to_allow"
|
|
138
|
+
if expected_decision == "DENY" and actual_decision == "ALLOW":
|
|
139
|
+
return "deny_to_allow"
|
|
140
|
+
if expected_exit_code != actual_exit_code:
|
|
141
|
+
return "exit_code_change"
|
|
142
|
+
|
|
143
|
+
return "decision_change"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class HookRunner:
|
|
147
|
+
"""Executes hooks as subprocesses for replay testing.
|
|
148
|
+
|
|
149
|
+
Creates an isolated temporary project directory for each batch run,
|
|
150
|
+
mimicking the .claude/ directory structure that hooks expect.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, hooks_dir: Path, project_root: Optional[Path] = None):
|
|
154
|
+
"""Initialize the runner.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
hooks_dir: Path to the directory containing hook .py files.
|
|
158
|
+
project_root: Optional path to use as the simulated project root.
|
|
159
|
+
If None, a temporary directory is created per batch.
|
|
160
|
+
"""
|
|
161
|
+
self.hooks_dir = hooks_dir
|
|
162
|
+
self.project_root = project_root
|
|
163
|
+
self._timeout = 30
|
|
164
|
+
|
|
165
|
+
def _state_file_path(self, work_dir: Path) -> Path:
|
|
166
|
+
"""Return the hook state file path for a replay work directory."""
|
|
167
|
+
return work_dir / ".claude" / ".hooks_state.json"
|
|
168
|
+
|
|
169
|
+
def _load_hook_state(self, work_dir: Path) -> dict[str, Any]:
|
|
170
|
+
"""Load hook state written by pre_tool_use, if present."""
|
|
171
|
+
path = self._state_file_path(work_dir)
|
|
172
|
+
if not path.exists():
|
|
173
|
+
return {}
|
|
174
|
+
try:
|
|
175
|
+
return json.loads(path.read_text())
|
|
176
|
+
except (OSError, json.JSONDecodeError):
|
|
177
|
+
return {}
|
|
178
|
+
|
|
179
|
+
def _prime_post_tool_use_state(self, event: ReplayEvent, work_dir: Path) -> None:
|
|
180
|
+
"""Seed the pre-hook state so post_tool_use can replay faithfully."""
|
|
181
|
+
tool_input = event.stdin_payload.get("tool_input", {})
|
|
182
|
+
command = ""
|
|
183
|
+
if isinstance(tool_input, dict):
|
|
184
|
+
command = str(tool_input.get("command", ""))
|
|
185
|
+
|
|
186
|
+
state = {
|
|
187
|
+
"tool_name": event.tool_name,
|
|
188
|
+
"command": command,
|
|
189
|
+
"tier": event.expected_tier or "unknown",
|
|
190
|
+
"start_time": "2026-01-01T00:00:00",
|
|
191
|
+
"start_time_epoch": 0.0,
|
|
192
|
+
"session_id": event.stdin_payload.get("session_id", "replay"),
|
|
193
|
+
"pre_hook_result": "allowed",
|
|
194
|
+
"metadata": {},
|
|
195
|
+
}
|
|
196
|
+
self._state_file_path(work_dir).write_text(json.dumps(state))
|
|
197
|
+
|
|
198
|
+
def _read_latest_audit_record(self, work_dir: Path) -> dict[str, Any]:
|
|
199
|
+
"""Read the most recent audit record emitted during replay, if any."""
|
|
200
|
+
logs_dir = work_dir / ".claude" / "logs"
|
|
201
|
+
audit_files = sorted(logs_dir.glob("audit-*.jsonl"))
|
|
202
|
+
if not audit_files:
|
|
203
|
+
return {}
|
|
204
|
+
lines = audit_files[-1].read_text(encoding="utf-8", errors="replace").splitlines()
|
|
205
|
+
for line in reversed(lines):
|
|
206
|
+
if not line.strip():
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
return json.loads(line)
|
|
210
|
+
except json.JSONDecodeError:
|
|
211
|
+
continue
|
|
212
|
+
return {}
|
|
213
|
+
|
|
214
|
+
def _parse_pre_tool_use_result(
|
|
215
|
+
self,
|
|
216
|
+
exit_code: int,
|
|
217
|
+
stdout: str,
|
|
218
|
+
stderr: str,
|
|
219
|
+
work_dir: Path,
|
|
220
|
+
) -> tuple[str, str, dict[str, Any]]:
|
|
221
|
+
"""Parse pre_tool_use results, including tier from hook state/log artifacts."""
|
|
222
|
+
decision, tier = _parse_decision_from_output(exit_code, stdout)
|
|
223
|
+
payload = _parse_last_json_line(stdout) or {}
|
|
224
|
+
hook_output = payload.get("hookSpecificOutput", {}) if isinstance(payload, dict) else {}
|
|
225
|
+
|
|
226
|
+
state = self._load_hook_state(work_dir)
|
|
227
|
+
if not tier:
|
|
228
|
+
tier = str(state.get("tier", "") or "")
|
|
229
|
+
if not tier:
|
|
230
|
+
tier = _extract_tier_from_text(
|
|
231
|
+
str(hook_output.get("permissionDecisionReason", "")),
|
|
232
|
+
stdout,
|
|
233
|
+
stderr,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
actual_metadata: dict[str, Any] = {}
|
|
237
|
+
if "updatedInput" in hook_output:
|
|
238
|
+
actual_metadata["updated_input"] = hook_output["updatedInput"]
|
|
239
|
+
if "permissionDecisionReason" in hook_output:
|
|
240
|
+
actual_metadata["permission_reason"] = hook_output["permissionDecisionReason"]
|
|
241
|
+
return decision, tier, actual_metadata
|
|
242
|
+
|
|
243
|
+
def _parse_post_tool_use_result(
|
|
244
|
+
self,
|
|
245
|
+
exit_code: int,
|
|
246
|
+
stdout: str,
|
|
247
|
+
work_dir: Path,
|
|
248
|
+
) -> tuple[str, str, dict[str, Any]]:
|
|
249
|
+
"""Parse post_tool_use results using the audit record it just emitted."""
|
|
250
|
+
decision = "PASS" if exit_code == 0 else "ERROR"
|
|
251
|
+
audit_record = self._read_latest_audit_record(work_dir)
|
|
252
|
+
actual_tier = str(audit_record.get("tier", "") or "")
|
|
253
|
+
actual_metadata = {}
|
|
254
|
+
if audit_record:
|
|
255
|
+
actual_metadata["tool_exit_code"] = audit_record.get("exit_code")
|
|
256
|
+
actual_metadata["duration_ms"] = audit_record.get("duration_ms")
|
|
257
|
+
return decision, actual_tier, actual_metadata
|
|
258
|
+
|
|
259
|
+
def _parse_stop_hook_result(
|
|
260
|
+
self,
|
|
261
|
+
exit_code: int,
|
|
262
|
+
stdout: str,
|
|
263
|
+
) -> tuple[str, str, dict[str, Any]]:
|
|
264
|
+
"""Parse stop_hook results from its JSON stdout payload."""
|
|
265
|
+
decision = "PASS" if exit_code == 0 else "ERROR"
|
|
266
|
+
payload = _parse_last_json_line(stdout) or {}
|
|
267
|
+
actual_metadata: dict[str, Any] = {}
|
|
268
|
+
if payload:
|
|
269
|
+
for key in ("quality_sufficient", "score", "recommendation"):
|
|
270
|
+
if key in payload:
|
|
271
|
+
actual_metadata[key] = payload[key]
|
|
272
|
+
return decision, "", actual_metadata
|
|
273
|
+
|
|
274
|
+
def _parse_result(
|
|
275
|
+
self,
|
|
276
|
+
event: ReplayEvent,
|
|
277
|
+
exit_code: int,
|
|
278
|
+
stdout: str,
|
|
279
|
+
stderr: str,
|
|
280
|
+
work_dir: Path,
|
|
281
|
+
) -> tuple[str, str, dict[str, Any]]:
|
|
282
|
+
"""Dispatch hook-specific result parsing."""
|
|
283
|
+
if event.hook_name == "pre_tool_use":
|
|
284
|
+
return self._parse_pre_tool_use_result(exit_code, stdout, stderr, work_dir)
|
|
285
|
+
if event.hook_name == "post_tool_use":
|
|
286
|
+
return self._parse_post_tool_use_result(exit_code, stdout, work_dir)
|
|
287
|
+
if event.hook_name == "stop_hook":
|
|
288
|
+
return self._parse_stop_hook_result(exit_code, stdout)
|
|
289
|
+
return ("PASS" if exit_code == 0 else "ERROR", "", {})
|
|
290
|
+
|
|
291
|
+
def _setup_project_dir(self, base_dir: Path) -> Path:
|
|
292
|
+
"""Create a minimal .claude/ directory structure for hooks.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
base_dir: Directory to set up as the project root.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
The base_dir path.
|
|
299
|
+
"""
|
|
300
|
+
claude_dir = base_dir / ".claude"
|
|
301
|
+
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
|
|
303
|
+
# Logs directory
|
|
304
|
+
(claude_dir / "logs").mkdir(exist_ok=True)
|
|
305
|
+
|
|
306
|
+
# Session directory
|
|
307
|
+
session_dir = claude_dir / "session" / "active"
|
|
308
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
|
|
310
|
+
# Project context directory
|
|
311
|
+
pc_dir = claude_dir / "project-context"
|
|
312
|
+
pc_dir.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
|
|
314
|
+
# Minimal project-context.json
|
|
315
|
+
minimal_context = {
|
|
316
|
+
"metadata": {
|
|
317
|
+
"version": "2.0",
|
|
318
|
+
"last_updated": "2026-01-01T00:00:00Z",
|
|
319
|
+
"scan_config": {
|
|
320
|
+
"last_scan": "2026-01-01T00:00:00Z",
|
|
321
|
+
"scanner_version": "0.1.0",
|
|
322
|
+
"staleness_hours": 24,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
"paths": {},
|
|
326
|
+
"sections": {
|
|
327
|
+
"project_identity": {
|
|
328
|
+
"name": "replay-test",
|
|
329
|
+
"type": "application",
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
(pc_dir / "project-context.json").write_text(
|
|
334
|
+
json.dumps(minimal_context, indent=2)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Workflow episodic memory dir
|
|
338
|
+
wem_dir = pc_dir / "workflow-episodic-memory"
|
|
339
|
+
wem_dir.mkdir(parents=True, exist_ok=True)
|
|
340
|
+
(wem_dir / "signals").mkdir(exist_ok=True)
|
|
341
|
+
|
|
342
|
+
# Config, memory, metrics directories
|
|
343
|
+
(claude_dir / "config").mkdir(exist_ok=True)
|
|
344
|
+
(claude_dir / "memory").mkdir(exist_ok=True)
|
|
345
|
+
(claude_dir / "metrics").mkdir(exist_ok=True)
|
|
346
|
+
|
|
347
|
+
# Settings.json
|
|
348
|
+
settings = {
|
|
349
|
+
"permissions": {"allow": ["Bash(*)"], "deny": []},
|
|
350
|
+
}
|
|
351
|
+
(claude_dir / "settings.json").write_text(json.dumps(settings, indent=2))
|
|
352
|
+
|
|
353
|
+
return base_dir
|
|
354
|
+
|
|
355
|
+
# Tools that the orchestrator is allowed to use directly.
|
|
356
|
+
# Payloads for these tools should NOT get agent_id injected, because
|
|
357
|
+
# they are orchestrator-level operations (dispatch, communication).
|
|
358
|
+
_ORCHESTRATOR_TOOLS = frozenset({
|
|
359
|
+
"agent", "task", "sendmessage", "skill",
|
|
360
|
+
"taskcreate", "taskupdate", "tasklist", "taskget",
|
|
361
|
+
"toolsearch", "websearch", "webfetch", "askuserquestion",
|
|
362
|
+
"stop", # stop_hook payloads are not subject to delegate mode
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
def _prepare_payload(self, event: ReplayEvent) -> str:
|
|
366
|
+
"""Serialize the event payload for the hook subprocess.
|
|
367
|
+
|
|
368
|
+
Injects ``agent_id`` into tool-call payloads that lack one, so
|
|
369
|
+
delegate mode recognises them as subagent context instead of
|
|
370
|
+
blocking them as orchestrator calls. Agent/SendMessage/Task
|
|
371
|
+
payloads are left untouched since the orchestrator context is
|
|
372
|
+
correct for those.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
event: The ReplayEvent being replayed.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
JSON string to feed to the hook subprocess via stdin.
|
|
379
|
+
"""
|
|
380
|
+
payload = event.stdin_payload
|
|
381
|
+
tool_name = (payload.get("tool_name") or event.tool_name or "").lower()
|
|
382
|
+
|
|
383
|
+
if not payload.get("agent_id") and tool_name not in self._ORCHESTRATOR_TOOLS:
|
|
384
|
+
payload = {**payload, "agent_id": "replay-simulator"}
|
|
385
|
+
|
|
386
|
+
return json.dumps(payload)
|
|
387
|
+
|
|
388
|
+
def _resolve_hook_script(self, hook_name: str) -> Path:
|
|
389
|
+
"""Resolve hook name to script path.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
hook_name: Hook name like "pre_tool_use" or "subagent_stop".
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Path to the hook script.
|
|
396
|
+
"""
|
|
397
|
+
script_name = f"{hook_name}.py"
|
|
398
|
+
return self.hooks_dir / script_name
|
|
399
|
+
|
|
400
|
+
def run(self, event: ReplayEvent, project_dir: Optional[Path] = None) -> ReplayResult:
|
|
401
|
+
"""Run the hook with the event's stdin_payload and compare results.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
event: The ReplayEvent to replay.
|
|
405
|
+
project_dir: Optional project directory to use. If None, uses
|
|
406
|
+
self.project_root or creates a temporary one.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
ReplayResult with actual vs expected comparison.
|
|
410
|
+
"""
|
|
411
|
+
work_dir = project_dir or self.project_root
|
|
412
|
+
if work_dir is None:
|
|
413
|
+
tmp = tempfile.mkdtemp(prefix="replay_")
|
|
414
|
+
work_dir = Path(tmp)
|
|
415
|
+
self._setup_project_dir(work_dir)
|
|
416
|
+
|
|
417
|
+
script_path = self._resolve_hook_script(event.hook_name)
|
|
418
|
+
if not script_path.exists():
|
|
419
|
+
return ReplayResult(
|
|
420
|
+
event=event,
|
|
421
|
+
actual_exit_code=-1,
|
|
422
|
+
actual_stdout="",
|
|
423
|
+
actual_stderr=f"Hook script not found: {script_path}",
|
|
424
|
+
actual_decision="ERROR",
|
|
425
|
+
actual_tier="",
|
|
426
|
+
matched=False,
|
|
427
|
+
regression_type="missing_hook",
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
env = os.environ.copy()
|
|
431
|
+
env.pop("CLAUDE_PLUGIN_ROOT", None)
|
|
432
|
+
|
|
433
|
+
if event.hook_name == "post_tool_use":
|
|
434
|
+
self._prime_post_tool_use_state(event, work_dir)
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
result = subprocess.run(
|
|
438
|
+
[sys.executable, str(script_path)],
|
|
439
|
+
input=self._prepare_payload(event),
|
|
440
|
+
capture_output=True,
|
|
441
|
+
text=True,
|
|
442
|
+
env=env,
|
|
443
|
+
timeout=self._timeout,
|
|
444
|
+
cwd=str(work_dir),
|
|
445
|
+
)
|
|
446
|
+
except subprocess.TimeoutExpired:
|
|
447
|
+
return ReplayResult(
|
|
448
|
+
event=event,
|
|
449
|
+
actual_exit_code=-1,
|
|
450
|
+
actual_stdout="",
|
|
451
|
+
actual_stderr="Timeout",
|
|
452
|
+
actual_decision="ERROR",
|
|
453
|
+
actual_tier="",
|
|
454
|
+
matched=False,
|
|
455
|
+
regression_type="timeout",
|
|
456
|
+
)
|
|
457
|
+
except OSError as exc:
|
|
458
|
+
return ReplayResult(
|
|
459
|
+
event=event,
|
|
460
|
+
actual_exit_code=-1,
|
|
461
|
+
actual_stdout="",
|
|
462
|
+
actual_stderr=str(exc),
|
|
463
|
+
actual_decision="ERROR",
|
|
464
|
+
actual_tier="",
|
|
465
|
+
matched=False,
|
|
466
|
+
regression_type="os_error",
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
actual_decision, actual_tier, actual_metadata = self._parse_result(
|
|
470
|
+
event,
|
|
471
|
+
result.returncode,
|
|
472
|
+
result.stdout,
|
|
473
|
+
result.stderr,
|
|
474
|
+
work_dir,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
regression = _classify_regression(
|
|
478
|
+
event.expected_decision,
|
|
479
|
+
actual_decision,
|
|
480
|
+
event.expected_exit_code,
|
|
481
|
+
result.returncode,
|
|
482
|
+
event.expected_tier,
|
|
483
|
+
actual_tier,
|
|
484
|
+
expected_metadata=event.expected_metadata,
|
|
485
|
+
actual_metadata=actual_metadata,
|
|
486
|
+
compare_tier=event.compare_tier,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
matched = regression is None
|
|
490
|
+
|
|
491
|
+
return ReplayResult(
|
|
492
|
+
event=event,
|
|
493
|
+
actual_exit_code=result.returncode,
|
|
494
|
+
actual_stdout=result.stdout,
|
|
495
|
+
actual_stderr=result.stderr,
|
|
496
|
+
actual_decision=actual_decision,
|
|
497
|
+
actual_tier=actual_tier,
|
|
498
|
+
matched=matched,
|
|
499
|
+
regression_type=regression,
|
|
500
|
+
actual_metadata=actual_metadata,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def run_batch(
|
|
504
|
+
self,
|
|
505
|
+
events: list[ReplayEvent],
|
|
506
|
+
progress_callback=None,
|
|
507
|
+
) -> list[ReplayResult]:
|
|
508
|
+
"""Run all events and return all results.
|
|
509
|
+
|
|
510
|
+
Creates a single isolated project directory for the batch to
|
|
511
|
+
share session state across sequential hook calls.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
events: List of ReplayEvents to replay.
|
|
515
|
+
progress_callback: Optional callable(current, total) for progress.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
List of ReplayResult instances in the same order as events.
|
|
519
|
+
"""
|
|
520
|
+
results: list[ReplayResult] = []
|
|
521
|
+
|
|
522
|
+
# Create a shared project directory for the batch
|
|
523
|
+
if self.project_root:
|
|
524
|
+
work_dir = self.project_root
|
|
525
|
+
else:
|
|
526
|
+
tmp = tempfile.mkdtemp(prefix="replay_batch_")
|
|
527
|
+
work_dir = Path(tmp)
|
|
528
|
+
|
|
529
|
+
self._setup_project_dir(work_dir)
|
|
530
|
+
|
|
531
|
+
total = len(events)
|
|
532
|
+
for idx, event in enumerate(events):
|
|
533
|
+
result = self.run(event, project_dir=work_dir)
|
|
534
|
+
results.append(result)
|
|
535
|
+
|
|
536
|
+
if progress_callback:
|
|
537
|
+
progress_callback(idx + 1, total)
|
|
538
|
+
|
|
539
|
+
return results
|