@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.
Files changed (371) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +12 -3
  3. package/ARCHITECTURE.md +9 -8
  4. package/CHANGELOG.md +34 -0
  5. package/README.md +43 -11
  6. package/agents/terraform-architect.md +1 -1
  7. package/bin/README.md +2 -2
  8. package/bin/gaia-doctor.js +18 -5
  9. package/bin/gaia-history.js +0 -1
  10. package/bin/gaia-metrics.js +2 -2
  11. package/bin/gaia-scan.py +23 -1
  12. package/bin/gaia-update.js +346 -54
  13. package/bin/pre-publish-validate.js +33 -10
  14. package/commands/gaia.md +37 -0
  15. package/config/README.md +3 -9
  16. package/config/context-contracts.json +47 -15
  17. package/config/surface-routing.json +9 -1
  18. package/dist/gaia-ops/.claude-plugin/plugin.json +22 -0
  19. package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
  20. package/dist/gaia-ops/agents/devops-developer.md +57 -0
  21. package/dist/gaia-ops/agents/gaia-system.md +58 -0
  22. package/dist/gaia-ops/agents/gitops-operator.md +60 -0
  23. package/dist/gaia-ops/agents/speckit-planner.md +71 -0
  24. package/dist/gaia-ops/agents/terraform-architect.md +60 -0
  25. package/dist/gaia-ops/commands/gaia.md +37 -0
  26. package/dist/gaia-ops/config/README.md +58 -0
  27. package/dist/gaia-ops/config/cloud/aws.json +140 -0
  28. package/dist/gaia-ops/config/cloud/gcp.json +145 -0
  29. package/dist/gaia-ops/config/context-contracts.json +131 -0
  30. package/dist/gaia-ops/config/git_standards.json +72 -0
  31. package/dist/gaia-ops/config/surface-routing.json +197 -0
  32. package/dist/gaia-ops/config/universal-rules.json +10 -0
  33. package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
  34. package/dist/gaia-ops/hooks/adapters/base.py +219 -0
  35. package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
  36. package/dist/gaia-ops/hooks/adapters/claude_code.py +1477 -0
  37. package/dist/gaia-ops/hooks/adapters/types.py +194 -0
  38. package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
  39. package/dist/gaia-ops/hooks/hooks.json +126 -0
  40. package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
  41. package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
  42. package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
  43. package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
  44. package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +124 -0
  45. package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
  46. package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
  47. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
  48. package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
  49. package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
  50. package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
  51. package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
  52. package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +576 -0
  53. package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
  54. package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
  55. package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
  56. package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +215 -0
  57. package/dist/gaia-ops/hooks/modules/context/context_cache.py +129 -0
  58. package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
  59. package/dist/gaia-ops/hooks/modules/context/context_injector.py +427 -0
  60. package/dist/gaia-ops/hooks/modules/context/context_writer.py +518 -0
  61. package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
  62. package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
  63. package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
  64. package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
  65. package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
  66. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +558 -0
  67. package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
  68. package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
  69. package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
  70. package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
  71. package/dist/gaia-ops/hooks/modules/identity/__init__.py +0 -0
  72. package/dist/gaia-ops/hooks/modules/identity/identity_provider.py +21 -0
  73. package/dist/gaia-ops/hooks/modules/identity/ops_identity.py +34 -0
  74. package/dist/gaia-ops/hooks/modules/identity/security_identity.py +10 -0
  75. package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
  76. package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +227 -0
  77. package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
  78. package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +128 -0
  79. package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
  80. package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
  81. package/dist/gaia-ops/hooks/modules/security/__init__.py +89 -0
  82. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
  83. package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
  84. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +912 -0
  85. package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
  86. package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +153 -0
  87. package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +584 -0
  88. package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +86 -0
  89. package/dist/gaia-ops/hooks/modules/security/command_semantics.py +130 -0
  90. package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
  91. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +850 -0
  92. package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
  93. package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
  94. package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
  95. package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
  96. package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +158 -0
  97. package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
  98. package/dist/gaia-ops/hooks/modules/tools/__init__.py +25 -0
  99. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +708 -0
  100. package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +181 -0
  101. package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
  102. package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
  103. package/dist/gaia-ops/hooks/modules/tools/task_validator.py +283 -0
  104. package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
  105. package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
  106. package/dist/gaia-ops/hooks/post_compact.py +43 -0
  107. package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
  108. package/dist/gaia-ops/hooks/pre_tool_use.py +383 -0
  109. package/dist/gaia-ops/hooks/session_start.py +69 -0
  110. package/dist/gaia-ops/hooks/stop_hook.py +69 -0
  111. package/dist/gaia-ops/hooks/subagent_start.py +71 -0
  112. package/dist/gaia-ops/hooks/subagent_stop.py +288 -0
  113. package/dist/gaia-ops/hooks/task_completed.py +70 -0
  114. package/dist/gaia-ops/hooks/user_prompt_submit.py +177 -0
  115. package/dist/gaia-ops/settings.json +72 -0
  116. package/dist/gaia-ops/skills/README.md +109 -0
  117. package/dist/gaia-ops/skills/agent-protocol/SKILL.md +105 -0
  118. package/dist/gaia-ops/skills/agent-protocol/examples.md +170 -0
  119. package/dist/gaia-ops/skills/agent-response/SKILL.md +53 -0
  120. package/dist/gaia-ops/skills/approval/SKILL.md +85 -0
  121. package/dist/gaia-ops/skills/approval/examples.md +140 -0
  122. package/dist/gaia-ops/skills/approval/reference.md +57 -0
  123. package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
  124. package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
  125. package/dist/gaia-ops/skills/context-updater/SKILL.md +76 -0
  126. package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
  127. package/dist/gaia-ops/skills/developer-patterns/SKILL.md +93 -0
  128. package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
  129. package/dist/gaia-ops/skills/execution/SKILL.md +66 -0
  130. package/dist/gaia-ops/skills/fast-queries/SKILL.md +47 -0
  131. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +92 -0
  132. package/dist/gaia-ops/skills/gaia-patterns/reference.md +22 -0
  133. package/dist/gaia-ops/skills/git-conventions/SKILL.md +48 -0
  134. package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +73 -0
  135. package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
  136. package/dist/gaia-ops/skills/investigation/SKILL.md +77 -0
  137. package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +64 -0
  138. package/dist/gaia-ops/skills/reference.md +134 -0
  139. package/dist/gaia-ops/skills/security-tiers/SKILL.md +61 -0
  140. package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
  141. package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
  142. package/dist/gaia-ops/skills/skill-creation/SKILL.md +119 -0
  143. package/dist/gaia-ops/skills/specification/SKILL.md +186 -0
  144. package/dist/gaia-ops/skills/speckit-workflow/SKILL.md +165 -0
  145. package/dist/gaia-ops/skills/speckit-workflow/reference.md +117 -0
  146. package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +63 -0
  147. package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
  148. package/dist/gaia-ops/speckit/README.md +516 -0
  149. package/dist/gaia-ops/speckit/scripts/.gitkeep +0 -0
  150. package/dist/gaia-ops/speckit/templates/adr-template.md +118 -0
  151. package/dist/gaia-ops/speckit/templates/agent-file-template.md +23 -0
  152. package/dist/gaia-ops/speckit/templates/plan-template.md +227 -0
  153. package/dist/gaia-ops/speckit/templates/spec-template.md +140 -0
  154. package/dist/gaia-ops/speckit/templates/tasks-template.md +257 -0
  155. package/dist/gaia-ops/tools/context/README.md +132 -0
  156. package/dist/gaia-ops/tools/context/__init__.py +42 -0
  157. package/dist/gaia-ops/tools/context/_paths.py +20 -0
  158. package/dist/gaia-ops/tools/context/context_provider.py +476 -0
  159. package/dist/gaia-ops/tools/context/context_section_reader.py +330 -0
  160. package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
  161. package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
  162. package/dist/gaia-ops/tools/context/surface_router.py +278 -0
  163. package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
  164. package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
  165. package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
  166. package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
  167. package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
  168. package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
  169. package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
  170. package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
  171. package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
  172. package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
  173. package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
  174. package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
  175. package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
  176. package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
  177. package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +262 -0
  178. package/dist/gaia-ops/tools/memory/README.md +0 -0
  179. package/dist/gaia-ops/tools/memory/__init__.py +20 -0
  180. package/dist/gaia-ops/tools/memory/episodic.py +1196 -0
  181. package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
  182. package/dist/gaia-ops/tools/review/__init__.py +1 -0
  183. package/dist/gaia-ops/tools/review/review_engine.py +157 -0
  184. package/dist/gaia-ops/tools/scan/__init__.py +35 -0
  185. package/dist/gaia-ops/tools/scan/config.py +247 -0
  186. package/dist/gaia-ops/tools/scan/merge.py +212 -0
  187. package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
  188. package/dist/gaia-ops/tools/scan/registry.py +127 -0
  189. package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
  190. package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
  191. package/dist/gaia-ops/tools/scan/scanners/environment.py +324 -0
  192. package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
  193. package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
  194. package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
  195. package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
  196. package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
  197. package/dist/gaia-ops/tools/scan/setup.py +753 -0
  198. package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
  199. package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
  200. package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
  201. package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
  202. package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
  203. package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
  204. package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
  205. package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
  206. package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
  207. package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
  208. package/dist/gaia-ops/tools/scan/ui.py +624 -0
  209. package/dist/gaia-ops/tools/scan/verify.py +266 -0
  210. package/dist/gaia-ops/tools/scan/walk.py +118 -0
  211. package/dist/gaia-ops/tools/scan/workspace.py +85 -0
  212. package/dist/gaia-ops/tools/validation/README.md +244 -0
  213. package/dist/gaia-ops/tools/validation/__init__.py +17 -0
  214. package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
  215. package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
  216. package/dist/gaia-security/.claude-plugin/plugin.json +22 -0
  217. package/dist/gaia-security/config/universal-rules.json +10 -0
  218. package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
  219. package/dist/gaia-security/hooks/adapters/base.py +219 -0
  220. package/dist/gaia-security/hooks/adapters/channel.py +17 -0
  221. package/dist/gaia-security/hooks/adapters/claude_code.py +1477 -0
  222. package/dist/gaia-security/hooks/adapters/types.py +194 -0
  223. package/dist/gaia-security/hooks/adapters/utils.py +25 -0
  224. package/dist/gaia-security/hooks/hooks.json +57 -0
  225. package/dist/gaia-security/hooks/modules/__init__.py +15 -0
  226. package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
  227. package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
  228. package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
  229. package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +124 -0
  230. package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
  231. package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
  232. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
  233. package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
  234. package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
  235. package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
  236. package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
  237. package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +576 -0
  238. package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
  239. package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
  240. package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
  241. package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +215 -0
  242. package/dist/gaia-security/hooks/modules/context/context_cache.py +129 -0
  243. package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
  244. package/dist/gaia-security/hooks/modules/context/context_injector.py +427 -0
  245. package/dist/gaia-security/hooks/modules/context/context_writer.py +518 -0
  246. package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
  247. package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
  248. package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
  249. package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
  250. package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
  251. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +558 -0
  252. package/dist/gaia-security/hooks/modules/core/state.py +179 -0
  253. package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
  254. package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
  255. package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
  256. package/dist/gaia-security/hooks/modules/identity/__init__.py +0 -0
  257. package/dist/gaia-security/hooks/modules/identity/identity_provider.py +21 -0
  258. package/dist/gaia-security/hooks/modules/identity/ops_identity.py +34 -0
  259. package/dist/gaia-security/hooks/modules/identity/security_identity.py +10 -0
  260. package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
  261. package/dist/gaia-security/hooks/modules/memory/episode_writer.py +227 -0
  262. package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
  263. package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +128 -0
  264. package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
  265. package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
  266. package/dist/gaia-security/hooks/modules/security/__init__.py +89 -0
  267. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
  268. package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
  269. package/dist/gaia-security/hooks/modules/security/approval_grants.py +912 -0
  270. package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
  271. package/dist/gaia-security/hooks/modules/security/approval_scopes.py +153 -0
  272. package/dist/gaia-security/hooks/modules/security/blocked_commands.py +584 -0
  273. package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +86 -0
  274. package/dist/gaia-security/hooks/modules/security/command_semantics.py +130 -0
  275. package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
  276. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +850 -0
  277. package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
  278. package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
  279. package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
  280. package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
  281. package/dist/gaia-security/hooks/modules/session/session_event_injector.py +158 -0
  282. package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
  283. package/dist/gaia-security/hooks/modules/tools/__init__.py +25 -0
  284. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +708 -0
  285. package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +181 -0
  286. package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
  287. package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
  288. package/dist/gaia-security/hooks/modules/tools/task_validator.py +283 -0
  289. package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
  290. package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
  291. package/dist/gaia-security/hooks/post_tool_use.py +54 -0
  292. package/dist/gaia-security/hooks/pre_tool_use.py +383 -0
  293. package/dist/gaia-security/hooks/session_start.py +69 -0
  294. package/dist/gaia-security/hooks/stop_hook.py +69 -0
  295. package/dist/gaia-security/hooks/user_prompt_submit.py +177 -0
  296. package/dist/gaia-security/settings.json +58 -0
  297. package/git-hooks/commit-msg +41 -0
  298. package/hooks/README.md +8 -6
  299. package/hooks/adapters/channel.py +0 -25
  300. package/hooks/adapters/claude_code.py +364 -125
  301. package/hooks/elicitation_result.py +132 -0
  302. package/hooks/hooks.json +10 -1
  303. package/hooks/modules/README.md +3 -2
  304. package/hooks/modules/agents/contract_validator.py +3 -51
  305. package/hooks/modules/agents/response_contract.py +4 -8
  306. package/hooks/modules/agents/transcript_reader.py +4 -5
  307. package/hooks/modules/audit/__init__.py +4 -6
  308. package/hooks/modules/audit/event_detector.py +0 -2
  309. package/hooks/modules/audit/metrics.py +108 -187
  310. package/hooks/modules/audit/workflow_auditor.py +0 -4
  311. package/hooks/modules/audit/workflow_recorder.py +0 -5
  312. package/hooks/modules/context/compact_context_builder.py +1 -0
  313. package/hooks/modules/context/context_cache.py +129 -0
  314. package/hooks/modules/context/context_injector.py +18 -40
  315. package/hooks/modules/context/context_writer.py +1 -25
  316. package/hooks/modules/context/contracts_loader.py +7 -10
  317. package/hooks/modules/core/hook_entry.py +1 -0
  318. package/hooks/modules/core/paths.py +12 -13
  319. package/hooks/modules/core/plugin_mode.py +74 -4
  320. package/hooks/modules/core/plugin_setup.py +395 -23
  321. package/hooks/modules/events/__init__.py +1 -0
  322. package/hooks/modules/events/event_writer.py +210 -0
  323. package/hooks/modules/identity/ops_identity.py +18 -27
  324. package/hooks/modules/memory/episode_writer.py +1 -6
  325. package/hooks/modules/orchestrator/__init__.py +1 -0
  326. package/hooks/modules/orchestrator/delegate_mode.py +128 -0
  327. package/hooks/modules/security/__init__.py +2 -4
  328. package/hooks/modules/security/approval_constants.py +5 -1
  329. package/hooks/modules/security/approval_grants.py +189 -6
  330. package/hooks/modules/security/approval_messages.py +9 -21
  331. package/hooks/modules/security/blocked_commands.py +98 -34
  332. package/hooks/modules/security/command_semantics.py +0 -4
  333. package/hooks/modules/security/gitops_validator.py +1 -11
  334. package/hooks/modules/security/mutative_verbs.py +179 -38
  335. package/hooks/modules/security/tiers.py +1 -19
  336. package/hooks/modules/session/session_event_injector.py +1 -25
  337. package/hooks/modules/tools/bash_validator.py +310 -94
  338. package/hooks/modules/tools/shell_parser.py +0 -1
  339. package/hooks/modules/tools/task_validator.py +9 -29
  340. package/hooks/post_tool_use.py +0 -72
  341. package/hooks/pre_tool_use.py +42 -102
  342. package/hooks/session_start.py +4 -2
  343. package/hooks/subagent_start.py +6 -2
  344. package/hooks/subagent_stop.py +1 -13
  345. package/hooks/user_prompt_submit.py +119 -37
  346. package/index.js +1 -1
  347. package/package.json +5 -3
  348. package/skills/README.md +3 -5
  349. package/skills/agent-protocol/SKILL.md +17 -16
  350. package/skills/agent-protocol/examples.md +6 -6
  351. package/skills/agent-response/SKILL.md +11 -14
  352. package/skills/approval/SKILL.md +28 -13
  353. package/skills/approval/reference.md +2 -2
  354. package/skills/execution/SKILL.md +1 -1
  355. package/skills/gaia-patterns/SKILL.md +2 -3
  356. package/skills/orchestrator-approval/SKILL.md +22 -50
  357. package/skills/security-tiers/SKILL.md +1 -1
  358. package/templates/README.md +9 -9
  359. package/templates/managed-settings.template.json +43 -0
  360. package/tools/gaia_simulator/runner.py +34 -1
  361. package/tools/scan/orchestrator.py +13 -0
  362. package/tools/scan/scanners/base.py +8 -0
  363. package/tools/scan/scanners/git.py +78 -0
  364. package/tools/scan/scanners/infrastructure.py +65 -0
  365. package/tools/scan/scanners/stack.py +110 -0
  366. package/tools/scan/setup.py +120 -13
  367. package/tools/scan/workspace.py +85 -0
  368. package/config/context-contracts.aws.json +0 -42
  369. package/config/context-contracts.gcp.json +0 -39
  370. package/skills/project-dispatch/SKILL.md +0 -34
  371. 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)