claude-dev-env 2.9.0 → 2.11.0

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 (318) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/advisor/CLAUDE.md +3 -2
  3. package/_shared/advisor/advisor-protocol.md +74 -108
  4. package/_shared/advisor/reference/advisor-block.md +37 -0
  5. package/_shared/advisor/reference/cli-chain.md +45 -0
  6. package/_shared/advisor/reference/consult-format.md +41 -0
  7. package/_shared/advisor/reference/lifecycle.md +21 -0
  8. package/_shared/advisor/reference/sol-rung.md +31 -0
  9. package/_shared/advisor/reference/spawn-walk-log.md +31 -0
  10. package/_shared/advisor/reference/third-party-bind.md +30 -0
  11. package/_shared/advisor/reference/warm-up.md +33 -0
  12. package/_shared/advisor/scripts/codex_sol_advisor.py +449 -0
  13. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +21 -0
  14. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +19 -17
  15. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +28 -0
  16. package/_shared/advisor/scripts/model_tier_run_validator.py +32 -9
  17. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +474 -0
  18. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +79 -0
  19. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +39 -17
  20. package/_shared/advisor/scripts/tier_model_ids.py +24 -0
  21. package/_shared/pr-loop/CLAUDE.md +1 -1
  22. package/_shared/pr-loop/audit-contract.md +17 -6
  23. package/_shared/pr-loop/audit-reply-template.md +4 -4
  24. package/_shared/pr-loop/code-rules-gate.md +3 -5
  25. package/_shared/pr-loop/fix-protocol.md +2 -3
  26. package/_shared/pr-loop/gh-payloads.md +1 -1
  27. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  28. package/_shared/pr-loop/scripts/README.md +1 -1
  29. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
  32. package/_shared/pr-loop/scripts/preflight.py +9 -4
  33. package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
  34. package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
  35. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
  36. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
  37. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  38. package/_shared/pr-loop/state-schema.md +5 -14
  39. package/agents/CLAUDE.md +2 -2
  40. package/agents/clean-coder.md +58 -548
  41. package/agents/code-quality-agent.md +10 -2
  42. package/agents/code-verifier.md +1 -1
  43. package/agents/test_agent_frontmatter.py +32 -40
  44. package/audit-rubrics/CLAUDE.md +2 -1
  45. package/audit-rubrics/audit-categories.json +704 -0
  46. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  47. package/bin/CLAUDE.md +16 -5
  48. package/bin/ever-shipped-skills.mjs +2 -0
  49. package/bin/install-plan.mjs +402 -0
  50. package/bin/install-transaction.mjs +455 -0
  51. package/bin/install.mjs +593 -147
  52. package/bin/install.plan.test.mjs +194 -0
  53. package/bin/install.profile-root.test.mjs +154 -0
  54. package/bin/install.profiles.test.mjs +253 -0
  55. package/bin/install.settings-defaults.test.mjs +200 -0
  56. package/bin/install.transaction.test.mjs +400 -0
  57. package/bin/install.uninstall-transaction.test.mjs +418 -0
  58. package/bin/merge_managed_permissions.mjs +130 -0
  59. package/bin/resolve-install-root.mjs +181 -0
  60. package/bin/select-install-targets.mjs +401 -0
  61. package/commands/CLAUDE.md +0 -2
  62. package/docs/references/CLAUDE.md +3 -1
  63. package/docs/references/advisor-tool.md +25 -7
  64. package/docs/references/prose-style-enforcement.md +25 -0
  65. package/docs/references/team-advisor-skill.md +3 -3
  66. package/docs/references/weak-executor-advisor.md +91 -0
  67. package/hooks/blocking/CLAUDE.md +6 -6
  68. package/hooks/blocking/_path_setup.py +9 -5
  69. package/hooks/blocking/code_rules_docstrings.py +124 -30
  70. package/hooks/blocking/code_rules_enforcer.py +161 -16
  71. package/hooks/blocking/code_rules_shared.py +40 -23
  72. package/hooks/blocking/config/CLAUDE.md +3 -5
  73. package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
  74. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
  75. package/hooks/blocking/eli11_reply_enforcer.py +70 -113
  76. package/hooks/blocking/hedging_language_blocker.py +103 -20
  77. package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
  78. package/hooks/blocking/intent_only_ending_blocker.py +6 -0
  79. package/hooks/blocking/plain_language_blocker.py +139 -20
  80. package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
  81. package/hooks/blocking/state_description_blocker.py +7 -1
  82. package/hooks/blocking/tdd_enforcer.py +8 -0
  83. package/hooks/blocking/test__path_setup.py +28 -0
  84. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
  85. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
  86. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
  87. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
  88. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
  89. package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
  90. package/hooks/blocking/test_fable_spawn_gate.py +18 -11
  91. package/hooks/blocking/test_hedging_language_blocker.py +120 -1
  92. package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
  93. package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
  94. package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
  95. package/hooks/blocking/test_plain_language_blocker.py +129 -19
  96. package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
  97. package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
  98. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
  99. package/hooks/blocking/test_state_description_blocker.py +45 -2
  100. package/hooks/blocking/test_stop_dispatcher.py +11 -7
  101. package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
  102. package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
  103. package/hooks/hooks.json +15 -0
  104. package/hooks/hooks_constants/CLAUDE.md +14 -3
  105. package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
  106. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
  107. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
  108. package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
  109. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
  110. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
  111. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
  112. package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
  113. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
  114. package/hooks/hooks_constants/project_paths_reader.py +31 -4
  115. package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
  116. package/hooks/hooks_constants/pytest_invocation.py +354 -0
  117. package/hooks/hooks_constants/session_start_injector.py +163 -0
  118. package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
  119. package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
  120. package/hooks/hooks_constants/shell_command_segments.py +5 -0
  121. package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
  122. package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
  123. package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
  124. package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
  125. package/hooks/hooks_constants/test_session_start_injector.py +168 -0
  126. package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
  127. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
  128. package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
  129. package/hooks/observability/CLAUDE.md +2 -0
  130. package/hooks/observability/prose_matcher_advisory.py +237 -0
  131. package/hooks/observability/test_prose_matcher_advisory.py +143 -0
  132. package/hooks/session/CLAUDE.md +9 -1
  133. package/hooks/session/_path_setup.py +13 -0
  134. package/hooks/session/issue_tracker_session_starter.py +135 -0
  135. package/hooks/session/orchestrator_auto_starter.py +100 -0
  136. package/hooks/session/test__path_setup.py +28 -0
  137. package/hooks/session/test_issue_tracker_session_starter.py +104 -0
  138. package/hooks/session/test_orchestrator_auto_starter.py +99 -0
  139. package/hooks/session/test_working_style_prompt.py +47 -0
  140. package/hooks/session/untracked_repo_detector.py +1 -24
  141. package/hooks/session/working_style_prompt.py +36 -0
  142. package/hooks/validators/_path_setup.py +19 -0
  143. package/hooks/validators/run_all_validators.py +8 -13
  144. package/installable-surfaces.manifest.json +21 -0
  145. package/output-styles/CLAUDE.md +1 -3
  146. package/package.json +4 -2
  147. package/rules/CLAUDE.md +1 -0
  148. package/rules/durable-post-artifacts.md +2 -2
  149. package/rules/eli11-replies.md +6 -1
  150. package/rules/hedging-claims.md +4 -2
  151. package/rules/long-horizon-autonomy.md +3 -1
  152. package/rules/opus5-communication-contract.md +45 -0
  153. package/rules/plain-language.md +2 -2
  154. package/rules/research-mode.md +1 -1
  155. package/scripts/CLAUDE.md +11 -0
  156. package/scripts/Sync-RepoMain.ps1 +215 -0
  157. package/scripts/active_capability_references.py +218 -0
  158. package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
  159. package/scripts/claude_chain_runner.py +394 -6
  160. package/scripts/claude_chain_usage.py +1 -1
  161. package/scripts/codex_compat_materializer.py +105 -85
  162. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  163. package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
  164. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
  165. package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
  166. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
  167. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
  168. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
  169. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
  170. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
  171. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
  172. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
  173. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
  174. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
  175. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
  176. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
  177. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
  178. package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
  179. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
  180. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
  181. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
  182. package/scripts/spawn_grok_batch.py +3 -0
  183. package/scripts/test_active_capability_references.py +108 -0
  184. package/scripts/test_claude_chain_runner.py +414 -82
  185. package/scripts/test_claude_chain_usage.py +12 -12
  186. package/scripts/test_resolve_worker_spawn.py +2 -2
  187. package/scripts/test_verify_installable_package.py +208 -0
  188. package/scripts/tests/test_codex_compat_materializer.py +33 -0
  189. package/scripts/verify_installable_package.py +612 -0
  190. package/settings.json +10 -0
  191. package/skills/CLAUDE.md +2 -0
  192. package/skills/_shared/advisor/CLAUDE.md +1 -1
  193. package/skills/_shared/advisor/scripts/README.md +2 -0
  194. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  195. package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
  196. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  197. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
  198. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
  199. package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
  200. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
  201. package/skills/autoconverge/reference/convergence.md +2 -1
  202. package/skills/autoconverge/reference/stop-conditions.md +5 -3
  203. package/skills/beat-sheet/SKILL.md +54 -0
  204. package/skills/beat-sheet/reference/visual-beats.md +29 -0
  205. package/skills/bugteam/CONSTRAINTS.md +4 -4
  206. package/skills/bugteam/EXAMPLES.md +1 -1
  207. package/skills/bugteam/reference/README.md +1 -1
  208. package/skills/e-code-review/SKILL.md +26 -5
  209. package/skills/e-code-review/reference/effort-evaluation.md +35 -0
  210. package/skills/e-code-review/reference/medium.md +15 -4
  211. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
  212. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
  213. package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
  214. package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
  215. package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
  216. package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
  217. package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
  218. package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
  219. package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
  220. package/skills/e-code-review/scripts/grok_code_review.py +16 -7
  221. package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
  222. package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
  223. package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
  224. package/skills/grokify/SKILL.md +1 -1
  225. package/skills/grokify/templates/handoff-template.md +2 -2
  226. package/skills/orchestrator/SKILL.md +5 -4
  227. package/skills/plan-to-pr/scripts/create_packet.py +4 -4
  228. package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
  229. package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
  230. package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
  231. package/skills/plan-to-pr/scripts/validate_run.py +4 -1
  232. package/skills/pr-converge/scripts/check_convergence.py +21 -19
  233. package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
  234. package/skills/pr-converge/scripts/conftest.py +35 -0
  235. package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
  236. package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
  237. package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
  238. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  239. package/skills/rebase/SKILL.md +15 -3
  240. package/skills/reviewer-gates/SKILL.md +2 -2
  241. package/skills/show/SKILL.md +51 -0
  242. package/skills/show/references/accessibility.md +7 -0
  243. package/skills/show/references/art.md +3 -0
  244. package/skills/show/references/charts.md +3 -0
  245. package/skills/show/references/core-design.md +14 -0
  246. package/skills/show/references/erds.md +3 -0
  247. package/skills/show/references/flowcharts.md +3 -0
  248. package/skills/show/references/host-and-html.md +3 -0
  249. package/skills/show/references/illustrative-diagrams.md +10 -0
  250. package/skills/show/references/interaction.md +3 -0
  251. package/skills/show/references/mockups.md +3 -0
  252. package/skills/show/references/quality-gates.md +7 -0
  253. package/skills/show/references/structural-diagrams.md +3 -0
  254. package/skills/show/references/subject-inventory.md +21 -0
  255. package/skills/show/references/svg-contract.md +22 -0
  256. package/skills/show/routing.yaml +30 -0
  257. package/skills/show/samples/pr1262-v2.svg +222 -0
  258. package/skills/show/scripts/README.md +6 -0
  259. package/skills/show/scripts/validate-artifact.py +91 -0
  260. package/skills/show/scripts/validate-package.py +18 -0
  261. package/skills/show/templates/html-widget.html +4 -0
  262. package/skills/show/templates/svg-base.svg +19 -0
  263. package/skills/show/tests/fixtures/css-var.svg +6 -0
  264. package/skills/show/tests/fixtures/dead-ref.svg +7 -0
  265. package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
  266. package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
  267. package/skills/show/tests/fixtures/invalid.svg +1 -0
  268. package/skills/show/tests/fixtures/large-canvas.svg +21 -0
  269. package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
  270. package/skills/show/tests/fixtures/valid.html +1 -0
  271. package/skills/show/tests/test_validate-artifact.py +74 -0
  272. package/skills/show/tests/test_validators.py +59 -0
  273. package/skills/show/workflows/create-visual.md +13 -0
  274. package/skills/show/workflows/review-visual.md +20 -0
  275. package/skills/split-pr/SKILL.md +85 -0
  276. package/skills/split-pr/reference/path-layers.md +16 -0
  277. package/skills/split-pr/reference/proposal-format.md +15 -0
  278. package/skills/split-pr/reference/split-further-loop.md +10 -0
  279. package/skills/split-pr/reference/splitting-principles.md +26 -0
  280. package/skills/split-pr/scripts/analyze_pr.py +279 -0
  281. package/skills/split-pr/scripts/categorize_files.py +106 -0
  282. package/skills/split-pr/scripts/config/__init__.py +1 -0
  283. package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
  284. package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
  285. package/skills/split-pr/scripts/config/packing_constants.py +61 -0
  286. package/skills/split-pr/scripts/config/plan_constants.py +49 -0
  287. package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
  288. package/skills/split-pr/scripts/execute_split_slices.py +82 -0
  289. package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
  290. package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
  291. package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
  292. package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
  293. package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
  294. package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
  295. package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
  296. package/skills/split-pr/scripts/split_pr_title.py +41 -0
  297. package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
  298. package/skills/split-pr/scripts/test_categorize_files.py +55 -0
  299. package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
  300. package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
  301. package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
  302. package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
  303. package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
  304. package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
  305. package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
  306. package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
  307. package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
  308. package/skills/split-pr/scripts/test_verify_plan.py +56 -0
  309. package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
  310. package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
  311. package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
  312. package/skills/split-pr/scripts/verify_plan.py +139 -0
  313. package/skills/team-advisor/SKILL.md +7 -4
  314. package/skills/team-advisor/reference/advisor-docs-review.md +207 -0
  315. package/system-prompts/software-engineer.xml +11 -2
  316. package/commands/initialize.md +0 -90
  317. package/commands/stubcheck.md +0 -88
  318. package/output-styles/caveman-agent.md +0 -37
@@ -0,0 +1,237 @@
1
+ """Privacy-safe advisory telemetry and precision classification for prose matchers.
2
+
3
+ Emits candidate records with matcher id, surface, hashed context fingerprint,
4
+ and optional human label. Classifies each matcher as keep, narrow, drop, or
5
+ remain advisory when the labeled sample floor is unmet. Never hard-blocks from
6
+ historical labels alone.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Mapping, Sequence
16
+
17
+ from hooks_constants.prose_matcher_precision_constants import (
18
+ ADVISORY_LOG_RELATIVE_PATH,
19
+ ALL_KNOWN_MATCHER_IDS,
20
+ ALL_LABEL_DECISIONS,
21
+ CLASSIFICATION_ADVISORY,
22
+ CLASSIFICATION_DROP,
23
+ CLASSIFICATION_KEEP,
24
+ CLASSIFICATION_NARROW,
25
+ CONTEXT_FINGERPRINT_HEX_LENGTH,
26
+ DECISION_FALSE_POSITIVE,
27
+ DECISION_TRUE_POSITIVE,
28
+ DECISION_UNLABELED,
29
+ KEEP_PRECISION_FLOOR,
30
+ NARROW_PRECISION_FLOOR,
31
+ SAMPLE_FLOOR_PER_MATCHER,
32
+ )
33
+
34
+
35
+ def context_fingerprint(context_text: str) -> str:
36
+ """Return a short hex fingerprint of context text (never the raw prose).
37
+
38
+ Args:
39
+ context_text: Snippet around a matcher hit.
40
+
41
+ Returns:
42
+ Lowercase hex digest truncated for log size.
43
+ """
44
+ digest = hashlib.sha256(context_text.encode("utf-8")).hexdigest()
45
+ return digest[:CONTEXT_FINGERPRINT_HEX_LENGTH]
46
+
47
+
48
+ def build_candidate_record(
49
+ matcher_id: str,
50
+ surface: str,
51
+ context_text: str,
52
+ decision: str = DECISION_UNLABELED,
53
+ ) -> dict[str, object]:
54
+ """Build one privacy-safe advisory candidate record.
55
+
56
+ Args:
57
+ matcher_id: Known matcher identifier.
58
+ surface: Tool or reply surface name.
59
+ context_text: Local snippet used only for hashing.
60
+ decision: Label decision (true_positive / false_positive / unlabeled).
61
+
62
+ Returns:
63
+ Record ready to append to the advisory JSONL log.
64
+
65
+ Raises:
66
+ ValueError: When matcher_id or decision is unknown.
67
+ """
68
+ if matcher_id not in ALL_KNOWN_MATCHER_IDS:
69
+ raise ValueError(f"unknown matcher_id: {matcher_id!r}")
70
+ if decision not in ALL_LABEL_DECISIONS:
71
+ raise ValueError(f"unknown decision: {decision!r}")
72
+ return {
73
+ "matcher_id": matcher_id,
74
+ "surface": surface,
75
+ "context_fingerprint": context_fingerprint(context_text),
76
+ "decision": decision,
77
+ "recorded_at": datetime.now(timezone.utc).isoformat(),
78
+ }
79
+
80
+
81
+ def advisory_log_path(home_directory: Path | None = None) -> Path:
82
+ """Return the advisory log path under the user home.
83
+
84
+ Args:
85
+ home_directory: Optional home override for tests.
86
+
87
+ Returns:
88
+ Absolute path to the JSONL log file.
89
+ """
90
+ home_root = home_directory if home_directory is not None else Path.home()
91
+ return home_root / ADVISORY_LOG_RELATIVE_PATH
92
+
93
+
94
+ def emit_advisory_candidate(
95
+ matcher_id: str,
96
+ surface: str,
97
+ context_text: str,
98
+ decision: str = DECISION_UNLABELED,
99
+ home_directory: Path | None = None,
100
+ ) -> dict[str, object]:
101
+ """Append one advisory candidate and return the record.
102
+
103
+ Args:
104
+ matcher_id: Known matcher identifier.
105
+ surface: Tool or reply surface name.
106
+ context_text: Snippet hashed into the fingerprint.
107
+ decision: Optional label decision.
108
+ home_directory: Optional home override for tests.
109
+
110
+ Returns:
111
+ The written candidate record.
112
+ """
113
+ candidate_record = build_candidate_record(
114
+ matcher_id, surface, context_text, decision
115
+ )
116
+ log_path = advisory_log_path(home_directory)
117
+ log_path.parent.mkdir(parents=True, exist_ok=True)
118
+ with log_path.open("a", encoding="utf-8") as log_file:
119
+ log_file.write(json.dumps(candidate_record) + "\n")
120
+ return candidate_record
121
+
122
+
123
+ def precision_for_labeled_candidates(
124
+ all_candidates: Sequence[Mapping[str, object]],
125
+ ) -> float | None:
126
+ """Return precision among labeled candidates, or None when none are labeled.
127
+
128
+ Args:
129
+ all_candidates: Candidate records for one matcher.
130
+
131
+ Returns:
132
+ true_positive / (true_positive + false_positive), or None when no labels.
133
+ """
134
+ true_positive_count = 0
135
+ false_positive_count = 0
136
+ for each_candidate in all_candidates:
137
+ decision = each_candidate.get("decision")
138
+ if decision == DECISION_TRUE_POSITIVE:
139
+ true_positive_count += 1
140
+ elif decision == DECISION_FALSE_POSITIVE:
141
+ false_positive_count += 1
142
+ labeled_count = true_positive_count + false_positive_count
143
+ if labeled_count == 0:
144
+ return None
145
+ return true_positive_count / labeled_count
146
+
147
+
148
+ def classify_matcher(
149
+ all_candidates: Sequence[Mapping[str, object]],
150
+ sample_floor: int = SAMPLE_FLOOR_PER_MATCHER,
151
+ keep_precision_floor: float = KEEP_PRECISION_FLOOR,
152
+ narrow_precision_floor: float = NARROW_PRECISION_FLOOR,
153
+ ) -> dict[str, object]:
154
+ """Classify a matcher from labeled evidence.
155
+
156
+ Args:
157
+ all_candidates: Candidate records for one matcher.
158
+ sample_floor: Minimum labeled count before leave advisory.
159
+ keep_precision_floor: Precision required to keep.
160
+ narrow_precision_floor: Precision required to narrow instead of drop.
161
+
162
+ Returns:
163
+ Classification record with status, labeled_count, precision, and
164
+ whether the production observation window must restart.
165
+ """
166
+ labeled_count = sum(
167
+ 1
168
+ for each_candidate in all_candidates
169
+ if each_candidate.get("decision")
170
+ in (DECISION_TRUE_POSITIVE, DECISION_FALSE_POSITIVE)
171
+ )
172
+ precision = precision_for_labeled_candidates(all_candidates)
173
+ if labeled_count < sample_floor:
174
+ return {
175
+ "status": CLASSIFICATION_ADVISORY,
176
+ "labeled_count": labeled_count,
177
+ "precision": precision,
178
+ "restart_observation_window": False,
179
+ "reason": "below sample floor",
180
+ }
181
+ assert precision is not None
182
+ if precision >= keep_precision_floor:
183
+ return {
184
+ "status": CLASSIFICATION_KEEP,
185
+ "labeled_count": labeled_count,
186
+ "precision": precision,
187
+ "restart_observation_window": False,
188
+ "reason": "meets keep precision floor",
189
+ }
190
+ if precision >= narrow_precision_floor:
191
+ return {
192
+ "status": CLASSIFICATION_NARROW,
193
+ "labeled_count": labeled_count,
194
+ "precision": precision,
195
+ "restart_observation_window": True,
196
+ "reason": "meets narrow floor; restart observation window",
197
+ }
198
+ return {
199
+ "status": CLASSIFICATION_DROP,
200
+ "labeled_count": labeled_count,
201
+ "precision": precision,
202
+ "restart_observation_window": False,
203
+ "reason": "below narrow precision floor",
204
+ }
205
+
206
+
207
+ def classify_all_known_matchers(
208
+ all_candidates: Sequence[Mapping[str, object]],
209
+ sample_floor: int = SAMPLE_FLOOR_PER_MATCHER,
210
+ keep_precision_floor: float = KEEP_PRECISION_FLOOR,
211
+ narrow_precision_floor: float = NARROW_PRECISION_FLOOR,
212
+ ) -> dict[str, dict[str, object]]:
213
+ """Classify every known matcher; missing matchers stay advisory.
214
+
215
+ Args:
216
+ all_candidates: Mixed candidates across matchers.
217
+ sample_floor: Minimum labeled count before leave advisory.
218
+ keep_precision_floor: Precision required to keep.
219
+ narrow_precision_floor: Precision required to narrow instead of drop.
220
+
221
+ Returns:
222
+ Mapping of matcher_id to classification record.
223
+ """
224
+ classification_by_matcher: dict[str, dict[str, object]] = {}
225
+ for each_matcher_id in ALL_KNOWN_MATCHER_IDS:
226
+ all_matcher_candidates = [
227
+ each_candidate
228
+ for each_candidate in all_candidates
229
+ if each_candidate.get("matcher_id") == each_matcher_id
230
+ ]
231
+ classification_by_matcher[each_matcher_id] = classify_matcher(
232
+ all_matcher_candidates,
233
+ sample_floor=sample_floor,
234
+ keep_precision_floor=keep_precision_floor,
235
+ narrow_precision_floor=narrow_precision_floor,
236
+ )
237
+ return classification_by_matcher
@@ -0,0 +1,143 @@
1
+ """Tests for prose matcher advisory telemetry and precision classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
12
+ if str(_HOOKS_ROOT) not in sys.path:
13
+ sys.path.insert(0, str(_HOOKS_ROOT))
14
+
15
+ from hooks_constants.prose_matcher_precision_constants import ( # noqa: E402
16
+ CLASSIFICATION_ADVISORY,
17
+ CLASSIFICATION_DROP,
18
+ CLASSIFICATION_KEEP,
19
+ CLASSIFICATION_NARROW,
20
+ DECISION_FALSE_POSITIVE,
21
+ DECISION_TRUE_POSITIVE,
22
+ DECISION_UNLABELED,
23
+ MATCHER_ID_HEDGING_WORD,
24
+ MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD,
25
+ SAMPLE_FLOOR_PER_MATCHER,
26
+ )
27
+ from observability.prose_matcher_advisory import ( # noqa: E402
28
+ build_candidate_record,
29
+ classify_all_known_matchers,
30
+ classify_matcher,
31
+ context_fingerprint,
32
+ emit_advisory_candidate,
33
+ )
34
+
35
+
36
+ def test_context_fingerprint_is_stable_and_not_raw_text() -> None:
37
+ fingerprint = context_fingerprint("utilize the cache layer now")
38
+ assert fingerprint == context_fingerprint("utilize the cache layer now")
39
+ assert "utilize" not in fingerprint
40
+ assert len(fingerprint) == 16
41
+
42
+
43
+ def test_build_candidate_record_rejects_unknown_matcher() -> None:
44
+ with pytest.raises(ValueError, match="unknown matcher"):
45
+ build_candidate_record("unknown", "Write", "snippet")
46
+
47
+
48
+ def test_emit_advisory_candidate_writes_privacy_safe_jsonl(tmp_path: Path) -> None:
49
+ record = emit_advisory_candidate(
50
+ MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD,
51
+ "Write",
52
+ "Please utilize the cache",
53
+ home_directory=tmp_path,
54
+ )
55
+ assert record["matcher_id"] == MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD
56
+ assert record["surface"] == "Write"
57
+ assert "utilize" not in json.dumps(record)
58
+ log_path = tmp_path / ".claude" / "logs" / "prose-matcher-advisory.jsonl"
59
+ assert log_path.is_file()
60
+ logged = json.loads(log_path.read_text(encoding="utf-8").strip())
61
+ assert logged["context_fingerprint"] == record["context_fingerprint"]
62
+ assert logged["decision"] == DECISION_UNLABELED
63
+
64
+
65
+ def test_below_sample_floor_remains_advisory() -> None:
66
+ all_candidates = [
67
+ {
68
+ "matcher_id": MATCHER_ID_HEDGING_WORD,
69
+ "decision": DECISION_TRUE_POSITIVE,
70
+ }
71
+ for _ in range(SAMPLE_FLOOR_PER_MATCHER - 1)
72
+ ]
73
+ classification = classify_matcher(all_candidates)
74
+ assert classification["status"] == CLASSIFICATION_ADVISORY
75
+ assert classification["restart_observation_window"] is False
76
+
77
+
78
+ def test_keep_when_precision_meets_floor() -> None:
79
+ all_candidates = [
80
+ {"decision": DECISION_TRUE_POSITIVE}
81
+ for _ in range(SAMPLE_FLOOR_PER_MATCHER)
82
+ ]
83
+ classification = classify_matcher(all_candidates)
84
+ assert classification["status"] == CLASSIFICATION_KEEP
85
+ assert classification["precision"] == 1.0
86
+ assert classification["restart_observation_window"] is False
87
+
88
+
89
+ def test_narrow_restarts_observation_window() -> None:
90
+ all_candidates = (
91
+ [{"decision": DECISION_TRUE_POSITIVE} for _ in range(15)]
92
+ + [{"decision": DECISION_FALSE_POSITIVE} for _ in range(15)]
93
+ )
94
+ classification = classify_matcher(all_candidates)
95
+ assert classification["status"] == CLASSIFICATION_NARROW
96
+ assert classification["restart_observation_window"] is True
97
+
98
+
99
+ def test_drop_when_precision_below_narrow_floor() -> None:
100
+ all_candidates = (
101
+ [{"decision": DECISION_TRUE_POSITIVE} for _ in range(5)]
102
+ + [{"decision": DECISION_FALSE_POSITIVE} for _ in range(25)]
103
+ )
104
+ classification = classify_matcher(all_candidates)
105
+ assert classification["status"] == CLASSIFICATION_DROP
106
+ assert classification["restart_observation_window"] is False
107
+
108
+
109
+ def test_classify_all_known_matchers_covers_each_matcher() -> None:
110
+ all_candidates = [
111
+ {
112
+ "matcher_id": MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD,
113
+ "decision": DECISION_TRUE_POSITIVE,
114
+ }
115
+ ]
116
+ classification_by_matcher = classify_all_known_matchers(all_candidates)
117
+ assert set(classification_by_matcher) == {
118
+ MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD,
119
+ MATCHER_ID_HEDGING_WORD,
120
+ }
121
+ assert (
122
+ classification_by_matcher[MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD]["status"]
123
+ == CLASSIFICATION_ADVISORY
124
+ )
125
+ assert (
126
+ classification_by_matcher[MATCHER_ID_HEDGING_WORD]["status"]
127
+ == CLASSIFICATION_ADVISORY
128
+ )
129
+
130
+
131
+ def test_classification_never_returns_hard_block_status() -> None:
132
+ all_candidates = [
133
+ {"decision": DECISION_TRUE_POSITIVE}
134
+ for _ in range(SAMPLE_FLOOR_PER_MATCHER)
135
+ ]
136
+ classification = classify_matcher(all_candidates)
137
+ assert classification["status"] in {
138
+ CLASSIFICATION_KEEP,
139
+ CLASSIFICATION_NARROW,
140
+ CLASSIFICATION_DROP,
141
+ CLASSIFICATION_ADVISORY,
142
+ }
143
+ assert "block" not in str(classification["status"]).lower()
@@ -1,6 +1,6 @@
1
1
  # hooks/session
2
2
 
3
- SessionStart and SessionEnd hooks for per-session setup and cleanup: removing stale session and plugin-data directories at startup, detecting unregistered repositories, starting the session's task-list maintenance loop, and clearing PR-author swap state at shutdown.
3
+ SessionStart and SessionEnd hooks for per-session setup and cleanup: removing stale session and plugin-data directories at startup, detecting unregistered repositories, starting the session's task-list maintenance loop, injecting working-style guidance, and clearing PR-author swap state at shutdown.
4
4
 
5
5
  ## Key files
6
6
 
@@ -12,11 +12,19 @@ SessionStart and SessionEnd hooks for per-session setup and cleanup: removing st
12
12
  | `plugin_data_dir_cleanup.py` | SessionStart | Removes empty plugin data directories at startup to prevent `EEXIST` when Claude Code recreates them |
13
13
  | `untracked_repo_detector.py` | SessionStart | Detects when the session cwd is inside a git repository that is not registered in `~/.claude/project-paths.json` and logs a warning |
14
14
  | `task_list_loop_starter.py` | SessionStart | Emits an `additionalContext` directive telling Claude to keep the task list current on a 10-minute cadence, starting the `/loop` skill when one is not already running. Writes nothing and runs no tools itself. |
15
+ | `orchestrator_auto_starter.py` | SessionStart | Opt-in (`CLAUDE_ORCHESTRATOR_AUTO_STARTER_ENABLED`) consumer of the shared SessionStart injector; emits orchestrator skill context when enabled. Manual `/orchestrator` unchanged. |
16
+ | `issue_tracker_session_starter.py` | SessionStart | Opt-in (`CLAUDE_ISSUE_TRACKER_SESSION_STARTER_ENABLED`) and repository-gated (git root in `~/.claude/project-paths.json`) issue-tracker skill context. |
17
+ | `_path_setup.py` | — | Inserts the hooks directory on `sys.path` so SessionStart scripts import `hooks_constants` with top-level imports. |
18
+ | `test_orchestrator_auto_starter.py` | — | Tests for `orchestrator_auto_starter.py` |
19
+ | `test_issue_tracker_session_starter.py` | — | Tests for `issue_tracker_session_starter.py` |
20
+ | `working_style_prompt.py` | SessionStart | Emits an `additionalContext` block with the fixed working-style prompt (running ledger, plain English, outcome-first finish, scope discipline). Writes nothing and runs no tools itself. |
15
21
  | `test_gh_pr_author_session_cleanup.py` | — | Tests for `gh_pr_author_session_cleanup.py` |
16
22
  | `test_session_edit_tracker_cleanup.py` | — | Tests for `session_edit_tracker_cleanup.py` |
17
23
  | `test_session_env_cleanup.py` | — | Tests for `session_env_cleanup.py` |
18
24
  | `test_untracked_repo_detector.py` | — | Tests for `untracked_repo_detector.py` |
19
25
  | `test_task_list_loop_starter.py` | — | Tests for `task_list_loop_starter.py` |
26
+ | `test__path_setup.py` | — | Tests for `_path_setup.py` |
27
+ | `test_working_style_prompt.py` | — | Tests for `working_style_prompt.py` |
20
28
 
21
29
  ## Conventions
22
30
 
@@ -0,0 +1,13 @@
1
+ """Add the hooks directory to sys.path for session entry-point scripts.
2
+
3
+ Importing this module inserts the hooks directory (this file's parent) at the
4
+ front of sys.path so a SessionStart script under session/ can import
5
+ hooks_constants with every import kept at module top.
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
12
+ if _hooks_directory not in sys.path:
13
+ sys.path.insert(0, _hooks_directory)
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook — repository-gated issue-tracker context via shared injector.
3
+
4
+ Default off. When CLAUDE_ISSUE_TRACKER_SESSION_STARTER_ENABLED is set and the
5
+ session cwd's git root appears in ~/.claude/project-paths.json, emit an
6
+ additionalContext directive for the issue-tracker skill. Missing registry or
7
+ unregistered repos fail closed with no output.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
18
+ if _hooks_dir not in sys.path:
19
+ sys.path.insert(0, _hooks_dir)
20
+
21
+ from hooks_constants.issue_tracker_session_starter_constants import ( # noqa: E402
22
+ ALL_ISSUE_TRACKER_STARTER_ENABLED_ENV_VALUES,
23
+ ISSUE_TRACKER_SESSION_START_DIRECTIVE,
24
+ ISSUE_TRACKER_SESSION_STARTER_ENABLED_ENV_VAR,
25
+ ISSUE_TRACKER_STARTER_TIMEOUT_MILLISECONDS,
26
+ )
27
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
28
+ read_hook_input_dictionary_from_stdin,
29
+ )
30
+ from hooks_constants.project_paths_reader import ( # noqa: E402
31
+ find_git_root,
32
+ load_registry,
33
+ registry_contains_path,
34
+ )
35
+ from hooks_constants.session_start_injector import ( # noqa: E402
36
+ InjectorConfiguration,
37
+ build_additional_context_payload,
38
+ inject_session_start_context,
39
+ )
40
+ from hooks_constants.session_start_injector_constants import ( # noqa: E402
41
+ ALL_KNOWN_SESSION_START_SOURCES,
42
+ )
43
+
44
+
45
+ def issue_tracker_session_starter_enabled_in_environment() -> bool:
46
+ """Return True only when the opt-in env var holds an enabled value."""
47
+ raw_setting = os.environ.get(ISSUE_TRACKER_SESSION_STARTER_ENABLED_ENV_VAR, "")
48
+ return raw_setting.strip().lower() in ALL_ISSUE_TRACKER_STARTER_ENABLED_ENV_VALUES
49
+
50
+
51
+ def repository_is_registered(
52
+ working_directory: str,
53
+ registry_by_name: dict[str, str],
54
+ ) -> bool:
55
+ """Return True when cwd git root is a value in the project-paths registry.
56
+
57
+ Missing registry, missing git root, or unregistered root fail closed.
58
+
59
+ Args:
60
+ working_directory: Session cwd used to locate the git root.
61
+ registry_by_name: Name-to-path map; empty map fails closed.
62
+ """
63
+ if not registry_by_name:
64
+ return False
65
+ git_root = find_git_root(working_directory)
66
+ if git_root is None:
67
+ return False
68
+ return registry_contains_path(registry_by_name, git_root)
69
+
70
+
71
+ def build_issue_tracker_injector_configuration(
72
+ timeout_milliseconds: int,
73
+ ) -> InjectorConfiguration:
74
+ """Build injector config with the issue-tracker directive per known source.
75
+
76
+ Args:
77
+ timeout_milliseconds: Injector budget; zero or less yields timeout status.
78
+ """
79
+ context_by_source = {
80
+ each_source: ISSUE_TRACKER_SESSION_START_DIRECTIVE
81
+ for each_source in ALL_KNOWN_SESSION_START_SOURCES
82
+ }
83
+ return InjectorConfiguration(
84
+ is_enabled=True,
85
+ timeout_milliseconds=timeout_milliseconds,
86
+ context_by_source=context_by_source,
87
+ default_context_for_unknown="",
88
+ )
89
+
90
+
91
+ def run_issue_tracker_session_starter(
92
+ payload_by_key: dict[str, object],
93
+ is_enabled: bool,
94
+ is_repository_eligible: bool,
95
+ timeout_milliseconds: int,
96
+ ) -> dict[str, str]:
97
+ """Return additionalContext when opt-in and repo gate both pass, else empty.
98
+
99
+ Args:
100
+ payload_by_key: Parsed SessionStart payload.
101
+ is_enabled: When False, return empty without calling the injector.
102
+ is_repository_eligible: When False, return empty without calling the injector.
103
+ timeout_milliseconds: Injector timeout budget.
104
+
105
+ Returns:
106
+ ``{"additionalContext": ...}`` when injected, else ``{}``.
107
+ """
108
+ if not is_enabled or not is_repository_eligible:
109
+ return {}
110
+ configuration = build_issue_tracker_injector_configuration(timeout_milliseconds)
111
+ injection_result = inject_session_start_context(payload_by_key, configuration)
112
+ return build_additional_context_payload(injection_result)
113
+
114
+
115
+ def main() -> None:
116
+ """Emit issue-tracker additionalContext when opt-in and registry gate pass."""
117
+ payload_by_key = read_hook_input_dictionary_from_stdin()
118
+ if payload_by_key is None:
119
+ return
120
+ is_enabled = issue_tracker_session_starter_enabled_in_environment()
121
+ is_repository_eligible = (
122
+ repository_is_registered(os.getcwd(), load_registry()) if is_enabled else False
123
+ )
124
+ payload = run_issue_tracker_session_starter(
125
+ payload_by_key,
126
+ is_enabled,
127
+ is_repository_eligible,
128
+ ISSUE_TRACKER_STARTER_TIMEOUT_MILLISECONDS,
129
+ )
130
+ if payload:
131
+ print(json.dumps(payload))
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook — opt-in orchestrator context via shared injector.
3
+
4
+ Default off. When CLAUDE_ORCHESTRATOR_AUTO_STARTER_ENABLED is set, emit an
5
+ additionalContext directive that points the session at the orchestrator skill.
6
+ Disabled paths exit without calling the injector (no timeout budget spent).
7
+
8
+ Manual /orchestrator and the orchestrator agent definition are unchanged.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
19
+ if _hooks_dir not in sys.path:
20
+ sys.path.insert(0, _hooks_dir)
21
+
22
+ from hooks_constants.orchestrator_auto_starter_constants import ( # noqa: E402
23
+ ALL_ORCHESTRATOR_STARTER_ENABLED_ENV_VALUES,
24
+ ORCHESTRATOR_AUTO_STARTER_ENABLED_ENV_VAR,
25
+ ORCHESTRATOR_SESSION_START_DIRECTIVE,
26
+ ORCHESTRATOR_STARTER_TIMEOUT_MILLISECONDS,
27
+ )
28
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
29
+ read_hook_input_dictionary_from_stdin,
30
+ )
31
+ from hooks_constants.session_start_injector import ( # noqa: E402
32
+ InjectorConfiguration,
33
+ build_additional_context_payload,
34
+ inject_session_start_context,
35
+ )
36
+ from hooks_constants.session_start_injector_constants import ( # noqa: E402
37
+ ALL_KNOWN_SESSION_START_SOURCES,
38
+ )
39
+
40
+
41
+ def orchestrator_auto_starter_enabled_in_environment() -> bool:
42
+ """Return True only when the opt-in env var holds an enabled value."""
43
+ raw_setting = os.environ.get(ORCHESTRATOR_AUTO_STARTER_ENABLED_ENV_VAR, "")
44
+ return raw_setting.strip().lower() in ALL_ORCHESTRATOR_STARTER_ENABLED_ENV_VALUES
45
+
46
+
47
+ def build_orchestrator_injector_configuration(
48
+ timeout_milliseconds: int,
49
+ ) -> InjectorConfiguration:
50
+ """Build injector config that injects the orchestrator directive per source.
51
+
52
+ Args:
53
+ timeout_milliseconds: Injector budget; zero or less yields timeout status.
54
+ """
55
+ context_by_source = {
56
+ each_source: ORCHESTRATOR_SESSION_START_DIRECTIVE
57
+ for each_source in ALL_KNOWN_SESSION_START_SOURCES
58
+ }
59
+ return InjectorConfiguration(
60
+ is_enabled=True,
61
+ timeout_milliseconds=timeout_milliseconds,
62
+ context_by_source=context_by_source,
63
+ default_context_for_unknown="",
64
+ )
65
+
66
+
67
+ def run_orchestrator_auto_starter(
68
+ payload_by_key: dict[str, object],
69
+ is_enabled: bool,
70
+ timeout_milliseconds: int,
71
+ ) -> dict[str, str]:
72
+ """Return additionalContext payload when opt-in injects, else empty dict.
73
+
74
+ Args:
75
+ payload_by_key: Parsed SessionStart payload.
76
+ is_enabled: When False, return empty without calling the injector.
77
+ timeout_milliseconds: Injector timeout budget.
78
+
79
+ Returns:
80
+ ``{"additionalContext": ...}`` when injected, else ``{}``.
81
+ """
82
+ if not is_enabled:
83
+ return {}
84
+ configuration = build_orchestrator_injector_configuration(timeout_milliseconds)
85
+ injection_result = inject_session_start_context(payload_by_key, configuration)
86
+ return build_additional_context_payload(injection_result)
87
+
88
+
89
+ def main() -> None:
90
+ """Emit orchestrator additionalContext when opt-in is enabled; else exit 0."""
91
+ payload_by_key = read_hook_input_dictionary_from_stdin()
92
+ if payload_by_key is None:
93
+ return
94
+ payload = run_orchestrator_auto_starter(payload_by_key, orchestrator_auto_starter_enabled_in_environment(), ORCHESTRATOR_STARTER_TIMEOUT_MILLISECONDS)
95
+ if payload:
96
+ print(json.dumps(payload))
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1,28 @@
1
+ """Tests for session/_path_setup path injection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import hooks_constants
10
+
11
+
12
+ def test_path_setup_puts_hooks_root_on_sys_path() -> None:
13
+ session_dir = Path(__file__).resolve().parent
14
+ hooks_root = str(session_dir.parent)
15
+ path_setup_file = session_dir / "_path_setup.py"
16
+ if hooks_root in sys.path:
17
+ sys.path.remove(hooks_root)
18
+
19
+ specification = importlib.util.spec_from_file_location(
20
+ "session_path_setup_under_test", path_setup_file
21
+ )
22
+ assert specification is not None
23
+ assert specification.loader is not None
24
+ module = importlib.util.module_from_spec(specification)
25
+ specification.loader.exec_module(module)
26
+
27
+ assert hooks_root in sys.path
28
+ assert hooks_constants is not None