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
|
@@ -13,7 +13,10 @@ flags from its first operand, the wrapper commands whose run passes through to
|
|
|
13
13
|
the program behind them, the separator that rejoins a wrapper's argument
|
|
14
14
|
tokens, the empty commenter set that leaves ``#`` to the comment-start pattern,
|
|
15
15
|
the quote characters stripped before a basename read, and the deny message.
|
|
16
|
-
Segment helpers come from ``shell_command_segments.py``.
|
|
16
|
+
Segment helpers come from ``shell_command_segments.py``. Quote-aware pipeline
|
|
17
|
+
parsing lives in ``shell_command_pipeline``. Pytest invocation classification
|
|
18
|
+
that consumes these program, wrapper, and option constants lives in
|
|
19
|
+
``pytest_invocation``.
|
|
17
20
|
|
|
18
21
|
Two wrapper shapes reach the program behind them differently. ``sudo`` and
|
|
19
22
|
``uvx`` take their own option flags and then the command, so the step-over drops
|
|
@@ -18,7 +18,9 @@ strippers the word scan runs first, and the lean-question-block tunables: the
|
|
|
18
18
|
block-level chat-detail markers that belong in chat text, the line-ending
|
|
19
19
|
spellings normalized before those markers run, the inline-code span the length
|
|
20
20
|
counts collapse to one word, and the sentence and word caps a question and an
|
|
21
|
-
option description each answer to.
|
|
21
|
+
option description each answer to. The pure analyzer in
|
|
22
|
+
``ask_user_question_shape`` reads the same lean-block tunables so shape grading
|
|
23
|
+
and the runtime hook share one contract.
|
|
22
24
|
"""
|
|
23
25
|
|
|
24
26
|
from __future__ import annotations
|
|
@@ -352,6 +354,7 @@ OPTION_DESCRIPTION_SURFACE_NAME: str = "an option description"
|
|
|
352
354
|
|
|
353
355
|
LEAN_QUESTION_BLOCK_PREFIX: str = "BLOCKED: [LEAN_QUESTION] Question block carries chat detail -- "
|
|
354
356
|
LEAN_QUESTION_VIOLATION_SEPARATOR: str = "; "
|
|
357
|
+
HEAVY_WORD_SWAP_LIST_SEPARATOR: str = ", "
|
|
355
358
|
LEAN_QUESTION_BLOCK_GUIDANCE: str = (
|
|
356
359
|
"AskUserQuestion renders as one plain text block. Print the plan, the counts, "
|
|
357
360
|
"and the tradeoffs in chat text before the call, keep the question block to a "
|
|
@@ -16,6 +16,8 @@ __all__ = [
|
|
|
16
16
|
"BLOCKING_CRASH_EXIT_CODE",
|
|
17
17
|
"EXIT_CODE_TWO_DENY_REASON",
|
|
18
18
|
"BLOCKING_CRASH_DENY_REASON",
|
|
19
|
+
"REASON_JOIN_SEPARATOR",
|
|
20
|
+
"CONTEXT_JOIN_SEPARATOR",
|
|
19
21
|
"WRITE_TOOL_NAME",
|
|
20
22
|
"EDIT_TOOL_NAME",
|
|
21
23
|
"MULTI_EDIT_TOOL_NAME",
|
|
@@ -23,6 +25,7 @@ __all__ = [
|
|
|
23
25
|
"ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES",
|
|
24
26
|
"STATE_DESCRIPTION_BLOCKER_MODULE_NAME",
|
|
25
27
|
"PLAIN_LANGUAGE_BLOCKER_MODULE_NAME",
|
|
28
|
+
"SYSTEM_MESSAGE_JOIN_SEPARATOR",
|
|
26
29
|
"HostedHookEntry",
|
|
27
30
|
"ALL_HOSTED_HOOK_ENTRIES",
|
|
28
31
|
]
|
|
@@ -30,9 +33,12 @@ __all__ = [
|
|
|
30
33
|
DENY_DECISION = "deny"
|
|
31
34
|
ALLOW_DECISION = "allow"
|
|
32
35
|
HOOK_EVENT_NAME = "PreToolUse"
|
|
36
|
+
SYSTEM_MESSAGE_JOIN_SEPARATOR = "\n"
|
|
33
37
|
BLOCKING_CRASH_EXIT_CODE = 2
|
|
34
38
|
EXIT_CODE_TWO_DENY_REASON = "[dispatcher] hook denied via exit code 2 — write blocked"
|
|
35
39
|
BLOCKING_CRASH_DENY_REASON = "[dispatcher] hook crash in blocking hook — write blocked for safety"
|
|
40
|
+
REASON_JOIN_SEPARATOR = " | "
|
|
41
|
+
CONTEXT_JOIN_SEPARATOR = "\n"
|
|
36
42
|
|
|
37
43
|
WRITE_TOOL_NAME = "Write"
|
|
38
44
|
EDIT_TOOL_NAME = "Edit"
|
|
@@ -8,8 +8,11 @@ import os
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
from hooks_constants.dynamic_stderr_handler import DynamicStderrHandler
|
|
11
|
-
from hooks_constants.setup_project_paths_constants import
|
|
12
|
-
|
|
11
|
+
from hooks_constants.setup_project_paths_constants import (
|
|
12
|
+
GIT_DIRECTORY_SEGMENT_NAME,
|
|
13
|
+
META_KEY,
|
|
14
|
+
UTF8_ENCODING,
|
|
15
|
+
)
|
|
13
16
|
|
|
14
17
|
_logger = logging.getLogger("project_paths_reader")
|
|
15
18
|
if not _logger.handlers:
|
|
@@ -34,6 +37,30 @@ def _normalize_path_separators(raw_path: str) -> str:
|
|
|
34
37
|
return os.path.normcase(os.path.normpath(forward_slash_form)).replace("\\", "/")
|
|
35
38
|
|
|
36
39
|
|
|
40
|
+
def find_git_root(start_path: str) -> str | None:
|
|
41
|
+
"""Walk upward for .git; stop at home or filesystem root.
|
|
42
|
+
|
|
43
|
+
Home bound prevents a parent .git above the user home from becoming the
|
|
44
|
+
session repo root (same contract as the untracked-repo SessionStart hook).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
start_path: Directory to begin the upward walk.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Absolute path of the git root, or None when none is found in bounds.
|
|
51
|
+
"""
|
|
52
|
+
home_directory = Path.home().resolve()
|
|
53
|
+
candidate = Path(start_path).resolve()
|
|
54
|
+
while True:
|
|
55
|
+
if (candidate / GIT_DIRECTORY_SEGMENT_NAME).exists():
|
|
56
|
+
return str(candidate)
|
|
57
|
+
if candidate == home_directory:
|
|
58
|
+
return None
|
|
59
|
+
parent = candidate.parent
|
|
60
|
+
if parent == candidate:
|
|
61
|
+
return None
|
|
62
|
+
candidate = parent
|
|
63
|
+
|
|
37
64
|
def load_registry(config_path: Path | None = None) -> dict[str, str]:
|
|
38
65
|
"""Return the name-to-absolute-path mapping with the _meta key stripped.
|
|
39
66
|
|
|
@@ -65,14 +92,14 @@ def load_registry(config_path: Path | None = None) -> dict[str, str]:
|
|
|
65
92
|
}
|
|
66
93
|
|
|
67
94
|
|
|
68
|
-
def registry_contains_path(
|
|
95
|
+
def registry_contains_path(all_known_registry: dict[str, str], path_to_find: str) -> bool:
|
|
69
96
|
"""Return True when the given path appears as any registry value.
|
|
70
97
|
|
|
71
98
|
Normalizes both sides before comparing so Windows and POSIX separator
|
|
72
99
|
forms of the same path compare equal.
|
|
73
100
|
"""
|
|
74
101
|
normalized_target = _normalize_path_separators(path_to_find)
|
|
75
|
-
for each_registered_path in
|
|
102
|
+
for each_registered_path in all_known_registry.values():
|
|
76
103
|
if _normalize_path_separators(each_registered_path) == normalized_target:
|
|
77
104
|
return True
|
|
78
105
|
return False
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Thresholds and paths for prose-matcher precision measurement (OP-07B).
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
labeled count < SAMPLE_FLOOR_PER_MATCHER -> remain advisory
|
|
6
|
+
precision >= KEEP_PRECISION_FLOOR -> keep
|
|
7
|
+
precision >= NARROW_PRECISION_FLOOR -> narrow
|
|
8
|
+
otherwise -> drop
|
|
9
|
+
|
|
10
|
+
No matcher becomes hard-blocking from historical labels alone; classification
|
|
11
|
+
only records keep/narrow/drop/advisory for the observation window.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
ADVISORY_LOG_RELATIVE_PATH = ".claude/logs/prose-matcher-advisory.jsonl"
|
|
17
|
+
SAMPLE_FLOOR_PER_MATCHER = 30
|
|
18
|
+
KEEP_PRECISION_FLOOR = 0.7
|
|
19
|
+
NARROW_PRECISION_FLOOR = 0.4
|
|
20
|
+
MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD = "plain_language_heavy_word"
|
|
21
|
+
MATCHER_ID_HEDGING_WORD = "hedging_word"
|
|
22
|
+
ALL_KNOWN_MATCHER_IDS = (
|
|
23
|
+
MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD,
|
|
24
|
+
MATCHER_ID_HEDGING_WORD,
|
|
25
|
+
)
|
|
26
|
+
DECISION_TRUE_POSITIVE = "true_positive"
|
|
27
|
+
DECISION_FALSE_POSITIVE = "false_positive"
|
|
28
|
+
DECISION_UNLABELED = "unlabeled"
|
|
29
|
+
ALL_LABEL_DECISIONS = (
|
|
30
|
+
DECISION_TRUE_POSITIVE,
|
|
31
|
+
DECISION_FALSE_POSITIVE,
|
|
32
|
+
DECISION_UNLABELED,
|
|
33
|
+
)
|
|
34
|
+
CLASSIFICATION_KEEP = "keep"
|
|
35
|
+
CLASSIFICATION_NARROW = "narrow"
|
|
36
|
+
CLASSIFICATION_DROP = "drop"
|
|
37
|
+
CLASSIFICATION_ADVISORY = "advisory"
|
|
38
|
+
CONTEXT_FINGERPRINT_HEX_LENGTH = 16
|
|
39
|
+
MAXIMUM_ADVISORY_EMITS_PER_CALL = 5
|
|
40
|
+
ADVISORY_CONTEXT_SNIPPET_MAX_CHARS = 120
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Classify whether a parsed simple-command segment runs pytest.
|
|
2
|
+
|
|
3
|
+
Takes operator-stripped segment tokens and reports whether that segment invokes
|
|
4
|
+
pytest — as a bare program, through a Python interpreter ``-m``, through a
|
|
5
|
+
pass-through wrapper, or through a shell string-exec command.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
segment_runs_pytest(["pytest", "tests"]) True
|
|
10
|
+
segment_runs_pytest(["python", "-m", "pytest"]) True
|
|
11
|
+
segment_runs_pytest(["uv", "run", "pytest", "tests"]) True
|
|
12
|
+
segment_runs_pytest(["python", "myscript.py", "-m", "pytest"]) False
|
|
13
|
+
segment_runs_pytest(["uv", "run", "--with", "pytest", "mypy", "."]) False
|
|
14
|
+
|
|
15
|
+
Shell parsing (tokenization, heredoc drop, segment pairing) lives in
|
|
16
|
+
``shell_command_pipeline``. Pipeline deny decisions stay with the blocker.
|
|
17
|
+
This module only answers "does this segment run pytest?"
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from hooks_constants.piped_pytest_blocker_constants import (
|
|
23
|
+
ALL_CLUSTERED_STRING_EXEC_OPTION_LETTERS,
|
|
24
|
+
ALL_FLAG_TAKING_WRAPPER_COMMANDS,
|
|
25
|
+
ALL_PYTEST_PROGRAM_BASENAMES,
|
|
26
|
+
ALL_QUOTE_CHARACTERS,
|
|
27
|
+
ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS,
|
|
28
|
+
ALL_SHORT_OPTION_CLUSTERING_SHELL_BASENAMES,
|
|
29
|
+
ALL_STRING_EXEC_COMMAND_FLAGS,
|
|
30
|
+
ALL_STRING_EXECUTING_SHELL_BASENAMES,
|
|
31
|
+
ALL_VALUE_TAKING_INTERPRETER_OPTION_FLAGS,
|
|
32
|
+
ALL_VALUE_TAKING_SHELL_OPTION_FLAGS,
|
|
33
|
+
ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS,
|
|
34
|
+
COMMAND_OPTION_TOKEN_PATTERN,
|
|
35
|
+
END_OF_OPTIONS_TOKEN,
|
|
36
|
+
MODULE_RUN_FLAG,
|
|
37
|
+
PYTEST_MODULE_NAME,
|
|
38
|
+
PYTHON_INTERPRETER_BASENAME_PATTERN,
|
|
39
|
+
RUN_SUBCOMMAND_NAME,
|
|
40
|
+
SHORT_OPTION_CLUSTER_PATTERN,
|
|
41
|
+
SHORT_OPTION_PREFIX,
|
|
42
|
+
TOOL_SUBCOMMAND_NAME,
|
|
43
|
+
WRAPPED_COMMAND_TOKEN_JOIN,
|
|
44
|
+
)
|
|
45
|
+
from hooks_constants.shell_command_pipeline import (
|
|
46
|
+
all_operator_aware_tokenizations,
|
|
47
|
+
segments_with_following_operator,
|
|
48
|
+
)
|
|
49
|
+
from hooks_constants.shell_command_segments import (
|
|
50
|
+
effective_leading_program,
|
|
51
|
+
token_basename,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"segment_reports_a_pytest_exit_code",
|
|
56
|
+
"segment_runs_pytest",
|
|
57
|
+
"string_exec_inner_command",
|
|
58
|
+
"unquoted_token",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def unquoted_token(token: str) -> str:
|
|
63
|
+
"""Return a token with its surrounding shell quotes removed.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
token: One shell token, possibly quoted.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The same token with leading and trailing quote characters stripped.
|
|
70
|
+
"""
|
|
71
|
+
return token.strip(ALL_QUOTE_CHARACTERS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _glued_module_name(token: str) -> str | None:
|
|
75
|
+
"""Return the module name glued onto a ``-m`` flag, or None for any other token.
|
|
76
|
+
|
|
77
|
+
::
|
|
78
|
+
|
|
79
|
+
-mpytest pytest
|
|
80
|
+
-mmypy mypy
|
|
81
|
+
-m None
|
|
82
|
+
"""
|
|
83
|
+
stripped_token = unquoted_token(token)
|
|
84
|
+
if not stripped_token.startswith(MODULE_RUN_FLAG):
|
|
85
|
+
return None
|
|
86
|
+
return stripped_token[len(MODULE_RUN_FLAG) :] or None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _some_module_run_flag_names_pytest(all_tokens: list[str]) -> bool:
|
|
90
|
+
"""Return True when any ``-m`` among the tokens names pytest."""
|
|
91
|
+
for each_index, each_token in enumerate(all_tokens):
|
|
92
|
+
if _glued_module_name(each_token) == PYTEST_MODULE_NAME:
|
|
93
|
+
return True
|
|
94
|
+
if each_token != MODULE_RUN_FLAG:
|
|
95
|
+
continue
|
|
96
|
+
module_index = each_index + 1
|
|
97
|
+
if module_index >= len(all_tokens):
|
|
98
|
+
continue
|
|
99
|
+
if unquoted_token(all_tokens[module_index]) == PYTEST_MODULE_NAME:
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _clustered_option_value_token_count(
|
|
105
|
+
stripped_token: str, all_value_taking_flags: frozenset[str]
|
|
106
|
+
) -> int:
|
|
107
|
+
"""Return how many tokens a short-option cluster takes as its last flag value."""
|
|
108
|
+
all_cluster_letters = stripped_token[len(SHORT_OPTION_PREFIX) :]
|
|
109
|
+
last_letter_position = len(all_cluster_letters) - 1
|
|
110
|
+
for each_position, each_letter in enumerate(all_cluster_letters):
|
|
111
|
+
if SHORT_OPTION_PREFIX + each_letter not in all_value_taking_flags:
|
|
112
|
+
continue
|
|
113
|
+
return int(each_position == last_letter_position)
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _option_value_token_count(
|
|
118
|
+
stripped_token: str, all_value_taking_flags: frozenset[str]
|
|
119
|
+
) -> int:
|
|
120
|
+
"""Return how many tokens after an option token are that option's value."""
|
|
121
|
+
if stripped_token in all_value_taking_flags:
|
|
122
|
+
return 1
|
|
123
|
+
if SHORT_OPTION_CLUSTER_PATTERN.fullmatch(stripped_token) is None:
|
|
124
|
+
return 0
|
|
125
|
+
return _clustered_option_value_token_count(stripped_token, all_value_taking_flags)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _all_tokens_from_the_interpreter_module_flag(
|
|
129
|
+
all_interpreter_argument_tokens: list[str],
|
|
130
|
+
) -> list[str] | None:
|
|
131
|
+
"""Return tokens from the interpreter's own ``-m`` on, or None when it has none."""
|
|
132
|
+
scan_index = 0
|
|
133
|
+
while scan_index < len(all_interpreter_argument_tokens):
|
|
134
|
+
each_token = all_interpreter_argument_tokens[scan_index]
|
|
135
|
+
stripped_token = unquoted_token(each_token)
|
|
136
|
+
if stripped_token == MODULE_RUN_FLAG or _glued_module_name(each_token) is not None:
|
|
137
|
+
return all_interpreter_argument_tokens[scan_index:]
|
|
138
|
+
if COMMAND_OPTION_TOKEN_PATTERN.match(stripped_token) is None:
|
|
139
|
+
return None
|
|
140
|
+
scan_index += 1 + _option_value_token_count(
|
|
141
|
+
stripped_token, ALL_VALUE_TAKING_INTERPRETER_OPTION_FLAGS
|
|
142
|
+
)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _runs_pytest_as_a_module(all_interpreter_argument_tokens: list[str]) -> bool:
|
|
147
|
+
"""Return True when the interpreter's own ``-m`` reaches a pytest run."""
|
|
148
|
+
all_module_tokens = _all_tokens_from_the_interpreter_module_flag(
|
|
149
|
+
all_interpreter_argument_tokens
|
|
150
|
+
)
|
|
151
|
+
if all_module_tokens is None:
|
|
152
|
+
return False
|
|
153
|
+
return _some_module_run_flag_names_pytest(all_module_tokens)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _all_tokens_from_the_first_operand(all_tokens: list[str]) -> list[str]:
|
|
157
|
+
"""Return the tokens from the first non-option one on, dropping option flags."""
|
|
158
|
+
all_remaining_tokens = all_tokens
|
|
159
|
+
while all_remaining_tokens:
|
|
160
|
+
stripped_token = unquoted_token(all_remaining_tokens[0])
|
|
161
|
+
if stripped_token == END_OF_OPTIONS_TOKEN:
|
|
162
|
+
return all_remaining_tokens[1:]
|
|
163
|
+
if COMMAND_OPTION_TOKEN_PATTERN.match(stripped_token) is None:
|
|
164
|
+
return all_remaining_tokens
|
|
165
|
+
flag_value_token_count = _option_value_token_count(
|
|
166
|
+
stripped_token, ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS
|
|
167
|
+
)
|
|
168
|
+
all_remaining_tokens = all_remaining_tokens[1 + flag_value_token_count :]
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _all_tokens_after_one_wrapper(all_segment_tokens: list[str]) -> list[str] | None:
|
|
173
|
+
"""Return the tokens a single leading pass-through wrapper runs, else None."""
|
|
174
|
+
leading_program = effective_leading_program(all_segment_tokens)
|
|
175
|
+
if leading_program is None:
|
|
176
|
+
return None
|
|
177
|
+
program_basename = token_basename(unquoted_token(leading_program))
|
|
178
|
+
leading_index = all_segment_tokens.index(leading_program)
|
|
179
|
+
all_argument_tokens = _all_tokens_from_the_first_operand(
|
|
180
|
+
all_segment_tokens[leading_index + 1 :]
|
|
181
|
+
)
|
|
182
|
+
if program_basename in ALL_FLAG_TAKING_WRAPPER_COMMANDS:
|
|
183
|
+
return all_argument_tokens
|
|
184
|
+
if program_basename not in ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS:
|
|
185
|
+
return None
|
|
186
|
+
if all_argument_tokens and unquoted_token(all_argument_tokens[0]) == TOOL_SUBCOMMAND_NAME:
|
|
187
|
+
all_argument_tokens = _all_tokens_from_the_first_operand(all_argument_tokens[1:])
|
|
188
|
+
if not all_argument_tokens or unquoted_token(all_argument_tokens[0]) != RUN_SUBCOMMAND_NAME:
|
|
189
|
+
return None
|
|
190
|
+
return _all_tokens_from_the_first_operand(all_argument_tokens[1:])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _all_tokens_after_wrappers(all_segment_tokens: list[str]) -> list[str]:
|
|
194
|
+
"""Return the segment tokens with every leading pass-through wrapper stepped over."""
|
|
195
|
+
all_remaining_tokens = all_segment_tokens
|
|
196
|
+
while True:
|
|
197
|
+
all_stepped_tokens = _all_tokens_after_one_wrapper(all_remaining_tokens)
|
|
198
|
+
if all_stepped_tokens is None:
|
|
199
|
+
return all_remaining_tokens
|
|
200
|
+
all_remaining_tokens = all_stepped_tokens
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def segment_runs_pytest(all_segment_tokens: list[str]) -> bool:
|
|
204
|
+
"""Return True when a simple-command segment invokes pytest.
|
|
205
|
+
|
|
206
|
+
::
|
|
207
|
+
|
|
208
|
+
['pytest', 'tests'] True
|
|
209
|
+
['python', '-m', 'pytest'] True
|
|
210
|
+
['python', '-mpytest'] True
|
|
211
|
+
['time', 'pytest', 'tests'] True
|
|
212
|
+
['sudo', 'pytest', 'tests'] True
|
|
213
|
+
['uv', 'run', 'pytest', 'tests'] True
|
|
214
|
+
['C:\\\\Python313\\\\python.exe', '-m', 'pytest'] True
|
|
215
|
+
['python', '-m', 'mypy'] False
|
|
216
|
+
['git', 'status'] False
|
|
217
|
+
['python', 'myscript.py', '-m', 'pytest'] False
|
|
218
|
+
['uv', 'run', '--with', 'pytest', 'mypy'] False
|
|
219
|
+
[] False
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
all_segment_tokens: Tokens of one simple command, operators removed.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True when the segment's program is pytest or a Python interpreter
|
|
226
|
+
running the pytest module, including through pass-through wrappers.
|
|
227
|
+
"""
|
|
228
|
+
all_unwrapped_tokens = _all_tokens_after_wrappers(all_segment_tokens)
|
|
229
|
+
leading_program = effective_leading_program(all_unwrapped_tokens)
|
|
230
|
+
if leading_program is None:
|
|
231
|
+
return False
|
|
232
|
+
program_basename = token_basename(unquoted_token(leading_program))
|
|
233
|
+
if program_basename in ALL_PYTEST_PROGRAM_BASENAMES:
|
|
234
|
+
return True
|
|
235
|
+
if not PYTHON_INTERPRETER_BASENAME_PATTERN.fullmatch(program_basename):
|
|
236
|
+
return False
|
|
237
|
+
interpreter_index = all_unwrapped_tokens.index(leading_program)
|
|
238
|
+
return _runs_pytest_as_a_module(all_unwrapped_tokens[interpreter_index + 1 :])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _clustered_string_exec_flag_offset(stripped_token: str) -> int | None:
|
|
242
|
+
"""Return how far past a cluster its command string sits, or None when absent."""
|
|
243
|
+
if SHORT_OPTION_CLUSTER_PATTERN.fullmatch(stripped_token) is None:
|
|
244
|
+
return None
|
|
245
|
+
all_cluster_letters = stripped_token[len(SHORT_OPTION_PREFIX) :]
|
|
246
|
+
if not any(
|
|
247
|
+
each_letter in ALL_CLUSTERED_STRING_EXEC_OPTION_LETTERS
|
|
248
|
+
for each_letter in all_cluster_letters
|
|
249
|
+
):
|
|
250
|
+
return None
|
|
251
|
+
return sum(
|
|
252
|
+
SHORT_OPTION_PREFIX + each_letter.lower() in ALL_VALUE_TAKING_SHELL_OPTION_FLAGS
|
|
253
|
+
for each_letter in all_cluster_letters
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _string_exec_flag_index(
|
|
258
|
+
all_argument_tokens: list[str], *, clusters_short_options: bool
|
|
259
|
+
) -> int | None:
|
|
260
|
+
"""Return the index of a wrapper's string-exec flag, or None when it takes none."""
|
|
261
|
+
scan_index = 0
|
|
262
|
+
while scan_index < len(all_argument_tokens):
|
|
263
|
+
stripped_token = unquoted_token(all_argument_tokens[scan_index])
|
|
264
|
+
lowercased_token = stripped_token.lower()
|
|
265
|
+
if lowercased_token == END_OF_OPTIONS_TOKEN:
|
|
266
|
+
return None
|
|
267
|
+
if lowercased_token in ALL_STRING_EXEC_COMMAND_FLAGS:
|
|
268
|
+
return scan_index
|
|
269
|
+
if clusters_short_options:
|
|
270
|
+
clustered_flag_offset = _clustered_string_exec_flag_offset(stripped_token)
|
|
271
|
+
if clustered_flag_offset is not None:
|
|
272
|
+
return scan_index + clustered_flag_offset
|
|
273
|
+
if COMMAND_OPTION_TOKEN_PATTERN.match(lowercased_token) is None:
|
|
274
|
+
return None
|
|
275
|
+
scan_index += 1 + _option_value_token_count(
|
|
276
|
+
lowercased_token, ALL_VALUE_TAKING_SHELL_OPTION_FLAGS
|
|
277
|
+
)
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def string_exec_inner_command(all_segment_tokens: list[str]) -> str | None:
|
|
282
|
+
"""Return the command string a shell wrapper runs, or None for any other segment.
|
|
283
|
+
|
|
284
|
+
::
|
|
285
|
+
|
|
286
|
+
bash -c 'pytest | tee run.log' pytest | tee run.log
|
|
287
|
+
bash -euc 'pytest | tee run.log' pytest | tee run.log
|
|
288
|
+
pwsh -Command 'pytest' pytest
|
|
289
|
+
cmd /c python -m pytest tests python -m pytest tests
|
|
290
|
+
bash scripts/ci.sh -c 'pytest tests' None
|
|
291
|
+
pytest tests None
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
all_segment_tokens: Tokens of one simple command, operators removed.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
The inner command string the shell executes, or None when the segment
|
|
298
|
+
is not a string-executing shell wrapper with a command string.
|
|
299
|
+
"""
|
|
300
|
+
all_unwrapped_tokens = _all_tokens_after_wrappers(all_segment_tokens)
|
|
301
|
+
leading_program = effective_leading_program(all_unwrapped_tokens)
|
|
302
|
+
if leading_program is None:
|
|
303
|
+
return None
|
|
304
|
+
shell_basename = token_basename(unquoted_token(leading_program))
|
|
305
|
+
if shell_basename not in ALL_STRING_EXECUTING_SHELL_BASENAMES:
|
|
306
|
+
return None
|
|
307
|
+
leading_index = all_unwrapped_tokens.index(leading_program)
|
|
308
|
+
all_argument_tokens = all_unwrapped_tokens[leading_index + 1 :]
|
|
309
|
+
flag_index = _string_exec_flag_index(
|
|
310
|
+
all_argument_tokens,
|
|
311
|
+
clusters_short_options=shell_basename in ALL_SHORT_OPTION_CLUSTERING_SHELL_BASENAMES,
|
|
312
|
+
)
|
|
313
|
+
if flag_index is None:
|
|
314
|
+
return None
|
|
315
|
+
inner_index = flag_index + 1
|
|
316
|
+
if inner_index >= len(all_argument_tokens):
|
|
317
|
+
return None
|
|
318
|
+
return WRAPPED_COMMAND_TOKEN_JOIN.join(all_argument_tokens[inner_index:])
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _wrapped_command_runs_pytest(inner_command: str) -> bool:
|
|
322
|
+
"""Return True when the command string a shell wrapper runs invokes pytest."""
|
|
323
|
+
for each_tokenization in all_operator_aware_tokenizations(inner_command):
|
|
324
|
+
for each_segment, _each_operator in segments_with_following_operator(
|
|
325
|
+
each_tokenization
|
|
326
|
+
):
|
|
327
|
+
if segment_runs_pytest(each_segment):
|
|
328
|
+
return True
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def segment_reports_a_pytest_exit_code(all_segment_tokens: list[str]) -> bool:
|
|
333
|
+
"""Return True when the segment's exit code is pytest's, directly or via a wrapper.
|
|
334
|
+
|
|
335
|
+
::
|
|
336
|
+
|
|
337
|
+
['pytest', 'tests'] True
|
|
338
|
+
['bash', '-c', 'pytest tests'] True
|
|
339
|
+
['bash', 'script.sh'] False
|
|
340
|
+
['python', '-m', 'pytest'] True
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
all_segment_tokens: Tokens of one simple command, operators removed.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
True when the segment runs pytest, or a shell wrapper whose command
|
|
347
|
+
string runs pytest (so the shell exits with pytest's code).
|
|
348
|
+
"""
|
|
349
|
+
if segment_runs_pytest(all_segment_tokens):
|
|
350
|
+
return True
|
|
351
|
+
inner_command = string_exec_inner_command(all_segment_tokens)
|
|
352
|
+
if inner_command is None:
|
|
353
|
+
return False
|
|
354
|
+
return _wrapped_command_runs_pytest(inner_command)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Shared SessionStart context injector for starter hooks.
|
|
2
|
+
|
|
3
|
+
Pure decision helper: normalize the SessionStart ``source``, honor enable and
|
|
4
|
+
timeout configuration, and return a structured result every caller can emit as
|
|
5
|
+
``additionalContext`` or ignore.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
inject({"source": "startup"}, config) -> status ok + context for startup
|
|
10
|
+
inject({"source": "resume"}, disabled) -> status disabled, no context
|
|
11
|
+
inject({"source": "weird"}, config) -> status unknown_source
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable, Mapping
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from hooks_constants.session_start_injector_constants import (
|
|
22
|
+
ALL_DEFAULT_CONTEXT_BY_SOURCE,
|
|
23
|
+
ALL_INJECTOR_ENABLED_ENV_VALUES,
|
|
24
|
+
ALL_KNOWN_SESSION_START_SOURCES,
|
|
25
|
+
DEFAULT_INJECTOR_TIMEOUT_MILLISECONDS,
|
|
26
|
+
INJECTION_STATUS_DISABLED,
|
|
27
|
+
INJECTION_STATUS_OK,
|
|
28
|
+
INJECTION_STATUS_TIMEOUT,
|
|
29
|
+
INJECTION_STATUS_UNKNOWN_SOURCE,
|
|
30
|
+
MILLISECONDS_PER_SECOND,
|
|
31
|
+
SESSION_START_INJECTOR_ENABLED_ENV_VAR,
|
|
32
|
+
SESSION_START_SOURCE_PAYLOAD_KEY,
|
|
33
|
+
SESSION_START_SOURCE_UNKNOWN,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class InjectorConfiguration:
|
|
39
|
+
"""Runtime knobs for one SessionStart injection.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
is_enabled: When False, inject returns quickly with status disabled.
|
|
43
|
+
timeout_milliseconds: Budget; at or below zero yields status timeout.
|
|
44
|
+
context_by_source: Map of known source -> additionalContext text.
|
|
45
|
+
default_context_for_unknown: Context when source is not a known value.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
is_enabled: bool
|
|
49
|
+
timeout_milliseconds: int
|
|
50
|
+
context_by_source: Mapping[str, str]
|
|
51
|
+
default_context_for_unknown: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class InjectionResult:
|
|
56
|
+
"""Structured outcome of one inject call.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
source: Normalized SessionStart source (or ``unknown``).
|
|
60
|
+
status: One of the INJECTION_STATUS_* constants.
|
|
61
|
+
additional_context: Text to emit, empty when not injected.
|
|
62
|
+
latency_milliseconds: Wall time for this inject call.
|
|
63
|
+
is_context_injected: True when additional_context is non-empty.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
source: str
|
|
67
|
+
status: str
|
|
68
|
+
additional_context: str
|
|
69
|
+
latency_milliseconds: float
|
|
70
|
+
is_context_injected: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def injector_enabled_in_environment() -> bool:
|
|
74
|
+
"""Return whether the injector is enabled from the environment.
|
|
75
|
+
|
|
76
|
+
Unset or empty means enabled (default on). Explicit falsey values disable.
|
|
77
|
+
"""
|
|
78
|
+
raw_setting = os.environ.get(SESSION_START_INJECTOR_ENABLED_ENV_VAR)
|
|
79
|
+
if raw_setting is None or raw_setting.strip() == "":
|
|
80
|
+
return True
|
|
81
|
+
return raw_setting.strip().lower() in ALL_INJECTOR_ENABLED_ENV_VALUES
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def default_injector_configuration() -> InjectorConfiguration:
|
|
85
|
+
"""Build the default configuration from environment and constants."""
|
|
86
|
+
return InjectorConfiguration(
|
|
87
|
+
is_enabled=injector_enabled_in_environment(),
|
|
88
|
+
timeout_milliseconds=DEFAULT_INJECTOR_TIMEOUT_MILLISECONDS,
|
|
89
|
+
context_by_source=dict(ALL_DEFAULT_CONTEXT_BY_SOURCE),
|
|
90
|
+
default_context_for_unknown="",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def normalize_session_start_source(payload_by_key: Mapping[str, object]) -> str:
|
|
95
|
+
"""Return the SessionStart source string, or unknown when missing/invalid."""
|
|
96
|
+
raw_source = payload_by_key.get(SESSION_START_SOURCE_PAYLOAD_KEY, "")
|
|
97
|
+
if not isinstance(raw_source, str):
|
|
98
|
+
return SESSION_START_SOURCE_UNKNOWN
|
|
99
|
+
normalized_source = raw_source.strip().lower()
|
|
100
|
+
if not normalized_source:
|
|
101
|
+
return SESSION_START_SOURCE_UNKNOWN
|
|
102
|
+
if normalized_source not in ALL_KNOWN_SESSION_START_SOURCES:
|
|
103
|
+
return SESSION_START_SOURCE_UNKNOWN
|
|
104
|
+
return normalized_source
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def inject_session_start_context(
|
|
108
|
+
payload_by_key: Mapping[str, object],
|
|
109
|
+
configuration: InjectorConfiguration,
|
|
110
|
+
*,
|
|
111
|
+
clock: Callable[[], float] | None = None,
|
|
112
|
+
) -> InjectionResult:
|
|
113
|
+
"""Decide SessionStart context for one payload under the given configuration.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
payload_by_key: Parsed SessionStart stdin payload.
|
|
117
|
+
configuration: Enable flag, timeout budget, and per-source context.
|
|
118
|
+
clock: Optional monotonic clock for latency (tests inject a stub).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
An InjectionResult with status, source, context, and latency.
|
|
122
|
+
"""
|
|
123
|
+
read_clock = clock if clock is not None else time.perf_counter
|
|
124
|
+
start_time = read_clock()
|
|
125
|
+
normalized_source = normalize_session_start_source(payload_by_key)
|
|
126
|
+
|
|
127
|
+
if not configuration.is_enabled:
|
|
128
|
+
status = INJECTION_STATUS_DISABLED
|
|
129
|
+
context_text = ""
|
|
130
|
+
elif configuration.timeout_milliseconds <= 0:
|
|
131
|
+
status = INJECTION_STATUS_TIMEOUT
|
|
132
|
+
context_text = ""
|
|
133
|
+
elif normalized_source == SESSION_START_SOURCE_UNKNOWN:
|
|
134
|
+
status = INJECTION_STATUS_UNKNOWN_SOURCE
|
|
135
|
+
context_text = configuration.default_context_for_unknown
|
|
136
|
+
else:
|
|
137
|
+
status = INJECTION_STATUS_OK
|
|
138
|
+
context_text = configuration.context_by_source.get(normalized_source, "")
|
|
139
|
+
|
|
140
|
+
end_time = read_clock()
|
|
141
|
+
return InjectionResult(
|
|
142
|
+
source=normalized_source,
|
|
143
|
+
status=status,
|
|
144
|
+
additional_context=context_text,
|
|
145
|
+
latency_milliseconds=(end_time - start_time)
|
|
146
|
+
* MILLISECONDS_PER_SECOND,
|
|
147
|
+
is_context_injected=bool(context_text),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_additional_context_payload(injection_result: InjectionResult) -> dict[str, str]:
|
|
152
|
+
"""Return the SessionStart stdout object when context was injected.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
injection_result: Result from inject_session_start_context.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
``{"additionalContext": ...}`` when injected, else empty dict.
|
|
159
|
+
"""
|
|
160
|
+
if not injection_result.is_context_injected:
|
|
161
|
+
return {}
|
|
162
|
+
return {"additionalContext": injection_result.additional_context}
|
|
163
|
+
|