@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,1477 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code Adapter -- concrete HookAdapter for Claude Code v2.1+ hook protocol.
|
|
3
|
+
|
|
4
|
+
Translates between Claude Code's stdin JSON format and the normalized types
|
|
5
|
+
defined in adapters.types. Business logic modules never see Claude Code JSON
|
|
6
|
+
directly; they consume and produce normalized types.
|
|
7
|
+
|
|
8
|
+
Distribution channel detection:
|
|
9
|
+
- PLUGIN: CLAUDE_PLUGIN_ROOT env var is set
|
|
10
|
+
- NPM: default (symlink to node_modules or direct invocation)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from .base import HookAdapter
|
|
24
|
+
from .types import (
|
|
25
|
+
AgentCompletion,
|
|
26
|
+
BootstrapResult,
|
|
27
|
+
CompletionResult,
|
|
28
|
+
ContextResult,
|
|
29
|
+
DistributionChannel,
|
|
30
|
+
HookEvent,
|
|
31
|
+
HookEventType,
|
|
32
|
+
HookResponse,
|
|
33
|
+
PermissionDecision,
|
|
34
|
+
QualityResult,
|
|
35
|
+
ToolResult,
|
|
36
|
+
ValidationRequest,
|
|
37
|
+
ValidationResult,
|
|
38
|
+
VerificationResult,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClaudeCodeAdapter(HookAdapter):
|
|
45
|
+
"""Concrete adapter for Claude Code v2.1+ hook protocol.
|
|
46
|
+
|
|
47
|
+
Claude Code sends JSON on stdin with these top-level fields:
|
|
48
|
+
- hook_event_name: str (e.g. "PreToolUse", "PostToolUse", "SubagentStop")
|
|
49
|
+
- session_id: str
|
|
50
|
+
- tool_name: str (PreToolUse / PostToolUse)
|
|
51
|
+
- tool_input: dict (PreToolUse / PostToolUse)
|
|
52
|
+
- tool_response: dict (PostToolUse only)
|
|
53
|
+
- agent_type: str (SubagentStop only)
|
|
54
|
+
- agent_id: str (SubagentStop only)
|
|
55
|
+
- agent_transcript_path: str (SubagentStop only)
|
|
56
|
+
- last_assistant_message: str (SubagentStop only)
|
|
57
|
+
- cwd: str (SubagentStop only)
|
|
58
|
+
|
|
59
|
+
Responses use hookSpecificOutput with permissionDecision for PreToolUse.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------ #
|
|
63
|
+
# parse_event: stdin JSON -> HookEvent
|
|
64
|
+
# ------------------------------------------------------------------ #
|
|
65
|
+
|
|
66
|
+
def parse_event(self, stdin_data: str) -> HookEvent:
|
|
67
|
+
"""Parse raw stdin JSON into a normalized HookEvent.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: If JSON is invalid, empty, or event type is unknown.
|
|
71
|
+
"""
|
|
72
|
+
if not stdin_data or not stdin_data.strip():
|
|
73
|
+
raise ValueError("Empty stdin data")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
raw = json.loads(stdin_data)
|
|
77
|
+
except json.JSONDecodeError as exc:
|
|
78
|
+
raise ValueError(f"Invalid JSON from stdin: {exc}") from exc
|
|
79
|
+
|
|
80
|
+
if not isinstance(raw, dict):
|
|
81
|
+
raise ValueError(f"Expected JSON object, got {type(raw).__name__}")
|
|
82
|
+
|
|
83
|
+
# Map hook_event_name to HookEventType enum
|
|
84
|
+
event_name = raw.get("hook_event_name", "")
|
|
85
|
+
if not event_name:
|
|
86
|
+
raise ValueError("Missing required field: hook_event_name")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
event_type = HookEventType(event_name)
|
|
90
|
+
except ValueError:
|
|
91
|
+
raise ValueError(f"Unknown hook event type: {event_name}")
|
|
92
|
+
|
|
93
|
+
session_id = raw.get("session_id", "")
|
|
94
|
+
|
|
95
|
+
channel = self.detect_channel()
|
|
96
|
+
plugin_root = self._get_plugin_root() if channel == DistributionChannel.PLUGIN else None
|
|
97
|
+
|
|
98
|
+
return HookEvent(
|
|
99
|
+
event_type=event_type,
|
|
100
|
+
session_id=session_id,
|
|
101
|
+
payload=raw,
|
|
102
|
+
channel=channel,
|
|
103
|
+
plugin_root=plugin_root,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------ #
|
|
107
|
+
# format_validation_response: ValidationResult -> HookResponse
|
|
108
|
+
# ------------------------------------------------------------------ #
|
|
109
|
+
|
|
110
|
+
def format_validation_response(self, result: ValidationResult) -> HookResponse:
|
|
111
|
+
"""Format a ValidationResult into Claude Code's hookSpecificOutput JSON.
|
|
112
|
+
|
|
113
|
+
Maps:
|
|
114
|
+
allowed=True -> permissionDecision: "allow", exit 0
|
|
115
|
+
allowed=False, nonce=None -> permissionDecision: "deny", exit 0
|
|
116
|
+
allowed=False, permanent -> permissionDecision: "deny", exit 2
|
|
117
|
+
nonce present -> include nonce in reason
|
|
118
|
+
|
|
119
|
+
When result.modified_input is set, includes updatedInput for Claude Code
|
|
120
|
+
to apply the modified parameters transparently.
|
|
121
|
+
"""
|
|
122
|
+
if result.allowed:
|
|
123
|
+
decision = PermissionDecision.ALLOW.value
|
|
124
|
+
else:
|
|
125
|
+
decision = PermissionDecision.DENY.value
|
|
126
|
+
|
|
127
|
+
output: Dict[str, Any] = {
|
|
128
|
+
"hookSpecificOutput": {
|
|
129
|
+
"hookEventName": "PreToolUse",
|
|
130
|
+
"permissionDecision": decision,
|
|
131
|
+
"permissionDecisionReason": result.reason,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Include updatedInput when the command was modified (e.g. footer stripping)
|
|
136
|
+
if result.modified_input is not None:
|
|
137
|
+
output["hookSpecificOutput"]["updatedInput"] = result.modified_input
|
|
138
|
+
|
|
139
|
+
# Exit code 2 = permanent block (blocked_commands.py), 0 = corrective deny
|
|
140
|
+
# Permanent blocks have no nonce and are not allowed
|
|
141
|
+
exit_code = 0
|
|
142
|
+
if not result.allowed and result.nonce is None and result.tier == "BLOCKED":
|
|
143
|
+
exit_code = 2
|
|
144
|
+
|
|
145
|
+
return HookResponse(output=output, exit_code=exit_code)
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------ #
|
|
148
|
+
# format_completion_response: CompletionResult -> HookResponse
|
|
149
|
+
# ------------------------------------------------------------------ #
|
|
150
|
+
|
|
151
|
+
def format_completion_response(self, result: CompletionResult) -> HookResponse:
|
|
152
|
+
"""Format a CompletionResult for SubagentStop.
|
|
153
|
+
|
|
154
|
+
Success case: minimal response with contract status.
|
|
155
|
+
Repair needed: includes anomaly details for orchestrator.
|
|
156
|
+
Exit code is always 0 (SubagentStop never blocks).
|
|
157
|
+
"""
|
|
158
|
+
output: Dict[str, Any] = {
|
|
159
|
+
"contract_valid": result.contract_valid,
|
|
160
|
+
"anomalies_detected": len(result.anomalies),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if result.episode_id:
|
|
164
|
+
output["episode_id"] = result.episode_id
|
|
165
|
+
|
|
166
|
+
if result.context_updated:
|
|
167
|
+
output["context_updated"] = True
|
|
168
|
+
|
|
169
|
+
if result.repair_needed:
|
|
170
|
+
output["repair_needed"] = True
|
|
171
|
+
output["anomalies"] = result.anomalies
|
|
172
|
+
|
|
173
|
+
return HookResponse(output=output, exit_code=0)
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------ #
|
|
176
|
+
# format_context_response: ContextResult -> HookResponse
|
|
177
|
+
# ------------------------------------------------------------------ #
|
|
178
|
+
|
|
179
|
+
def format_context_response(self, result: ContextResult) -> HookResponse:
|
|
180
|
+
"""Format a ContextResult for SubagentStart context injection.
|
|
181
|
+
|
|
182
|
+
Claude Code expects SubagentStart hooks to return::
|
|
183
|
+
|
|
184
|
+
{"hookSpecificOutput": {"hookEventName": "SubagentStart",
|
|
185
|
+
"additionalContext": "..."}}
|
|
186
|
+
|
|
187
|
+
The additionalContext string is appended to the subagent's system prompt.
|
|
188
|
+
"""
|
|
189
|
+
hook_specific: Dict[str, Any] = {
|
|
190
|
+
"hookEventName": "SubagentStart",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if result.context_injected and result.additional_context:
|
|
194
|
+
hook_specific["additionalContext"] = result.additional_context
|
|
195
|
+
|
|
196
|
+
output: Dict[str, Any] = {"hookSpecificOutput": hook_specific}
|
|
197
|
+
|
|
198
|
+
if result.sections_provided:
|
|
199
|
+
output["sections_provided"] = result.sections_provided
|
|
200
|
+
|
|
201
|
+
return HookResponse(output=output, exit_code=0)
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------ #
|
|
204
|
+
# P1: adapt_session_start
|
|
205
|
+
# ------------------------------------------------------------------ #
|
|
206
|
+
|
|
207
|
+
def adapt_session_start(self, raw: dict) -> BootstrapResult:
|
|
208
|
+
"""Parse SessionStart event and return bootstrap actions.
|
|
209
|
+
|
|
210
|
+
SessionStart payload contains session_type which determines
|
|
211
|
+
what bootstrap actions to take:
|
|
212
|
+
- startup: full scan + refresh
|
|
213
|
+
- resume: refresh only (no scan)
|
|
214
|
+
- clear/compact: no scan, no refresh
|
|
215
|
+
"""
|
|
216
|
+
session_type = raw.get("session_type", "startup")
|
|
217
|
+
return BootstrapResult(
|
|
218
|
+
should_scan=session_type == "startup",
|
|
219
|
+
should_refresh=session_type in ("startup", "resume"),
|
|
220
|
+
session_type=session_type,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# ------------------------------------------------------------------ #
|
|
224
|
+
# P1: format_bootstrap_response
|
|
225
|
+
# ------------------------------------------------------------------ #
|
|
226
|
+
|
|
227
|
+
def format_bootstrap_response(self, result: BootstrapResult) -> HookResponse:
|
|
228
|
+
"""Format a BootstrapResult for SessionStart.
|
|
229
|
+
|
|
230
|
+
SessionStart hooks are informational -- exit code is always 0.
|
|
231
|
+
"""
|
|
232
|
+
output: Dict[str, Any] = {
|
|
233
|
+
"session_type": result.session_type,
|
|
234
|
+
"should_scan": result.should_scan,
|
|
235
|
+
"should_refresh": result.should_refresh,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if result.project_scanned:
|
|
239
|
+
output["project_scanned"] = True
|
|
240
|
+
if result.context_path:
|
|
241
|
+
output["context_path"] = str(result.context_path)
|
|
242
|
+
if result.tools_detected:
|
|
243
|
+
output["tools_detected"] = result.tools_detected
|
|
244
|
+
|
|
245
|
+
return HookResponse(output=output, exit_code=0)
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------ #
|
|
248
|
+
# detect_channel: determine NPM vs PLUGIN distribution
|
|
249
|
+
# ------------------------------------------------------------------ #
|
|
250
|
+
|
|
251
|
+
def detect_channel(self) -> DistributionChannel:
|
|
252
|
+
"""Detect distribution channel.
|
|
253
|
+
|
|
254
|
+
Priority:
|
|
255
|
+
1. CLAUDE_PLUGIN_ROOT env var set -> PLUGIN
|
|
256
|
+
2. Default -> NPM
|
|
257
|
+
"""
|
|
258
|
+
if os.environ.get("CLAUDE_PLUGIN_ROOT"):
|
|
259
|
+
return DistributionChannel.PLUGIN
|
|
260
|
+
return DistributionChannel.NPM
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------ #
|
|
263
|
+
# Helper: get_plugin_root
|
|
264
|
+
# ------------------------------------------------------------------ #
|
|
265
|
+
|
|
266
|
+
def _get_plugin_root(self) -> Optional[Path]:
|
|
267
|
+
"""Resolve plugin root from CLAUDE_PLUGIN_ROOT env var."""
|
|
268
|
+
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
|
269
|
+
if plugin_root:
|
|
270
|
+
return Path(plugin_root)
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------ #
|
|
274
|
+
# T005: parse_pre_tool_use helper
|
|
275
|
+
# ------------------------------------------------------------------ #
|
|
276
|
+
|
|
277
|
+
def parse_pre_tool_use(self, raw: Dict[str, Any]) -> ValidationRequest:
|
|
278
|
+
"""Extract a ValidationRequest from a PreToolUse payload.
|
|
279
|
+
|
|
280
|
+
Extracts:
|
|
281
|
+
- tool_name: the tool being invoked (Bash, Task, Agent, etc.)
|
|
282
|
+
- command: for Bash, the command string; for Task/Agent, the prompt
|
|
283
|
+
- tool_input: the full tool_input dict
|
|
284
|
+
- session_id: session identifier
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
raw: The full stdin JSON dict (HookEvent.payload).
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
ValidationRequest with normalized fields.
|
|
291
|
+
"""
|
|
292
|
+
tool_name = raw.get("tool_name", "")
|
|
293
|
+
tool_input = raw.get("tool_input", {})
|
|
294
|
+
session_id = raw.get("session_id", "")
|
|
295
|
+
|
|
296
|
+
# Extract the primary command/prompt string based on tool type
|
|
297
|
+
if tool_name.lower() == "bash":
|
|
298
|
+
command = tool_input.get("command", "")
|
|
299
|
+
elif tool_name.lower() in ("task", "agent"):
|
|
300
|
+
command = tool_input.get("prompt", "")
|
|
301
|
+
else:
|
|
302
|
+
# For other tools, use the first string value or empty
|
|
303
|
+
command = tool_input.get("command", "") or tool_input.get("prompt", "")
|
|
304
|
+
|
|
305
|
+
return ValidationRequest(
|
|
306
|
+
tool_name=tool_name,
|
|
307
|
+
command=command,
|
|
308
|
+
tool_input=tool_input,
|
|
309
|
+
session_id=session_id,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------ #
|
|
313
|
+
# T006: parse_post_tool_use helper
|
|
314
|
+
# ------------------------------------------------------------------ #
|
|
315
|
+
|
|
316
|
+
def parse_post_tool_use(self, raw: Dict[str, Any]) -> ToolResult:
|
|
317
|
+
"""Extract a ToolResult from a PostToolUse payload.
|
|
318
|
+
|
|
319
|
+
Extracts:
|
|
320
|
+
- tool_name: the tool that was invoked
|
|
321
|
+
- command: the command that was run (from tool_input)
|
|
322
|
+
- output: tool execution output
|
|
323
|
+
- exit_code: execution exit code
|
|
324
|
+
- session_id: session identifier
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
raw: The full stdin JSON dict (HookEvent.payload).
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
ToolResult with execution data.
|
|
331
|
+
"""
|
|
332
|
+
tool_name = raw.get("tool_name", "")
|
|
333
|
+
tool_input = raw.get("tool_input", {})
|
|
334
|
+
tool_response = raw.get("tool_response", {})
|
|
335
|
+
session_id = raw.get("session_id", "")
|
|
336
|
+
|
|
337
|
+
command = tool_input.get("command", "")
|
|
338
|
+
output = tool_response.get("output", "")
|
|
339
|
+
exit_code = tool_response.get("exit_code", 0)
|
|
340
|
+
|
|
341
|
+
return ToolResult(
|
|
342
|
+
tool_name=tool_name,
|
|
343
|
+
command=command,
|
|
344
|
+
output=output,
|
|
345
|
+
exit_code=exit_code,
|
|
346
|
+
session_id=session_id,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# ------------------------------------------------------------------ #
|
|
350
|
+
# T007: parse_agent_completion helper
|
|
351
|
+
# ------------------------------------------------------------------ #
|
|
352
|
+
|
|
353
|
+
def parse_agent_completion(self, raw: Dict[str, Any]) -> AgentCompletion:
|
|
354
|
+
"""Extract an AgentCompletion from a SubagentStop payload.
|
|
355
|
+
|
|
356
|
+
Extracts:
|
|
357
|
+
- agent_type: the type/name of the agent (e.g. "cloud-troubleshooter")
|
|
358
|
+
- agent_id: unique agent instance identifier
|
|
359
|
+
- transcript_path: path to the agent's transcript JSONL
|
|
360
|
+
- last_message: the agent's final assistant message
|
|
361
|
+
- session_id: session identifier
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
raw: The full stdin JSON dict (HookEvent.payload).
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
AgentCompletion with agent data.
|
|
368
|
+
"""
|
|
369
|
+
return AgentCompletion(
|
|
370
|
+
agent_type=raw.get("agent_type", ""),
|
|
371
|
+
agent_id=raw.get("agent_id", ""),
|
|
372
|
+
transcript_path=raw.get("agent_transcript_path", ""),
|
|
373
|
+
last_message=raw.get("last_assistant_message", ""),
|
|
374
|
+
session_id=raw.get("session_id", ""),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# ------------------------------------------------------------------ #
|
|
378
|
+
# format_ask_response: for interactive permission requests
|
|
379
|
+
# ------------------------------------------------------------------ #
|
|
380
|
+
|
|
381
|
+
def format_ask_response(
|
|
382
|
+
self, reason: str, updated_input: dict | None = None
|
|
383
|
+
) -> HookResponse:
|
|
384
|
+
"""Format an 'ask' permission response.
|
|
385
|
+
|
|
386
|
+
Used when the hook wants Claude Code to ask the user for permission.
|
|
387
|
+
This is distinct from deny (which silently blocks).
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
reason: Human-readable explanation forwarded to the agent.
|
|
391
|
+
updated_input: Optional modified tool input (e.g. footer-stripped
|
|
392
|
+
command) to include as ``updatedInput`` so the modification
|
|
393
|
+
survives the native permission dialog.
|
|
394
|
+
"""
|
|
395
|
+
output: Dict[str, Any] = {
|
|
396
|
+
"hookSpecificOutput": {
|
|
397
|
+
"hookEventName": "PreToolUse",
|
|
398
|
+
"permissionDecision": PermissionDecision.ASK.value,
|
|
399
|
+
"permissionDecisionReason": reason,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if updated_input:
|
|
403
|
+
output["hookSpecificOutput"]["updatedInput"] = updated_input
|
|
404
|
+
return HookResponse(output=output, exit_code=0)
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------------ #
|
|
407
|
+
# adapt_pre_tool_use: full pre-tool-use lifecycle
|
|
408
|
+
# ------------------------------------------------------------------ #
|
|
409
|
+
|
|
410
|
+
def adapt_pre_tool_use(self, event: HookEvent) -> HookResponse:
|
|
411
|
+
"""Run all pre-tool-use business logic and return a formatted response.
|
|
412
|
+
|
|
413
|
+
Orchestrates: routing (bash vs task), validation, state management,
|
|
414
|
+
context injection, approval handling, and response formatting.
|
|
415
|
+
"""
|
|
416
|
+
from modules.core.state import create_pre_hook_state, save_hook_state
|
|
417
|
+
from modules.security.approval_grants import (
|
|
418
|
+
cleanup_expired_grants,
|
|
419
|
+
)
|
|
420
|
+
from modules.tools.bash_validator import BashValidator
|
|
421
|
+
from modules.tools.task_validator import TaskValidator, AVAILABLE_AGENTS, META_AGENTS
|
|
422
|
+
hook_data = event.payload
|
|
423
|
+
tool_name = hook_data.get("tool_name") or ""
|
|
424
|
+
tool_input = hook_data.get("tool_input", {})
|
|
425
|
+
|
|
426
|
+
logger.info("Hook invoked: tool=%s, params=%s", tool_name, json.dumps(tool_input)[:200])
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
# ── Delegate mode gate ─────────────────────────────────
|
|
430
|
+
# Must run before any other logic. When enabled, the
|
|
431
|
+
# orchestrator (main session) is restricted to dispatch-only
|
|
432
|
+
# tools. Subagents are unaffected.
|
|
433
|
+
from modules.orchestrator.delegate_mode import check_delegate_mode
|
|
434
|
+
|
|
435
|
+
dm_result = check_delegate_mode(tool_name, hook_data)
|
|
436
|
+
if dm_result.blocked:
|
|
437
|
+
logger.warning(
|
|
438
|
+
"DELEGATE_MODE denied %s for orchestrator", tool_name,
|
|
439
|
+
)
|
|
440
|
+
return HookResponse(
|
|
441
|
+
output={
|
|
442
|
+
"hookSpecificOutput": {
|
|
443
|
+
"hookEventName": "PreToolUse",
|
|
444
|
+
"permissionDecision": "deny",
|
|
445
|
+
"permissionDecisionReason": dm_result.reason,
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
exit_code=0,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Periodic cleanup of expired approval grants
|
|
452
|
+
cleanup_expired_grants()
|
|
453
|
+
|
|
454
|
+
if not isinstance(tool_name, str):
|
|
455
|
+
return HookResponse(output="Error: Invalid tool name", exit_code=2)
|
|
456
|
+
if not isinstance(tool_input, dict):
|
|
457
|
+
return HookResponse(output="Error: Invalid parameters", exit_code=2)
|
|
458
|
+
|
|
459
|
+
if tool_name.lower() == "bash":
|
|
460
|
+
return self._adapt_bash(tool_name, tool_input, hook_data=hook_data)
|
|
461
|
+
elif tool_name.lower() in ("task", "agent"):
|
|
462
|
+
hooks_dir = Path(__file__).parent.parent
|
|
463
|
+
project_agents = [a for a in AVAILABLE_AGENTS if a not in META_AGENTS]
|
|
464
|
+
return self._adapt_task(
|
|
465
|
+
tool_name, tool_input, project_agents, hooks_dir,
|
|
466
|
+
session_id=event.session_id,
|
|
467
|
+
)
|
|
468
|
+
elif tool_name.lower() == "sendmessage":
|
|
469
|
+
return self._adapt_send_message(tool_name, tool_input)
|
|
470
|
+
else:
|
|
471
|
+
# Other tools pass through
|
|
472
|
+
return HookResponse(output={}, exit_code=0)
|
|
473
|
+
|
|
474
|
+
except Exception as e:
|
|
475
|
+
logger.error("Unexpected error in adapt_pre_tool_use: %s", e, exc_info=True)
|
|
476
|
+
return HookResponse(
|
|
477
|
+
output=f"Error during security validation: {str(e)}",
|
|
478
|
+
exit_code=2,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def _adapt_bash(
|
|
482
|
+
self,
|
|
483
|
+
tool_name: str,
|
|
484
|
+
parameters: dict,
|
|
485
|
+
hook_data: dict | None = None,
|
|
486
|
+
) -> HookResponse:
|
|
487
|
+
"""Handle Bash tool validation within the adapter.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
tool_name: The tool name ("Bash").
|
|
491
|
+
parameters: The tool_input dict (contains "command").
|
|
492
|
+
hook_data: Full hook event payload -- used to detect subagent
|
|
493
|
+
context via the ``agent_id`` field.
|
|
494
|
+
"""
|
|
495
|
+
from modules.core.state import create_pre_hook_state, save_hook_state
|
|
496
|
+
from modules.tools.bash_validator import BashValidator
|
|
497
|
+
|
|
498
|
+
command = parameters.get("command", "")
|
|
499
|
+
if not command:
|
|
500
|
+
return HookResponse(output="Error: Bash tool requires a command", exit_code=2)
|
|
501
|
+
|
|
502
|
+
# Detect subagent context: if agent_id is present in the hook event,
|
|
503
|
+
# the command is running inside a subagent (not the orchestrator).
|
|
504
|
+
is_subagent = bool(hook_data and hook_data.get("agent_id"))
|
|
505
|
+
session_id = (hook_data or {}).get("session_id", "")
|
|
506
|
+
|
|
507
|
+
validator = BashValidator()
|
|
508
|
+
result = validator.validate(
|
|
509
|
+
command, is_subagent=is_subagent, session_id=session_id,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
if not result.allowed:
|
|
513
|
+
from modules.core.plugin_mode import is_ops_mode
|
|
514
|
+
logger.warning("BLOCKED: %s - %s", command[:100], result.reason)
|
|
515
|
+
# Security-only mode: delegate T3 approval to native Claude Code dialog
|
|
516
|
+
# instead of blocking with nonce (which requires orchestrator + agents)
|
|
517
|
+
if not is_ops_mode():
|
|
518
|
+
reason_line = result.reason.split('\n')[0] if result.reason else f"T3 operation: {command[:80]}"
|
|
519
|
+
# Permanently blocked commands (rm -rf, kubectl delete namespace, etc.)
|
|
520
|
+
# are denied even in security mode — user cannot override
|
|
521
|
+
is_permanently_blocked = "blocked by security policy" in (result.reason or "").lower()
|
|
522
|
+
if is_permanently_blocked:
|
|
523
|
+
logger.info("SECURITY MODE: permanently denied: %s", command[:80])
|
|
524
|
+
output = {
|
|
525
|
+
"hookSpecificOutput": {
|
|
526
|
+
"hookEventName": "PreToolUse",
|
|
527
|
+
"permissionDecision": "deny",
|
|
528
|
+
"permissionDecisionReason": f"[BLOCKED] {reason_line}",
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return HookResponse(output=output, exit_code=2)
|
|
532
|
+
# Mutative commands (git commit, terraform apply, etc.) → ask user
|
|
533
|
+
logger.info("SECURITY MODE: returning 'ask' for T3: %s", command[:80])
|
|
534
|
+
output = {
|
|
535
|
+
"hookSpecificOutput": {
|
|
536
|
+
"hookEventName": "PreToolUse",
|
|
537
|
+
"permissionDecision": "ask",
|
|
538
|
+
"permissionDecisionReason": f"[{result.tier}] {reason_line}",
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return HookResponse(output=output, exit_code=0)
|
|
542
|
+
# Ops mode: block with nonce for orchestrator approval flow
|
|
543
|
+
if result.block_response is not None:
|
|
544
|
+
return HookResponse(output=result.block_response, exit_code=0)
|
|
545
|
+
return HookResponse(
|
|
546
|
+
output=self._format_blocked_message(result),
|
|
547
|
+
exit_code=2,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Save state for post-hook
|
|
551
|
+
effective_command = result.modified_input.get("command", command) if result.modified_input else command
|
|
552
|
+
state = create_pre_hook_state(
|
|
553
|
+
tool_name=tool_name,
|
|
554
|
+
command=effective_command,
|
|
555
|
+
tier=str(result.tier),
|
|
556
|
+
allowed=True,
|
|
557
|
+
)
|
|
558
|
+
save_hook_state(state)
|
|
559
|
+
|
|
560
|
+
if result.modified_input:
|
|
561
|
+
logger.info("MODIFIED: %s -> stripped footer - tier=%s", command[:80], result.tier)
|
|
562
|
+
output = {
|
|
563
|
+
"hookSpecificOutput": {
|
|
564
|
+
"hookEventName": "PreToolUse",
|
|
565
|
+
"permissionDecision": "allow",
|
|
566
|
+
"permissionDecisionReason": result.reason,
|
|
567
|
+
"updatedInput": result.modified_input,
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return HookResponse(output=output, exit_code=0)
|
|
571
|
+
|
|
572
|
+
logger.info("ALLOWED: %s - tier=%s", command[:100], result.tier)
|
|
573
|
+
return HookResponse(output={}, exit_code=0)
|
|
574
|
+
|
|
575
|
+
def _adapt_task(
|
|
576
|
+
self,
|
|
577
|
+
tool_name: str,
|
|
578
|
+
parameters: dict,
|
|
579
|
+
project_agents: list,
|
|
580
|
+
hooks_dir: Path,
|
|
581
|
+
session_id: str = "",
|
|
582
|
+
) -> HookResponse:
|
|
583
|
+
"""Handle Task/Agent tool validation within the adapter.
|
|
584
|
+
|
|
585
|
+
Builds project context and caches it for SubagentStart to forward.
|
|
586
|
+
PreToolUse no longer returns additionalContext directly -- that would
|
|
587
|
+
inject it into the orchestrator instead of the subagent.
|
|
588
|
+
"""
|
|
589
|
+
from modules.core.state import create_pre_hook_state, save_hook_state
|
|
590
|
+
from modules.tools.task_validator import TaskValidator
|
|
591
|
+
from modules.context.context_injector import build_project_context
|
|
592
|
+
from modules.session.session_event_injector import build_session_events
|
|
593
|
+
|
|
594
|
+
context_text, _telemetry = build_project_context(parameters, project_agents, hooks_dir)
|
|
595
|
+
events_text = build_session_events(parameters, project_agents)
|
|
596
|
+
|
|
597
|
+
# Standard task validation (runs against ORIGINAL prompt -- no workaround needed)
|
|
598
|
+
validator = TaskValidator()
|
|
599
|
+
result = validator.validate(parameters)
|
|
600
|
+
|
|
601
|
+
if not result.allowed:
|
|
602
|
+
logger.warning("BLOCKED Task: %s - %s", result.agent_name, result.reason)
|
|
603
|
+
return HookResponse(output=result.reason, exit_code=2)
|
|
604
|
+
|
|
605
|
+
state = create_pre_hook_state(
|
|
606
|
+
tool_name=tool_name,
|
|
607
|
+
command=f"Task:{result.agent_name}",
|
|
608
|
+
tier=str(result.tier),
|
|
609
|
+
allowed=True,
|
|
610
|
+
is_t3=result.is_t3_operation,
|
|
611
|
+
)
|
|
612
|
+
save_hook_state(state)
|
|
613
|
+
|
|
614
|
+
logger.info("ALLOWED Task: %s", result.agent_name)
|
|
615
|
+
|
|
616
|
+
# Cache context for SubagentStart to pick up and forward to the subagent.
|
|
617
|
+
# PreToolUse:Agent additionalContext goes to the orchestrator (wrong target).
|
|
618
|
+
additional = "\n".join(filter(None, [context_text, events_text]))
|
|
619
|
+
|
|
620
|
+
# Fallback: if build_project_context returned None because the
|
|
621
|
+
# orchestrator already embedded context in the prompt (dedup guard),
|
|
622
|
+
# extract the embedded context so SubagentStart can still inject it
|
|
623
|
+
# via additionalContext.
|
|
624
|
+
if not additional:
|
|
625
|
+
prompt = parameters.get("prompt", "")
|
|
626
|
+
marker = "# Project Context"
|
|
627
|
+
if marker in prompt:
|
|
628
|
+
# Extract everything from the marker onwards as context.
|
|
629
|
+
# The orchestrator copied its own injected context into the
|
|
630
|
+
# Agent tool prompt; we forward it to SubagentStart instead.
|
|
631
|
+
idx = prompt.index(marker)
|
|
632
|
+
additional = prompt[idx:]
|
|
633
|
+
logger.info(
|
|
634
|
+
"Extracted embedded context from prompt for caching "
|
|
635
|
+
"(len=%d, agent=%s)",
|
|
636
|
+
len(additional), result.agent_name,
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
if additional:
|
|
640
|
+
effective_session_id = session_id or "unknown"
|
|
641
|
+
agent_type = result.agent_name or "unknown"
|
|
642
|
+
self._cache_context_for_subagent(effective_session_id, agent_type, additional)
|
|
643
|
+
logger.info(
|
|
644
|
+
"Cached context for SubagentStart: agent=%s, session=%s",
|
|
645
|
+
agent_type, effective_session_id,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# Write AGENT_DISPATCH event (non-blocking)
|
|
649
|
+
try:
|
|
650
|
+
from modules.events.event_writer import EventWriter, AGENT_DISPATCH
|
|
651
|
+
prompt = parameters.get("prompt", "")
|
|
652
|
+
EventWriter().write_event(
|
|
653
|
+
AGENT_DISPATCH, "hook", result.agent_name or "unknown",
|
|
654
|
+
f"dispatched for: {prompt[:100]}",
|
|
655
|
+
)
|
|
656
|
+
except Exception:
|
|
657
|
+
pass # Events are non-critical
|
|
658
|
+
|
|
659
|
+
return HookResponse(output={}, exit_code=0)
|
|
660
|
+
|
|
661
|
+
def _adapt_send_message(
|
|
662
|
+
self, tool_name: str, parameters: dict,
|
|
663
|
+
) -> HookResponse:
|
|
664
|
+
"""Handle SendMessage tool validation for agent resumption.
|
|
665
|
+
|
|
666
|
+
Validates agent ID format and message content. Does NOT inject
|
|
667
|
+
project context (it's a resume). Nonce relay is no longer processed
|
|
668
|
+
here -- approval grants are activated by the UserPromptSubmit hook.
|
|
669
|
+
"""
|
|
670
|
+
from modules.core.state import create_pre_hook_state, save_hook_state
|
|
671
|
+
|
|
672
|
+
agent_id = parameters.get("to", "")
|
|
673
|
+
message = parameters.get("message", "")
|
|
674
|
+
|
|
675
|
+
# Validate agentId format
|
|
676
|
+
if not agent_id or not re.match(r'^a[0-9a-f]{5,}$', agent_id):
|
|
677
|
+
logger.warning("BLOCKED SendMessage: Invalid agentId format '%s'", agent_id)
|
|
678
|
+
msg = (
|
|
679
|
+
f"[ERROR] Invalid agent ID format: '{agent_id}'\n\n"
|
|
680
|
+
"Agent ID should be 'a' followed by hex characters.\n"
|
|
681
|
+
"Example: a12345f or a51a0cbbf6afb831d\n\n"
|
|
682
|
+
"The agent ID is returned at the end of agent responses.\n"
|
|
683
|
+
"Look for: 'agentId: a...' in the previous agent output."
|
|
684
|
+
)
|
|
685
|
+
return HookResponse(output=msg, exit_code=2)
|
|
686
|
+
|
|
687
|
+
if not message or not message.strip():
|
|
688
|
+
logger.warning("BLOCKED SendMessage: Missing message for agent %s", agent_id)
|
|
689
|
+
msg = (
|
|
690
|
+
"[ERROR] SendMessage requires a message\n\n"
|
|
691
|
+
"When resuming an agent, you must provide a message:\n\n"
|
|
692
|
+
"SendMessage(\n"
|
|
693
|
+
" to=\"a12345\",\n"
|
|
694
|
+
" message=\"Continue with the latest user instruction.\"\n"
|
|
695
|
+
")\n\n"
|
|
696
|
+
"The message tells the agent what to do next."
|
|
697
|
+
)
|
|
698
|
+
return HookResponse(output=msg, exit_code=2)
|
|
699
|
+
|
|
700
|
+
logger.info("SENDMESSAGE: Resuming agent %s", agent_id)
|
|
701
|
+
|
|
702
|
+
state = create_pre_hook_state(
|
|
703
|
+
tool_name=tool_name,
|
|
704
|
+
command=f"SendMessage:{agent_id}",
|
|
705
|
+
tier="T0",
|
|
706
|
+
allowed=True,
|
|
707
|
+
is_t3=False,
|
|
708
|
+
has_approval=False,
|
|
709
|
+
)
|
|
710
|
+
save_hook_state(state)
|
|
711
|
+
|
|
712
|
+
logger.info("ALLOWED SendMessage: agent %s - message length: %d", agent_id, len(message))
|
|
713
|
+
return HookResponse(output={}, exit_code=0)
|
|
714
|
+
|
|
715
|
+
@staticmethod
|
|
716
|
+
def _format_blocked_message(result) -> str:
|
|
717
|
+
"""Format blocked command message. Delegates to blocked_message_formatter."""
|
|
718
|
+
from modules.security.blocked_message_formatter import format_blocked_message
|
|
719
|
+
return format_blocked_message(result)
|
|
720
|
+
|
|
721
|
+
# ------------------------------------------------------------------ #
|
|
722
|
+
# adapt_post_tool_use: full post-tool-use lifecycle
|
|
723
|
+
# ------------------------------------------------------------------ #
|
|
724
|
+
|
|
725
|
+
def adapt_post_tool_use(self, event: HookEvent) -> HookResponse:
|
|
726
|
+
"""Run all post-tool-use business logic and return a formatted response.
|
|
727
|
+
|
|
728
|
+
Orchestrates: state retrieval, duration computation, audit logging,
|
|
729
|
+
T3 grant confirmation, critical event detection, session context
|
|
730
|
+
writing, state cleanup, and AskUserQuestion grant activation.
|
|
731
|
+
"""
|
|
732
|
+
from modules.core.state import get_hook_state, clear_hook_state
|
|
733
|
+
from modules.audit.logger import log_execution
|
|
734
|
+
from modules.audit.event_detector import detect_critical_event
|
|
735
|
+
from modules.session.session_context_writer import SessionContextWriter
|
|
736
|
+
from modules.security.approval_grants import check_approval_grant, confirm_grant, consume_grant
|
|
737
|
+
|
|
738
|
+
hook_data = event.payload
|
|
739
|
+
tool_result_data = self.parse_post_tool_use(hook_data)
|
|
740
|
+
logger.info("Post-hook event: %s", hook_data.get("hook_event_name"))
|
|
741
|
+
|
|
742
|
+
raw_tool_response = hook_data.get("tool_response", {})
|
|
743
|
+
tool_name = tool_result_data.tool_name
|
|
744
|
+
parameters = hook_data.get("tool_input", {})
|
|
745
|
+
output = tool_result_data.output
|
|
746
|
+
duration = raw_tool_response.get("duration_ms", 0) / 1000.0
|
|
747
|
+
success = tool_result_data.exit_code == 0
|
|
748
|
+
|
|
749
|
+
# ------------------------------------------------------------- #
|
|
750
|
+
# AskUserQuestion: check if user approved a pending T3 grant
|
|
751
|
+
# ------------------------------------------------------------- #
|
|
752
|
+
if tool_name == "AskUserQuestion":
|
|
753
|
+
self._handle_ask_user_question_result(hook_data)
|
|
754
|
+
return HookResponse(output={}, exit_code=0)
|
|
755
|
+
|
|
756
|
+
try:
|
|
757
|
+
pre_state = get_hook_state()
|
|
758
|
+
tier = pre_state.tier if pre_state else "unknown"
|
|
759
|
+
|
|
760
|
+
# Prefer wall-clock duration from pre-hook timestamp
|
|
761
|
+
computed_duration = duration
|
|
762
|
+
if pre_state and pre_state.start_time_epoch > 0:
|
|
763
|
+
computed_duration = time.time() - pre_state.start_time_epoch
|
|
764
|
+
|
|
765
|
+
log_execution(
|
|
766
|
+
tool_name=tool_name,
|
|
767
|
+
parameters=parameters,
|
|
768
|
+
result=output,
|
|
769
|
+
duration=computed_duration,
|
|
770
|
+
exit_code=0 if success else 1,
|
|
771
|
+
tier=tier,
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
# Confirm unconfirmed T3 grants after successful Bash execution
|
|
775
|
+
if tool_name == "Bash" and success:
|
|
776
|
+
command = parameters.get("command", "")
|
|
777
|
+
session_id = hook_data.get("session_id", "")
|
|
778
|
+
if command:
|
|
779
|
+
grant = check_approval_grant(command, session_id=session_id)
|
|
780
|
+
if grant is not None and not grant.confirmed:
|
|
781
|
+
confirm_grant(command, session_id=session_id)
|
|
782
|
+
consume_grant(command, session_id=session_id) # Single-use: mark as consumed
|
|
783
|
+
logger.info(
|
|
784
|
+
"T3 grant confirmed and consumed post-execution: %s", command[:80],
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
events = detect_critical_event(tool_name, parameters, output, success)
|
|
788
|
+
if events:
|
|
789
|
+
writer = SessionContextWriter()
|
|
790
|
+
for evt in events:
|
|
791
|
+
writer.update_context(evt.to_dict())
|
|
792
|
+
|
|
793
|
+
# Write COMMAND_EXECUTED event for T2+ Bash commands only (non-blocking)
|
|
794
|
+
if tool_name == "Bash" and tier in ("T2", "T3"):
|
|
795
|
+
try:
|
|
796
|
+
from modules.events.event_writer import EventWriter, COMMAND_EXECUTED
|
|
797
|
+
cmd = parameters.get("command", "")
|
|
798
|
+
EventWriter().write_event(
|
|
799
|
+
COMMAND_EXECUTED, "hook", "",
|
|
800
|
+
f"{'ok' if success else 'error'}: {cmd[:120]}",
|
|
801
|
+
severity="info" if success else "warning",
|
|
802
|
+
meta={"tier": tier},
|
|
803
|
+
)
|
|
804
|
+
except Exception:
|
|
805
|
+
pass # Events are non-critical
|
|
806
|
+
|
|
807
|
+
clear_hook_state()
|
|
808
|
+
logger.debug("Post-hook completed for %s", tool_name)
|
|
809
|
+
|
|
810
|
+
except Exception as e:
|
|
811
|
+
logger.error("Error in adapt_post_tool_use: %s", e, exc_info=True)
|
|
812
|
+
|
|
813
|
+
return HookResponse(output={}, exit_code=0)
|
|
814
|
+
|
|
815
|
+
# ------------------------------------------------------------------ #
|
|
816
|
+
# _handle_ask_user_question_result: grant activation from user answer
|
|
817
|
+
# ------------------------------------------------------------------ #
|
|
818
|
+
|
|
819
|
+
def _handle_ask_user_question_result(self, hook_data: Dict[str, Any]) -> None:
|
|
820
|
+
"""Conditionally activate pending grants based on user's answer.
|
|
821
|
+
|
|
822
|
+
Inspects the answers dict from tool_response (or tool_input as fallback)
|
|
823
|
+
to determine if the user approved. Only activates grants when at least
|
|
824
|
+
one answer value contains "approve" (case-insensitive).
|
|
825
|
+
|
|
826
|
+
Never blocks (no exceptions raised to caller).
|
|
827
|
+
"""
|
|
828
|
+
import json as _json
|
|
829
|
+
from modules.security.approval_grants import (
|
|
830
|
+
activate_grants_for_session,
|
|
831
|
+
get_pending_approvals_for_session,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_SESSION_ID", "")
|
|
835
|
+
|
|
836
|
+
# Debug logging
|
|
837
|
+
tool_response = hook_data.get("tool_response", {})
|
|
838
|
+
logger.info("AskUserQuestion PostToolUse keys: %s", list(hook_data.keys()))
|
|
839
|
+
logger.info("AskUserQuestion tool_response: %s", _json.dumps(tool_response, default=str)[:500])
|
|
840
|
+
|
|
841
|
+
# Extract answers from tool_response first, then tool_input as fallback
|
|
842
|
+
answers = {}
|
|
843
|
+
if isinstance(tool_response, dict):
|
|
844
|
+
answers = tool_response.get("answers", {})
|
|
845
|
+
if not answers and isinstance(hook_data.get("tool_input", {}), dict):
|
|
846
|
+
answers = hook_data.get("tool_input", {}).get("answers", {})
|
|
847
|
+
|
|
848
|
+
if not answers:
|
|
849
|
+
logger.info("AskUserQuestion: no answers found in payload, skipping grant activation")
|
|
850
|
+
return
|
|
851
|
+
|
|
852
|
+
# Check if any answer contains "approve"
|
|
853
|
+
user_approved = any("approve" in str(v).lower() for v in answers.values())
|
|
854
|
+
|
|
855
|
+
if not user_approved:
|
|
856
|
+
logger.info(
|
|
857
|
+
"AskUserQuestion: user did not approve (answers: %s), skipping grant activation",
|
|
858
|
+
{k: v for k, v in answers.items()},
|
|
859
|
+
)
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
# User approved -- activate grants
|
|
863
|
+
logger.info("AskUserQuestion: user approved, activating grants for session %s", session_id[:12])
|
|
864
|
+
|
|
865
|
+
try:
|
|
866
|
+
if not session_id:
|
|
867
|
+
logger.info("AskUserQuestion: no session_id available, skipping grant activation")
|
|
868
|
+
return
|
|
869
|
+
|
|
870
|
+
# Check for pending approvals before activating
|
|
871
|
+
pending = get_pending_approvals_for_session(session_id)
|
|
872
|
+
if not pending:
|
|
873
|
+
logger.info("AskUserQuestion: no pending grants for session %s", session_id)
|
|
874
|
+
return
|
|
875
|
+
|
|
876
|
+
results = activate_grants_for_session(session_id)
|
|
877
|
+
activated = sum(1 for r in results if r.success)
|
|
878
|
+
logger.info(
|
|
879
|
+
"AskUserQuestion activated %d/%d pending grants for session %s",
|
|
880
|
+
activated, len(results), session_id,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
except Exception as e:
|
|
884
|
+
logger.error("Error in _handle_ask_user_question_result: %s", e, exc_info=True)
|
|
885
|
+
|
|
886
|
+
# ------------------------------------------------------------------ #
|
|
887
|
+
# adapt_subagent_stop: full subagent-stop lifecycle
|
|
888
|
+
# ------------------------------------------------------------------ #
|
|
889
|
+
|
|
890
|
+
def adapt_subagent_stop(self, event: HookEvent) -> HookResponse:
|
|
891
|
+
"""Run all subagent-stop business logic and return a formatted response.
|
|
892
|
+
|
|
893
|
+
Orchestrates: contract parsing/validation, approval cleanup,
|
|
894
|
+
context updates, workflow recording, response contract validation,
|
|
895
|
+
anomaly detection, episodic memory, and result assembly.
|
|
896
|
+
"""
|
|
897
|
+
from modules.agents.contract_validator import (
|
|
898
|
+
extract_commands_from_evidence,
|
|
899
|
+
parse_contract,
|
|
900
|
+
requires_consolidation_report,
|
|
901
|
+
validate as validate_contract,
|
|
902
|
+
validate_approval_request,
|
|
903
|
+
validate_verbatim_outputs_consistency,
|
|
904
|
+
)
|
|
905
|
+
from modules.agents.response_contract import (
|
|
906
|
+
save_validation_result,
|
|
907
|
+
validate_response_contract,
|
|
908
|
+
resolve_agent_id,
|
|
909
|
+
)
|
|
910
|
+
from modules.agents.task_info_builder import build_task_info_from_hook_data
|
|
911
|
+
from modules.agents.transcript_reader import read_transcript
|
|
912
|
+
from modules.audit.workflow_auditor import audit as audit_workflow, signal_gaia_analysis
|
|
913
|
+
from modules.audit.workflow_recorder import record as record_workflow
|
|
914
|
+
from modules.context.context_writer import process_context_updates
|
|
915
|
+
from modules.memory.episode_writer import write as write_episode
|
|
916
|
+
from modules.security.approval_cleanup import cleanup as cleanup_approval
|
|
917
|
+
from modules.session.session_manager import get_or_create_session_id
|
|
918
|
+
|
|
919
|
+
hook_data = event.payload
|
|
920
|
+
logger.info(
|
|
921
|
+
"Hook event: %s, agent: %s",
|
|
922
|
+
hook_data.get("hook_event_name"),
|
|
923
|
+
hook_data.get("agent_type", "unknown"),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
# Parse agent completion data
|
|
927
|
+
completion = self.parse_agent_completion(hook_data)
|
|
928
|
+
|
|
929
|
+
# ----------------------------------------------------------
|
|
930
|
+
# Transcript analysis (T011)
|
|
931
|
+
# ----------------------------------------------------------
|
|
932
|
+
transcript_analysis = None
|
|
933
|
+
try:
|
|
934
|
+
from modules.agents.transcript_analyzer import analyze as analyze_transcript
|
|
935
|
+
if completion.transcript_path:
|
|
936
|
+
transcript_analysis = analyze_transcript(completion.transcript_path)
|
|
937
|
+
logger.info(
|
|
938
|
+
"Transcript analysis: %d tool calls, %d API calls, model=%s",
|
|
939
|
+
transcript_analysis.tool_call_count,
|
|
940
|
+
transcript_analysis.api_call_count,
|
|
941
|
+
transcript_analysis.model,
|
|
942
|
+
)
|
|
943
|
+
except Exception as exc:
|
|
944
|
+
logger.debug("Transcript analysis failed (non-fatal): %s", exc)
|
|
945
|
+
|
|
946
|
+
# Resolve agent output: prefer last_assistant_message, fall back to transcript
|
|
947
|
+
agent_output = completion.last_message
|
|
948
|
+
if not agent_output:
|
|
949
|
+
transcript_path = completion.transcript_path
|
|
950
|
+
agent_output = read_transcript(transcript_path) if transcript_path else ""
|
|
951
|
+
logger.info("Agent output: %d chars from transcript (fallback)", len(agent_output))
|
|
952
|
+
else:
|
|
953
|
+
logger.info("Agent output: %d chars from last_assistant_message", len(agent_output))
|
|
954
|
+
|
|
955
|
+
task_info = build_task_info_from_hook_data(hook_data, agent_output)
|
|
956
|
+
|
|
957
|
+
# Run the main processing chain
|
|
958
|
+
try:
|
|
959
|
+
from datetime import datetime as _dt
|
|
960
|
+
session_id = get_or_create_session_id()
|
|
961
|
+
agent_type = task_info.get("agent", "unknown")
|
|
962
|
+
|
|
963
|
+
parsed_contract = parse_contract(agent_output)
|
|
964
|
+
|
|
965
|
+
contract_result = validate_contract(agent_output, task_info)
|
|
966
|
+
if not contract_result.is_valid:
|
|
967
|
+
logger.warning(
|
|
968
|
+
"Contract validation failed for %s: %s",
|
|
969
|
+
agent_type, contract_result.error_message,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
cleanup_approval(agent_type)
|
|
973
|
+
|
|
974
|
+
commands_executed = extract_commands_from_evidence(agent_output)
|
|
975
|
+
context_update_result = process_context_updates(agent_output, task_info)
|
|
976
|
+
|
|
977
|
+
# Compute context anchor hit tracking
|
|
978
|
+
anchor_hits = None
|
|
979
|
+
try:
|
|
980
|
+
from modules.context.anchor_tracker import (
|
|
981
|
+
cleanup_anchors,
|
|
982
|
+
compute_anchor_hits,
|
|
983
|
+
extract_tool_calls_from_transcript,
|
|
984
|
+
load_anchors,
|
|
985
|
+
)
|
|
986
|
+
transcript_path = task_info.get("agent_transcript_path", "")
|
|
987
|
+
anchors = load_anchors(session_id, agent_type)
|
|
988
|
+
if anchors and transcript_path:
|
|
989
|
+
tool_calls = extract_tool_calls_from_transcript(transcript_path)
|
|
990
|
+
anchor_hits = compute_anchor_hits(tool_calls, anchors)
|
|
991
|
+
logger.info(
|
|
992
|
+
"Anchor hits for %s: %d/%d (%.0f%%)",
|
|
993
|
+
agent_type,
|
|
994
|
+
anchor_hits.get("hits", 0),
|
|
995
|
+
anchor_hits.get("total_checked", 0),
|
|
996
|
+
anchor_hits.get("hit_rate", 0) * 100,
|
|
997
|
+
)
|
|
998
|
+
cleanup_anchors(session_id, agent_type)
|
|
999
|
+
except Exception as exc:
|
|
1000
|
+
logger.debug("Anchor hit tracking failed (non-fatal): %s", exc)
|
|
1001
|
+
|
|
1002
|
+
session_context = {
|
|
1003
|
+
"timestamp": _dt.now().isoformat(),
|
|
1004
|
+
"session_id": session_id,
|
|
1005
|
+
"task_id": task_info.get("task_id", "unknown"),
|
|
1006
|
+
"agent_id": task_info.get("agent_id", "unknown"),
|
|
1007
|
+
"agent": agent_type,
|
|
1008
|
+
}
|
|
1009
|
+
workflow_metrics = record_workflow(
|
|
1010
|
+
task_info,
|
|
1011
|
+
agent_output,
|
|
1012
|
+
session_context,
|
|
1013
|
+
commands_executed=commands_executed,
|
|
1014
|
+
context_update_result=context_update_result,
|
|
1015
|
+
anchor_hits=anchor_hits,
|
|
1016
|
+
transcript_analysis=transcript_analysis,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
response_contract = validate_response_contract(
|
|
1020
|
+
agent_output,
|
|
1021
|
+
task_agent_id=resolve_agent_id(task_info),
|
|
1022
|
+
consolidation_required=requires_consolidation_report(task_info),
|
|
1023
|
+
parsed_contract=parsed_contract,
|
|
1024
|
+
)
|
|
1025
|
+
save_validation_result(task_info, response_contract)
|
|
1026
|
+
|
|
1027
|
+
anomalies = audit_workflow(
|
|
1028
|
+
workflow_metrics,
|
|
1029
|
+
agent_output,
|
|
1030
|
+
task_info,
|
|
1031
|
+
rejected_sections=(context_update_result or {}).get("rejected", []),
|
|
1032
|
+
transcript_analysis=transcript_analysis,
|
|
1033
|
+
)
|
|
1034
|
+
if not response_contract.valid:
|
|
1035
|
+
missing = ", ".join(response_contract.missing) or "none"
|
|
1036
|
+
invalid = ", ".join(response_contract.invalid) or "none"
|
|
1037
|
+
anomalies.append({
|
|
1038
|
+
"type": "response_contract_violation",
|
|
1039
|
+
"severity": "critical",
|
|
1040
|
+
"message": (
|
|
1041
|
+
f"Agent response contract invalid for {task_info.get('agent', 'unknown')}: "
|
|
1042
|
+
f"missing=[{missing}] invalid=[{invalid}]"
|
|
1043
|
+
),
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
# ----------------------------------------------------------
|
|
1047
|
+
# Compliance score (T011)
|
|
1048
|
+
# Computed after audit so anomalies are available for
|
|
1049
|
+
# has_scope_escalation detection.
|
|
1050
|
+
# ----------------------------------------------------------
|
|
1051
|
+
compliance_result = None
|
|
1052
|
+
try:
|
|
1053
|
+
from modules.agents.transcript_analyzer import compute_compliance_score
|
|
1054
|
+
if transcript_analysis is not None:
|
|
1055
|
+
_contract_valid = contract_result.is_valid
|
|
1056
|
+
_has_scope_escalation = any(
|
|
1057
|
+
a.get("type") == "scope_escalation"
|
|
1058
|
+
for a in anomalies
|
|
1059
|
+
) if anomalies else False
|
|
1060
|
+
_anchor_hit_rate = (
|
|
1061
|
+
anchor_hits.get("hit_rate", 0.0)
|
|
1062
|
+
if anchor_hits else 0.0
|
|
1063
|
+
)
|
|
1064
|
+
compliance_result = compute_compliance_score(
|
|
1065
|
+
transcript_analysis,
|
|
1066
|
+
contract_valid=_contract_valid,
|
|
1067
|
+
has_scope_escalation=_has_scope_escalation,
|
|
1068
|
+
anchor_hit_rate=_anchor_hit_rate,
|
|
1069
|
+
)
|
|
1070
|
+
logger.info(
|
|
1071
|
+
"Compliance score for %s: %d (%s)",
|
|
1072
|
+
agent_type, compliance_result.total, compliance_result.grade,
|
|
1073
|
+
)
|
|
1074
|
+
workflow_metrics["compliance_score"] = {
|
|
1075
|
+
"total": compliance_result.total,
|
|
1076
|
+
"grade": compliance_result.grade,
|
|
1077
|
+
"factors": compliance_result.factors,
|
|
1078
|
+
"deductions": compliance_result.deductions,
|
|
1079
|
+
}
|
|
1080
|
+
except Exception as exc:
|
|
1081
|
+
logger.debug("Compliance score computation failed (non-fatal): %s", exc)
|
|
1082
|
+
|
|
1083
|
+
if anomalies:
|
|
1084
|
+
logger.warning("%d anomalies detected in workflow", len(anomalies))
|
|
1085
|
+
signal_gaia_analysis(anomalies, workflow_metrics)
|
|
1086
|
+
|
|
1087
|
+
workflow_metrics["anomalies_detected"] = len(anomalies)
|
|
1088
|
+
workflow_metrics["anomaly_types"] = [a.get("type", "") for a in anomalies]
|
|
1089
|
+
|
|
1090
|
+
episode_id = write_episode(
|
|
1091
|
+
workflow_metrics,
|
|
1092
|
+
anomalies=anomalies if anomalies else None,
|
|
1093
|
+
commands_executed=commands_executed,
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
# Write AGENT_COMPLETE event (non-blocking)
|
|
1097
|
+
try:
|
|
1098
|
+
from modules.events.event_writer import EventWriter, AGENT_COMPLETE
|
|
1099
|
+
_plan = ""
|
|
1100
|
+
if parsed_contract and isinstance(parsed_contract.get("agent_status"), dict):
|
|
1101
|
+
_plan = str(parsed_contract["agent_status"].get("plan_status", ""))
|
|
1102
|
+
_key_outputs = []
|
|
1103
|
+
if parsed_contract and isinstance(parsed_contract.get("evidence_report"), dict):
|
|
1104
|
+
_key_outputs = parsed_contract["evidence_report"].get("key_outputs", [])
|
|
1105
|
+
_summary = "; ".join(str(o) for o in _key_outputs[:2]) if _key_outputs else ""
|
|
1106
|
+
EventWriter().write_event(
|
|
1107
|
+
AGENT_COMPLETE, "hook", agent_type,
|
|
1108
|
+
_plan or "completed",
|
|
1109
|
+
meta={"episode_id": episode_id, "summary": _summary[:200]},
|
|
1110
|
+
)
|
|
1111
|
+
except Exception:
|
|
1112
|
+
pass # Events are non-critical
|
|
1113
|
+
|
|
1114
|
+
contract_attempts = 0
|
|
1115
|
+
if not response_contract.valid:
|
|
1116
|
+
try:
|
|
1117
|
+
repair_data = response_contract.to_dict()
|
|
1118
|
+
contract_attempts = int(repair_data.get("repair_attempts", 0))
|
|
1119
|
+
except Exception:
|
|
1120
|
+
contract_attempts = 0
|
|
1121
|
+
|
|
1122
|
+
# ----------------------------------------------------------
|
|
1123
|
+
# Option D: Cross-field validation for verbatim_outputs
|
|
1124
|
+
# Advisory only -- adds to anomalies but never blocks.
|
|
1125
|
+
# ----------------------------------------------------------
|
|
1126
|
+
verbatim_check = validate_verbatim_outputs_consistency(parsed_contract)
|
|
1127
|
+
if verbatim_check:
|
|
1128
|
+
anomalies.append(verbatim_check)
|
|
1129
|
+
logger.info(
|
|
1130
|
+
"Verbatim outputs consistency warning for %s: %s",
|
|
1131
|
+
agent_type, verbatim_check.get("message", ""),
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
# ----------------------------------------------------------
|
|
1135
|
+
# Extract plan_status for downstream checks
|
|
1136
|
+
# ----------------------------------------------------------
|
|
1137
|
+
_plan_status = ""
|
|
1138
|
+
if parsed_contract and isinstance(parsed_contract.get("agent_status"), dict):
|
|
1139
|
+
_plan_status = str(parsed_contract["agent_status"].get("plan_status", ""))
|
|
1140
|
+
|
|
1141
|
+
# ----------------------------------------------------------
|
|
1142
|
+
# Approval request validation
|
|
1143
|
+
# Advisory only -- adds to anomalies but never blocks.
|
|
1144
|
+
# ----------------------------------------------------------
|
|
1145
|
+
if parsed_contract is not None:
|
|
1146
|
+
approval_check = validate_approval_request(parsed_contract, _plan_status)
|
|
1147
|
+
if approval_check:
|
|
1148
|
+
anomalies.append(approval_check)
|
|
1149
|
+
logger.info(
|
|
1150
|
+
"Approval request validation for %s: %s",
|
|
1151
|
+
agent_type, approval_check.get("detail", ""),
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
# ----------------------------------------------------------
|
|
1155
|
+
# Skill injection verification
|
|
1156
|
+
# Advisory only -- adds to anomalies but never blocks.
|
|
1157
|
+
# ----------------------------------------------------------
|
|
1158
|
+
try:
|
|
1159
|
+
from modules.agents.skill_injection_verifier import verify_skill_injection
|
|
1160
|
+
from modules.audit.workflow_recorder import load_agent_runtime_profile
|
|
1161
|
+
agent_profile = load_agent_runtime_profile(agent_type)
|
|
1162
|
+
declared_skills = agent_profile.get("skills", [])
|
|
1163
|
+
if declared_skills and agent_output:
|
|
1164
|
+
skill_check = verify_skill_injection(
|
|
1165
|
+
agent_type, agent_output, declared_skills,
|
|
1166
|
+
)
|
|
1167
|
+
if skill_check:
|
|
1168
|
+
anomalies.append(skill_check)
|
|
1169
|
+
logger.info(
|
|
1170
|
+
"Skill injection gap for %s: %s",
|
|
1171
|
+
agent_type, skill_check.get("message", ""),
|
|
1172
|
+
)
|
|
1173
|
+
except Exception as exc:
|
|
1174
|
+
logger.debug("Skill injection verification failed (non-fatal): %s", exc)
|
|
1175
|
+
|
|
1176
|
+
# ----------------------------------------------------------
|
|
1177
|
+
# Option B: Selective enforcement for critical structural failures.
|
|
1178
|
+
# Only 3 cases set contract_rejected=True:
|
|
1179
|
+
# 1. json:contract block completely missing
|
|
1180
|
+
# 2. plan_status missing or not one of the 8 valid statuses
|
|
1181
|
+
# 3. agent_status block missing entirely
|
|
1182
|
+
# ----------------------------------------------------------
|
|
1183
|
+
contract_rejected = False
|
|
1184
|
+
contract_rejection_reason = ""
|
|
1185
|
+
|
|
1186
|
+
if parsed_contract is None:
|
|
1187
|
+
contract_rejected = True
|
|
1188
|
+
contract_rejection_reason = (
|
|
1189
|
+
"[CONTRACT REJECTED] No json:contract block found in agent response.\n"
|
|
1190
|
+
"The agent must end its response with a ```json:contract``` fenced block.\n"
|
|
1191
|
+
"Reissue the response with a complete json:contract block."
|
|
1192
|
+
)
|
|
1193
|
+
elif not parsed_contract.get("agent_status") or not isinstance(
|
|
1194
|
+
parsed_contract.get("agent_status"), dict
|
|
1195
|
+
):
|
|
1196
|
+
contract_rejected = True
|
|
1197
|
+
contract_rejection_reason = (
|
|
1198
|
+
"[CONTRACT REJECTED] agent_status block missing from json:contract.\n"
|
|
1199
|
+
"The json:contract block must include an agent_status object with "
|
|
1200
|
+
"plan_status, agent_id, pending_steps, and next_action."
|
|
1201
|
+
)
|
|
1202
|
+
else:
|
|
1203
|
+
from modules.agents.response_contract import VALID_PLAN_STATUSES
|
|
1204
|
+
raw_plan_status = parsed_contract["agent_status"].get("plan_status", "")
|
|
1205
|
+
normalized = str(raw_plan_status).upper().rstrip(".,;") if raw_plan_status else ""
|
|
1206
|
+
if not normalized or normalized not in VALID_PLAN_STATUSES:
|
|
1207
|
+
contract_rejected = True
|
|
1208
|
+
valid_list = ", ".join(sorted(VALID_PLAN_STATUSES))
|
|
1209
|
+
contract_rejection_reason = (
|
|
1210
|
+
f"[CONTRACT REJECTED] plan_status is missing or invalid: "
|
|
1211
|
+
f"'{raw_plan_status}'.\n"
|
|
1212
|
+
f"Valid statuses: {valid_list}.\n"
|
|
1213
|
+
f"Set plan_status to one of these values in agent_status."
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
result = {
|
|
1217
|
+
"success": True,
|
|
1218
|
+
"session_id": session_id,
|
|
1219
|
+
"status": "metrics_captured",
|
|
1220
|
+
"metrics_captured": True,
|
|
1221
|
+
"anomalies_detected": len(anomalies) if anomalies else 0,
|
|
1222
|
+
"episode_id": episode_id,
|
|
1223
|
+
"context_updated": context_update_result.get("updated", False) if context_update_result else False,
|
|
1224
|
+
"response_contract": response_contract.to_dict(),
|
|
1225
|
+
"contract_validated": contract_result.is_valid,
|
|
1226
|
+
"contract_attempts": contract_attempts,
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if contract_rejected:
|
|
1230
|
+
result["contract_rejected"] = True
|
|
1231
|
+
result["contract_rejection_reason"] = contract_rejection_reason
|
|
1232
|
+
logger.warning(
|
|
1233
|
+
"Contract rejected for %s: %s",
|
|
1234
|
+
agent_type, contract_rejection_reason.split("\n")[0],
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
except Exception as e:
|
|
1238
|
+
logger.error("Error in adapt_subagent_stop: %s", e, exc_info=True)
|
|
1239
|
+
result = {
|
|
1240
|
+
"success": False,
|
|
1241
|
+
"error": str(e),
|
|
1242
|
+
"status": "partial_update",
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if result.get("contract_rejected"):
|
|
1246
|
+
logger.warning("Returning exit_code=2 due to contract rejection")
|
|
1247
|
+
return HookResponse(output=result, exit_code=2)
|
|
1248
|
+
|
|
1249
|
+
return HookResponse(output=result, exit_code=0)
|
|
1250
|
+
|
|
1251
|
+
# ------------------------------------------------------------------ #
|
|
1252
|
+
# P2: adapt_stop
|
|
1253
|
+
# ------------------------------------------------------------------ #
|
|
1254
|
+
|
|
1255
|
+
def adapt_stop(self, raw: dict) -> QualityResult:
|
|
1256
|
+
"""Parse Stop event and assess response quality.
|
|
1257
|
+
|
|
1258
|
+
Extracts the response content from the Stop payload and evaluates
|
|
1259
|
+
whether the output meets evidence quality thresholds.
|
|
1260
|
+
|
|
1261
|
+
Returns:
|
|
1262
|
+
QualityResult with quality assessment.
|
|
1263
|
+
Default: quality_sufficient=True (passthrough until business logic wired).
|
|
1264
|
+
"""
|
|
1265
|
+
# Write SESSION_END event (non-blocking)
|
|
1266
|
+
try:
|
|
1267
|
+
from modules.events.event_writer import EventWriter, SESSION_END
|
|
1268
|
+
stop_reason = raw.get("stop_reason", "unknown")
|
|
1269
|
+
EventWriter().write_event(
|
|
1270
|
+
SESSION_END, "hook", "",
|
|
1271
|
+
f"session ended: {stop_reason}",
|
|
1272
|
+
)
|
|
1273
|
+
except Exception:
|
|
1274
|
+
pass # Events are non-critical
|
|
1275
|
+
|
|
1276
|
+
return QualityResult(
|
|
1277
|
+
quality_sufficient=True,
|
|
1278
|
+
score=1.0,
|
|
1279
|
+
missing_elements=[],
|
|
1280
|
+
recommendation="continue",
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
# ------------------------------------------------------------------ #
|
|
1284
|
+
# P2: adapt_task_completed
|
|
1285
|
+
# ------------------------------------------------------------------ #
|
|
1286
|
+
|
|
1287
|
+
def adapt_task_completed(self, raw: dict) -> VerificationResult:
|
|
1288
|
+
"""Parse TaskCompleted event and verify completion criteria.
|
|
1289
|
+
|
|
1290
|
+
Extracts task output and metadata from the TaskCompleted payload.
|
|
1291
|
+
Checks if the task's acceptance criteria are met.
|
|
1292
|
+
|
|
1293
|
+
Returns:
|
|
1294
|
+
VerificationResult with criteria assessment.
|
|
1295
|
+
Default: criteria_met=True (passthrough until business logic wired).
|
|
1296
|
+
"""
|
|
1297
|
+
return VerificationResult(
|
|
1298
|
+
criteria_met=True,
|
|
1299
|
+
verified_items=[],
|
|
1300
|
+
failed_items=[],
|
|
1301
|
+
block_completion=False,
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
# ------------------------------------------------------------------ #
|
|
1305
|
+
# Context cache: PreToolUse -> SubagentStart bridge
|
|
1306
|
+
# ------------------------------------------------------------------ #
|
|
1307
|
+
|
|
1308
|
+
CONTEXT_CACHE_DIR = Path("/tmp/gaia-context-cache")
|
|
1309
|
+
CONTEXT_CACHE_TTL_SECONDS = 60 # Cache entries older than this are stale
|
|
1310
|
+
|
|
1311
|
+
def _cache_context_for_subagent(
|
|
1312
|
+
self, session_id: str, agent_type: str, context: str,
|
|
1313
|
+
) -> Path:
|
|
1314
|
+
"""Write built context to a cache file for SubagentStart consumption.
|
|
1315
|
+
|
|
1316
|
+
Returns the path to the cache file.
|
|
1317
|
+
"""
|
|
1318
|
+
self.CONTEXT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
1319
|
+
timestamp = int(time.time() * 1000)
|
|
1320
|
+
cache_file = self.CONTEXT_CACHE_DIR / f"{session_id}-{timestamp}.json"
|
|
1321
|
+
payload = {
|
|
1322
|
+
"context": context,
|
|
1323
|
+
"agent_type": agent_type,
|
|
1324
|
+
"session_id": session_id,
|
|
1325
|
+
"created_at": time.time(),
|
|
1326
|
+
}
|
|
1327
|
+
cache_file.write_text(json.dumps(payload))
|
|
1328
|
+
logger.debug("Context cache written: %s", cache_file)
|
|
1329
|
+
return cache_file
|
|
1330
|
+
|
|
1331
|
+
def _read_cached_context(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
1332
|
+
"""Read and consume the most recent cached context for a session.
|
|
1333
|
+
|
|
1334
|
+
Finds the newest cache file matching the session_id, reads it,
|
|
1335
|
+
deletes it (one-shot consumption), and cleans up stale entries.
|
|
1336
|
+
|
|
1337
|
+
Returns None if no cache is found.
|
|
1338
|
+
"""
|
|
1339
|
+
if not self.CONTEXT_CACHE_DIR.exists():
|
|
1340
|
+
return None
|
|
1341
|
+
|
|
1342
|
+
# Find all cache files for this session, sorted newest-first
|
|
1343
|
+
candidates: List[Path] = sorted(
|
|
1344
|
+
self.CONTEXT_CACHE_DIR.glob(f"{session_id}-*.json"),
|
|
1345
|
+
key=lambda p: p.stat().st_mtime,
|
|
1346
|
+
reverse=True,
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
if not candidates:
|
|
1350
|
+
# Fallback: try to find the most recent cache file regardless of
|
|
1351
|
+
# session_id, since the orchestrator session_id and the subagent
|
|
1352
|
+
# session_id may differ.
|
|
1353
|
+
all_files = sorted(
|
|
1354
|
+
self.CONTEXT_CACHE_DIR.glob("*.json"),
|
|
1355
|
+
key=lambda p: p.stat().st_mtime,
|
|
1356
|
+
reverse=True,
|
|
1357
|
+
)
|
|
1358
|
+
candidates = all_files
|
|
1359
|
+
|
|
1360
|
+
now = time.time()
|
|
1361
|
+
result = None
|
|
1362
|
+
|
|
1363
|
+
for cache_file in candidates:
|
|
1364
|
+
try:
|
|
1365
|
+
data = json.loads(cache_file.read_text())
|
|
1366
|
+
age = now - data.get("created_at", 0)
|
|
1367
|
+
|
|
1368
|
+
if age > self.CONTEXT_CACHE_TTL_SECONDS:
|
|
1369
|
+
# Stale entry -- clean up
|
|
1370
|
+
cache_file.unlink(missing_ok=True)
|
|
1371
|
+
logger.debug("Cleaned stale context cache: %s (age=%.1fs)", cache_file.name, age)
|
|
1372
|
+
continue
|
|
1373
|
+
|
|
1374
|
+
# Found a valid entry -- consume it
|
|
1375
|
+
result = data
|
|
1376
|
+
cache_file.unlink(missing_ok=True)
|
|
1377
|
+
logger.debug("Consumed context cache: %s (age=%.1fs)", cache_file.name, age)
|
|
1378
|
+
break
|
|
1379
|
+
|
|
1380
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
1381
|
+
logger.warning("Failed to read context cache %s: %s", cache_file, exc)
|
|
1382
|
+
cache_file.unlink(missing_ok=True)
|
|
1383
|
+
continue
|
|
1384
|
+
|
|
1385
|
+
# Clean up any remaining stale files (background hygiene)
|
|
1386
|
+
self._cleanup_stale_cache(now)
|
|
1387
|
+
|
|
1388
|
+
return result
|
|
1389
|
+
|
|
1390
|
+
def _cleanup_stale_cache(self, now: float) -> None:
|
|
1391
|
+
"""Remove cache files older than TTL."""
|
|
1392
|
+
if not self.CONTEXT_CACHE_DIR.exists():
|
|
1393
|
+
return
|
|
1394
|
+
for f in self.CONTEXT_CACHE_DIR.glob("*.json"):
|
|
1395
|
+
try:
|
|
1396
|
+
data = json.loads(f.read_text())
|
|
1397
|
+
if now - data.get("created_at", 0) > self.CONTEXT_CACHE_TTL_SECONDS:
|
|
1398
|
+
f.unlink(missing_ok=True)
|
|
1399
|
+
except (json.JSONDecodeError, OSError):
|
|
1400
|
+
f.unlink(missing_ok=True)
|
|
1401
|
+
|
|
1402
|
+
# ------------------------------------------------------------------ #
|
|
1403
|
+
# P2: adapt_subagent_start
|
|
1404
|
+
# ------------------------------------------------------------------ #
|
|
1405
|
+
|
|
1406
|
+
def adapt_subagent_start(self, raw: dict) -> ContextResult:
|
|
1407
|
+
"""Parse SubagentStart event and forward cached context to the subagent.
|
|
1408
|
+
|
|
1409
|
+
PreToolUse:Agent caches the built project context. This method reads
|
|
1410
|
+
the cache and returns it as additionalContext so Claude Code injects
|
|
1411
|
+
it into the subagent (not the orchestrator).
|
|
1412
|
+
"""
|
|
1413
|
+
session_id = raw.get("session_id", "")
|
|
1414
|
+
|
|
1415
|
+
cached = self._read_cached_context(session_id)
|
|
1416
|
+
if cached:
|
|
1417
|
+
logger.info(
|
|
1418
|
+
"SubagentStart: forwarding cached context for agent=%s (session=%s)",
|
|
1419
|
+
cached.get("agent_type", "unknown"),
|
|
1420
|
+
session_id,
|
|
1421
|
+
)
|
|
1422
|
+
return ContextResult(
|
|
1423
|
+
context_injected=True,
|
|
1424
|
+
additional_context=cached["context"],
|
|
1425
|
+
sections_provided=[],
|
|
1426
|
+
)
|
|
1427
|
+
|
|
1428
|
+
logger.info(
|
|
1429
|
+
"SubagentStart: no cached context found for session=%s (passthrough)",
|
|
1430
|
+
session_id,
|
|
1431
|
+
)
|
|
1432
|
+
return ContextResult(
|
|
1433
|
+
context_injected=False,
|
|
1434
|
+
additional_context=None,
|
|
1435
|
+
sections_provided=[],
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
# ------------------------------------------------------------------ #
|
|
1439
|
+
# P2: format_quality_response
|
|
1440
|
+
# ------------------------------------------------------------------ #
|
|
1441
|
+
|
|
1442
|
+
def format_quality_response(self, result: QualityResult) -> HookResponse:
|
|
1443
|
+
"""Format a QualityResult for CLI consumption.
|
|
1444
|
+
|
|
1445
|
+
Stop events are informational -- exit code is always 0.
|
|
1446
|
+
"""
|
|
1447
|
+
output: Dict[str, Any] = {
|
|
1448
|
+
"quality_sufficient": result.quality_sufficient,
|
|
1449
|
+
"score": result.score,
|
|
1450
|
+
"recommendation": result.recommendation,
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if result.missing_elements:
|
|
1454
|
+
output["missing_elements"] = result.missing_elements
|
|
1455
|
+
|
|
1456
|
+
return HookResponse(output=output, exit_code=0)
|
|
1457
|
+
|
|
1458
|
+
# ------------------------------------------------------------------ #
|
|
1459
|
+
# P2: format_verification_response
|
|
1460
|
+
# ------------------------------------------------------------------ #
|
|
1461
|
+
|
|
1462
|
+
def format_verification_response(self, result: VerificationResult) -> HookResponse:
|
|
1463
|
+
"""Format a VerificationResult for CLI consumption.
|
|
1464
|
+
|
|
1465
|
+
TaskCompleted events are informational -- exit code is always 0.
|
|
1466
|
+
"""
|
|
1467
|
+
output: Dict[str, Any] = {
|
|
1468
|
+
"criteria_met": result.criteria_met,
|
|
1469
|
+
"block_completion": result.block_completion,
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
if result.verified_items:
|
|
1473
|
+
output["verified_items"] = result.verified_items
|
|
1474
|
+
if result.failed_items:
|
|
1475
|
+
output["failed_items"] = result.failed_items
|
|
1476
|
+
|
|
1477
|
+
return HookResponse(output=output, exit_code=0)
|