@jaguilar87/gaia 5.0.0-rc1

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 (609) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +26 -0
  3. package/ARCHITECTURE.md +335 -0
  4. package/CHANGELOG.md +1212 -0
  5. package/CODE_OF_CONDUCT.md +11 -0
  6. package/CONTRIBUTING.md +146 -0
  7. package/INSTALL.md +436 -0
  8. package/LICENSE +21 -0
  9. package/README.md +222 -0
  10. package/SECURITY.md +47 -0
  11. package/agents/README.md +78 -0
  12. package/agents/cloud-troubleshooter.md +73 -0
  13. package/agents/developer.md +65 -0
  14. package/agents/gaia-operator.md +64 -0
  15. package/agents/gaia-orchestrator.md +237 -0
  16. package/agents/gaia-planner.md +53 -0
  17. package/agents/gaia-system.md +70 -0
  18. package/agents/gitops-operator.md +61 -0
  19. package/agents/terraform-architect.md +63 -0
  20. package/bin/README.md +106 -0
  21. package/bin/cli/__init__.py +1 -0
  22. package/bin/cli/approvals.py +740 -0
  23. package/bin/cli/cleanup.py +562 -0
  24. package/bin/cli/context.py +283 -0
  25. package/bin/cli/doctor.py +628 -0
  26. package/bin/cli/history.py +305 -0
  27. package/bin/cli/memory.py +464 -0
  28. package/bin/cli/metrics.py +1068 -0
  29. package/bin/cli/plans.py +515 -0
  30. package/bin/cli/status.py +302 -0
  31. package/bin/cli/update.py +382 -0
  32. package/bin/gaia +112 -0
  33. package/bin/gaia-cleanup.js +531 -0
  34. package/bin/gaia-doctor.js +635 -0
  35. package/bin/gaia-evidence +126 -0
  36. package/bin/gaia-history.js +251 -0
  37. package/bin/gaia-metrics.js +1278 -0
  38. package/bin/gaia-review.js +269 -0
  39. package/bin/gaia-scan +44 -0
  40. package/bin/gaia-scan.py +589 -0
  41. package/bin/gaia-skills-diagnose.js +929 -0
  42. package/bin/gaia-status.js +278 -0
  43. package/bin/gaia-uninstall.js +111 -0
  44. package/bin/gaia-update.js +816 -0
  45. package/bin/pre-publish-validate.js +610 -0
  46. package/bin/python-detect.js +60 -0
  47. package/commands/README.md +64 -0
  48. package/commands/gaia.md +37 -0
  49. package/commands/scan-project.md +67 -0
  50. package/config/README.md +71 -0
  51. package/config/cloud/aws.json +134 -0
  52. package/config/cloud/gcp.json +139 -0
  53. package/config/context-contracts.json +158 -0
  54. package/config/crons-schema.md +81 -0
  55. package/config/git_standards.json +72 -0
  56. package/config/surface-routing.json +421 -0
  57. package/config/universal-rules.json +102 -0
  58. package/dist/gaia-ops/.claude-plugin/plugin.json +24 -0
  59. package/dist/gaia-ops/README.md +80 -0
  60. package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
  61. package/dist/gaia-ops/agents/developer.md +65 -0
  62. package/dist/gaia-ops/agents/gaia-operator.md +64 -0
  63. package/dist/gaia-ops/agents/gaia-orchestrator.md +237 -0
  64. package/dist/gaia-ops/agents/gaia-planner.md +53 -0
  65. package/dist/gaia-ops/agents/gaia-system.md +70 -0
  66. package/dist/gaia-ops/agents/gitops-operator.md +61 -0
  67. package/dist/gaia-ops/agents/terraform-architect.md +63 -0
  68. package/dist/gaia-ops/commands/gaia.md +37 -0
  69. package/dist/gaia-ops/config/README.md +71 -0
  70. package/dist/gaia-ops/config/cloud/aws.json +134 -0
  71. package/dist/gaia-ops/config/cloud/gcp.json +139 -0
  72. package/dist/gaia-ops/config/context-contracts.json +158 -0
  73. package/dist/gaia-ops/config/crons-schema.md +81 -0
  74. package/dist/gaia-ops/config/git_standards.json +72 -0
  75. package/dist/gaia-ops/config/surface-routing.json +421 -0
  76. package/dist/gaia-ops/config/universal-rules.json +102 -0
  77. package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
  78. package/dist/gaia-ops/hooks/adapters/base.py +219 -0
  79. package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
  80. package/dist/gaia-ops/hooks/adapters/claude_code.py +1890 -0
  81. package/dist/gaia-ops/hooks/adapters/types.py +194 -0
  82. package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
  83. package/dist/gaia-ops/hooks/hooks.json +163 -0
  84. package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
  85. package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
  86. package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
  87. package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
  88. package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +120 -0
  89. package/dist/gaia-ops/hooks/modules/agents/state_tracker.py +267 -0
  90. package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
  91. package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
  92. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
  93. package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
  94. package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
  95. package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
  96. package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
  97. package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +611 -0
  98. package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
  99. package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
  100. package/dist/gaia-ops/hooks/modules/context/agentic_loop_detector.py +165 -0
  101. package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
  102. package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +218 -0
  103. package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
  104. package/dist/gaia-ops/hooks/modules/context/context_injector.py +558 -0
  105. package/dist/gaia-ops/hooks/modules/context/context_writer.py +530 -0
  106. package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
  107. package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
  108. package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
  109. package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
  110. package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
  111. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +577 -0
  112. package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
  113. package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
  114. package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
  115. package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
  116. package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
  117. package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +216 -0
  118. package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
  119. package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +122 -0
  120. package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
  121. package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
  122. package/dist/gaia-ops/hooks/modules/security/__init__.py +120 -0
  123. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
  124. package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
  125. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +1638 -0
  126. package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
  127. package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +222 -0
  128. package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +595 -0
  129. package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +87 -0
  130. package/dist/gaia-ops/hooks/modules/security/command_semantics.py +181 -0
  131. package/dist/gaia-ops/hooks/modules/security/composition_rules.py +547 -0
  132. package/dist/gaia-ops/hooks/modules/security/flag_classifiers.py +873 -0
  133. package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
  134. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +1131 -0
  135. package/dist/gaia-ops/hooks/modules/security/network_hosts.py +481 -0
  136. package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
  137. package/dist/gaia-ops/hooks/modules/security/shell_unwrapper.py +165 -0
  138. package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
  139. package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
  140. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +174 -0
  141. package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
  142. package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +160 -0
  143. package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
  144. package/dist/gaia-ops/hooks/modules/session/session_registry.py +232 -0
  145. package/dist/gaia-ops/hooks/modules/tools/__init__.py +29 -0
  146. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +1008 -0
  147. package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +231 -0
  148. package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
  149. package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
  150. package/dist/gaia-ops/hooks/modules/tools/stage_decomposer.py +315 -0
  151. package/dist/gaia-ops/hooks/modules/tools/task_validator.py +294 -0
  152. package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
  153. package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
  154. package/dist/gaia-ops/hooks/post_compact.py +43 -0
  155. package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
  156. package/dist/gaia-ops/hooks/pre_compact.py +60 -0
  157. package/dist/gaia-ops/hooks/pre_tool_use.py +413 -0
  158. package/dist/gaia-ops/hooks/session_start.py +81 -0
  159. package/dist/gaia-ops/hooks/stop_hook.py +82 -0
  160. package/dist/gaia-ops/hooks/subagent_start.py +71 -0
  161. package/dist/gaia-ops/hooks/subagent_stop.py +295 -0
  162. package/dist/gaia-ops/hooks/task_completed.py +70 -0
  163. package/dist/gaia-ops/hooks/user_prompt_submit.py +246 -0
  164. package/dist/gaia-ops/settings.json +72 -0
  165. package/dist/gaia-ops/skills/README.md +154 -0
  166. package/dist/gaia-ops/skills/agent-protocol/SKILL.md +93 -0
  167. package/dist/gaia-ops/skills/agent-protocol/examples.md +223 -0
  168. package/dist/gaia-ops/skills/agent-response/SKILL.md +69 -0
  169. package/dist/gaia-ops/skills/agentic-loop/SKILL.md +80 -0
  170. package/dist/gaia-ops/skills/agentic-loop/reference.md +378 -0
  171. package/dist/gaia-ops/skills/blog-writing/SKILL.md +98 -0
  172. package/dist/gaia-ops/skills/blog-writing/reference.md +130 -0
  173. package/dist/gaia-ops/skills/brief-spec/SKILL.md +182 -0
  174. package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
  175. package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
  176. package/dist/gaia-ops/skills/context-updater/SKILL.md +87 -0
  177. package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
  178. package/dist/gaia-ops/skills/developer-patterns/SKILL.md +50 -0
  179. package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
  180. package/dist/gaia-ops/skills/execution/SKILL.md +99 -0
  181. package/dist/gaia-ops/skills/fast-queries/SKILL.md +43 -0
  182. package/dist/gaia-ops/skills/gaia-compact/SKILL.md +74 -0
  183. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +108 -0
  184. package/dist/gaia-ops/skills/gaia-patterns/reference.md +395 -0
  185. package/dist/gaia-ops/skills/gaia-planner/SKILL.md +37 -0
  186. package/dist/gaia-ops/skills/gaia-planner/reference.md +107 -0
  187. package/dist/gaia-ops/skills/gaia-release/SKILL.md +82 -0
  188. package/dist/gaia-ops/skills/gaia-release/reference.md +102 -0
  189. package/dist/gaia-ops/skills/gaia-self-check/SKILL.md +114 -0
  190. package/dist/gaia-ops/skills/gaia-self-check/reference.md +453 -0
  191. package/dist/gaia-ops/skills/gaia-verify/SKILL.md +77 -0
  192. package/dist/gaia-ops/skills/gaia-verify/reference.md +80 -0
  193. package/dist/gaia-ops/skills/git-conventions/SKILL.md +47 -0
  194. package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +60 -0
  195. package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
  196. package/dist/gaia-ops/skills/gmail-policy/SKILL.md +200 -0
  197. package/dist/gaia-ops/skills/gmail-policy/reference.md +150 -0
  198. package/dist/gaia-ops/skills/gmail-triage/SKILL.md +100 -0
  199. package/dist/gaia-ops/skills/gws-setup/SKILL.md +99 -0
  200. package/dist/gaia-ops/skills/gws-setup/reference.md +73 -0
  201. package/dist/gaia-ops/skills/investigation/SKILL.md +100 -0
  202. package/dist/gaia-ops/skills/memory-curation/SKILL.md +83 -0
  203. package/dist/gaia-ops/skills/memory-search/SKILL.md +88 -0
  204. package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +160 -0
  205. package/dist/gaia-ops/skills/orchestrator-approval/reference.md +174 -0
  206. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +72 -0
  207. package/dist/gaia-ops/skills/pending-approvals/reference.md +214 -0
  208. package/dist/gaia-ops/skills/readme-writing/SKILL.md +71 -0
  209. package/dist/gaia-ops/skills/readme-writing/reference.md +188 -0
  210. package/dist/gaia-ops/skills/reference.md +135 -0
  211. package/dist/gaia-ops/skills/request-approval/SKILL.md +140 -0
  212. package/dist/gaia-ops/skills/request-approval/examples.md +140 -0
  213. package/dist/gaia-ops/skills/request-approval/reference.md +57 -0
  214. package/dist/gaia-ops/skills/schedule-task/SKILL.md +64 -0
  215. package/dist/gaia-ops/skills/schedule-task/reference.md +233 -0
  216. package/dist/gaia-ops/skills/security-tiers/SKILL.md +141 -0
  217. package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
  218. package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
  219. package/dist/gaia-ops/skills/skill-creation/SKILL.md +92 -0
  220. package/dist/gaia-ops/skills/skill-creation/reference.md +29 -0
  221. package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +89 -0
  222. package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
  223. package/dist/gaia-ops/tools/__init__.py +9 -0
  224. package/dist/gaia-ops/tools/agentic-loop/decide-status.py +210 -0
  225. package/dist/gaia-ops/tools/agentic-loop/parse-metric.py +106 -0
  226. package/dist/gaia-ops/tools/agentic-loop/record-iteration.py +221 -0
  227. package/dist/gaia-ops/tools/context/README.md +132 -0
  228. package/dist/gaia-ops/tools/context/__init__.py +42 -0
  229. package/dist/gaia-ops/tools/context/_paths.py +20 -0
  230. package/dist/gaia-ops/tools/context/context_provider.py +721 -0
  231. package/dist/gaia-ops/tools/context/context_section_reader.py +342 -0
  232. package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
  233. package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
  234. package/dist/gaia-ops/tools/context/surface_router.py +278 -0
  235. package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
  236. package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
  237. package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
  238. package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
  239. package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
  240. package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
  241. package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
  242. package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
  243. package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
  244. package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
  245. package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
  246. package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
  247. package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
  248. package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
  249. package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +264 -0
  250. package/dist/gaia-ops/tools/memory/README.md +0 -0
  251. package/dist/gaia-ops/tools/memory/__init__.py +20 -0
  252. package/dist/gaia-ops/tools/memory/backfill_fts5.py +107 -0
  253. package/dist/gaia-ops/tools/memory/conflict_detector.py +295 -0
  254. package/dist/gaia-ops/tools/memory/episodic.py +1210 -0
  255. package/dist/gaia-ops/tools/memory/git_invalidator.py +262 -0
  256. package/dist/gaia-ops/tools/memory/paths.py +102 -0
  257. package/dist/gaia-ops/tools/memory/scoring.py +193 -0
  258. package/dist/gaia-ops/tools/memory/search_store.py +360 -0
  259. package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
  260. package/dist/gaia-ops/tools/review/__init__.py +1 -0
  261. package/dist/gaia-ops/tools/review/review_engine.py +157 -0
  262. package/dist/gaia-ops/tools/scan/__init__.py +35 -0
  263. package/dist/gaia-ops/tools/scan/config.py +247 -0
  264. package/dist/gaia-ops/tools/scan/merge.py +212 -0
  265. package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
  266. package/dist/gaia-ops/tools/scan/registry.py +127 -0
  267. package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
  268. package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
  269. package/dist/gaia-ops/tools/scan/scanners/environment.py +349 -0
  270. package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
  271. package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
  272. package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
  273. package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
  274. package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
  275. package/dist/gaia-ops/tools/scan/setup.py +686 -0
  276. package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
  277. package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
  278. package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
  279. package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
  280. package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
  281. package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
  282. package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
  283. package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
  284. package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
  285. package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
  286. package/dist/gaia-ops/tools/scan/ui.py +624 -0
  287. package/dist/gaia-ops/tools/scan/verify.py +270 -0
  288. package/dist/gaia-ops/tools/scan/walk.py +118 -0
  289. package/dist/gaia-ops/tools/scan/workspace.py +85 -0
  290. package/dist/gaia-ops/tools/validation/README.md +244 -0
  291. package/dist/gaia-ops/tools/validation/__init__.py +17 -0
  292. package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
  293. package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
  294. package/dist/gaia-security/.claude-plugin/plugin.json +24 -0
  295. package/dist/gaia-security/README.md +90 -0
  296. package/dist/gaia-security/config/universal-rules.json +102 -0
  297. package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
  298. package/dist/gaia-security/hooks/adapters/base.py +219 -0
  299. package/dist/gaia-security/hooks/adapters/channel.py +17 -0
  300. package/dist/gaia-security/hooks/adapters/claude_code.py +1890 -0
  301. package/dist/gaia-security/hooks/adapters/types.py +194 -0
  302. package/dist/gaia-security/hooks/adapters/utils.py +25 -0
  303. package/dist/gaia-security/hooks/hooks.json +84 -0
  304. package/dist/gaia-security/hooks/modules/__init__.py +15 -0
  305. package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
  306. package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
  307. package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
  308. package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +120 -0
  309. package/dist/gaia-security/hooks/modules/agents/state_tracker.py +267 -0
  310. package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
  311. package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
  312. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
  313. package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
  314. package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
  315. package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
  316. package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
  317. package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +611 -0
  318. package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
  319. package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
  320. package/dist/gaia-security/hooks/modules/context/agentic_loop_detector.py +165 -0
  321. package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
  322. package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +218 -0
  323. package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
  324. package/dist/gaia-security/hooks/modules/context/context_injector.py +558 -0
  325. package/dist/gaia-security/hooks/modules/context/context_writer.py +530 -0
  326. package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
  327. package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
  328. package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
  329. package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
  330. package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
  331. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +577 -0
  332. package/dist/gaia-security/hooks/modules/core/state.py +179 -0
  333. package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
  334. package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
  335. package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
  336. package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
  337. package/dist/gaia-security/hooks/modules/memory/episode_writer.py +216 -0
  338. package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
  339. package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +122 -0
  340. package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
  341. package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
  342. package/dist/gaia-security/hooks/modules/security/__init__.py +120 -0
  343. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
  344. package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
  345. package/dist/gaia-security/hooks/modules/security/approval_grants.py +1638 -0
  346. package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
  347. package/dist/gaia-security/hooks/modules/security/approval_scopes.py +222 -0
  348. package/dist/gaia-security/hooks/modules/security/blocked_commands.py +595 -0
  349. package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +87 -0
  350. package/dist/gaia-security/hooks/modules/security/command_semantics.py +181 -0
  351. package/dist/gaia-security/hooks/modules/security/composition_rules.py +547 -0
  352. package/dist/gaia-security/hooks/modules/security/flag_classifiers.py +873 -0
  353. package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
  354. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +1131 -0
  355. package/dist/gaia-security/hooks/modules/security/network_hosts.py +481 -0
  356. package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
  357. package/dist/gaia-security/hooks/modules/security/shell_unwrapper.py +165 -0
  358. package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
  359. package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
  360. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +174 -0
  361. package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
  362. package/dist/gaia-security/hooks/modules/session/session_event_injector.py +160 -0
  363. package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
  364. package/dist/gaia-security/hooks/modules/session/session_registry.py +232 -0
  365. package/dist/gaia-security/hooks/modules/tools/__init__.py +29 -0
  366. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +1008 -0
  367. package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +231 -0
  368. package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
  369. package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
  370. package/dist/gaia-security/hooks/modules/tools/stage_decomposer.py +315 -0
  371. package/dist/gaia-security/hooks/modules/tools/task_validator.py +294 -0
  372. package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
  373. package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
  374. package/dist/gaia-security/hooks/post_tool_use.py +54 -0
  375. package/dist/gaia-security/hooks/pre_tool_use.py +413 -0
  376. package/dist/gaia-security/hooks/session_start.py +81 -0
  377. package/dist/gaia-security/hooks/stop_hook.py +82 -0
  378. package/dist/gaia-security/hooks/user_prompt_submit.py +246 -0
  379. package/dist/gaia-security/settings.json +58 -0
  380. package/git-hooks/commit-msg +41 -0
  381. package/hooks/README.md +100 -0
  382. package/hooks/adapters/__init__.py +52 -0
  383. package/hooks/adapters/base.py +219 -0
  384. package/hooks/adapters/channel.py +17 -0
  385. package/hooks/adapters/claude_code.py +1890 -0
  386. package/hooks/adapters/types.py +194 -0
  387. package/hooks/adapters/utils.py +25 -0
  388. package/hooks/elicitation_result.py +179 -0
  389. package/hooks/hooks.json +84 -0
  390. package/hooks/modules/README.md +189 -0
  391. package/hooks/modules/__init__.py +15 -0
  392. package/hooks/modules/agents/__init__.py +29 -0
  393. package/hooks/modules/agents/contract_validator.py +647 -0
  394. package/hooks/modules/agents/response_contract.py +496 -0
  395. package/hooks/modules/agents/skill_injection_verifier.py +120 -0
  396. package/hooks/modules/agents/state_tracker.py +267 -0
  397. package/hooks/modules/agents/task_info_builder.py +74 -0
  398. package/hooks/modules/agents/transcript_analyzer.py +458 -0
  399. package/hooks/modules/agents/transcript_reader.py +152 -0
  400. package/hooks/modules/audit/__init__.py +28 -0
  401. package/hooks/modules/audit/event_detector.py +168 -0
  402. package/hooks/modules/audit/logger.py +131 -0
  403. package/hooks/modules/audit/metrics.py +134 -0
  404. package/hooks/modules/audit/workflow_auditor.py +611 -0
  405. package/hooks/modules/audit/workflow_recorder.py +296 -0
  406. package/hooks/modules/context/__init__.py +11 -0
  407. package/hooks/modules/context/agentic_loop_detector.py +165 -0
  408. package/hooks/modules/context/anchor_tracker.py +317 -0
  409. package/hooks/modules/context/compact_context_builder.py +218 -0
  410. package/hooks/modules/context/context_freshness.py +145 -0
  411. package/hooks/modules/context/context_injector.py +558 -0
  412. package/hooks/modules/context/context_writer.py +530 -0
  413. package/hooks/modules/context/contracts_loader.py +161 -0
  414. package/hooks/modules/core/__init__.py +40 -0
  415. package/hooks/modules/core/hook_entry.py +78 -0
  416. package/hooks/modules/core/paths.py +160 -0
  417. package/hooks/modules/core/plugin_mode.py +149 -0
  418. package/hooks/modules/core/plugin_setup.py +577 -0
  419. package/hooks/modules/core/state.py +179 -0
  420. package/hooks/modules/core/stdin.py +24 -0
  421. package/hooks/modules/events/__init__.py +1 -0
  422. package/hooks/modules/events/event_writer.py +210 -0
  423. package/hooks/modules/evidence/__init__.py +34 -0
  424. package/hooks/modules/evidence/assertions.py +137 -0
  425. package/hooks/modules/evidence/index_writer.py +57 -0
  426. package/hooks/modules/evidence/loader.py +126 -0
  427. package/hooks/modules/evidence/runner.py +241 -0
  428. package/hooks/modules/memory/__init__.py +8 -0
  429. package/hooks/modules/memory/episode_writer.py +216 -0
  430. package/hooks/modules/orchestrator/__init__.py +1 -0
  431. package/hooks/modules/orchestrator/delegate_mode.py +122 -0
  432. package/hooks/modules/scanning/__init__.py +8 -0
  433. package/hooks/modules/scanning/scan_trigger.py +84 -0
  434. package/hooks/modules/security/__init__.py +120 -0
  435. package/hooks/modules/security/approval_cleanup.py +87 -0
  436. package/hooks/modules/security/approval_constants.py +23 -0
  437. package/hooks/modules/security/approval_grants.py +1638 -0
  438. package/hooks/modules/security/approval_messages.py +71 -0
  439. package/hooks/modules/security/approval_scopes.py +222 -0
  440. package/hooks/modules/security/blocked_commands.py +595 -0
  441. package/hooks/modules/security/blocked_message_formatter.py +87 -0
  442. package/hooks/modules/security/command_semantics.py +181 -0
  443. package/hooks/modules/security/composition_rules.py +547 -0
  444. package/hooks/modules/security/flag_classifiers.py +873 -0
  445. package/hooks/modules/security/gitops_validator.py +179 -0
  446. package/hooks/modules/security/mutative_verbs.py +1131 -0
  447. package/hooks/modules/security/network_hosts.py +481 -0
  448. package/hooks/modules/security/prompt_validator.py +40 -0
  449. package/hooks/modules/security/shell_unwrapper.py +165 -0
  450. package/hooks/modules/security/tiers.py +196 -0
  451. package/hooks/modules/session/__init__.py +10 -0
  452. package/hooks/modules/session/pending_scanner.py +174 -0
  453. package/hooks/modules/session/session_context_writer.py +100 -0
  454. package/hooks/modules/session/session_event_injector.py +160 -0
  455. package/hooks/modules/session/session_manager.py +31 -0
  456. package/hooks/modules/session/session_registry.py +232 -0
  457. package/hooks/modules/tools/__init__.py +29 -0
  458. package/hooks/modules/tools/bash_validator.py +1008 -0
  459. package/hooks/modules/tools/cloud_pipe_validator.py +231 -0
  460. package/hooks/modules/tools/hook_response.py +55 -0
  461. package/hooks/modules/tools/shell_parser.py +227 -0
  462. package/hooks/modules/tools/stage_decomposer.py +315 -0
  463. package/hooks/modules/tools/task_validator.py +294 -0
  464. package/hooks/modules/validation/__init__.py +23 -0
  465. package/hooks/modules/validation/commit_validator.py +380 -0
  466. package/hooks/post_compact.py +43 -0
  467. package/hooks/post_tool_use.py +54 -0
  468. package/hooks/pre_compact.py +60 -0
  469. package/hooks/pre_tool_use.py +413 -0
  470. package/hooks/session_start.py +81 -0
  471. package/hooks/stop_hook.py +82 -0
  472. package/hooks/subagent_start.py +71 -0
  473. package/hooks/subagent_stop.py +295 -0
  474. package/hooks/task_completed.py +70 -0
  475. package/hooks/user_prompt_submit.py +246 -0
  476. package/index.js +83 -0
  477. package/package.json +99 -0
  478. package/pyproject.toml +32 -0
  479. package/skills/README.md +154 -0
  480. package/skills/agent-protocol/SKILL.md +93 -0
  481. package/skills/agent-protocol/examples.md +223 -0
  482. package/skills/agent-response/SKILL.md +69 -0
  483. package/skills/agentic-loop/SKILL.md +80 -0
  484. package/skills/agentic-loop/reference.md +378 -0
  485. package/skills/blog-writing/SKILL.md +98 -0
  486. package/skills/blog-writing/reference.md +130 -0
  487. package/skills/brief-spec/SKILL.md +182 -0
  488. package/skills/command-execution/SKILL.md +64 -0
  489. package/skills/command-execution/reference.md +83 -0
  490. package/skills/context-updater/SKILL.md +87 -0
  491. package/skills/context-updater/examples.md +71 -0
  492. package/skills/developer-patterns/SKILL.md +50 -0
  493. package/skills/developer-patterns/reference.md +112 -0
  494. package/skills/execution/SKILL.md +99 -0
  495. package/skills/fast-queries/SKILL.md +43 -0
  496. package/skills/gaia-compact/SKILL.md +74 -0
  497. package/skills/gaia-patterns/SKILL.md +108 -0
  498. package/skills/gaia-patterns/reference.md +395 -0
  499. package/skills/gaia-planner/SKILL.md +37 -0
  500. package/skills/gaia-planner/reference.md +107 -0
  501. package/skills/gaia-release/SKILL.md +82 -0
  502. package/skills/gaia-release/reference.md +102 -0
  503. package/skills/gaia-self-check/SKILL.md +114 -0
  504. package/skills/gaia-self-check/reference.md +453 -0
  505. package/skills/gaia-verify/SKILL.md +77 -0
  506. package/skills/gaia-verify/reference.md +80 -0
  507. package/skills/git-conventions/SKILL.md +47 -0
  508. package/skills/gitops-patterns/SKILL.md +60 -0
  509. package/skills/gitops-patterns/reference.md +183 -0
  510. package/skills/gmail-policy/SKILL.md +200 -0
  511. package/skills/gmail-policy/reference.md +150 -0
  512. package/skills/gmail-triage/SKILL.md +100 -0
  513. package/skills/gws-setup/SKILL.md +99 -0
  514. package/skills/gws-setup/reference.md +73 -0
  515. package/skills/investigation/SKILL.md +100 -0
  516. package/skills/memory-curation/SKILL.md +83 -0
  517. package/skills/memory-search/SKILL.md +88 -0
  518. package/skills/orchestrator-approval/SKILL.md +160 -0
  519. package/skills/orchestrator-approval/reference.md +174 -0
  520. package/skills/pending-approvals/SKILL.md +72 -0
  521. package/skills/pending-approvals/reference.md +214 -0
  522. package/skills/readme-writing/SKILL.md +71 -0
  523. package/skills/readme-writing/reference.md +188 -0
  524. package/skills/reference.md +135 -0
  525. package/skills/request-approval/SKILL.md +140 -0
  526. package/skills/request-approval/examples.md +140 -0
  527. package/skills/request-approval/reference.md +57 -0
  528. package/skills/schedule-task/SKILL.md +64 -0
  529. package/skills/schedule-task/reference.md +233 -0
  530. package/skills/security-tiers/SKILL.md +141 -0
  531. package/skills/security-tiers/destructive-commands-reference.md +623 -0
  532. package/skills/security-tiers/reference.md +39 -0
  533. package/skills/skill-creation/SKILL.md +92 -0
  534. package/skills/skill-creation/reference.md +29 -0
  535. package/skills/terraform-patterns/SKILL.md +89 -0
  536. package/skills/terraform-patterns/reference.md +93 -0
  537. package/templates/README.md +69 -0
  538. package/templates/managed-settings.template.json +43 -0
  539. package/tools/__init__.py +9 -0
  540. package/tools/agentic-loop/decide-status.py +210 -0
  541. package/tools/agentic-loop/parse-metric.py +106 -0
  542. package/tools/agentic-loop/record-iteration.py +221 -0
  543. package/tools/context/README.md +132 -0
  544. package/tools/context/__init__.py +42 -0
  545. package/tools/context/_paths.py +20 -0
  546. package/tools/context/context_provider.py +721 -0
  547. package/tools/context/context_section_reader.py +342 -0
  548. package/tools/context/deep_merge.py +159 -0
  549. package/tools/context/pending_updates.py +760 -0
  550. package/tools/context/surface_router.py +278 -0
  551. package/tools/fast-queries/README.md +65 -0
  552. package/tools/fast-queries/__init__.py +30 -0
  553. package/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
  554. package/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
  555. package/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
  556. package/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
  557. package/tools/fast-queries/run_triage.sh +59 -0
  558. package/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
  559. package/tools/gaia_simulator/__init__.py +33 -0
  560. package/tools/gaia_simulator/cli.py +354 -0
  561. package/tools/gaia_simulator/extractor.py +457 -0
  562. package/tools/gaia_simulator/reporter.py +258 -0
  563. package/tools/gaia_simulator/routing_simulator.py +334 -0
  564. package/tools/gaia_simulator/runner.py +539 -0
  565. package/tools/gaia_simulator/skills_mapper.py +264 -0
  566. package/tools/memory/README.md +0 -0
  567. package/tools/memory/__init__.py +20 -0
  568. package/tools/memory/backfill_fts5.py +107 -0
  569. package/tools/memory/conflict_detector.py +295 -0
  570. package/tools/memory/episodic.py +1210 -0
  571. package/tools/memory/git_invalidator.py +262 -0
  572. package/tools/memory/paths.py +102 -0
  573. package/tools/memory/scoring.py +193 -0
  574. package/tools/memory/search_store.py +360 -0
  575. package/tools/persist_transcript_analysis.py +85 -0
  576. package/tools/review/__init__.py +1 -0
  577. package/tools/review/review_engine.py +157 -0
  578. package/tools/scan/__init__.py +35 -0
  579. package/tools/scan/config.py +247 -0
  580. package/tools/scan/merge.py +212 -0
  581. package/tools/scan/orchestrator.py +549 -0
  582. package/tools/scan/registry.py +127 -0
  583. package/tools/scan/scanners/__init__.py +18 -0
  584. package/tools/scan/scanners/base.py +137 -0
  585. package/tools/scan/scanners/environment.py +349 -0
  586. package/tools/scan/scanners/git.py +570 -0
  587. package/tools/scan/scanners/infrastructure.py +875 -0
  588. package/tools/scan/scanners/orchestration.py +600 -0
  589. package/tools/scan/scanners/stack.py +1085 -0
  590. package/tools/scan/scanners/tools.py +260 -0
  591. package/tools/scan/setup.py +686 -0
  592. package/tools/scan/tests/__init__.py +1 -0
  593. package/tools/scan/tests/conftest.py +796 -0
  594. package/tools/scan/tests/test_environment.py +323 -0
  595. package/tools/scan/tests/test_git.py +419 -0
  596. package/tools/scan/tests/test_infrastructure.py +382 -0
  597. package/tools/scan/tests/test_integration.py +920 -0
  598. package/tools/scan/tests/test_merge.py +269 -0
  599. package/tools/scan/tests/test_orchestration.py +304 -0
  600. package/tools/scan/tests/test_stack.py +604 -0
  601. package/tools/scan/tests/test_tools.py +349 -0
  602. package/tools/scan/ui.py +624 -0
  603. package/tools/scan/verify.py +270 -0
  604. package/tools/scan/walk.py +118 -0
  605. package/tools/scan/workspace.py +85 -0
  606. package/tools/validation/README.md +244 -0
  607. package/tools/validation/__init__.py +17 -0
  608. package/tools/validation/approval_gate.py +321 -0
  609. package/tools/validation/validate_skills.py +189 -0
@@ -0,0 +1,1890 @@
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
+ # _get_gaia_agent_names: discover Gaia-managed agents from agents/ dir
379
+ # ------------------------------------------------------------------ #
380
+
381
+ def _get_gaia_agent_names(self) -> set:
382
+ """Get names of Gaia-managed agents from the agents/ directory.
383
+
384
+ Returns a set of agent names (filenames without .md extension).
385
+ Native Claude Code agents (Explore, Plan, claude-code-guide) will
386
+ not appear in this set, enabling bypass of contract validation.
387
+ """
388
+ agents_dir = Path(__file__).resolve().parent.parent.parent / "agents"
389
+ if not agents_dir.is_dir():
390
+ return set()
391
+ return {
392
+ f.stem
393
+ for f in agents_dir.iterdir()
394
+ if f.suffix == ".md" and f.is_file()
395
+ }
396
+
397
+ # ------------------------------------------------------------------ #
398
+ # format_ask_response: for interactive permission requests
399
+ # ------------------------------------------------------------------ #
400
+
401
+ def format_ask_response(
402
+ self, reason: str, updated_input: dict | None = None
403
+ ) -> HookResponse:
404
+ """Format an 'ask' permission response.
405
+
406
+ Used when the hook wants Claude Code to ask the user for permission.
407
+ This is distinct from deny (which silently blocks).
408
+
409
+ Args:
410
+ reason: Human-readable explanation forwarded to the agent.
411
+ updated_input: Optional modified tool input (e.g. footer-stripped
412
+ command) to include as ``updatedInput`` so the modification
413
+ survives the native permission dialog.
414
+ """
415
+ output: Dict[str, Any] = {
416
+ "hookSpecificOutput": {
417
+ "hookEventName": "PreToolUse",
418
+ "permissionDecision": PermissionDecision.ASK.value,
419
+ "permissionDecisionReason": reason,
420
+ }
421
+ }
422
+ if updated_input:
423
+ output["hookSpecificOutput"]["updatedInput"] = updated_input
424
+ return HookResponse(output=output, exit_code=0)
425
+
426
+ # ------------------------------------------------------------------ #
427
+ # adapt_pre_tool_use: full pre-tool-use lifecycle
428
+ # ------------------------------------------------------------------ #
429
+
430
+ def adapt_pre_tool_use(self, event: HookEvent) -> HookResponse:
431
+ """Run all pre-tool-use business logic and return a formatted response.
432
+
433
+ Orchestrates: routing (bash vs task), validation, state management,
434
+ context injection, approval handling, and response formatting.
435
+ """
436
+ from modules.core.state import create_pre_hook_state, save_hook_state
437
+ from modules.security.approval_grants import (
438
+ cleanup_expired_grants,
439
+ )
440
+ from modules.tools.bash_validator import BashValidator
441
+ from modules.tools.task_validator import TaskValidator, AVAILABLE_AGENTS, META_AGENTS
442
+ hook_data = event.payload
443
+ tool_name = hook_data.get("tool_name") or ""
444
+ tool_input = hook_data.get("tool_input", {})
445
+
446
+ logger.info("Hook invoked: tool=%s, params=%s", tool_name, json.dumps(tool_input)[:200])
447
+
448
+ try:
449
+ # ── Delegate mode gate ─────────────────────────────────
450
+ # Must run before any other logic. When enabled, the
451
+ # orchestrator (main session) is restricted to dispatch-only
452
+ # tools. Subagents are unaffected.
453
+ from modules.orchestrator.delegate_mode import check_delegate_mode
454
+
455
+ dm_result = check_delegate_mode(tool_name, hook_data)
456
+ if dm_result.blocked:
457
+ logger.warning(
458
+ "DELEGATE_MODE denied %s for orchestrator", tool_name,
459
+ )
460
+ return HookResponse(
461
+ output={
462
+ "hookSpecificOutput": {
463
+ "hookEventName": "PreToolUse",
464
+ "permissionDecision": "deny",
465
+ "permissionDecisionReason": dm_result.reason,
466
+ }
467
+ },
468
+ exit_code=0,
469
+ )
470
+
471
+ # Periodic cleanup of expired approval grants
472
+ cleanup_expired_grants()
473
+
474
+ if not isinstance(tool_name, str):
475
+ return HookResponse(output="Error: Invalid tool name", exit_code=2)
476
+ if not isinstance(tool_input, dict):
477
+ return HookResponse(output="Error: Invalid parameters", exit_code=2)
478
+
479
+ if tool_name.lower() == "bash":
480
+ return self._adapt_bash(tool_name, tool_input, hook_data=hook_data)
481
+ elif tool_name.lower() in ("task", "agent"):
482
+ hooks_dir = Path(__file__).parent.parent
483
+ project_agents = [a for a in AVAILABLE_AGENTS if a not in META_AGENTS]
484
+ return self._adapt_task(
485
+ tool_name, tool_input, project_agents, hooks_dir,
486
+ session_id=event.session_id,
487
+ )
488
+ elif tool_name.lower() == "sendmessage":
489
+ return self._adapt_send_message(tool_name, tool_input)
490
+ elif tool_name.lower() in ("write", "edit"):
491
+ is_subagent = bool(hook_data and hook_data.get("agent_id"))
492
+ session_id = (hook_data or {}).get("session_id", "")
493
+ return self._adapt_write_edit(
494
+ tool_name, tool_input,
495
+ session_id=session_id,
496
+ is_subagent=is_subagent,
497
+ )
498
+ else:
499
+ # Other tools pass through
500
+ return HookResponse(output={}, exit_code=0)
501
+
502
+ except Exception as e:
503
+ logger.error("Unexpected error in adapt_pre_tool_use: %s", e, exc_info=True)
504
+ return HookResponse(
505
+ output=f"Error during security validation: {str(e)}",
506
+ exit_code=2,
507
+ )
508
+
509
+ def _adapt_bash(
510
+ self,
511
+ tool_name: str,
512
+ parameters: dict,
513
+ hook_data: dict | None = None,
514
+ ) -> HookResponse:
515
+ """Handle Bash tool validation within the adapter.
516
+
517
+ Args:
518
+ tool_name: The tool name ("Bash").
519
+ parameters: The tool_input dict (contains "command").
520
+ hook_data: Full hook event payload -- used to detect subagent
521
+ context via the ``agent_id`` field.
522
+ """
523
+ from modules.core.state import create_pre_hook_state, save_hook_state
524
+ from modules.tools.bash_validator import BashValidator
525
+
526
+ command = parameters.get("command", "")
527
+ if not command:
528
+ return HookResponse(output="Error: Bash tool requires a command", exit_code=2)
529
+
530
+ # Detect subagent context: if agent_id is present in the hook event,
531
+ # the command is running inside a subagent (not the orchestrator).
532
+ is_subagent = bool(hook_data and hook_data.get("agent_id"))
533
+ session_id = (hook_data or {}).get("session_id", "")
534
+
535
+ validator = BashValidator()
536
+ result = validator.validate(
537
+ command, is_subagent=is_subagent, session_id=session_id,
538
+ )
539
+
540
+ if not result.allowed:
541
+ from modules.core.plugin_mode import is_ops_mode
542
+ logger.warning("BLOCKED: %s - %s", command[:100], result.reason)
543
+ # Security-only mode: delegate T3 approval to native Claude Code dialog
544
+ # instead of blocking with nonce (which requires orchestrator + agents)
545
+ if not is_ops_mode():
546
+ reason_line = result.reason.split('\n')[0] if result.reason else f"T3 operation: {command[:80]}"
547
+ # Permanently blocked commands (rm -rf, kubectl delete namespace, etc.)
548
+ # are denied even in security mode — user cannot override
549
+ is_permanently_blocked = "blocked by security policy" in (result.reason or "").lower()
550
+ if is_permanently_blocked:
551
+ logger.info("SECURITY MODE: permanently denied: %s", command[:80])
552
+ output = {
553
+ "hookSpecificOutput": {
554
+ "hookEventName": "PreToolUse",
555
+ "permissionDecision": "deny",
556
+ "permissionDecisionReason": f"[BLOCKED] {reason_line}",
557
+ }
558
+ }
559
+ return HookResponse(output=output, exit_code=2)
560
+ # Mutative commands (git commit, terraform apply, etc.) → ask user
561
+ logger.info("SECURITY MODE: returning 'ask' for T3: %s", command[:80])
562
+ output = {
563
+ "hookSpecificOutput": {
564
+ "hookEventName": "PreToolUse",
565
+ "permissionDecision": "ask",
566
+ "permissionDecisionReason": f"[{result.tier}] {reason_line}",
567
+ }
568
+ }
569
+ return HookResponse(output=output, exit_code=0)
570
+ # Ops mode: block with nonce for orchestrator approval flow
571
+ if result.block_response is not None:
572
+ return HookResponse(output=result.block_response, exit_code=0)
573
+ return HookResponse(
574
+ output=self._format_blocked_message(result),
575
+ exit_code=2,
576
+ )
577
+
578
+ # Save state for post-hook
579
+ effective_command = result.modified_input.get("command", command) if result.modified_input else command
580
+ state = create_pre_hook_state(
581
+ tool_name=tool_name,
582
+ command=effective_command,
583
+ tier=str(result.tier),
584
+ allowed=True,
585
+ )
586
+ save_hook_state(state)
587
+
588
+ if result.modified_input:
589
+ logger.info("MODIFIED: %s -> stripped footer - tier=%s", command[:80], result.tier)
590
+ output = {
591
+ "hookSpecificOutput": {
592
+ "hookEventName": "PreToolUse",
593
+ "permissionDecision": "allow",
594
+ "permissionDecisionReason": result.reason,
595
+ "updatedInput": result.modified_input,
596
+ }
597
+ }
598
+ return HookResponse(output=output, exit_code=0)
599
+
600
+ logger.info("ALLOWED: %s - tier=%s", command[:100], result.tier)
601
+ return HookResponse(output={}, exit_code=0)
602
+
603
+ def _adapt_task(
604
+ self,
605
+ tool_name: str,
606
+ parameters: dict,
607
+ project_agents: list,
608
+ hooks_dir: Path,
609
+ session_id: str = "",
610
+ ) -> HookResponse:
611
+ """Handle Task/Agent tool validation within the adapter.
612
+
613
+ Builds project context and caches it for SubagentStart to forward.
614
+ PreToolUse no longer returns additionalContext directly -- that would
615
+ inject it into the orchestrator instead of the subagent.
616
+ """
617
+ from modules.core.state import create_pre_hook_state, save_hook_state
618
+ from modules.tools.task_validator import TaskValidator
619
+ from modules.context.context_injector import build_project_context
620
+ from modules.session.session_event_injector import build_session_events
621
+
622
+ context_text, _telemetry = build_project_context(parameters, project_agents, hooks_dir)
623
+ events_text = build_session_events(parameters, project_agents)
624
+
625
+ # Standard task validation (runs against ORIGINAL prompt -- no workaround needed)
626
+ validator = TaskValidator()
627
+ result = validator.validate(parameters)
628
+
629
+ if not result.allowed:
630
+ logger.warning("BLOCKED Task: %s - %s", result.agent_name, result.reason)
631
+ return HookResponse(output=result.reason, exit_code=2)
632
+
633
+ state = create_pre_hook_state(
634
+ tool_name=tool_name,
635
+ command=f"Task:{result.agent_name}",
636
+ tier=str(result.tier),
637
+ allowed=True,
638
+ is_t3=result.is_t3_operation,
639
+ )
640
+ save_hook_state(state)
641
+
642
+ logger.info("ALLOWED Task: %s", result.agent_name)
643
+
644
+ # Cache context for SubagentStart to pick up and forward to the subagent.
645
+ # PreToolUse:Agent additionalContext goes to the orchestrator (wrong target).
646
+ additional = "\n".join(filter(None, [context_text, events_text]))
647
+
648
+ # Fallback: if build_project_context returned None because the
649
+ # orchestrator already embedded context in the prompt (dedup guard),
650
+ # extract the embedded context so SubagentStart can still inject it
651
+ # via additionalContext.
652
+ if not additional:
653
+ prompt = parameters.get("prompt", "")
654
+ marker = "# Project Context"
655
+ if marker in prompt:
656
+ # Extract everything from the marker onwards as context.
657
+ # The orchestrator copied its own injected context into the
658
+ # Agent tool prompt; we forward it to SubagentStart instead.
659
+ idx = prompt.index(marker)
660
+ additional = prompt[idx:]
661
+ logger.info(
662
+ "Extracted embedded context from prompt for caching "
663
+ "(len=%d, agent=%s)",
664
+ len(additional), result.agent_name,
665
+ )
666
+
667
+ if additional:
668
+ effective_session_id = session_id or "unknown"
669
+ agent_type = result.agent_name or "unknown"
670
+ self._cache_context_for_subagent(effective_session_id, agent_type, additional)
671
+ logger.info(
672
+ "Cached context for SubagentStart: agent=%s, session=%s",
673
+ agent_type, effective_session_id,
674
+ )
675
+
676
+ # Write AGENT_DISPATCH event (non-blocking)
677
+ try:
678
+ from modules.events.event_writer import EventWriter, AGENT_DISPATCH
679
+ prompt = parameters.get("prompt", "")
680
+ EventWriter().write_event(
681
+ AGENT_DISPATCH, "hook", result.agent_name or "unknown",
682
+ f"dispatched for: {prompt[:100]}",
683
+ )
684
+ except Exception:
685
+ pass # Events are non-critical
686
+
687
+ return HookResponse(output={}, exit_code=0)
688
+
689
+ def _adapt_send_message(
690
+ self, tool_name: str, parameters: dict,
691
+ ) -> HookResponse:
692
+ """Handle SendMessage tool validation for agent resumption.
693
+
694
+ Validates agent ID format and message content. Does NOT inject
695
+ project context (it's a resume). Nonce relay is no longer processed
696
+ here -- approval grants are activated by the UserPromptSubmit hook.
697
+ """
698
+ from modules.core.state import create_pre_hook_state, save_hook_state
699
+
700
+ agent_id = parameters.get("to", "")
701
+ message = parameters.get("message", "")
702
+
703
+ # Validate agentId format
704
+ if not agent_id or not re.match(r'^a[0-9a-f]{5,}$', agent_id):
705
+ logger.warning("BLOCKED SendMessage: Invalid agentId format '%s'", agent_id)
706
+ msg = (
707
+ f"[ERROR] Invalid agent ID format: '{agent_id}'\n\n"
708
+ "Agent ID should be 'a' followed by hex characters.\n"
709
+ "Example: a12345f or a51a0cbbf6afb831d\n\n"
710
+ "The agent ID is returned at the end of agent responses.\n"
711
+ "Look for: 'agentId: a...' in the previous agent output."
712
+ )
713
+ return HookResponse(output=msg, exit_code=2)
714
+
715
+ if not message or not message.strip():
716
+ logger.warning("BLOCKED SendMessage: Missing message for agent %s", agent_id)
717
+ msg = (
718
+ "[ERROR] SendMessage requires a message\n\n"
719
+ "When resuming an agent, you must provide a message:\n\n"
720
+ "SendMessage(\n"
721
+ " to=\"a12345\",\n"
722
+ " message=\"Continue with the latest user instruction.\"\n"
723
+ ")\n\n"
724
+ "The message tells the agent what to do next."
725
+ )
726
+ return HookResponse(output=msg, exit_code=2)
727
+
728
+ logger.info("SENDMESSAGE: Resuming agent %s", agent_id)
729
+
730
+ state = create_pre_hook_state(
731
+ tool_name=tool_name,
732
+ command=f"SendMessage:{agent_id}",
733
+ tier="T0",
734
+ allowed=True,
735
+ is_t3=False,
736
+ has_approval=False,
737
+ )
738
+ save_hook_state(state)
739
+
740
+ logger.info("ALLOWED SendMessage: agent %s - message length: %d", agent_id, len(message))
741
+ return HookResponse(output={}, exit_code=0)
742
+
743
+ def _adapt_write_edit(
744
+ self,
745
+ tool_name: str,
746
+ parameters: dict,
747
+ session_id: str = "",
748
+ is_subagent: bool = False,
749
+ ) -> HookResponse:
750
+ """Handle Write and Edit tool path protection.
751
+
752
+ Blocks modifications to Gaia hooks, settings, and security config
753
+ by requiring user approval for any path that matches protected path
754
+ patterns.
755
+
756
+ Foreground (orchestrator) flow: returns permissionDecision "ask" so
757
+ the native Claude Code dialog handles approval.
758
+
759
+ Subagent flow: mirrors the bash_validator nonce-based pattern.
760
+ - Checks for an existing pending approval (retry guard).
761
+ - If found, returns deny with the existing approval_id.
762
+ - If not found, writes a pending approval and returns deny with a
763
+ new approval_id so the orchestrator can ask the user and activate
764
+ the grant via the ElicitationResult hook.
765
+ - On retry, if an active grant exists for this path, allows through.
766
+
767
+ Protected paths:
768
+ - Any path that resolves within the gaia-ops hooks directory (Path.resolve().relative_to(hooks_dir)), EXCEPT .md files — documentation does not execute code and is exempt
769
+ - .claude/settings.json and .claude/settings.local.json
770
+ """
771
+ from modules.security.approval_grants import (
772
+ check_approval_grant_for_file,
773
+ find_pending_for_file,
774
+ generate_nonce,
775
+ write_pending_approval_for_file,
776
+ )
777
+
778
+ file_path = parameters.get("file_path", "")
779
+ if not file_path:
780
+ return HookResponse(output={}, exit_code=0)
781
+
782
+ hooks_dir = Path(__file__).parent.parent.resolve()
783
+
784
+ def _is_protected(path_str):
785
+ p = Path(path_str)
786
+ try:
787
+ rp = p.resolve()
788
+ except Exception:
789
+ rp = p
790
+ try:
791
+ rp.relative_to(hooks_dir)
792
+ if rp.suffix == ".md":
793
+ return False # docs don't execute code; exempt from protection
794
+ return True
795
+ except ValueError:
796
+ pass
797
+ if p.name in ("settings.json", "settings.local.json"):
798
+ for part in rp.parts:
799
+ if part == ".claude":
800
+ return True
801
+ return False
802
+
803
+ if not _is_protected(file_path):
804
+ return HookResponse(output={}, exit_code=0)
805
+
806
+ logger.warning(
807
+ "PROTECTED_PATH: %s attempted to modify %s (subagent=%s)",
808
+ tool_name, file_path, is_subagent,
809
+ )
810
+
811
+ if not is_subagent:
812
+ # Foreground / orchestrator context: use native approval dialog.
813
+ reason = (
814
+ "[PROTECTED_PATH] Modifications to Gaia hooks and security config "
815
+ "require approval."
816
+ )
817
+ return HookResponse(
818
+ output={
819
+ "hookSpecificOutput": {
820
+ "hookEventName": "PreToolUse",
821
+ "permissionDecision": "ask",
822
+ "permissionDecisionReason": reason,
823
+ }
824
+ },
825
+ exit_code=0,
826
+ )
827
+
828
+ # Subagent context: nonce-based pending approval flow.
829
+
830
+ # 1. Check if a grant has already been activated for this path (retry
831
+ # after user approved).
832
+ existing_grant = check_approval_grant_for_file(file_path, session_id or None)
833
+ if existing_grant:
834
+ logger.info(
835
+ "File-path grant active, allowing %s through: %s",
836
+ tool_name, file_path,
837
+ )
838
+ return HookResponse(output={}, exit_code=0)
839
+
840
+ # 2. Check if a pending approval already exists (guard against infinite
841
+ # approval_id generation while the user is still reviewing).
842
+ existing_nonce = find_pending_for_file(session_id or "", file_path)
843
+ if existing_nonce:
844
+ approval_id = existing_nonce
845
+ logger.info(
846
+ "Reusing pending approval_id=%s for retry: %s",
847
+ approval_id, file_path,
848
+ )
849
+ else:
850
+ # 3. No existing pending -- generate a new nonce.
851
+ approval_id = generate_nonce()
852
+ pending_path = write_pending_approval_for_file(
853
+ nonce=approval_id,
854
+ file_path=file_path,
855
+ session_id=session_id or None,
856
+ )
857
+ if pending_path is None:
858
+ # Persistence failure -- fall back to native ask dialog.
859
+ logger.warning(
860
+ "Failed to persist pending file-path approval for subagent; "
861
+ "falling back to ask: %s",
862
+ file_path,
863
+ )
864
+ reason = (
865
+ "[PROTECTED_PATH] Modifications to Gaia hooks and security config "
866
+ "require approval. (Pending approval persistence failed; "
867
+ "native dialog fallback.)"
868
+ )
869
+ return HookResponse(
870
+ output={
871
+ "hookSpecificOutput": {
872
+ "hookEventName": "PreToolUse",
873
+ "permissionDecision": "ask",
874
+ "permissionDecisionReason": reason,
875
+ }
876
+ },
877
+ exit_code=0,
878
+ )
879
+
880
+ reason = (
881
+ f"[T3_BLOCKED] This file modification requires user approval.\n"
882
+ f"Do NOT retry this operation. Report APPROVAL_REQUEST with this approval_id "
883
+ f"in your json:contract.\n"
884
+ f"File: {file_path}\n"
885
+ f"Tool: {tool_name}\n"
886
+ f"approval_id: {approval_id}"
887
+ )
888
+ return HookResponse(
889
+ output={
890
+ "hookSpecificOutput": {
891
+ "hookEventName": "PreToolUse",
892
+ "permissionDecision": "deny",
893
+ "permissionDecisionReason": reason,
894
+ }
895
+ },
896
+ exit_code=0,
897
+ )
898
+
899
+ @staticmethod
900
+ def _format_blocked_message(result) -> str:
901
+ """Format blocked command message. Delegates to blocked_message_formatter."""
902
+ from modules.security.blocked_message_formatter import format_blocked_message
903
+ return format_blocked_message(result)
904
+
905
+ # ------------------------------------------------------------------ #
906
+ # adapt_post_tool_use: full post-tool-use lifecycle
907
+ # ------------------------------------------------------------------ #
908
+
909
+ def adapt_post_tool_use(self, event: HookEvent) -> HookResponse:
910
+ """Run all post-tool-use business logic and return a formatted response.
911
+
912
+ Orchestrates: state retrieval, duration computation, audit logging,
913
+ T3 grant confirmation, critical event detection, session context
914
+ writing, state cleanup, and AskUserQuestion grant activation.
915
+ """
916
+ from modules.core.state import get_hook_state, clear_hook_state
917
+ from modules.audit.logger import log_execution
918
+ from modules.audit.event_detector import detect_critical_event
919
+ from modules.session.session_context_writer import SessionContextWriter
920
+ from modules.security.approval_grants import check_approval_grant, confirm_grant
921
+
922
+ hook_data = event.payload
923
+ tool_result_data = self.parse_post_tool_use(hook_data)
924
+ logger.info("Post-hook event: %s", hook_data.get("hook_event_name"))
925
+
926
+ raw_tool_response = hook_data.get("tool_response", {})
927
+ tool_name = tool_result_data.tool_name
928
+ parameters = hook_data.get("tool_input", {})
929
+ output = tool_result_data.output
930
+ duration = raw_tool_response.get("duration_ms", 0) / 1000.0
931
+ success = tool_result_data.exit_code == 0
932
+
933
+ # ------------------------------------------------------------- #
934
+ # AskUserQuestion: check if user approved a pending T3 grant
935
+ # ------------------------------------------------------------- #
936
+ if tool_name == "AskUserQuestion":
937
+ self._handle_ask_user_question_result(hook_data)
938
+ return HookResponse(output={}, exit_code=0)
939
+
940
+ try:
941
+ pre_state = get_hook_state()
942
+ tier = pre_state.tier if pre_state else "unknown"
943
+
944
+ # Prefer wall-clock duration from pre-hook timestamp
945
+ computed_duration = duration
946
+ if pre_state and pre_state.start_time_epoch > 0:
947
+ computed_duration = time.time() - pre_state.start_time_epoch
948
+
949
+ log_execution(
950
+ tool_name=tool_name,
951
+ parameters=parameters,
952
+ result=output,
953
+ duration=computed_duration,
954
+ exit_code=0 if success else 1,
955
+ tier=tier,
956
+ )
957
+
958
+ # Confirm unconfirmed T3 grants after successful Bash execution.
959
+ # Grants are consumed later at SubagentStop, not here -- the grant
960
+ # lives for the full subagent session so retries work naturally.
961
+ if tool_name == "Bash" and success:
962
+ command = parameters.get("command", "")
963
+ session_id = hook_data.get("session_id", "")
964
+ if command:
965
+ grant = check_approval_grant(command, session_id=session_id)
966
+ if grant is not None and not grant.confirmed:
967
+ confirm_grant(command, session_id=session_id)
968
+ logger.info(
969
+ "T3 grant confirmed (will be consumed at SubagentStop): %s", command[:80],
970
+ )
971
+
972
+ events = detect_critical_event(tool_name, parameters, output, success)
973
+ if events:
974
+ writer = SessionContextWriter()
975
+ for evt in events:
976
+ writer.update_context(evt.to_dict())
977
+
978
+ # Write COMMAND_EXECUTED event for T2+ Bash commands only (non-blocking)
979
+ if tool_name == "Bash" and tier in ("T2", "T3"):
980
+ try:
981
+ from modules.events.event_writer import EventWriter, COMMAND_EXECUTED
982
+ cmd = parameters.get("command", "")
983
+ EventWriter().write_event(
984
+ COMMAND_EXECUTED, "hook", "",
985
+ f"{'ok' if success else 'error'}: {cmd[:120]}",
986
+ severity="info" if success else "warning",
987
+ meta={"tier": tier},
988
+ )
989
+ except Exception:
990
+ pass # Events are non-critical
991
+
992
+ clear_hook_state()
993
+ logger.debug("Post-hook completed for %s", tool_name)
994
+
995
+ except Exception as e:
996
+ logger.error("Error in adapt_post_tool_use: %s", e, exc_info=True)
997
+
998
+ return HookResponse(output={}, exit_code=0)
999
+
1000
+ # ------------------------------------------------------------------ #
1001
+ # _handle_ask_user_question_result: grant activation from user answer
1002
+ # ------------------------------------------------------------------ #
1003
+
1004
+ def _handle_ask_user_question_result(self, hook_data: Dict[str, Any]) -> None:
1005
+ """Conditionally activate pending grants based on user's answer.
1006
+
1007
+ Uses nonce-targeted activation when the approved answer contains a
1008
+ ``[P-<hex>]`` tag (the nonce prefix). This works identically for
1009
+ same-session and cross-session approvals:
1010
+ 1. Extract the nonce prefix from the approved label.
1011
+ 2. Load the specific pending file by prefix (any session).
1012
+ 3. Activate the grant under the CURRENT session.
1013
+
1014
+ Falls back to session-wide activation when no nonce is present in
1015
+ the answer (backward compatibility with older approval labels).
1016
+
1017
+ When the answer also contains "batch", a SCOPE_VERB_FAMILY multi-use
1018
+ grant is created alongside the normal semantic grants. This allows
1019
+ batch operations (e.g., modifying hundreds of emails) to proceed
1020
+ without per-command approval.
1021
+
1022
+ Never blocks (no exceptions raised to caller).
1023
+ """
1024
+ from modules.security.approval_grants import (
1025
+ activate_cross_session_pending,
1026
+ activate_grants_for_session,
1027
+ activate_pending_approval,
1028
+ create_verb_family_grant,
1029
+ extract_nonce_from_label,
1030
+ get_pending_approvals_for_session,
1031
+ load_pending_by_nonce_prefix,
1032
+ )
1033
+
1034
+ session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_SESSION_ID", "")
1035
+
1036
+ # Extract answers from tool_response first, then tool_input as fallback
1037
+ tool_response = hook_data.get("tool_response", {})
1038
+ answers = {}
1039
+ if isinstance(tool_response, dict):
1040
+ answers = tool_response.get("answers", {})
1041
+ if not answers and isinstance(hook_data.get("tool_input", {}), dict):
1042
+ answers = hook_data.get("tool_input", {}).get("answers", {})
1043
+
1044
+ if not answers:
1045
+ logger.info("AskUserQuestion: no answers found in payload, skipping grant activation")
1046
+ return
1047
+
1048
+ user_approved = any("approve" in str(v).lower() for v in answers.values())
1049
+
1050
+ if not user_approved:
1051
+ logger.info(
1052
+ "AskUserQuestion: user did not approve (answers: %s), skipping grant activation",
1053
+ {k: v for k, v in answers.items()},
1054
+ )
1055
+ return
1056
+
1057
+ # Detect batch intent: answer contains "batch" alongside "approve"
1058
+ is_batch = any("batch" in str(v).lower() for v in answers.values())
1059
+
1060
+ # User approved -- activate grants
1061
+ logger.info("AskUserQuestion: user approved, activating grants for session %s", session_id[:12])
1062
+
1063
+ try:
1064
+ if not session_id:
1065
+ logger.info("AskUserQuestion: no session_id available, skipping grant activation")
1066
+ return
1067
+
1068
+ # Try nonce-targeted activation first: extract nonce from answer labels
1069
+ nonce_prefix = None
1070
+ for v in answers.values():
1071
+ nonce_prefix = extract_nonce_from_label(str(v))
1072
+ if nonce_prefix:
1073
+ break
1074
+
1075
+ activated_pending_data = None # Track for batch grant creation
1076
+
1077
+ if nonce_prefix:
1078
+ # Nonce-targeted: load this specific pending regardless of session
1079
+ pending_data = load_pending_by_nonce_prefix(nonce_prefix)
1080
+ if pending_data:
1081
+ pending_session = pending_data.get("session_id", "")
1082
+ full_nonce = pending_data.get("nonce", "")
1083
+
1084
+ if pending_session == session_id:
1085
+ # Same session -- use standard activation
1086
+ result = activate_pending_approval(
1087
+ nonce=full_nonce,
1088
+ session_id=session_id,
1089
+ )
1090
+ else:
1091
+ # Cross session -- activate under current session
1092
+ result = activate_cross_session_pending(
1093
+ pending_data,
1094
+ session_id=session_id,
1095
+ )
1096
+
1097
+ if result.success:
1098
+ logger.info(
1099
+ "AskUserQuestion nonce-targeted activation: prefix=%s, "
1100
+ "pending_session=%s, current_session=%s, status=%s",
1101
+ nonce_prefix, pending_session[:12], session_id[:12],
1102
+ getattr(result.status, "value", str(result.status)),
1103
+ )
1104
+ activated_pending_data = pending_data
1105
+ else:
1106
+ logger.warning(
1107
+ "AskUserQuestion nonce-targeted activation failed: "
1108
+ "prefix=%s, status=%s, reason=%s",
1109
+ nonce_prefix,
1110
+ getattr(result.status, "value", str(result.status)),
1111
+ result.reason,
1112
+ )
1113
+ else:
1114
+ logger.warning(
1115
+ "AskUserQuestion: nonce prefix %s found in label but no "
1116
+ "matching pending file -- falling back to session-wide",
1117
+ nonce_prefix,
1118
+ )
1119
+ # Fall through to session-wide activation below
1120
+ nonce_prefix = None
1121
+
1122
+ if not nonce_prefix:
1123
+ # No nonce in label (or prefix lookup failed) -- fall back to
1124
+ # session-wide activation for backward compatibility
1125
+ pending = get_pending_approvals_for_session(session_id)
1126
+ if not pending:
1127
+ logger.info("AskUserQuestion: no pending grants for session %s", session_id)
1128
+ return
1129
+
1130
+ results = activate_grants_for_session(session_id)
1131
+ activated = sum(1 for r in results if r.success)
1132
+ logger.info(
1133
+ "AskUserQuestion session-wide activation: %d/%d pending grants for session %s",
1134
+ activated, len(results), session_id,
1135
+ )
1136
+ # Use the pending list for batch grant creation
1137
+ if is_batch:
1138
+ activated_pending_data = pending # List for batch iteration
1139
+
1140
+ # Batch approval: create a verb-family grant for each activated
1141
+ # pending's base_cmd + verb, so future commands with different
1142
+ # arguments are covered without per-command approval.
1143
+ if is_batch and activated_pending_data:
1144
+ from modules.security.approval_grants import DEFAULT_BATCH_TTL_MINUTES
1145
+ from modules.security.approval_scopes import ApprovalSignature
1146
+
1147
+ # Normalize to list: nonce-targeted gives a single dict,
1148
+ # session-wide gives a list
1149
+ pending_list = (
1150
+ activated_pending_data
1151
+ if isinstance(activated_pending_data, list)
1152
+ else [activated_pending_data]
1153
+ )
1154
+
1155
+ for pd in pending_list:
1156
+ sig_data = pd.get("scope_signature")
1157
+ if not sig_data:
1158
+ continue
1159
+ try:
1160
+ sig = ApprovalSignature.from_dict(sig_data)
1161
+ if sig.base_cmd and sig.verb:
1162
+ batch_path = create_verb_family_grant(
1163
+ session_id=session_id,
1164
+ base_cmd=sig.base_cmd,
1165
+ verb=sig.verb,
1166
+ danger_category=sig.danger_category,
1167
+ ttl_minutes=DEFAULT_BATCH_TTL_MINUTES,
1168
+ )
1169
+ if batch_path:
1170
+ logger.info(
1171
+ "Batch verb-family grant created: %s %s -> %s",
1172
+ sig.base_cmd, sig.verb, batch_path.name,
1173
+ )
1174
+ except Exception as e:
1175
+ logger.warning(
1176
+ "Failed to create batch grant from pending: %s", e,
1177
+ )
1178
+
1179
+ except Exception as e:
1180
+ logger.error("Error in _handle_ask_user_question_result: %s", e, exc_info=True)
1181
+
1182
+ # ------------------------------------------------------------------ #
1183
+ # adapt_subagent_stop: full subagent-stop lifecycle
1184
+ # ------------------------------------------------------------------ #
1185
+
1186
+ def adapt_subagent_stop(self, event: HookEvent) -> HookResponse:
1187
+ """Run all subagent-stop business logic and return a formatted response.
1188
+
1189
+ Orchestrates: contract parsing/validation, approval cleanup,
1190
+ context updates, workflow recording, response contract validation,
1191
+ anomaly detection, episodic memory, and result assembly.
1192
+ """
1193
+ from modules.agents.contract_validator import (
1194
+ extract_commands_from_evidence,
1195
+ parse_contract,
1196
+ requires_consolidation_report,
1197
+ validate as validate_contract,
1198
+ validate_approval_request,
1199
+ validate_verbatim_outputs_consistency,
1200
+ )
1201
+ from modules.agents.response_contract import (
1202
+ save_validation_result,
1203
+ validate_response_contract,
1204
+ resolve_agent_id,
1205
+ )
1206
+ from modules.agents.task_info_builder import build_task_info_from_hook_data
1207
+ from modules.agents.transcript_reader import read_transcript
1208
+ from modules.audit.workflow_auditor import audit as audit_workflow, signal_gaia_analysis
1209
+ from modules.audit.workflow_recorder import record as record_workflow
1210
+ from modules.context.context_writer import process_context_updates
1211
+ from modules.memory.episode_writer import write as write_episode
1212
+ from modules.security.approval_cleanup import cleanup as cleanup_approval
1213
+ from modules.session.session_manager import get_or_create_session_id
1214
+
1215
+ hook_data = event.payload
1216
+ logger.info(
1217
+ "Hook event: %s, agent: %s",
1218
+ hook_data.get("hook_event_name"),
1219
+ hook_data.get("agent_type", "unknown"),
1220
+ )
1221
+
1222
+ # Parse agent completion data
1223
+ completion = self.parse_agent_completion(hook_data)
1224
+
1225
+ # ----------------------------------------------------------
1226
+ # Transcript analysis (T011)
1227
+ # ----------------------------------------------------------
1228
+ transcript_analysis = None
1229
+ try:
1230
+ from modules.agents.transcript_analyzer import analyze as analyze_transcript
1231
+ if completion.transcript_path:
1232
+ transcript_analysis = analyze_transcript(completion.transcript_path)
1233
+ logger.info(
1234
+ "Transcript analysis: %d tool calls, %d API calls, model=%s",
1235
+ transcript_analysis.tool_call_count,
1236
+ transcript_analysis.api_call_count,
1237
+ transcript_analysis.model,
1238
+ )
1239
+ except Exception as exc:
1240
+ logger.debug("Transcript analysis failed (non-fatal): %s", exc)
1241
+
1242
+ # Resolve agent output: prefer last_assistant_message, fall back to transcript
1243
+ agent_output = completion.last_message
1244
+ if not agent_output:
1245
+ transcript_path = completion.transcript_path
1246
+ agent_output = read_transcript(transcript_path) if transcript_path else ""
1247
+ logger.info("Agent output: %d chars from transcript (fallback)", len(agent_output))
1248
+ else:
1249
+ logger.info("Agent output: %d chars from last_assistant_message", len(agent_output))
1250
+
1251
+ task_info = build_task_info_from_hook_data(hook_data, agent_output)
1252
+
1253
+ # ----------------------------------------------------------
1254
+ # Native agent bypass: agents not defined in agents/ dir
1255
+ # (e.g. claude-code-guide, Explore, Plan) do not emit
1256
+ # json:contract. Skip contract validation to avoid an
1257
+ # infinite retry loop (exit_code=2 -> retry -> no contract).
1258
+ # ----------------------------------------------------------
1259
+ _native_agent_type = task_info.get("agent", "unknown")
1260
+ _gaia_agents = self._get_gaia_agent_names()
1261
+ if _native_agent_type not in _gaia_agents:
1262
+ logger.info(
1263
+ "Native agent '%s' — skipping contract validation (gaia agents: %s)",
1264
+ _native_agent_type, _gaia_agents,
1265
+ )
1266
+ return HookResponse(
1267
+ output={"success": True, "native_agent": True, "agent": _native_agent_type},
1268
+ exit_code=0,
1269
+ )
1270
+
1271
+ # Run the main processing chain
1272
+ try:
1273
+ from datetime import datetime as _dt
1274
+ session_id = get_or_create_session_id()
1275
+ agent_type = task_info.get("agent", "unknown")
1276
+
1277
+ parsed_contract = parse_contract(agent_output)
1278
+
1279
+ contract_result = validate_contract(agent_output, task_info)
1280
+ if not contract_result.is_valid:
1281
+ logger.warning(
1282
+ "Contract validation failed for %s: %s",
1283
+ agent_type, contract_result.error_message,
1284
+ )
1285
+
1286
+ cleanup_approval(agent_type)
1287
+
1288
+ # Consume all confirmed grants for this session -- the subagent
1289
+ # is done, so grants should not survive past its lifetime.
1290
+ try:
1291
+ from modules.security.approval_grants import consume_session_grants
1292
+ consumed = consume_session_grants(session_id)
1293
+ if consumed:
1294
+ logger.info(
1295
+ "SubagentStop consumed %d grant(s) for session %s",
1296
+ consumed, session_id[:12],
1297
+ )
1298
+ except Exception as exc:
1299
+ logger.debug("Grant consumption at SubagentStop failed (non-fatal): %s", exc)
1300
+
1301
+ commands_executed = extract_commands_from_evidence(agent_output)
1302
+ context_update_result = process_context_updates(agent_output, task_info)
1303
+
1304
+ # Compute context anchor hit tracking
1305
+ anchor_hits = None
1306
+ try:
1307
+ from modules.context.anchor_tracker import (
1308
+ cleanup_anchors,
1309
+ compute_anchor_hits,
1310
+ extract_tool_calls_from_transcript,
1311
+ load_anchors,
1312
+ )
1313
+ transcript_path = task_info.get("agent_transcript_path", "")
1314
+ anchors = load_anchors(session_id, agent_type)
1315
+ if anchors and transcript_path:
1316
+ tool_calls = extract_tool_calls_from_transcript(transcript_path)
1317
+ anchor_hits = compute_anchor_hits(tool_calls, anchors)
1318
+ logger.info(
1319
+ "Anchor hits for %s: %d/%d (%.0f%%)",
1320
+ agent_type,
1321
+ anchor_hits.get("hits", 0),
1322
+ anchor_hits.get("total_checked", 0),
1323
+ anchor_hits.get("hit_rate", 0) * 100,
1324
+ )
1325
+ cleanup_anchors(session_id, agent_type)
1326
+ except Exception as exc:
1327
+ logger.debug("Anchor hit tracking failed (non-fatal): %s", exc)
1328
+
1329
+ session_context = {
1330
+ "timestamp": _dt.now().isoformat(),
1331
+ "session_id": session_id,
1332
+ "task_id": task_info.get("task_id", "unknown"),
1333
+ "agent_id": task_info.get("agent_id", "unknown"),
1334
+ "agent": agent_type,
1335
+ }
1336
+ workflow_metrics = record_workflow(
1337
+ task_info,
1338
+ agent_output,
1339
+ session_context,
1340
+ commands_executed=commands_executed,
1341
+ context_update_result=context_update_result,
1342
+ anchor_hits=anchor_hits,
1343
+ transcript_analysis=transcript_analysis,
1344
+ )
1345
+
1346
+ response_contract = validate_response_contract(
1347
+ agent_output,
1348
+ task_agent_id=resolve_agent_id(task_info),
1349
+ consolidation_required=requires_consolidation_report(task_info),
1350
+ parsed_contract=parsed_contract,
1351
+ )
1352
+ save_validation_result(task_info, response_contract)
1353
+
1354
+ anomalies = audit_workflow(
1355
+ workflow_metrics,
1356
+ agent_output,
1357
+ task_info,
1358
+ rejected_sections=(context_update_result or {}).get("rejected", []),
1359
+ transcript_analysis=transcript_analysis,
1360
+ )
1361
+ if not response_contract.valid:
1362
+ missing = ", ".join(response_contract.missing) or "none"
1363
+ invalid = ", ".join(response_contract.invalid) or "none"
1364
+ anomalies.append({
1365
+ "type": "response_contract_violation",
1366
+ "severity": "critical",
1367
+ "message": (
1368
+ f"Agent response contract invalid for {task_info.get('agent', 'unknown')}: "
1369
+ f"missing=[{missing}] invalid=[{invalid}]"
1370
+ ),
1371
+ })
1372
+
1373
+ # ----------------------------------------------------------
1374
+ # Compliance score (T011)
1375
+ # Computed after audit so anomalies are available for
1376
+ # has_scope_escalation detection.
1377
+ # ----------------------------------------------------------
1378
+ compliance_result = None
1379
+ try:
1380
+ from modules.agents.transcript_analyzer import compute_compliance_score
1381
+ if transcript_analysis is not None:
1382
+ _contract_valid = contract_result.is_valid
1383
+ _has_scope_escalation = any(
1384
+ a.get("type") == "scope_escalation"
1385
+ for a in anomalies
1386
+ ) if anomalies else False
1387
+ _anchor_hit_rate = (
1388
+ anchor_hits.get("hit_rate", 0.0)
1389
+ if anchor_hits else 0.0
1390
+ )
1391
+ compliance_result = compute_compliance_score(
1392
+ transcript_analysis,
1393
+ contract_valid=_contract_valid,
1394
+ has_scope_escalation=_has_scope_escalation,
1395
+ anchor_hit_rate=_anchor_hit_rate,
1396
+ )
1397
+ logger.info(
1398
+ "Compliance score for %s: %d (%s)",
1399
+ agent_type, compliance_result.total, compliance_result.grade,
1400
+ )
1401
+ workflow_metrics["compliance_score"] = {
1402
+ "total": compliance_result.total,
1403
+ "grade": compliance_result.grade,
1404
+ "factors": compliance_result.factors,
1405
+ "deductions": compliance_result.deductions,
1406
+ }
1407
+ except Exception as exc:
1408
+ logger.debug("Compliance score computation failed (non-fatal): %s", exc)
1409
+
1410
+ if anomalies:
1411
+ logger.warning("%d anomalies detected in workflow", len(anomalies))
1412
+ signal_gaia_analysis(anomalies, workflow_metrics)
1413
+
1414
+ workflow_metrics["anomalies_detected"] = len(anomalies)
1415
+ workflow_metrics["anomaly_types"] = [a.get("type", "") for a in anomalies]
1416
+
1417
+ episode_id = write_episode(
1418
+ workflow_metrics,
1419
+ anomalies=anomalies if anomalies else None,
1420
+ commands_executed=commands_executed,
1421
+ )
1422
+
1423
+ # Write AGENT_COMPLETE event (non-blocking)
1424
+ try:
1425
+ from modules.events.event_writer import EventWriter, AGENT_COMPLETE
1426
+ _plan = ""
1427
+ if parsed_contract and isinstance(parsed_contract.get("agent_status"), dict):
1428
+ _plan = str(parsed_contract["agent_status"].get("plan_status", ""))
1429
+ _key_outputs = []
1430
+ if parsed_contract and isinstance(parsed_contract.get("evidence_report"), dict):
1431
+ _key_outputs = parsed_contract["evidence_report"].get("key_outputs", [])
1432
+ _summary = "; ".join(str(o) for o in _key_outputs[:2]) if _key_outputs else ""
1433
+ EventWriter().write_event(
1434
+ AGENT_COMPLETE, "hook", agent_type,
1435
+ _plan or "completed",
1436
+ meta={"episode_id": episode_id, "summary": _summary[:200]},
1437
+ )
1438
+ except Exception:
1439
+ pass # Events are non-critical
1440
+
1441
+ contract_attempts = 0
1442
+ if not response_contract.valid:
1443
+ try:
1444
+ repair_data = response_contract.to_dict()
1445
+ contract_attempts = int(repair_data.get("repair_attempts", 0))
1446
+ except Exception:
1447
+ contract_attempts = 0
1448
+
1449
+ # ----------------------------------------------------------
1450
+ # Option D: Cross-field validation for verbatim_outputs
1451
+ # Advisory only -- adds to anomalies but never blocks.
1452
+ # ----------------------------------------------------------
1453
+ verbatim_check = validate_verbatim_outputs_consistency(parsed_contract)
1454
+ if verbatim_check:
1455
+ anomalies.append(verbatim_check)
1456
+ logger.info(
1457
+ "Verbatim outputs consistency warning for %s: %s",
1458
+ agent_type, verbatim_check.get("message", ""),
1459
+ )
1460
+
1461
+ # ----------------------------------------------------------
1462
+ # Extract plan_status for downstream checks
1463
+ # ----------------------------------------------------------
1464
+ _plan_status = ""
1465
+ if parsed_contract and isinstance(parsed_contract.get("agent_status"), dict):
1466
+ _plan_status = str(parsed_contract["agent_status"].get("plan_status", ""))
1467
+
1468
+ # ----------------------------------------------------------
1469
+ # State transition tracking
1470
+ # Validates that agent state transitions follow the state
1471
+ # machine (e.g., no IN_PROGRESS -> COMPLETE without REVIEW
1472
+ # when T3 is involved). Advisory warnings, hard reject only
1473
+ # for illegal transitions.
1474
+ # ----------------------------------------------------------
1475
+ try:
1476
+ from modules.agents.state_tracker import track_transition
1477
+ _agent_id = resolve_agent_id(task_info)
1478
+ if _plan_status and _agent_id:
1479
+ transition_result = track_transition(
1480
+ _agent_id,
1481
+ _plan_status,
1482
+ has_review_phase=False, # Conservative: no T3 detection yet
1483
+ )
1484
+ if not transition_result.valid:
1485
+ anomalies.append({
1486
+ "type": "illegal_state_transition",
1487
+ "severity": "warning",
1488
+ "message": transition_result.error,
1489
+ })
1490
+ logger.warning(
1491
+ "State transition rejected for %s: %s",
1492
+ agent_type, transition_result.error,
1493
+ )
1494
+ elif transition_result.warning:
1495
+ anomalies.append({
1496
+ "type": "state_transition_warning",
1497
+ "severity": "info",
1498
+ "message": transition_result.warning,
1499
+ })
1500
+ logger.info(
1501
+ "State transition warning for %s: %s",
1502
+ agent_type, transition_result.warning,
1503
+ )
1504
+ except Exception as exc:
1505
+ logger.debug("State transition tracking failed (non-fatal): %s", exc)
1506
+
1507
+ # ----------------------------------------------------------
1508
+ # Approval request validation
1509
+ # Advisory only -- adds to anomalies but never blocks.
1510
+ # ----------------------------------------------------------
1511
+ if parsed_contract is not None:
1512
+ approval_check = validate_approval_request(parsed_contract, _plan_status)
1513
+ if approval_check:
1514
+ anomalies.append(approval_check)
1515
+ logger.info(
1516
+ "Approval request validation for %s: %s",
1517
+ agent_type, approval_check.get("detail", ""),
1518
+ )
1519
+
1520
+ # ----------------------------------------------------------
1521
+ # Skill injection verification
1522
+ # Advisory only -- adds to anomalies but never blocks.
1523
+ # ----------------------------------------------------------
1524
+ try:
1525
+ from modules.agents.skill_injection_verifier import verify_skill_injection
1526
+ from modules.audit.workflow_recorder import load_agent_runtime_profile
1527
+ agent_profile = load_agent_runtime_profile(agent_type)
1528
+ declared_skills = agent_profile.get("skills", [])
1529
+ if declared_skills and agent_output:
1530
+ skill_check = verify_skill_injection(
1531
+ agent_type, agent_output, declared_skills,
1532
+ )
1533
+ if skill_check:
1534
+ anomalies.append(skill_check)
1535
+ logger.info(
1536
+ "Skill injection gap for %s: %s",
1537
+ agent_type, skill_check.get("message", ""),
1538
+ )
1539
+ except Exception as exc:
1540
+ logger.debug("Skill injection verification failed (non-fatal): %s", exc)
1541
+
1542
+ # ----------------------------------------------------------
1543
+ # Option B: Selective enforcement for critical structural failures.
1544
+ # Only 3 cases set contract_rejected=True:
1545
+ # 1. json:contract block completely missing
1546
+ # 2. plan_status missing or not one of the 8 valid statuses
1547
+ # 3. agent_status block missing entirely
1548
+ # ----------------------------------------------------------
1549
+ contract_rejected = False
1550
+ contract_rejection_reason = ""
1551
+
1552
+ if parsed_contract is None:
1553
+ contract_rejected = True
1554
+ contract_rejection_reason = (
1555
+ "[CONTRACT REJECTED] No json:contract block found in agent response.\n"
1556
+ "The agent must end its response with a ```json:contract``` fenced block.\n"
1557
+ "Reissue the response with a complete json:contract block."
1558
+ )
1559
+ elif not parsed_contract.get("agent_status") or not isinstance(
1560
+ parsed_contract.get("agent_status"), dict
1561
+ ):
1562
+ contract_rejected = True
1563
+ contract_rejection_reason = (
1564
+ "[CONTRACT REJECTED] agent_status block missing from json:contract.\n"
1565
+ "The json:contract block must include an agent_status object with "
1566
+ "plan_status, agent_id, pending_steps, and next_action."
1567
+ )
1568
+ else:
1569
+ from modules.agents.response_contract import VALID_PLAN_STATUSES
1570
+ raw_plan_status = parsed_contract["agent_status"].get("plan_status", "")
1571
+ normalized = str(raw_plan_status).upper().rstrip(".,;") if raw_plan_status else ""
1572
+ if not normalized or normalized not in VALID_PLAN_STATUSES:
1573
+ contract_rejected = True
1574
+ valid_list = ", ".join(sorted(VALID_PLAN_STATUSES))
1575
+ contract_rejection_reason = (
1576
+ f"[CONTRACT REJECTED] plan_status is missing or invalid: "
1577
+ f"'{raw_plan_status}'.\n"
1578
+ f"Valid statuses: {valid_list}.\n"
1579
+ f"Set plan_status to one of these values in agent_status."
1580
+ )
1581
+
1582
+ result = {
1583
+ "success": True,
1584
+ "session_id": session_id,
1585
+ "status": "metrics_captured",
1586
+ "metrics_captured": True,
1587
+ "anomalies_detected": len(anomalies) if anomalies else 0,
1588
+ "episode_id": episode_id,
1589
+ "context_updated": context_update_result.get("updated", False) if context_update_result else False,
1590
+ "response_contract": response_contract.to_dict(),
1591
+ "contract_validated": contract_result.is_valid,
1592
+ "contract_attempts": contract_attempts,
1593
+ }
1594
+
1595
+ if contract_rejected:
1596
+ result["contract_rejected"] = True
1597
+ result["contract_rejection_reason"] = contract_rejection_reason
1598
+ logger.warning(
1599
+ "Contract rejected for %s: %s",
1600
+ agent_type, contract_rejection_reason.split("\n")[0],
1601
+ )
1602
+
1603
+ except Exception as e:
1604
+ logger.error("Error in adapt_subagent_stop: %s", e, exc_info=True)
1605
+ result = {
1606
+ "success": False,
1607
+ "error": str(e),
1608
+ "status": "partial_update",
1609
+ }
1610
+
1611
+ if result.get("contract_rejected"):
1612
+ logger.warning("Returning exit_code=2 due to contract rejection")
1613
+ return HookResponse(output=result, exit_code=2)
1614
+
1615
+ return HookResponse(output=result, exit_code=0)
1616
+
1617
+ # ------------------------------------------------------------------ #
1618
+ # P2: adapt_stop
1619
+ # ------------------------------------------------------------------ #
1620
+
1621
+ def adapt_stop(self, raw: dict) -> QualityResult:
1622
+ """Parse Stop event and assess response quality.
1623
+
1624
+ Extracts the response content from the Stop payload and evaluates
1625
+ whether the output meets evidence quality thresholds.
1626
+
1627
+ Returns:
1628
+ QualityResult with quality assessment.
1629
+ Default: quality_sufficient=True (passthrough until business logic wired).
1630
+ """
1631
+ # Write SESSION_END event (non-blocking)
1632
+ try:
1633
+ from modules.events.event_writer import EventWriter, SESSION_END
1634
+ stop_reason = raw.get("stop_reason", "unknown")
1635
+ EventWriter().write_event(
1636
+ SESSION_END, "hook", "",
1637
+ f"session ended: {stop_reason}",
1638
+ )
1639
+ except Exception:
1640
+ pass # Events are non-critical
1641
+
1642
+ return QualityResult(
1643
+ quality_sufficient=True,
1644
+ score=1.0,
1645
+ missing_elements=[],
1646
+ recommendation="continue",
1647
+ )
1648
+
1649
+ # ------------------------------------------------------------------ #
1650
+ # P2: adapt_task_completed
1651
+ # ------------------------------------------------------------------ #
1652
+
1653
+ def adapt_task_completed(self, raw: dict) -> VerificationResult:
1654
+ """Parse TaskCompleted event and verify completion criteria.
1655
+
1656
+ Extracts task output and metadata from the TaskCompleted payload.
1657
+ Checks if the task's acceptance criteria are met.
1658
+
1659
+ Returns:
1660
+ VerificationResult with criteria assessment.
1661
+ Default: criteria_met=True (passthrough until business logic wired).
1662
+ """
1663
+ return VerificationResult(
1664
+ criteria_met=True,
1665
+ verified_items=[],
1666
+ failed_items=[],
1667
+ block_completion=False,
1668
+ )
1669
+
1670
+ # ------------------------------------------------------------------ #
1671
+ # Context cache: PreToolUse -> SubagentStart bridge
1672
+ # ------------------------------------------------------------------ #
1673
+
1674
+ CONTEXT_CACHE_DIR = Path("/tmp/gaia-context-cache")
1675
+ CONTEXT_CACHE_TTL_SECONDS = 60 # Cache entries older than this are stale
1676
+
1677
+ def _cache_context_for_subagent(
1678
+ self, session_id: str, agent_type: str, context: str,
1679
+ ) -> Path:
1680
+ """Write built context to a cache file for SubagentStart consumption.
1681
+
1682
+ Returns the path to the cache file.
1683
+ """
1684
+ self.CONTEXT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
1685
+ timestamp = int(time.time() * 1000)
1686
+ cache_file = self.CONTEXT_CACHE_DIR / f"{session_id}-{timestamp}.json"
1687
+ payload = {
1688
+ "context": context,
1689
+ "agent_type": agent_type,
1690
+ "session_id": session_id,
1691
+ "created_at": time.time(),
1692
+ }
1693
+ cache_file.write_text(json.dumps(payload))
1694
+ logger.debug("Context cache written: %s", cache_file)
1695
+ return cache_file
1696
+
1697
+ def _read_cached_context(self, session_id: str) -> Optional[Dict[str, Any]]:
1698
+ """Read and consume the most recent cached context for a session.
1699
+
1700
+ Finds the newest cache file matching the session_id, reads it,
1701
+ deletes it (one-shot consumption), and cleans up stale entries.
1702
+
1703
+ Returns None if no cache is found.
1704
+ """
1705
+ if not self.CONTEXT_CACHE_DIR.exists():
1706
+ return None
1707
+
1708
+ # Find all cache files for this session, sorted newest-first
1709
+ candidates: List[Path] = sorted(
1710
+ self.CONTEXT_CACHE_DIR.glob(f"{session_id}-*.json"),
1711
+ key=lambda p: p.stat().st_mtime,
1712
+ reverse=True,
1713
+ )
1714
+
1715
+ if not candidates:
1716
+ # Fallback: try to find the most recent cache file regardless of
1717
+ # session_id, since the orchestrator session_id and the subagent
1718
+ # session_id may differ.
1719
+ all_files = sorted(
1720
+ self.CONTEXT_CACHE_DIR.glob("*.json"),
1721
+ key=lambda p: p.stat().st_mtime,
1722
+ reverse=True,
1723
+ )
1724
+ candidates = all_files
1725
+
1726
+ now = time.time()
1727
+ result = None
1728
+
1729
+ for cache_file in candidates:
1730
+ try:
1731
+ data = json.loads(cache_file.read_text())
1732
+ age = now - data.get("created_at", 0)
1733
+
1734
+ if age > self.CONTEXT_CACHE_TTL_SECONDS:
1735
+ # Stale entry -- clean up
1736
+ cache_file.unlink(missing_ok=True)
1737
+ logger.debug("Cleaned stale context cache: %s (age=%.1fs)", cache_file.name, age)
1738
+ continue
1739
+
1740
+ # Found a valid entry -- consume it
1741
+ result = data
1742
+ cache_file.unlink(missing_ok=True)
1743
+ logger.debug("Consumed context cache: %s (age=%.1fs)", cache_file.name, age)
1744
+ break
1745
+
1746
+ except (json.JSONDecodeError, OSError) as exc:
1747
+ logger.warning("Failed to read context cache %s: %s", cache_file, exc)
1748
+ cache_file.unlink(missing_ok=True)
1749
+ continue
1750
+
1751
+ # Clean up any remaining stale files (background hygiene)
1752
+ self._cleanup_stale_cache(now)
1753
+
1754
+ return result
1755
+
1756
+ def _cleanup_stale_cache(self, now: float) -> None:
1757
+ """Remove cache files older than TTL."""
1758
+ if not self.CONTEXT_CACHE_DIR.exists():
1759
+ return
1760
+ for f in self.CONTEXT_CACHE_DIR.glob("*.json"):
1761
+ try:
1762
+ data = json.loads(f.read_text())
1763
+ if now - data.get("created_at", 0) > self.CONTEXT_CACHE_TTL_SECONDS:
1764
+ f.unlink(missing_ok=True)
1765
+ except (json.JSONDecodeError, OSError):
1766
+ f.unlink(missing_ok=True)
1767
+
1768
+ # ------------------------------------------------------------------ #
1769
+ # P2: adapt_subagent_start
1770
+ # ------------------------------------------------------------------ #
1771
+
1772
+ def adapt_subagent_start(self, raw: dict) -> ContextResult:
1773
+ """Parse SubagentStart event and forward cached context to the subagent.
1774
+
1775
+ Two paths:
1776
+ 1. Cache hit (normal start via Task/Agent tool): PreToolUse:Agent
1777
+ caches context, this method reads and forwards it.
1778
+ 2. Cache miss (resume via SendMessage): No PreToolUse:Agent fires,
1779
+ so no cache exists. If agent_type is present in the payload and
1780
+ is a known project agent, rebuild context on-demand.
1781
+ """
1782
+ session_id = raw.get("session_id", "")
1783
+
1784
+ cached = self._read_cached_context(session_id)
1785
+ if cached:
1786
+ logger.info(
1787
+ "SubagentStart: forwarding cached context for agent=%s (session=%s)",
1788
+ cached.get("agent_type", "unknown"),
1789
+ session_id,
1790
+ )
1791
+ return ContextResult(
1792
+ context_injected=True,
1793
+ additional_context=cached["context"],
1794
+ sections_provided=[],
1795
+ )
1796
+
1797
+ # Resume path: SendMessage skips PreToolUse:Agent so no cache is
1798
+ # written. If agent_type is present in the payload, rebuild context
1799
+ # on-demand so the resumed agent has its project context and tools.
1800
+ agent_type = raw.get("agent_type", "")
1801
+ if agent_type:
1802
+ try:
1803
+ from modules.context.context_injector import build_project_context
1804
+ from modules.session.session_event_injector import build_session_events
1805
+ from modules.tools.task_validator import AVAILABLE_AGENTS, META_AGENTS
1806
+
1807
+ project_agents = [a for a in AVAILABLE_AGENTS if a not in META_AGENTS]
1808
+
1809
+ if agent_type in project_agents:
1810
+ hooks_dir = Path(__file__).parent.parent
1811
+ task_description = raw.get("task_description", "")
1812
+ parameters = {
1813
+ "subagent_type": agent_type,
1814
+ "prompt": task_description or f"resume {agent_type}",
1815
+ }
1816
+
1817
+ context_text, _telemetry = build_project_context(
1818
+ parameters, project_agents, hooks_dir,
1819
+ )
1820
+ events_text = build_session_events(parameters, project_agents)
1821
+ additional = "\n".join(filter(None, [context_text, events_text]))
1822
+
1823
+ if additional:
1824
+ logger.info(
1825
+ "SubagentStart: rebuilt context on resume for "
1826
+ "agent=%s (session=%s)",
1827
+ agent_type, session_id,
1828
+ )
1829
+ return ContextResult(
1830
+ context_injected=True,
1831
+ additional_context=additional,
1832
+ sections_provided=[],
1833
+ )
1834
+ except Exception as exc:
1835
+ logger.warning(
1836
+ "SubagentStart: resume context rebuild failed for "
1837
+ "agent=%s: %s", agent_type, exc,
1838
+ )
1839
+
1840
+ logger.info(
1841
+ "SubagentStart: no cached context found for session=%s "
1842
+ "agent=%s (passthrough)",
1843
+ session_id, agent_type or "unknown",
1844
+ )
1845
+ return ContextResult(
1846
+ context_injected=False,
1847
+ additional_context=None,
1848
+ sections_provided=[],
1849
+ )
1850
+
1851
+ # ------------------------------------------------------------------ #
1852
+ # P2: format_quality_response
1853
+ # ------------------------------------------------------------------ #
1854
+
1855
+ def format_quality_response(self, result: QualityResult) -> HookResponse:
1856
+ """Format a QualityResult for CLI consumption.
1857
+
1858
+ Stop events are informational -- exit code is always 0.
1859
+ """
1860
+ output: Dict[str, Any] = {
1861
+ "quality_sufficient": result.quality_sufficient,
1862
+ "score": result.score,
1863
+ "recommendation": result.recommendation,
1864
+ }
1865
+
1866
+ if result.missing_elements:
1867
+ output["missing_elements"] = result.missing_elements
1868
+
1869
+ return HookResponse(output=output, exit_code=0)
1870
+
1871
+ # ------------------------------------------------------------------ #
1872
+ # P2: format_verification_response
1873
+ # ------------------------------------------------------------------ #
1874
+
1875
+ def format_verification_response(self, result: VerificationResult) -> HookResponse:
1876
+ """Format a VerificationResult for CLI consumption.
1877
+
1878
+ TaskCompleted events are informational -- exit code is always 0.
1879
+ """
1880
+ output: Dict[str, Any] = {
1881
+ "criteria_met": result.criteria_met,
1882
+ "block_completion": result.block_completion,
1883
+ }
1884
+
1885
+ if result.verified_items:
1886
+ output["verified_items"] = result.verified_items
1887
+ if result.failed_items:
1888
+ output["failed_items"] = result.failed_items
1889
+
1890
+ return HookResponse(output=output, exit_code=0)