claude-dev-env 2.8.0 → 2.10.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.
- package/CLAUDE.md +8 -2
- package/_shared/pr-loop/CLAUDE.md +1 -1
- package/_shared/pr-loop/audit-contract.md +17 -6
- package/_shared/pr-loop/audit-reply-template.md +4 -4
- package/_shared/pr-loop/code-rules-gate.md +3 -5
- package/_shared/pr-loop/fix-protocol.md +2 -3
- package/_shared/pr-loop/gh-payloads.md +1 -1
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/README.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
- package/_shared/pr-loop/scripts/preflight.py +9 -4
- package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
- package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
- package/_shared/pr-loop/state-schema.md +5 -14
- package/agents/CLAUDE.md +2 -2
- package/agents/clean-coder.md +62 -562
- package/agents/code-quality-agent.md +10 -2
- package/agents/code-verifier.md +1 -1
- package/agents/test_agent_frontmatter.py +58 -40
- package/audit-rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/audit-categories.json +704 -0
- package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
- package/bin/CLAUDE.md +16 -5
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/install-plan.mjs +402 -0
- package/bin/install-transaction.mjs +455 -0
- package/bin/install.mjs +593 -147
- package/bin/install.plan.test.mjs +194 -0
- package/bin/install.profile-root.test.mjs +154 -0
- package/bin/install.profiles.test.mjs +253 -0
- package/bin/install.settings-defaults.test.mjs +200 -0
- package/bin/install.transaction.test.mjs +400 -0
- package/bin/install.uninstall-transaction.test.mjs +418 -0
- package/bin/merge_managed_permissions.mjs +130 -0
- package/bin/resolve-install-root.mjs +181 -0
- package/bin/select-install-targets.mjs +401 -0
- package/commands/CLAUDE.md +0 -2
- package/docs/CODE_RULES.md +4 -2
- package/docs/references/CLAUDE.md +3 -2
- package/docs/references/advisor-tool.md +44 -6
- package/docs/references/prose-style-enforcement.md +25 -0
- package/docs/references/team-advisor-skill.md +14 -8
- package/hooks/blocking/CLAUDE.md +6 -6
- package/hooks/blocking/_path_setup.py +9 -5
- package/hooks/blocking/code_rules_docstrings.py +124 -30
- package/hooks/blocking/code_rules_enforcer.py +161 -16
- package/hooks/blocking/code_rules_shared.py +40 -23
- package/hooks/blocking/config/CLAUDE.md +3 -5
- package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
- package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
- package/hooks/blocking/eli11_reply_enforcer.py +70 -113
- package/hooks/blocking/hedging_language_blocker.py +103 -20
- package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
- package/hooks/blocking/intent_only_ending_blocker.py +6 -0
- package/hooks/blocking/plain_language_blocker.py +139 -20
- package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
- package/hooks/blocking/state_description_blocker.py +7 -1
- package/hooks/blocking/tdd_enforcer.py +8 -0
- package/hooks/blocking/test__path_setup.py +28 -0
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
- package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
- package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
- package/hooks/blocking/test_hedging_language_blocker.py +120 -1
- package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
- package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
- package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
- package/hooks/blocking/test_plain_language_blocker.py +129 -19
- package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
- package/hooks/blocking/test_state_description_blocker.py +45 -2
- package/hooks/blocking/test_stop_dispatcher.py +11 -7
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
- package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/CLAUDE.md +14 -3
- package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
- package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
- package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
- package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
- package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/project_paths_reader.py +31 -4
- package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
- package/hooks/hooks_constants/pytest_invocation.py +354 -0
- package/hooks/hooks_constants/session_start_injector.py +163 -0
- package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
- package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
- package/hooks/hooks_constants/shell_command_segments.py +5 -0
- package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
- package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
- package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
- package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
- package/hooks/hooks_constants/test_session_start_injector.py +168 -0
- package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
- package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/prose_matcher_advisory.py +237 -0
- package/hooks/observability/test_prose_matcher_advisory.py +143 -0
- package/hooks/session/CLAUDE.md +9 -1
- package/hooks/session/_path_setup.py +13 -0
- package/hooks/session/issue_tracker_session_starter.py +135 -0
- package/hooks/session/orchestrator_auto_starter.py +100 -0
- package/hooks/session/test__path_setup.py +28 -0
- package/hooks/session/test_issue_tracker_session_starter.py +104 -0
- package/hooks/session/test_orchestrator_auto_starter.py +99 -0
- package/hooks/session/test_working_style_prompt.py +47 -0
- package/hooks/session/untracked_repo_detector.py +1 -24
- package/hooks/session/working_style_prompt.py +36 -0
- package/hooks/validators/_path_setup.py +19 -0
- package/hooks/validators/run_all_validators.py +8 -13
- package/installable-surfaces.manifest.json +21 -0
- package/output-styles/CLAUDE.md +15 -0
- package/package.json +5 -2
- package/rules/CLAUDE.md +1 -0
- package/rules/code-standards.md +33 -7
- package/rules/durable-post-artifacts.md +2 -2
- package/rules/eli11-replies.md +7 -2
- package/rules/hedging-claims.md +4 -2
- package/rules/long-horizon-autonomy.md +3 -1
- package/rules/opus5-communication-contract.md +45 -0
- package/rules/plain-language.md +2 -2
- package/rules/research-mode.md +1 -1
- package/scripts/CLAUDE.md +13 -2
- package/scripts/Sync-RepoMain.ps1 +215 -0
- package/scripts/active_capability_references.py +218 -0
- package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
- package/scripts/claude_chain_runner.py +394 -6
- package/scripts/claude_chain_usage.py +1 -1
- package/scripts/codex_compat_materializer.py +105 -85
- package/scripts/dev_env_scripts_constants/CLAUDE.md +3 -1
- package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
- package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +50 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +104 -0
- package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
- package/scripts/grok_patch_artifacts.py +123 -0
- package/scripts/grok_run_ledger.py +318 -0
- package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
- package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
- package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
- package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
- package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
- package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
- package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
- package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
- package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
- package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
- package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
- package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
- package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
- package/scripts/spawn_grok_batch.py +556 -9
- package/scripts/test_active_capability_references.py +108 -0
- package/scripts/test_claude_chain_runner.py +414 -82
- package/scripts/test_claude_chain_usage.py +12 -12
- package/scripts/test_grok_patch_artifacts.py +82 -0
- package/scripts/test_grok_run_ledger.py +116 -0
- package/scripts/test_resolve_worker_spawn.py +2 -2
- package/scripts/test_spawn_grok_batch.py +295 -0
- package/scripts/test_verify_installable_package.py +208 -0
- package/scripts/tests/test_codex_compat_materializer.py +33 -0
- package/scripts/verify_installable_package.py +612 -0
- package/settings.json +10 -0
- package/skills/CLAUDE.md +6 -2
- package/skills/_shared/CLAUDE.md +37 -4
- package/skills/_shared/advisor/CLAUDE.md +9 -0
- package/skills/_shared/advisor/advisor-protocol.md +5 -0
- package/skills/_shared/advisor/scripts/README.md +9 -0
- package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
- package/skills/_shared/pr-loop/CLAUDE.md +18 -1
- package/skills/_shared/pr-loop/audit-contract.md +5 -0
- package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
- package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
- package/skills/_shared/pr-loop/fix-protocol.md +5 -0
- package/skills/_shared/pr-loop/gh-payloads.md +5 -0
- package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
- package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +9 -1
- package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -0
- package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
- package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
- package/skills/_shared/pr-loop/state-schema.md +5 -0
- package/skills/_shared/pr-loop/worker-spawn.md +5 -0
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/stop-conditions.md +5 -3
- package/skills/beat-sheet/SKILL.md +54 -0
- package/skills/beat-sheet/reference/visual-beats.md +29 -0
- package/skills/bugteam/CONSTRAINTS.md +4 -4
- package/skills/bugteam/EXAMPLES.md +1 -1
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/e-code-review/SKILL.md +29 -3
- package/skills/e-code-review/reference/effort-evaluation.md +35 -0
- package/skills/e-code-review/reference/medium.md +15 -4
- package/skills/e-code-review/reference/runner-selection.md +40 -0
- package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
- package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +1 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -0
- package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
- package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
- package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
- package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
- package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
- package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
- package/skills/e-code-review/scripts/grok_code_review.py +230 -0
- package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
- package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
- package/skills/e-code-review/scripts/test_grok_code_review.py +289 -0
- package/skills/grok-spawn/SKILL.md +5 -0
- package/skills/orchestrator/SKILL.md +5 -0
- package/skills/plan-to-pr/scripts/create_packet.py +4 -4
- package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
- package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
- package/skills/plan-to-pr/scripts/validate_run.py +4 -1
- package/skills/pr-converge/scripts/check_convergence.py +21 -19
- package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
- package/skills/pr-converge/scripts/conftest.py +35 -0
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/rebase/SKILL.md +15 -3
- package/skills/reviewer-gates/SKILL.md +2 -2
- package/skills/show/SKILL.md +51 -0
- package/skills/show/references/accessibility.md +7 -0
- package/skills/show/references/art.md +3 -0
- package/skills/show/references/charts.md +3 -0
- package/skills/show/references/core-design.md +14 -0
- package/skills/show/references/erds.md +3 -0
- package/skills/show/references/flowcharts.md +3 -0
- package/skills/show/references/host-and-html.md +3 -0
- package/skills/show/references/illustrative-diagrams.md +10 -0
- package/skills/show/references/interaction.md +3 -0
- package/skills/show/references/mockups.md +3 -0
- package/skills/show/references/quality-gates.md +7 -0
- package/skills/show/references/structural-diagrams.md +3 -0
- package/skills/show/references/subject-inventory.md +21 -0
- package/skills/show/references/svg-contract.md +22 -0
- package/skills/show/routing.yaml +30 -0
- package/skills/show/samples/pr1262-v2.svg +222 -0
- package/skills/show/scripts/README.md +6 -0
- package/skills/show/scripts/validate-artifact.py +91 -0
- package/skills/show/scripts/validate-package.py +18 -0
- package/skills/show/templates/html-widget.html +4 -0
- package/skills/show/templates/svg-base.svg +19 -0
- package/skills/show/tests/fixtures/css-var.svg +6 -0
- package/skills/show/tests/fixtures/dead-ref.svg +7 -0
- package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
- package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
- package/skills/show/tests/fixtures/invalid.svg +1 -0
- package/skills/show/tests/fixtures/large-canvas.svg +21 -0
- package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
- package/skills/show/tests/fixtures/valid.html +1 -0
- package/skills/show/tests/test_validate-artifact.py +74 -0
- package/skills/show/tests/test_validators.py +59 -0
- package/skills/show/workflows/create-visual.md +13 -0
- package/skills/show/workflows/review-visual.md +20 -0
- package/skills/split-pr/SKILL.md +85 -0
- package/skills/split-pr/reference/path-layers.md +16 -0
- package/skills/split-pr/reference/proposal-format.md +15 -0
- package/skills/split-pr/reference/split-further-loop.md +10 -0
- package/skills/split-pr/reference/splitting-principles.md +26 -0
- package/skills/split-pr/scripts/analyze_pr.py +279 -0
- package/skills/split-pr/scripts/categorize_files.py +106 -0
- package/skills/split-pr/scripts/config/__init__.py +1 -0
- package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
- package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
- package/skills/split-pr/scripts/config/packing_constants.py +61 -0
- package/skills/split-pr/scripts/config/plan_constants.py +49 -0
- package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
- package/skills/split-pr/scripts/execute_split_slices.py +82 -0
- package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
- package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
- package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
- package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
- package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
- package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
- package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
- package/skills/split-pr/scripts/split_pr_title.py +41 -0
- package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
- package/skills/split-pr/scripts/test_categorize_files.py +55 -0
- package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
- package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
- package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
- package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
- package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
- package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
- package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
- package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
- package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
- package/skills/split-pr/scripts/test_verify_plan.py +56 -0
- package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
- package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
- package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
- package/skills/split-pr/scripts/verify_plan.py +139 -0
- package/skills/task-build/reference/tool-routing.md +3 -0
- package/skills/team-advisor/SKILL.md +23 -44
- package/system-prompts/software-engineer.xml +17 -5
- package/commands/initialize.md +0 -90
- package/commands/stubcheck.md +0 -88
- package/skills/test_markdown_link_integrity.py +0 -107
|
@@ -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()
|
package/hooks/session/CLAUDE.md
CHANGED
|
@@ -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
|