@jaguilar87/gaia-ops 4.4.0 → 4.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +12 -3
  3. package/ARCHITECTURE.md +9 -8
  4. package/CHANGELOG.md +34 -0
  5. package/README.md +43 -11
  6. package/agents/terraform-architect.md +1 -1
  7. package/bin/README.md +2 -2
  8. package/bin/gaia-doctor.js +18 -5
  9. package/bin/gaia-history.js +0 -1
  10. package/bin/gaia-metrics.js +2 -2
  11. package/bin/gaia-scan.py +23 -1
  12. package/bin/gaia-update.js +346 -54
  13. package/bin/pre-publish-validate.js +33 -10
  14. package/commands/gaia.md +37 -0
  15. package/config/README.md +3 -9
  16. package/config/context-contracts.json +47 -15
  17. package/config/surface-routing.json +9 -1
  18. package/dist/gaia-ops/.claude-plugin/plugin.json +22 -0
  19. package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
  20. package/dist/gaia-ops/agents/devops-developer.md +57 -0
  21. package/dist/gaia-ops/agents/gaia-system.md +58 -0
  22. package/dist/gaia-ops/agents/gitops-operator.md +60 -0
  23. package/dist/gaia-ops/agents/speckit-planner.md +71 -0
  24. package/dist/gaia-ops/agents/terraform-architect.md +60 -0
  25. package/dist/gaia-ops/commands/gaia.md +37 -0
  26. package/dist/gaia-ops/config/README.md +58 -0
  27. package/dist/gaia-ops/config/cloud/aws.json +140 -0
  28. package/dist/gaia-ops/config/cloud/gcp.json +145 -0
  29. package/dist/gaia-ops/config/context-contracts.json +131 -0
  30. package/dist/gaia-ops/config/git_standards.json +72 -0
  31. package/dist/gaia-ops/config/surface-routing.json +197 -0
  32. package/dist/gaia-ops/config/universal-rules.json +10 -0
  33. package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
  34. package/dist/gaia-ops/hooks/adapters/base.py +219 -0
  35. package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
  36. package/dist/gaia-ops/hooks/adapters/claude_code.py +1477 -0
  37. package/dist/gaia-ops/hooks/adapters/types.py +194 -0
  38. package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
  39. package/dist/gaia-ops/hooks/hooks.json +126 -0
  40. package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
  41. package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
  42. package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
  43. package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
  44. package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +124 -0
  45. package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
  46. package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
  47. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
  48. package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
  49. package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
  50. package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
  51. package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
  52. package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +576 -0
  53. package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
  54. package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
  55. package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
  56. package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +215 -0
  57. package/dist/gaia-ops/hooks/modules/context/context_cache.py +129 -0
  58. package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
  59. package/dist/gaia-ops/hooks/modules/context/context_injector.py +427 -0
  60. package/dist/gaia-ops/hooks/modules/context/context_writer.py +518 -0
  61. package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
  62. package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
  63. package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
  64. package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
  65. package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
  66. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +558 -0
  67. package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
  68. package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
  69. package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
  70. package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
  71. package/dist/gaia-ops/hooks/modules/identity/__init__.py +0 -0
  72. package/dist/gaia-ops/hooks/modules/identity/identity_provider.py +21 -0
  73. package/dist/gaia-ops/hooks/modules/identity/ops_identity.py +34 -0
  74. package/dist/gaia-ops/hooks/modules/identity/security_identity.py +10 -0
  75. package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
  76. package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +227 -0
  77. package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
  78. package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +128 -0
  79. package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
  80. package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
  81. package/dist/gaia-ops/hooks/modules/security/__init__.py +89 -0
  82. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
  83. package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
  84. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +912 -0
  85. package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
  86. package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +153 -0
  87. package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +584 -0
  88. package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +86 -0
  89. package/dist/gaia-ops/hooks/modules/security/command_semantics.py +130 -0
  90. package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
  91. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +850 -0
  92. package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
  93. package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
  94. package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
  95. package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
  96. package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +158 -0
  97. package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
  98. package/dist/gaia-ops/hooks/modules/tools/__init__.py +25 -0
  99. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +708 -0
  100. package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +181 -0
  101. package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
  102. package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
  103. package/dist/gaia-ops/hooks/modules/tools/task_validator.py +283 -0
  104. package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
  105. package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
  106. package/dist/gaia-ops/hooks/post_compact.py +43 -0
  107. package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
  108. package/dist/gaia-ops/hooks/pre_tool_use.py +383 -0
  109. package/dist/gaia-ops/hooks/session_start.py +69 -0
  110. package/dist/gaia-ops/hooks/stop_hook.py +69 -0
  111. package/dist/gaia-ops/hooks/subagent_start.py +71 -0
  112. package/dist/gaia-ops/hooks/subagent_stop.py +288 -0
  113. package/dist/gaia-ops/hooks/task_completed.py +70 -0
  114. package/dist/gaia-ops/hooks/user_prompt_submit.py +177 -0
  115. package/dist/gaia-ops/settings.json +72 -0
  116. package/dist/gaia-ops/skills/README.md +109 -0
  117. package/dist/gaia-ops/skills/agent-protocol/SKILL.md +105 -0
  118. package/dist/gaia-ops/skills/agent-protocol/examples.md +170 -0
  119. package/dist/gaia-ops/skills/agent-response/SKILL.md +53 -0
  120. package/dist/gaia-ops/skills/approval/SKILL.md +85 -0
  121. package/dist/gaia-ops/skills/approval/examples.md +140 -0
  122. package/dist/gaia-ops/skills/approval/reference.md +57 -0
  123. package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
  124. package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
  125. package/dist/gaia-ops/skills/context-updater/SKILL.md +76 -0
  126. package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
  127. package/dist/gaia-ops/skills/developer-patterns/SKILL.md +93 -0
  128. package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
  129. package/dist/gaia-ops/skills/execution/SKILL.md +66 -0
  130. package/dist/gaia-ops/skills/fast-queries/SKILL.md +47 -0
  131. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +92 -0
  132. package/dist/gaia-ops/skills/gaia-patterns/reference.md +22 -0
  133. package/dist/gaia-ops/skills/git-conventions/SKILL.md +48 -0
  134. package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +73 -0
  135. package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
  136. package/dist/gaia-ops/skills/investigation/SKILL.md +77 -0
  137. package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +64 -0
  138. package/dist/gaia-ops/skills/reference.md +134 -0
  139. package/dist/gaia-ops/skills/security-tiers/SKILL.md +61 -0
  140. package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
  141. package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
  142. package/dist/gaia-ops/skills/skill-creation/SKILL.md +119 -0
  143. package/dist/gaia-ops/skills/specification/SKILL.md +186 -0
  144. package/dist/gaia-ops/skills/speckit-workflow/SKILL.md +165 -0
  145. package/dist/gaia-ops/skills/speckit-workflow/reference.md +117 -0
  146. package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +63 -0
  147. package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
  148. package/dist/gaia-ops/speckit/README.md +516 -0
  149. package/dist/gaia-ops/speckit/scripts/.gitkeep +0 -0
  150. package/dist/gaia-ops/speckit/templates/adr-template.md +118 -0
  151. package/dist/gaia-ops/speckit/templates/agent-file-template.md +23 -0
  152. package/dist/gaia-ops/speckit/templates/plan-template.md +227 -0
  153. package/dist/gaia-ops/speckit/templates/spec-template.md +140 -0
  154. package/dist/gaia-ops/speckit/templates/tasks-template.md +257 -0
  155. package/dist/gaia-ops/tools/context/README.md +132 -0
  156. package/dist/gaia-ops/tools/context/__init__.py +42 -0
  157. package/dist/gaia-ops/tools/context/_paths.py +20 -0
  158. package/dist/gaia-ops/tools/context/context_provider.py +476 -0
  159. package/dist/gaia-ops/tools/context/context_section_reader.py +330 -0
  160. package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
  161. package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
  162. package/dist/gaia-ops/tools/context/surface_router.py +278 -0
  163. package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
  164. package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
  165. package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
  166. package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
  167. package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
  168. package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
  169. package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
  170. package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
  171. package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
  172. package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
  173. package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
  174. package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
  175. package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
  176. package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
  177. package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +262 -0
  178. package/dist/gaia-ops/tools/memory/README.md +0 -0
  179. package/dist/gaia-ops/tools/memory/__init__.py +20 -0
  180. package/dist/gaia-ops/tools/memory/episodic.py +1196 -0
  181. package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
  182. package/dist/gaia-ops/tools/review/__init__.py +1 -0
  183. package/dist/gaia-ops/tools/review/review_engine.py +157 -0
  184. package/dist/gaia-ops/tools/scan/__init__.py +35 -0
  185. package/dist/gaia-ops/tools/scan/config.py +247 -0
  186. package/dist/gaia-ops/tools/scan/merge.py +212 -0
  187. package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
  188. package/dist/gaia-ops/tools/scan/registry.py +127 -0
  189. package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
  190. package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
  191. package/dist/gaia-ops/tools/scan/scanners/environment.py +324 -0
  192. package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
  193. package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
  194. package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
  195. package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
  196. package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
  197. package/dist/gaia-ops/tools/scan/setup.py +753 -0
  198. package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
  199. package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
  200. package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
  201. package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
  202. package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
  203. package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
  204. package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
  205. package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
  206. package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
  207. package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
  208. package/dist/gaia-ops/tools/scan/ui.py +624 -0
  209. package/dist/gaia-ops/tools/scan/verify.py +266 -0
  210. package/dist/gaia-ops/tools/scan/walk.py +118 -0
  211. package/dist/gaia-ops/tools/scan/workspace.py +85 -0
  212. package/dist/gaia-ops/tools/validation/README.md +244 -0
  213. package/dist/gaia-ops/tools/validation/__init__.py +17 -0
  214. package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
  215. package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
  216. package/dist/gaia-security/.claude-plugin/plugin.json +22 -0
  217. package/dist/gaia-security/config/universal-rules.json +10 -0
  218. package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
  219. package/dist/gaia-security/hooks/adapters/base.py +219 -0
  220. package/dist/gaia-security/hooks/adapters/channel.py +17 -0
  221. package/dist/gaia-security/hooks/adapters/claude_code.py +1477 -0
  222. package/dist/gaia-security/hooks/adapters/types.py +194 -0
  223. package/dist/gaia-security/hooks/adapters/utils.py +25 -0
  224. package/dist/gaia-security/hooks/hooks.json +57 -0
  225. package/dist/gaia-security/hooks/modules/__init__.py +15 -0
  226. package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
  227. package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
  228. package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
  229. package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +124 -0
  230. package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
  231. package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
  232. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
  233. package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
  234. package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
  235. package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
  236. package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
  237. package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +576 -0
  238. package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
  239. package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
  240. package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
  241. package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +215 -0
  242. package/dist/gaia-security/hooks/modules/context/context_cache.py +129 -0
  243. package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
  244. package/dist/gaia-security/hooks/modules/context/context_injector.py +427 -0
  245. package/dist/gaia-security/hooks/modules/context/context_writer.py +518 -0
  246. package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
  247. package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
  248. package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
  249. package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
  250. package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
  251. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +558 -0
  252. package/dist/gaia-security/hooks/modules/core/state.py +179 -0
  253. package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
  254. package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
  255. package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
  256. package/dist/gaia-security/hooks/modules/identity/__init__.py +0 -0
  257. package/dist/gaia-security/hooks/modules/identity/identity_provider.py +21 -0
  258. package/dist/gaia-security/hooks/modules/identity/ops_identity.py +34 -0
  259. package/dist/gaia-security/hooks/modules/identity/security_identity.py +10 -0
  260. package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
  261. package/dist/gaia-security/hooks/modules/memory/episode_writer.py +227 -0
  262. package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
  263. package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +128 -0
  264. package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
  265. package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
  266. package/dist/gaia-security/hooks/modules/security/__init__.py +89 -0
  267. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
  268. package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
  269. package/dist/gaia-security/hooks/modules/security/approval_grants.py +912 -0
  270. package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
  271. package/dist/gaia-security/hooks/modules/security/approval_scopes.py +153 -0
  272. package/dist/gaia-security/hooks/modules/security/blocked_commands.py +584 -0
  273. package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +86 -0
  274. package/dist/gaia-security/hooks/modules/security/command_semantics.py +130 -0
  275. package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
  276. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +850 -0
  277. package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
  278. package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
  279. package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
  280. package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
  281. package/dist/gaia-security/hooks/modules/session/session_event_injector.py +158 -0
  282. package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
  283. package/dist/gaia-security/hooks/modules/tools/__init__.py +25 -0
  284. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +708 -0
  285. package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +181 -0
  286. package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
  287. package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
  288. package/dist/gaia-security/hooks/modules/tools/task_validator.py +283 -0
  289. package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
  290. package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
  291. package/dist/gaia-security/hooks/post_tool_use.py +54 -0
  292. package/dist/gaia-security/hooks/pre_tool_use.py +383 -0
  293. package/dist/gaia-security/hooks/session_start.py +69 -0
  294. package/dist/gaia-security/hooks/stop_hook.py +69 -0
  295. package/dist/gaia-security/hooks/user_prompt_submit.py +177 -0
  296. package/dist/gaia-security/settings.json +58 -0
  297. package/git-hooks/commit-msg +41 -0
  298. package/hooks/README.md +8 -6
  299. package/hooks/adapters/channel.py +0 -25
  300. package/hooks/adapters/claude_code.py +364 -125
  301. package/hooks/elicitation_result.py +132 -0
  302. package/hooks/hooks.json +10 -1
  303. package/hooks/modules/README.md +3 -2
  304. package/hooks/modules/agents/contract_validator.py +3 -51
  305. package/hooks/modules/agents/response_contract.py +4 -8
  306. package/hooks/modules/agents/transcript_reader.py +4 -5
  307. package/hooks/modules/audit/__init__.py +4 -6
  308. package/hooks/modules/audit/event_detector.py +0 -2
  309. package/hooks/modules/audit/metrics.py +108 -187
  310. package/hooks/modules/audit/workflow_auditor.py +0 -4
  311. package/hooks/modules/audit/workflow_recorder.py +0 -5
  312. package/hooks/modules/context/compact_context_builder.py +1 -0
  313. package/hooks/modules/context/context_cache.py +129 -0
  314. package/hooks/modules/context/context_injector.py +18 -40
  315. package/hooks/modules/context/context_writer.py +1 -25
  316. package/hooks/modules/context/contracts_loader.py +7 -10
  317. package/hooks/modules/core/hook_entry.py +1 -0
  318. package/hooks/modules/core/paths.py +12 -13
  319. package/hooks/modules/core/plugin_mode.py +74 -4
  320. package/hooks/modules/core/plugin_setup.py +395 -23
  321. package/hooks/modules/events/__init__.py +1 -0
  322. package/hooks/modules/events/event_writer.py +210 -0
  323. package/hooks/modules/identity/ops_identity.py +18 -27
  324. package/hooks/modules/memory/episode_writer.py +1 -6
  325. package/hooks/modules/orchestrator/__init__.py +1 -0
  326. package/hooks/modules/orchestrator/delegate_mode.py +128 -0
  327. package/hooks/modules/security/__init__.py +2 -4
  328. package/hooks/modules/security/approval_constants.py +5 -1
  329. package/hooks/modules/security/approval_grants.py +189 -6
  330. package/hooks/modules/security/approval_messages.py +9 -21
  331. package/hooks/modules/security/blocked_commands.py +98 -34
  332. package/hooks/modules/security/command_semantics.py +0 -4
  333. package/hooks/modules/security/gitops_validator.py +1 -11
  334. package/hooks/modules/security/mutative_verbs.py +179 -38
  335. package/hooks/modules/security/tiers.py +1 -19
  336. package/hooks/modules/session/session_event_injector.py +1 -25
  337. package/hooks/modules/tools/bash_validator.py +310 -94
  338. package/hooks/modules/tools/shell_parser.py +0 -1
  339. package/hooks/modules/tools/task_validator.py +9 -29
  340. package/hooks/post_tool_use.py +0 -72
  341. package/hooks/pre_tool_use.py +42 -102
  342. package/hooks/session_start.py +4 -2
  343. package/hooks/subagent_start.py +6 -2
  344. package/hooks/subagent_stop.py +1 -13
  345. package/hooks/user_prompt_submit.py +119 -37
  346. package/index.js +1 -1
  347. package/package.json +5 -3
  348. package/skills/README.md +3 -5
  349. package/skills/agent-protocol/SKILL.md +17 -16
  350. package/skills/agent-protocol/examples.md +6 -6
  351. package/skills/agent-response/SKILL.md +11 -14
  352. package/skills/approval/SKILL.md +28 -13
  353. package/skills/approval/reference.md +2 -2
  354. package/skills/execution/SKILL.md +1 -1
  355. package/skills/gaia-patterns/SKILL.md +2 -3
  356. package/skills/orchestrator-approval/SKILL.md +22 -50
  357. package/skills/security-tiers/SKILL.md +1 -1
  358. package/templates/README.md +9 -9
  359. package/templates/managed-settings.template.json +43 -0
  360. package/tools/gaia_simulator/runner.py +34 -1
  361. package/tools/scan/orchestrator.py +13 -0
  362. package/tools/scan/scanners/base.py +8 -0
  363. package/tools/scan/scanners/git.py +78 -0
  364. package/tools/scan/scanners/infrastructure.py +65 -0
  365. package/tools/scan/scanners/stack.py +110 -0
  366. package/tools/scan/setup.py +120 -13
  367. package/tools/scan/workspace.py +85 -0
  368. package/config/context-contracts.aws.json +0 -42
  369. package/config/context-contracts.gcp.json +0 -39
  370. package/skills/project-dispatch/SKILL.md +0 -34
  371. package/templates/settings.template.json +0 -226
@@ -0,0 +1,1085 @@
1
+ """
2
+ Stack Scanner
3
+
4
+ Detects project languages, frameworks, build tools, monorepo structure,
5
+ and project identity from manifest files and dependency declarations.
6
+
7
+ Owned sections: project_identity, stack
8
+ Contract: specs/002-gaia-scan/data-model.md sections 2.3, 2.4
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import re
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ from tools.scan.scanners.base import BaseScanner, ScanResult
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Language detection: mapping of manifest files to language names
24
+ # ---------------------------------------------------------------------------
25
+ LANGUAGE_MANIFESTS: List[Tuple[str, str]] = [
26
+ # (filename_or_glob, language_name)
27
+ ("package.json", "javascript"),
28
+ ("pyproject.toml", "python"),
29
+ ("setup.py", "python"),
30
+ ("requirements.txt", "python"),
31
+ ("go.mod", "go"),
32
+ ("Cargo.toml", "rust"),
33
+ ("pom.xml", "java"),
34
+ ("build.gradle", "java"),
35
+ ("build.gradle.kts", "java"),
36
+ ("composer.json", "php"),
37
+ ("Gemfile", "ruby"),
38
+ ]
39
+
40
+ # C#/.NET uses glob patterns
41
+ CSHARP_EXTENSIONS = (".csproj", ".sln")
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Framework detection: mapping of dependency names to framework info
45
+ # ---------------------------------------------------------------------------
46
+ # (dep_name, framework_name, language)
47
+ JS_FRAMEWORKS: List[Tuple[str, str, str]] = [
48
+ ("@nestjs/core", "nestjs", "javascript"),
49
+ ("express", "express", "javascript"),
50
+ ("react", "react", "javascript"),
51
+ ("next", "next.js", "javascript"),
52
+ ("@angular/core", "angular", "javascript"),
53
+ ("vue", "vue", "javascript"),
54
+ ("nuxt", "nuxt", "javascript"),
55
+ ("svelte", "svelte", "javascript"),
56
+ ("hono", "hono", "javascript"),
57
+ ("fastify", "fastify", "javascript"),
58
+ ("koa", "koa", "javascript"),
59
+ ]
60
+
61
+ PYTHON_FRAMEWORKS: List[Tuple[str, str, str]] = [
62
+ ("fastapi", "fastapi", "python"),
63
+ ("flask", "flask", "python"),
64
+ ("django", "django", "python"),
65
+ ("starlette", "starlette", "python"),
66
+ ("tornado", "tornado", "python"),
67
+ ("sanic", "sanic", "python"),
68
+ ("aiohttp", "aiohttp", "python"),
69
+ ]
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Build tool / lock file detection
73
+ # ---------------------------------------------------------------------------
74
+ LOCK_FILE_TO_TOOL: List[Tuple[str, str]] = [
75
+ ("package-lock.json", "npm"),
76
+ ("pnpm-lock.yaml", "pnpm"),
77
+ ("yarn.lock", "yarn"),
78
+ ("poetry.lock", "poetry"),
79
+ ("Pipfile.lock", "pipenv"),
80
+ ("Cargo.lock", "cargo"),
81
+ ("go.sum", "go"),
82
+ ("Gemfile.lock", "bundler"),
83
+ ("composer.lock", "composer"),
84
+ ]
85
+
86
+ MANIFEST_TO_BUILD_TOOL: List[Tuple[str, str]] = [
87
+ ("Makefile", "make"),
88
+ ("pom.xml", "maven"),
89
+ ("build.gradle", "gradle"),
90
+ ("build.gradle.kts", "gradle"),
91
+ ]
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Monorepo detection
95
+ # ---------------------------------------------------------------------------
96
+ MONOREPO_TOOLS: Dict[str, str] = {
97
+ "turbo.json": "turborepo",
98
+ "nx.json": "nx",
99
+ "lerna.json": "lerna",
100
+ }
101
+
102
+ # Maximum depth for monorepo subdirectory scanning
103
+ MONOREPO_SCAN_DEPTH = 3
104
+
105
+ # Directories to always skip during scanning
106
+ SKIP_DIRS = frozenset({
107
+ "node_modules", ".git", "__pycache__", ".tox", ".venv",
108
+ "venv", "dist", "build", ".next", ".nuxt", "target",
109
+ ".pytest_cache", ".mypy_cache", ".ruff_cache", "vendor",
110
+ ".terraform", ".terragrunt-cache",
111
+ })
112
+
113
+
114
+ class StackScanner(BaseScanner):
115
+ """Detects project stack: languages, frameworks, build tools, and identity.
116
+
117
+ Scans the project root and subdirectories (for monorepo support) to
118
+ detect languages from manifest files, frameworks from dependency
119
+ declarations, and build tools from lock files.
120
+
121
+ Owned sections: project_identity, stack
122
+ """
123
+
124
+ @property
125
+ def SCANNER_NAME(self) -> str:
126
+ return "stack"
127
+
128
+ @property
129
+ def SCANNER_VERSION(self) -> str:
130
+ return "1.1.0"
131
+
132
+ @property
133
+ def OWNED_SECTIONS(self) -> List[str]:
134
+ return ["project_identity", "stack"]
135
+
136
+ def scan(self, root: Path) -> ScanResult:
137
+ """Scan the project at root and return project_identity and stack sections.
138
+
139
+ Args:
140
+ root: Absolute path to the project root directory.
141
+
142
+ Returns:
143
+ ScanResult with project_identity and stack sections.
144
+ """
145
+ start_ms = time.monotonic() * 1000
146
+ warnings: List[str] = []
147
+
148
+ try:
149
+ languages = self._detect_languages(root, warnings)
150
+ frameworks = self._detect_frameworks(root, languages, warnings)
151
+ build_tools = self._detect_build_tools(root, warnings)
152
+ project_identity = self._detect_project_identity(root, languages, warnings)
153
+
154
+ # Multi-repo workspace override: if orchestrator detected multi-repo,
155
+ # set project type and add workspace_repos listing
156
+ if self.workspace_info and self.workspace_info.is_multi_repo:
157
+ project_identity["type"] = "multi-repo-workspace"
158
+ project_identity["workspace_repos"] = self._build_workspace_repos(
159
+ root, self.workspace_info.repo_dirs, warnings
160
+ )
161
+
162
+ sections: Dict[str, Any] = {
163
+ "project_identity": project_identity,
164
+ "stack": {
165
+ "languages": languages,
166
+ "frameworks": frameworks,
167
+ "build_tools": build_tools,
168
+ },
169
+ }
170
+ except Exception as exc:
171
+ logger.warning("Stack scanner failed: %s", exc)
172
+ sections = {
173
+ "project_identity": {
174
+ "name": root.name,
175
+ "type": "unknown",
176
+ "description": None,
177
+ "manifest_file": None,
178
+ "monorepo": {
179
+ "detected": False,
180
+ "tool": None,
181
+ "workspace_roots": [],
182
+ },
183
+ },
184
+ "stack": {
185
+ "languages": [],
186
+ "frameworks": [],
187
+ "build_tools": [],
188
+ },
189
+ }
190
+ warnings.append(f"Stack scanner error: {exc}")
191
+
192
+ elapsed_ms = (time.monotonic() * 1000) - start_ms
193
+ return self.make_result(sections, warnings=warnings, duration_ms=elapsed_ms)
194
+
195
+ # ------------------------------------------------------------------
196
+ # Language detection
197
+ # ------------------------------------------------------------------
198
+
199
+ def _detect_languages(
200
+ self, root: Path, warnings: List[str]
201
+ ) -> List[Dict[str, Any]]:
202
+ """Detect programming languages from manifest files.
203
+
204
+ Scans root directory and subdirectories for language-specific
205
+ manifest files. Handles monorepo by scanning subdirs up to
206
+ MONOREPO_SCAN_DEPTH.
207
+ """
208
+ seen_languages: Dict[str, Dict[str, Any]] = {}
209
+ first_found = True
210
+
211
+ # Scan root and subdirectories
212
+ for manifest_file, language in LANGUAGE_MANIFESTS:
213
+ for path in self._find_files(root, manifest_file):
214
+ rel_path = str(path.relative_to(root))
215
+ if language not in seen_languages:
216
+ seen_languages[language] = {
217
+ "name": language,
218
+ "manifest": rel_path,
219
+ "primary": first_found,
220
+ }
221
+ first_found = False
222
+
223
+ # C#/.NET detection via glob patterns
224
+ for ext in CSHARP_EXTENSIONS:
225
+ for path in self._find_files_by_extension(root, ext):
226
+ if "csharp" not in seen_languages:
227
+ rel_path = str(path.relative_to(root))
228
+ seen_languages["csharp"] = {
229
+ "name": "csharp",
230
+ "manifest": rel_path,
231
+ "primary": first_found,
232
+ }
233
+ first_found = False
234
+ break # Only need one match per extension type
235
+
236
+ # Check for TypeScript: tsconfig*.json at root or subdirectories,
237
+ # or .ts/.tsx file extensions in the project tree
238
+ if "javascript" in seen_languages and self._has_typescript_indicators(root):
239
+ js_entry = seen_languages.pop("javascript")
240
+ seen_languages["typescript"] = {
241
+ "name": "typescript",
242
+ "manifest": js_entry["manifest"],
243
+ "primary": js_entry["primary"],
244
+ }
245
+
246
+ return list(seen_languages.values())
247
+
248
+ def _has_typescript_indicators(self, root: Path) -> bool:
249
+ """Check for TypeScript indicators: tsconfig files or .ts/.tsx extensions.
250
+
251
+ Searches root and subdirectories (for monorepo support).
252
+ """
253
+ # Check for tsconfig*.json at root
254
+ for f in root.iterdir() if root.is_dir() else []:
255
+ if f.is_file() and f.name.startswith("tsconfig") and f.name.endswith(".json"):
256
+ return True
257
+
258
+ # Check subdirectories for tsconfig*.json (monorepo workspace roots)
259
+ for path in self._find_files(root, "tsconfig.json"):
260
+ return True
261
+
262
+ # Also check for tsconfig.*.json patterns in subdirectories
263
+ try:
264
+ for entry in self._iter_subdirs(root, depth=0):
265
+ for f in entry.iterdir():
266
+ if f.is_file() and f.name.startswith("tsconfig") and f.name.endswith(".json"):
267
+ return True
268
+ except (PermissionError, OSError):
269
+ pass
270
+
271
+ # Check for .ts or .tsx file extensions
272
+ for ext in (".ts", ".tsx"):
273
+ if self._find_files_by_extension(root, ext):
274
+ return True
275
+
276
+ return False
277
+
278
+ def _iter_subdirs(self, root: Path, depth: int) -> List[Path]:
279
+ """Iterate subdirectories respecting MONOREPO_SCAN_DEPTH and SKIP_DIRS."""
280
+ if depth >= MONOREPO_SCAN_DEPTH:
281
+ return []
282
+ results: List[Path] = []
283
+ try:
284
+ for entry in sorted(root.iterdir()):
285
+ if entry.is_dir() and entry.name not in SKIP_DIRS and not entry.name.startswith("."):
286
+ results.append(entry)
287
+ results.extend(self._iter_subdirs(entry, depth + 1))
288
+ except PermissionError:
289
+ pass
290
+ return results
291
+
292
+ # ------------------------------------------------------------------
293
+ # Framework detection
294
+ # ------------------------------------------------------------------
295
+
296
+ def _detect_frameworks(
297
+ self,
298
+ root: Path,
299
+ languages: List[Dict[str, Any]],
300
+ warnings: List[str],
301
+ ) -> List[Dict[str, Any]]:
302
+ """Detect frameworks from dependency declarations."""
303
+ frameworks: List[Dict[str, Any]] = []
304
+ lang_names = {lang["name"] for lang in languages}
305
+
306
+ # JavaScript/TypeScript frameworks from package.json
307
+ if "javascript" in lang_names or "typescript" in lang_names:
308
+ js_lang = "typescript" if "typescript" in lang_names else "javascript"
309
+ for path in self._find_files(root, "package.json"):
310
+ found = self._detect_js_frameworks(path, js_lang, warnings)
311
+ for fw in found:
312
+ if not any(f["name"] == fw["name"] for f in frameworks):
313
+ frameworks.append(fw)
314
+
315
+ # NestJS wraps Express/Fastify -- promote NestJS to primary position
316
+ # and mark the underlying framework as secondary
317
+ self._promote_meta_framework(frameworks, "nestjs", ["express", "fastify"])
318
+
319
+ # Python frameworks from pyproject.toml and requirements.txt
320
+ if "python" in lang_names:
321
+ for path in self._find_files(root, "pyproject.toml"):
322
+ found = self._detect_python_frameworks_pyproject(path, warnings)
323
+ for fw in found:
324
+ if not any(f["name"] == fw["name"] for f in frameworks):
325
+ frameworks.append(fw)
326
+
327
+ for path in self._find_files(root, "requirements.txt"):
328
+ found = self._detect_python_frameworks_requirements(path, warnings)
329
+ for fw in found:
330
+ if not any(f["name"] == fw["name"] for f in frameworks):
331
+ frameworks.append(fw)
332
+
333
+ for path in self._find_files(root, "setup.py"):
334
+ found = self._detect_python_frameworks_setup_py(path, warnings)
335
+ for fw in found:
336
+ if not any(f["name"] == fw["name"] for f in frameworks):
337
+ frameworks.append(fw)
338
+
339
+ return frameworks
340
+
341
+ def _detect_js_frameworks(
342
+ self, package_json_path: Path, language: str, warnings: List[str]
343
+ ) -> List[Dict[str, Any]]:
344
+ """Detect JavaScript/TypeScript frameworks from package.json."""
345
+ frameworks: List[Dict[str, Any]] = []
346
+ try:
347
+ data = json.loads(package_json_path.read_text(encoding="utf-8"))
348
+ except (json.JSONDecodeError, OSError) as exc:
349
+ warnings.append(f"Cannot read {package_json_path}: {exc}")
350
+ return frameworks
351
+
352
+ # Merge dependencies and devDependencies
353
+ deps: Dict[str, str] = {}
354
+ deps.update(data.get("dependencies", {}))
355
+ deps.update(data.get("devDependencies", {}))
356
+
357
+ for dep_name, framework_name, _ in JS_FRAMEWORKS:
358
+ if dep_name in deps:
359
+ version = deps[dep_name]
360
+ # Strip version prefix (^, ~, >=, etc.)
361
+ version_clean = re.sub(r"^[\^~>=<]*", "", version) if version else None
362
+ frameworks.append({
363
+ "name": framework_name,
364
+ "language": language,
365
+ "version": version_clean or None,
366
+ })
367
+
368
+ return frameworks
369
+
370
+ def _promote_meta_framework(
371
+ self,
372
+ frameworks: List[Dict[str, Any]],
373
+ meta_name: str,
374
+ underlying_names: List[str],
375
+ ) -> None:
376
+ """Promote a meta-framework (e.g. NestJS) above its underlying frameworks.
377
+
378
+ When a meta-framework like NestJS is detected alongside its underlying
379
+ framework (Express), move it to the first position and mark the
380
+ underlying framework as secondary (role='underlying').
381
+ """
382
+ meta_idx = None
383
+ for i, fw in enumerate(frameworks):
384
+ if fw["name"] == meta_name:
385
+ meta_idx = i
386
+ break
387
+
388
+ if meta_idx is None:
389
+ return
390
+
391
+ # Move meta-framework to front
392
+ if meta_idx > 0:
393
+ meta_fw = frameworks.pop(meta_idx)
394
+ frameworks.insert(0, meta_fw)
395
+
396
+ # Mark underlying frameworks
397
+ for fw in frameworks:
398
+ if fw["name"] in underlying_names:
399
+ fw["role"] = "underlying"
400
+
401
+ def _detect_python_frameworks_pyproject(
402
+ self, pyproject_path: Path, warnings: List[str]
403
+ ) -> List[Dict[str, Any]]:
404
+ """Detect Python frameworks from pyproject.toml dependencies."""
405
+ frameworks: List[Dict[str, Any]] = []
406
+ try:
407
+ content = pyproject_path.read_text(encoding="utf-8")
408
+ except OSError as exc:
409
+ warnings.append(f"Cannot read {pyproject_path}: {exc}")
410
+ return frameworks
411
+
412
+ # Parse dependencies from [project.dependencies] and [tool.poetry.dependencies]
413
+ # Using simple regex since we avoid external TOML parser dependency
414
+ deps_text = self._extract_toml_deps(content)
415
+
416
+ for dep_name, framework_name, lang in PYTHON_FRAMEWORKS:
417
+ # Match dep_name with optional version specifier
418
+ pattern = rf'(?:^|\n)\s*["\']?{re.escape(dep_name)}(?:\[.*?\])?["\']?\s*(?:[>=<~!]|$)'
419
+ if re.search(pattern, deps_text, re.IGNORECASE):
420
+ version = self._extract_python_version(deps_text, dep_name)
421
+ frameworks.append({
422
+ "name": framework_name,
423
+ "language": lang,
424
+ "version": version,
425
+ })
426
+
427
+ return frameworks
428
+
429
+ def _detect_python_frameworks_requirements(
430
+ self, requirements_path: Path, warnings: List[str]
431
+ ) -> List[Dict[str, Any]]:
432
+ """Detect Python frameworks from requirements.txt."""
433
+ frameworks: List[Dict[str, Any]] = []
434
+ try:
435
+ content = requirements_path.read_text(encoding="utf-8")
436
+ except OSError as exc:
437
+ warnings.append(f"Cannot read {requirements_path}: {exc}")
438
+ return frameworks
439
+
440
+ for dep_name, framework_name, lang in PYTHON_FRAMEWORKS:
441
+ pattern = rf"(?:^|\n)\s*{re.escape(dep_name)}(?:\[.*?\])?\s*(?:([>=<~!]+)\s*([\d.]+))?"
442
+ match = re.search(pattern, content, re.IGNORECASE)
443
+ if match:
444
+ version = match.group(2) if match.group(2) else None
445
+ frameworks.append({
446
+ "name": framework_name,
447
+ "language": lang,
448
+ "version": version,
449
+ })
450
+
451
+ return frameworks
452
+
453
+ def _detect_python_frameworks_setup_py(
454
+ self, setup_py_path: Path, warnings: List[str]
455
+ ) -> List[Dict[str, Any]]:
456
+ """Detect Python frameworks from setup.py install_requires."""
457
+ frameworks: List[Dict[str, Any]] = []
458
+ try:
459
+ content = setup_py_path.read_text(encoding="utf-8")
460
+ except OSError as exc:
461
+ warnings.append(f"Cannot read {setup_py_path}: {exc}")
462
+ return frameworks
463
+
464
+ for dep_name, framework_name, lang in PYTHON_FRAMEWORKS:
465
+ pattern = rf'["\']({re.escape(dep_name)}(?:\[.*?\])?(?:\s*[>=<~!]+\s*[\d.]+)?)["\']'
466
+ match = re.search(pattern, content, re.IGNORECASE)
467
+ if match:
468
+ version = self._extract_inline_version(match.group(1))
469
+ frameworks.append({
470
+ "name": framework_name,
471
+ "language": lang,
472
+ "version": version,
473
+ })
474
+
475
+ return frameworks
476
+
477
+ # ------------------------------------------------------------------
478
+ # Build tool detection
479
+ # ------------------------------------------------------------------
480
+
481
+ def _detect_build_tools(
482
+ self, root: Path, warnings: List[str]
483
+ ) -> List[Dict[str, Any]]:
484
+ """Detect build tools from lock files and manifest files."""
485
+ tools: List[Dict[str, Any]] = []
486
+ seen_tools: set = set()
487
+
488
+ # Lock file detection
489
+ for lock_file, tool_name in LOCK_FILE_TO_TOOL:
490
+ for path in self._find_files(root, lock_file):
491
+ if tool_name not in seen_tools:
492
+ rel_path = str(path.relative_to(root))
493
+ tools.append({
494
+ "name": tool_name,
495
+ "detected_by": "lock_file",
496
+ "lock_file": rel_path,
497
+ })
498
+ seen_tools.add(tool_name)
499
+
500
+ # Manifest-based build tool detection
501
+ for manifest_file, tool_name in MANIFEST_TO_BUILD_TOOL:
502
+ for path in self._find_files(root, manifest_file):
503
+ if tool_name not in seen_tools:
504
+ tools.append({
505
+ "name": tool_name,
506
+ "detected_by": "manifest",
507
+ "lock_file": None,
508
+ })
509
+ seen_tools.add(tool_name)
510
+
511
+ # Detect pip from requirements.txt (no lock file equivalent)
512
+ for path in self._find_files(root, "requirements.txt"):
513
+ if "pip" not in seen_tools:
514
+ tools.append({
515
+ "name": "pip",
516
+ "detected_by": "manifest",
517
+ "lock_file": None,
518
+ })
519
+ seen_tools.add("pip")
520
+
521
+ # Detect poetry from pyproject.toml [tool.poetry] section
522
+ if "poetry" not in seen_tools:
523
+ for path in self._find_files(root, "pyproject.toml"):
524
+ try:
525
+ content = path.read_text(encoding="utf-8")
526
+ if "[tool.poetry]" in content:
527
+ tools.append({
528
+ "name": "poetry",
529
+ "detected_by": "manifest",
530
+ "lock_file": None,
531
+ })
532
+ seen_tools.add("poetry")
533
+ break
534
+ except OSError:
535
+ pass
536
+
537
+ # Detect monorepo build orchestrators (turbo.json, nx.json, lerna.json)
538
+ # at root and in subdirectories
539
+ for config_file, tool_name in MONOREPO_TOOLS.items():
540
+ if tool_name not in seen_tools:
541
+ for path in self._find_files(root, config_file):
542
+ rel_path = str(path.relative_to(root))
543
+ tools.append({
544
+ "name": tool_name,
545
+ "detected_by": "config_file",
546
+ "lock_file": None,
547
+ "config_file": rel_path,
548
+ })
549
+ seen_tools.add(tool_name)
550
+ break # One match per tool is enough
551
+
552
+ return tools
553
+
554
+ # ------------------------------------------------------------------
555
+ # Project identity detection
556
+ # ------------------------------------------------------------------
557
+
558
+ def _detect_project_identity(
559
+ self,
560
+ root: Path,
561
+ languages: List[Dict[str, Any]],
562
+ warnings: List[str],
563
+ ) -> Dict[str, Any]:
564
+ """Detect project identity from manifest files.
565
+
566
+ Reads name, description, and type from the primary manifest file
567
+ (package.json or pyproject.toml). Falls back to directory name.
568
+ """
569
+ name: Optional[str] = None
570
+ description: Optional[str] = None
571
+ manifest_file: Optional[str] = None
572
+ project_type = "unknown"
573
+
574
+ # Try package.json first
575
+ pkg_json = root / "package.json"
576
+ if pkg_json.is_file():
577
+ try:
578
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
579
+ name = data.get("name")
580
+ description = data.get("description")
581
+ manifest_file = "package.json"
582
+
583
+ # Detect monorepo indicators in package.json
584
+ if data.get("workspaces"):
585
+ project_type = "monorepo"
586
+ elif data.get("private") is True and not data.get("main"):
587
+ project_type = "monorepo"
588
+ else:
589
+ project_type = "application"
590
+ except (json.JSONDecodeError, OSError) as exc:
591
+ warnings.append(f"Cannot read package.json: {exc}")
592
+
593
+ # Try pyproject.toml
594
+ pyproject = root / "pyproject.toml"
595
+ if pyproject.is_file():
596
+ try:
597
+ content = pyproject.read_text(encoding="utf-8")
598
+ if name is None:
599
+ name = self._extract_toml_value(content, "name")
600
+ if description is None:
601
+ description = self._extract_toml_value(content, "description")
602
+ if manifest_file is None:
603
+ manifest_file = "pyproject.toml"
604
+ if project_type == "unknown":
605
+ project_type = "library" if "[build-system]" in content else "application"
606
+ except OSError as exc:
607
+ warnings.append(f"Cannot read pyproject.toml: {exc}")
608
+
609
+ # Try go.mod
610
+ go_mod = root / "go.mod"
611
+ if go_mod.is_file() and name is None:
612
+ try:
613
+ content = go_mod.read_text(encoding="utf-8")
614
+ match = re.search(r"^module\s+(\S+)", content, re.MULTILINE)
615
+ if match:
616
+ name = match.group(1).split("/")[-1]
617
+ manifest_file = "go.mod"
618
+ if project_type == "unknown":
619
+ project_type = "application"
620
+ except OSError as exc:
621
+ warnings.append(f"Cannot read go.mod: {exc}")
622
+
623
+ # Try Cargo.toml
624
+ cargo_toml = root / "Cargo.toml"
625
+ if cargo_toml.is_file() and name is None:
626
+ try:
627
+ content = cargo_toml.read_text(encoding="utf-8")
628
+ name = self._extract_toml_value(content, "name")
629
+ if description is None:
630
+ description = self._extract_toml_value(content, "description")
631
+ manifest_file = "Cargo.toml"
632
+ if project_type == "unknown":
633
+ # Cargo workspace = monorepo
634
+ if "[workspace]" in content:
635
+ project_type = "monorepo"
636
+ else:
637
+ project_type = "application"
638
+ except OSError as exc:
639
+ warnings.append(f"Cannot read Cargo.toml: {exc}")
640
+
641
+ # Try composer.json
642
+ composer = root / "composer.json"
643
+ if composer.is_file() and name is None:
644
+ try:
645
+ data = json.loads(composer.read_text(encoding="utf-8"))
646
+ name = data.get("name")
647
+ description = data.get("description")
648
+ manifest_file = "composer.json"
649
+ if project_type == "unknown":
650
+ project_type = "application"
651
+ except (json.JSONDecodeError, OSError) as exc:
652
+ warnings.append(f"Cannot read composer.json: {exc}")
653
+
654
+ # Before falling back to directory name, check monorepo subdirectory
655
+ # package.json files for a project name
656
+ if name is None:
657
+ name = self._derive_name_from_subdirs(root, warnings)
658
+
659
+ # Fallback to directory name
660
+ if name is None:
661
+ name = root.name
662
+
663
+ # Monorepo detection
664
+ monorepo_info = self._detect_monorepo(root, warnings)
665
+ if monorepo_info["detected"]:
666
+ project_type = "monorepo"
667
+
668
+ # Multi-language implies potential monorepo
669
+ if len(languages) > 1 and not monorepo_info["detected"]:
670
+ # Check if languages come from different subdirectories
671
+ subdirs = set()
672
+ for lang in languages:
673
+ manifest_dir = str(Path(lang["manifest"]).parent)
674
+ if manifest_dir != ".":
675
+ subdirs.add(manifest_dir)
676
+ if len(subdirs) > 1:
677
+ project_type = "monorepo"
678
+ monorepo_info["detected"] = True
679
+
680
+ return {
681
+ "name": name,
682
+ "type": project_type,
683
+ "description": description,
684
+ "manifest_file": manifest_file,
685
+ "monorepo": monorepo_info,
686
+ }
687
+
688
+ def _derive_name_from_subdirs(
689
+ self, root: Path, warnings: List[str]
690
+ ) -> Optional[str]:
691
+ """Derive project name from package.json in immediate subdirectories.
692
+
693
+ Checks subdirectories that look like monorepo roots (contain
694
+ package.json with a name field) and returns the first name found.
695
+ """
696
+ try:
697
+ for entry in sorted(root.iterdir()):
698
+ if not entry.is_dir():
699
+ continue
700
+ if entry.name in SKIP_DIRS or entry.name.startswith("."):
701
+ continue
702
+ sub_pkg = entry / "package.json"
703
+ if sub_pkg.is_file():
704
+ try:
705
+ data = json.loads(sub_pkg.read_text(encoding="utf-8"))
706
+ pkg_name = data.get("name")
707
+ if pkg_name and isinstance(pkg_name, str):
708
+ return pkg_name
709
+ except (json.JSONDecodeError, OSError):
710
+ continue
711
+ except PermissionError:
712
+ pass
713
+ return None
714
+
715
+ # ------------------------------------------------------------------
716
+ # Monorepo detection
717
+ # ------------------------------------------------------------------
718
+
719
+ def _detect_monorepo(
720
+ self, root: Path, warnings: List[str]
721
+ ) -> Dict[str, Any]:
722
+ """Detect monorepo tool and workspace roots."""
723
+ result: Dict[str, Any] = {
724
+ "detected": False,
725
+ "tool": None,
726
+ "workspace_roots": [],
727
+ }
728
+
729
+ # Check for monorepo tool config files at root level
730
+ for config_file, tool_name in MONOREPO_TOOLS.items():
731
+ if (root / config_file).is_file():
732
+ result["detected"] = True
733
+ result["tool"] = tool_name
734
+ break
735
+
736
+ # Check pnpm workspaces at root level
737
+ pnpm_workspace = root / "pnpm-workspace.yaml"
738
+ if pnpm_workspace.is_file():
739
+ result["detected"] = True
740
+ if result["tool"] is None:
741
+ result["tool"] = "pnpm-workspaces"
742
+
743
+ # Check npm/yarn workspaces in package.json at root level
744
+ pkg_json = root / "package.json"
745
+ if pkg_json.is_file():
746
+ try:
747
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
748
+ workspaces = data.get("workspaces")
749
+ if workspaces:
750
+ result["detected"] = True
751
+ if result["tool"] is None:
752
+ result["tool"] = "npm-workspaces"
753
+ # Extract workspace patterns
754
+ if isinstance(workspaces, list):
755
+ workspace_patterns = workspaces
756
+ elif isinstance(workspaces, dict):
757
+ workspace_patterns = workspaces.get("packages", [])
758
+ else:
759
+ workspace_patterns = []
760
+ # Resolve workspace roots from patterns
761
+ for pattern in workspace_patterns:
762
+ result["workspace_roots"].append(str(pattern))
763
+ except (json.JSONDecodeError, OSError):
764
+ pass
765
+
766
+ # If not yet detected, scan immediate subdirectories for workspace
767
+ # config files (handles projects where monorepo root is a subdirectory)
768
+ if not result["detected"]:
769
+ result = self._detect_monorepo_in_subdirs(root, result, warnings)
770
+
771
+ return result
772
+
773
+ def _detect_monorepo_in_subdirs(
774
+ self,
775
+ root: Path,
776
+ result: Dict[str, Any],
777
+ warnings: List[str],
778
+ ) -> Dict[str, Any]:
779
+ """Scan immediate subdirectories for monorepo workspace config files.
780
+
781
+ Detects monorepo when a subdirectory contains workspace config files
782
+ like pnpm-workspace.yaml, pnpm-lock.yaml, lerna.json, etc.
783
+ """
784
+ # Workspace config files that indicate a monorepo root
785
+ workspace_markers = [
786
+ ("pnpm-workspace.yaml", "pnpm-workspaces"),
787
+ ("pnpm-lock.yaml", "pnpm-workspaces"),
788
+ ("lerna.json", "lerna"),
789
+ ("turbo.json", "turborepo"),
790
+ ("nx.json", "nx"),
791
+ ]
792
+
793
+ try:
794
+ for entry in sorted(root.iterdir()):
795
+ if not entry.is_dir():
796
+ continue
797
+ if entry.name in SKIP_DIRS or entry.name.startswith("."):
798
+ continue
799
+
800
+ for marker_file, tool_name in workspace_markers:
801
+ if (entry / marker_file).is_file():
802
+ result["detected"] = True
803
+ if result["tool"] is None:
804
+ result["tool"] = tool_name
805
+ subdir_rel = str(entry.relative_to(root))
806
+ if subdir_rel not in result["workspace_roots"]:
807
+ result["workspace_roots"].append(subdir_rel)
808
+ break
809
+
810
+ # Also check for package.json with workspaces in subdirs
811
+ sub_pkg = entry / "package.json"
812
+ if sub_pkg.is_file() and not result["detected"]:
813
+ try:
814
+ data = json.loads(sub_pkg.read_text(encoding="utf-8"))
815
+ if data.get("workspaces"):
816
+ result["detected"] = True
817
+ if result["tool"] is None:
818
+ result["tool"] = "npm-workspaces"
819
+ subdir_rel = str(entry.relative_to(root))
820
+ if subdir_rel not in result["workspace_roots"]:
821
+ result["workspace_roots"].append(subdir_rel)
822
+ except (json.JSONDecodeError, OSError):
823
+ pass
824
+
825
+ if result["detected"]:
826
+ break
827
+ except PermissionError:
828
+ pass
829
+
830
+ return result
831
+
832
+ # ------------------------------------------------------------------
833
+ # Multi-repo workspace helpers
834
+ # ------------------------------------------------------------------
835
+
836
+ def _build_workspace_repos(
837
+ self,
838
+ root: Path,
839
+ repo_dirs: List[Path],
840
+ warnings: List[str],
841
+ ) -> List[Dict[str, Any]]:
842
+ """Build workspace_repos list for multi-repo workspaces.
843
+
844
+ For each subdirectory with .git, extracts name, relative path,
845
+ and primary language from the most prominent manifest file.
846
+
847
+ Args:
848
+ root: Workspace root path.
849
+ repo_dirs: List of subdirectory Paths that contain .git.
850
+ warnings: Warning accumulator.
851
+
852
+ Returns:
853
+ List of repo descriptor dicts.
854
+ """
855
+ repos: List[Dict[str, Any]] = []
856
+
857
+ for repo_dir in repo_dirs:
858
+ repo_entry: Dict[str, Any] = {
859
+ "name": repo_dir.name,
860
+ "path": str(repo_dir.relative_to(root)),
861
+ }
862
+
863
+ # Detect primary language from manifest files
864
+ primary_language = self._detect_primary_language(repo_dir)
865
+ if primary_language:
866
+ repo_entry["primary_language"] = primary_language
867
+
868
+ # Detect role from directory naming conventions
869
+ repo_entry["role"] = self._infer_repo_role(repo_dir, primary_language)
870
+
871
+ repos.append(repo_entry)
872
+
873
+ return repos
874
+
875
+ @staticmethod
876
+ def _detect_primary_language(repo_dir: Path) -> Optional[str]:
877
+ """Detect the primary language of a repo from its manifest files."""
878
+ manifest_checks = [
879
+ ("package.json", "javascript"),
880
+ ("pyproject.toml", "python"),
881
+ ("setup.py", "python"),
882
+ ("requirements.txt", "python"),
883
+ ("go.mod", "go"),
884
+ ("Cargo.toml", "rust"),
885
+ ("pom.xml", "java"),
886
+ ("build.gradle", "java"),
887
+ ("composer.json", "php"),
888
+ ("Gemfile", "ruby"),
889
+ ]
890
+
891
+ for filename, language in manifest_checks:
892
+ if (repo_dir / filename).is_file():
893
+ # Check for TypeScript indicator
894
+ if language == "javascript":
895
+ for f in repo_dir.iterdir():
896
+ if f.is_file() and f.name.startswith("tsconfig") and f.name.endswith(".json"):
897
+ return "typescript"
898
+ return language
899
+
900
+ return None
901
+
902
+ @staticmethod
903
+ def _infer_repo_role(repo_dir: Path, primary_language: Optional[str]) -> str:
904
+ """Infer the role of a repo from its name and contents.
905
+
906
+ Returns one of: gitops, iac, platform, agent, library, application.
907
+ """
908
+ name_lower = repo_dir.name.lower()
909
+
910
+ # GitOps indicators
911
+ if any(kw in name_lower for kw in ("gitops", "flux", "argocd", "deploy")):
912
+ return "gitops"
913
+
914
+ # IaC indicators
915
+ if any(kw in name_lower for kw in ("iac", "infra", "terraform")):
916
+ return "iac"
917
+ # Also check for .tf files at root
918
+ try:
919
+ if any(f.suffix == ".tf" for f in repo_dir.iterdir() if f.is_file()):
920
+ return "iac"
921
+ except OSError:
922
+ pass
923
+
924
+ # Platform indicators
925
+ if any(kw in name_lower for kw in ("platform", "core", "shared", "common")):
926
+ return "platform"
927
+
928
+ # Agent indicators
929
+ if any(kw in name_lower for kw in ("agent", "bot", "assistant")):
930
+ return "agent"
931
+
932
+ return "application"
933
+
934
+ # ------------------------------------------------------------------
935
+ # File search helpers
936
+ # ------------------------------------------------------------------
937
+
938
+ def _find_files(self, root: Path, filename: str) -> List[Path]:
939
+ """Find files matching filename in root and subdirectories.
940
+
941
+ Respects MONOREPO_SCAN_DEPTH and SKIP_DIRS. Returns root-level
942
+ matches first, then subdirectory matches.
943
+ """
944
+ results: List[Path] = []
945
+
946
+ # Check root first
947
+ root_file = root / filename
948
+ if root_file.is_file():
949
+ results.append(root_file)
950
+
951
+ # Scan subdirectories up to MONOREPO_SCAN_DEPTH
952
+ self._find_files_recursive(root, filename, results, 0)
953
+
954
+ return results
955
+
956
+ def _find_files_recursive(
957
+ self, directory: Path, filename: str, results: List[Path], depth: int
958
+ ) -> None:
959
+ """Recursively search for files, respecting depth and skip dirs."""
960
+ if depth >= MONOREPO_SCAN_DEPTH:
961
+ return
962
+
963
+ try:
964
+ for entry in sorted(directory.iterdir()):
965
+ if not entry.is_dir():
966
+ continue
967
+ if entry.name in SKIP_DIRS or entry.name.startswith("."):
968
+ continue
969
+
970
+ target = entry / filename
971
+ if target.is_file():
972
+ results.append(target)
973
+
974
+ self._find_files_recursive(entry, filename, results, depth + 1)
975
+ except PermissionError:
976
+ pass
977
+
978
+ def _find_files_by_extension(self, root: Path, ext: str) -> List[Path]:
979
+ """Find files with a given extension in root and subdirectories."""
980
+ results: List[Path] = []
981
+
982
+ # Check root
983
+ try:
984
+ for entry in root.iterdir():
985
+ if entry.is_file() and entry.name.endswith(ext):
986
+ results.append(entry)
987
+ return results # One match is enough
988
+ except PermissionError:
989
+ pass
990
+
991
+ # Check subdirectories
992
+ self._find_ext_recursive(root, ext, results, 0)
993
+ return results
994
+
995
+ def _find_ext_recursive(
996
+ self, directory: Path, ext: str, results: List[Path], depth: int
997
+ ) -> None:
998
+ """Recursively search for files by extension."""
999
+ if depth >= MONOREPO_SCAN_DEPTH or results:
1000
+ return
1001
+
1002
+ try:
1003
+ for entry in sorted(directory.iterdir()):
1004
+ if entry.is_dir() and entry.name not in SKIP_DIRS and not entry.name.startswith("."):
1005
+ # Check for matching files in this directory
1006
+ try:
1007
+ for child in entry.iterdir():
1008
+ if child.is_file() and child.name.endswith(ext):
1009
+ results.append(child)
1010
+ return
1011
+ except PermissionError:
1012
+ pass
1013
+ self._find_ext_recursive(entry, ext, results, depth + 1)
1014
+ except PermissionError:
1015
+ pass
1016
+
1017
+ # ------------------------------------------------------------------
1018
+ # TOML parsing helpers (no external dependency)
1019
+ # ------------------------------------------------------------------
1020
+
1021
+ def _extract_toml_value(self, content: str, key: str) -> Optional[str]:
1022
+ """Extract a simple string value from TOML content.
1023
+
1024
+ Handles: key = "value" patterns. Does NOT handle nested tables
1025
+ or multiline values -- for those, we use section-specific parsers.
1026
+ """
1027
+ pattern = rf'^\s*{re.escape(key)}\s*=\s*["\']([^"\']*)["\']'
1028
+ match = re.search(pattern, content, re.MULTILINE)
1029
+ return match.group(1) if match else None
1030
+
1031
+ def _extract_toml_deps(self, content: str) -> str:
1032
+ """Extract dependency-related sections from pyproject.toml content.
1033
+
1034
+ Returns a combined string of all dependency declarations for
1035
+ framework matching.
1036
+ """
1037
+ sections: List[str] = []
1038
+
1039
+ # [project.dependencies]
1040
+ dep_match = re.search(
1041
+ r"\[project\]\s*\n(.*?)(?=\n\[|\Z)",
1042
+ content,
1043
+ re.DOTALL,
1044
+ )
1045
+ if dep_match:
1046
+ sections.append(dep_match.group(1))
1047
+
1048
+ # dependencies = [...] array
1049
+ dep_array = re.search(
1050
+ r"dependencies\s*=\s*\[(.*?)\]",
1051
+ content,
1052
+ re.DOTALL,
1053
+ )
1054
+ if dep_array:
1055
+ sections.append(dep_array.group(1))
1056
+
1057
+ # [tool.poetry.dependencies]
1058
+ poetry_deps = re.search(
1059
+ r"\[tool\.poetry\.dependencies\]\s*\n(.*?)(?=\n\[|\Z)",
1060
+ content,
1061
+ re.DOTALL,
1062
+ )
1063
+ if poetry_deps:
1064
+ sections.append(poetry_deps.group(1))
1065
+
1066
+ # optional-dependencies sections
1067
+ opt_deps = re.findall(
1068
+ r"\[(?:project\.)?optional-dependencies(?:\.\w+)?\]\s*\n(.*?)(?=\n\[|\Z)",
1069
+ content,
1070
+ re.DOTALL,
1071
+ )
1072
+ sections.extend(opt_deps)
1073
+
1074
+ return "\n".join(sections)
1075
+
1076
+ def _extract_python_version(self, deps_text: str, dep_name: str) -> Optional[str]:
1077
+ """Extract version specifier for a Python dependency."""
1078
+ pattern = rf'["\']?{re.escape(dep_name)}(?:\[.*?\])?\s*[>=<~!]+\s*([\d.]+)'
1079
+ match = re.search(pattern, deps_text, re.IGNORECASE)
1080
+ return match.group(1) if match else None
1081
+
1082
+ def _extract_inline_version(self, dep_string: str) -> Optional[str]:
1083
+ """Extract version from an inline dependency string like 'fastapi>=0.100.0'."""
1084
+ match = re.search(r"[>=<~!]+\s*([\d.]+)", dep_string)
1085
+ return match.group(1) if match else None