claude-dev-env 7.4.1 → 7.6.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/.agents/skills/everything-search/scripts/everything_search.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +1 -0
- package/.agents/skills/fresh-branch/SKILL.md +1 -0
- package/.agents/skills/fresh-branch/scripts/fresh_branch_git_commands.py +3 -0
- package/.agents/skills/fresh-branch/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/syncing-submodules/SKILL.md +1 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync.py +4 -0
- package/.agents/skills/syncing-submodules/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/test-runner/SKILL.md +1 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +5 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +11 -0
- package/.agents/skills/test-runner/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +9 -7
- package/_shared/pr-loop/scripts/check_bugbot_ci.py +3 -0
- package/_shared/pr-loop/scripts/check_convergence_gates.py +3 -0
- package/_shared/pr-loop/scripts/check_pending_reviews.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +5 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +4 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +3 -0
- package/_shared/pr-loop/scripts/codex_usage_probe.py +3 -0
- package/_shared/pr-loop/scripts/copilot_quota.py +3 -0
- package/_shared/pr-loop/scripts/fetch_copilot_reviews.py +3 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +7 -0
- package/_shared/pr-loop/scripts/post_fix_reply.py +4 -0
- package/_shared/pr-loop/scripts/preflight.py +7 -0
- package/_shared/pr-loop/scripts/preflight_self_heal.py +5 -0
- package/_shared/pr-loop/scripts/run_codex_review.py +3 -0
- package/_shared/pr-loop/scripts/subprocess_window_access.py +41 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +5 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +3 -0
- package/_shared/process-tree/scripts/subprocess_window_access.py +41 -0
- package/bin/install-constants.mjs +4 -2
- package/bin/install.cursor-rules.test.mjs +57 -15
- package/bin/install.mjs +10 -16
- package/bin/install.policy-lint.test.mjs +1 -2
- package/bin/install.test.mjs +0 -1
- package/bin/pstack-model-preferences.claude.json +23 -0
- package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
- package/hooks/advisory/pr_done_reminder.py +14 -13
- package/hooks/advisory/refactor_guard.py +8 -0
- package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
- package/hooks/advisory/test_pr_done_reminder.py +22 -0
- package/hooks/advisory/test_refactor_guard.py +40 -0
- package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
- package/hooks/blocking/block_main_commit.py +4 -0
- package/hooks/blocking/code_rules_comments.py +39 -5
- package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +3 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +19 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +4 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +25 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
- package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +10 -0
- package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +0 -2
- package/hooks/git-hooks/pull_request_handoff.py +3 -0
- package/hooks/git-hooks/subprocess_window_access.py +41 -0
- package/hooks/git-hooks/test_verification_start.py +4 -0
- package/hooks/git-hooks/verification_notice_context.py +3 -0
- package/hooks/git-hooks/verification_start.py +7 -8
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
- package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
- package/hooks/hooks_constants/post_tool_use_context.py +34 -0
- package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
- package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
- package/hooks/hooks_constants/subprocess_window.py +141 -0
- package/hooks/hooks_constants/subprocess_window_constants.py +9 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
- package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
- package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
- package/hooks/hooks_constants/test_subprocess_window.py +128 -0
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +3 -0
- package/hooks/observability/test_failure_recorder.py +3 -1
- package/hooks/validation/mypy_validator.py +4 -0
- package/hooks/validators/README.md +1 -0
- package/hooks/validators/git_checks.py +4 -0
- package/hooks/validators/health_check.py +3 -0
- package/hooks/validators/hook_timing_harness.py +3 -0
- package/hooks/validators/mypy_integration.py +4 -0
- package/hooks/validators/ruff_integration.py +6 -0
- package/hooks/validators/run_all_validators.py +7 -0
- package/hooks/validators/subprocess_window_access.py +41 -0
- package/hooks/workflow/auto_formatter.py +8 -0
- package/hooks/workflow/test_auto_formatter.py +39 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +3 -3
- package/rules/code-standards.md +3 -2
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/destructive-commands.md +19 -19
- package/rules/doc-inventory-integrity.md +4 -4
- package/rules/docstring-prose-matches-implementation.md +2 -2
- package/rules/failure-blast-radius.md +4 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +34 -0
- package/rules/long-horizon-autonomy.md +2 -0
- package/rules/nas-ssh-invocation.md +3 -3
- package/rules/no-cross-skill-duplicate-helpers.md +6 -6
- package/rules/orphan-css-class.md +5 -5
- package/rules/paired-test-coverage.md +6 -6
- package/rules/plain-illustrative-docstrings.md +3 -3
- package/rules/pstack-models.md +4 -3
- package/rules/re-stage-before-commit.md +7 -7
- package/rules/retired-hook-prose.md +16 -0
- package/rules/shell-invocation.md +17 -1
- package/rules/testing.md +3 -5
- package/rules/verify-runtime-state.md +1 -1
- package/rules/windows-filesystem-safe.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/automatic_advisory/checkout.py +8 -1
- package/scripts/automatic_advisory/cli.py +9 -3
- package/scripts/automatic_advisory/config/constants.py +0 -2
- package/scripts/automatic_advisory/execution.py +7 -1
- package/scripts/automatic_advisory/git.py +9 -1
- package/scripts/automatic_advisory/process_host.py +3 -0
- package/scripts/claude_chain_runner.py +3 -0
- package/scripts/durable_post_lint_config/config/constants.py +0 -3
- package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
- package/scripts/gh_artifact_upload.py +3 -0
- package/scripts/grok_headless_runner.py +3 -0
- package/scripts/grok_patch_artifacts.py +4 -0
- package/scripts/grok_worker_preflight.py +3 -0
- package/scripts/invoke_code_review.py +3 -0
- package/scripts/local_verification/command_runner.py +3 -0
- package/scripts/local_verification/git_state.py +3 -0
- package/scripts/policy_lint/adapter_configuration.py +24 -0
- package/scripts/policy_lint/adapter_detectors.py +48 -2
- package/scripts/policy_lint/adapter_pairing.py +150 -2
- package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
- package/scripts/policy_lint/adapters.py +82 -5
- package/scripts/policy_lint/config/approved_test_pairs.py +25 -9
- package/scripts/policy_lint/config/constants.py +77 -0
- package/scripts/policy_lint/registry.py +6 -1
- package/scripts/policy_lint/selection.py +37 -18
- package/scripts/policy_lint/selection_git.py +17 -2
- package/scripts/repository_checks/config/constants.py +5 -0
- package/scripts/repository_checks/config/policy_document.py +19 -0
- package/scripts/repository_checks/email_exemptions.py +14 -71
- package/scripts/repository_checks/path_exemptions.py +35 -0
- package/scripts/repository_checks/policy_document.py +120 -0
- package/scripts/repository_checks/tracked_secrets.py +27 -4
- package/scripts/setup_project_paths.py +3 -0
- package/scripts/spawn_grok_batch.py +3 -0
- package/scripts/subprocess_window_access.py +41 -0
- package/scripts/test_dispatcher_profile_import.py +4 -0
- package/scripts/test_durable_post_lint.py +42 -9
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +17 -11
- package/scripts/tests/test_email_exemptions.py +37 -0
- package/scripts/tests/test_path_exemptions.py +164 -0
- package/scripts/tests/test_policy_document.py +104 -0
- package/scripts/tests/test_policy_lint_rules_function_length_scope.py +101 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +375 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +116 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
- package/scripts/tests/test_policy_lint_selection.py +141 -0
- package/scripts/tests/test_subprocess_window_access.py +118 -0
- package/scripts/tests/test_tracked_secrets.py +79 -0
- package/scripts/validate_instruction_pairs.py +3 -0
- package/scripts/verify_installable_package.py +5 -0
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
- package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
- package/hooks/hooks_constants/test_text_stripping.py +0 -39
- package/hooks/hooks_constants/text_stripping.py +0 -36
- package/scripts/automatic_advisory/window_flags.py +0 -41
- package/scripts/repository_checks/config/email_exemptions.py +0 -10
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Tests for the shared PreToolUse allow-with-rewrite stdout emitter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _emitted_payload(updated_tool_input: dict[str, object]) -> dict[str, object]:
|
|
13
|
+
"""Return the parsed payload the emitter wrote for one updated tool input."""
|
|
14
|
+
captured_stdout = StringIO()
|
|
15
|
+
with patch("sys.stdout", captured_stdout):
|
|
16
|
+
write_pre_tool_use_allow_to_stdout(updated_tool_input)
|
|
17
|
+
return json.loads(captured_stdout.getvalue())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_emitter_writes_an_allow_decision_carrying_the_updated_input() -> None:
|
|
21
|
+
assert _emitted_payload({"command": "git status", "description": "check"}) == {
|
|
22
|
+
"hookSpecificOutput": {
|
|
23
|
+
"hookEventName": "PreToolUse",
|
|
24
|
+
"permissionDecision": "allow",
|
|
25
|
+
"updatedInput": {"command": "git status", "description": "check"},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Tests for the shared hidden-console creation flags."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
11
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
13
|
+
|
|
14
|
+
from hooks_constants.subprocess_window import (
|
|
15
|
+
detached_hidden_window_creation_flags,
|
|
16
|
+
hidden_window_creation_flags,
|
|
17
|
+
inherited_stream_startup_info,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
WINDOWS_NO_WINDOW_FLAG = 0x08000000
|
|
21
|
+
WINDOWS_NEW_PROCESS_GROUP_FLAG = 0x00000200
|
|
22
|
+
WINDOWS_ONLY = pytest.mark.skipif(
|
|
23
|
+
sys.platform != "win32", reason="subprocess.STARTUPINFO exists only on Windows"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_windows_reports_the_hidden_console_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
28
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
29
|
+
monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", WINDOWS_NO_WINDOW_FLAG, raising=False)
|
|
30
|
+
assert hidden_window_creation_flags() == WINDOWS_NO_WINDOW_FLAG
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_other_platforms_report_no_creation_flags(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
34
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
35
|
+
assert hidden_window_creation_flags() == 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_windows_without_the_flag_attribute_reports_no_creation_flags(
|
|
39
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
40
|
+
) -> None:
|
|
41
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
42
|
+
monkeypatch.delattr(subprocess, "CREATE_NO_WINDOW", raising=False)
|
|
43
|
+
assert hidden_window_creation_flags() == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_windows_detached_flags_add_a_new_process_group(
|
|
47
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
48
|
+
) -> None:
|
|
49
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
50
|
+
monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", WINDOWS_NO_WINDOW_FLAG, raising=False)
|
|
51
|
+
monkeypatch.setattr(
|
|
52
|
+
subprocess,
|
|
53
|
+
"CREATE_NEW_PROCESS_GROUP",
|
|
54
|
+
WINDOWS_NEW_PROCESS_GROUP_FLAG,
|
|
55
|
+
raising=False,
|
|
56
|
+
)
|
|
57
|
+
assert detached_hidden_window_creation_flags() == (
|
|
58
|
+
WINDOWS_NO_WINDOW_FLAG | WINDOWS_NEW_PROCESS_GROUP_FLAG
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_other_platforms_report_no_detached_creation_flags(
|
|
63
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
64
|
+
) -> None:
|
|
65
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
66
|
+
assert detached_hidden_window_creation_flags() == 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _captured_run_options(monkeypatch: pytest.MonkeyPatch) -> Mapping[str, Any]:
|
|
70
|
+
"""Run one command through a patched subprocess.run and return its options."""
|
|
71
|
+
all_captured_options: dict[str, Any] = {}
|
|
72
|
+
|
|
73
|
+
def fake_run(*_all_arguments: Any, **all_options: Any) -> None:
|
|
74
|
+
all_captured_options.update(all_options)
|
|
75
|
+
|
|
76
|
+
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
77
|
+
subprocess.run(
|
|
78
|
+
["git", "status"],
|
|
79
|
+
creationflags=hidden_window_creation_flags(),
|
|
80
|
+
)
|
|
81
|
+
return all_captured_options
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_a_call_site_passes_the_hidden_flag_on_windows(
|
|
85
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
86
|
+
) -> None:
|
|
87
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
88
|
+
monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", WINDOWS_NO_WINDOW_FLAG, raising=False)
|
|
89
|
+
assert _captured_run_options(monkeypatch)["creationflags"] == WINDOWS_NO_WINDOW_FLAG
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_a_call_site_passes_the_default_flags_off_windows(
|
|
93
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
94
|
+
) -> None:
|
|
95
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
96
|
+
assert _captured_run_options(monkeypatch)["creationflags"] == 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@WINDOWS_ONLY
|
|
100
|
+
def test_windows_startup_info_asks_for_a_hidden_window(
|
|
101
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
102
|
+
) -> None:
|
|
103
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
104
|
+
startup_information = inherited_stream_startup_info()
|
|
105
|
+
assert startup_information is not None
|
|
106
|
+
assert startup_information.dwFlags & subprocess.STARTF_USESHOWWINDOW
|
|
107
|
+
assert startup_information.wShowWindow == subprocess.SW_HIDE
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_other_platforms_report_no_startup_info(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
111
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
112
|
+
assert inherited_stream_startup_info() is None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@WINDOWS_ONLY
|
|
116
|
+
def test_a_stream_inheriting_call_site_passes_no_creation_flags(
|
|
117
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
118
|
+
) -> None:
|
|
119
|
+
all_captured_options: dict[str, Any] = {}
|
|
120
|
+
|
|
121
|
+
def fake_run(*_all_arguments: Any, **all_options: Any) -> None:
|
|
122
|
+
all_captured_options.update(all_options)
|
|
123
|
+
|
|
124
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
125
|
+
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
126
|
+
subprocess.run(["python", "-m", "pytest"], startupinfo=inherited_stream_startup_info())
|
|
127
|
+
assert "creationflags" not in all_captured_options
|
|
128
|
+
assert all_captured_options["startupinfo"] is not None
|
|
@@ -44,6 +44,8 @@ from hooks_constants.enter_worktree_prefetch_constants import ( # noqa: E402
|
|
|
44
44
|
)
|
|
45
45
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
46
46
|
|
|
47
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
48
|
+
|
|
47
49
|
|
|
48
50
|
def is_enter_worktree_creation(payload_by_field: dict[str, object]) -> bool:
|
|
49
51
|
"""Return True when this hook invocation is an EnterWorktree creation call.
|
|
@@ -91,6 +93,7 @@ def _run_git_quietly(
|
|
|
91
93
|
timeout=timeout_seconds,
|
|
92
94
|
cwd=repo_directory,
|
|
93
95
|
check=False,
|
|
96
|
+
creationflags=hidden_window_creation_flags(),
|
|
94
97
|
)
|
|
95
98
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
96
99
|
return None
|
|
@@ -101,6 +101,9 @@ try:
|
|
|
101
101
|
for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
|
|
102
102
|
if each_bootstrap_directory not in sys.path:
|
|
103
103
|
sys.path.insert(0, each_bootstrap_directory)
|
|
104
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
105
|
+
EXIT_CODE_ERROR_PREFIX,
|
|
106
|
+
)
|
|
104
107
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
105
108
|
from hooks_constants.test_failure_recorder_constants import (
|
|
106
109
|
ALL_CHAINING_OPERATOR_SUBSTRINGS,
|
|
@@ -108,7 +111,6 @@ try:
|
|
|
108
111
|
ALL_PYTEST_PROGRAM_BASENAMES,
|
|
109
112
|
ALL_REDIRECTION_TOKENS,
|
|
110
113
|
BASH_TOOL_NAME,
|
|
111
|
-
EXIT_CODE_ERROR_PREFIX,
|
|
112
114
|
MODULE_RUN_FLAG,
|
|
113
115
|
NODE_ID_FILE_PATH_SEPARATOR,
|
|
114
116
|
OPTION_TOKEN_PREFIX,
|
|
@@ -57,6 +57,8 @@ from hooks_constants.mypy_validator_cache_constants import ( # noqa: E402
|
|
|
57
57
|
)
|
|
58
58
|
from json_file_reader import read_json_object # noqa: E402
|
|
59
59
|
|
|
60
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
61
|
+
|
|
60
62
|
|
|
61
63
|
def load_notification_utils() -> ModuleType | None:
|
|
62
64
|
try:
|
|
@@ -88,6 +90,7 @@ def discover_project_root(target_file: str) -> Path | None:
|
|
|
88
90
|
text=True,
|
|
89
91
|
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
90
92
|
cwd=str(Path(target_file).parent),
|
|
93
|
+
creationflags=hidden_window_creation_flags(),
|
|
91
94
|
)
|
|
92
95
|
if completed_process.returncode != 0:
|
|
93
96
|
return None
|
|
@@ -493,6 +496,7 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
|
493
496
|
env=os.environ.copy(),
|
|
494
497
|
timeout=MYPY_TIMEOUT_SECONDS,
|
|
495
498
|
cwd=project_root,
|
|
499
|
+
creationflags=hidden_window_creation_flags(),
|
|
496
500
|
)
|
|
497
501
|
|
|
498
502
|
stdout_output = completed_process.stdout.strip()
|
|
@@ -31,6 +31,7 @@ The checks live in `python_style_checks.py`. Shared source-line splitting, funct
|
|
|
31
31
|
| `ruff_integration.py` | Ruff lint integration |
|
|
32
32
|
| `run_all_validators.py` | Pre-push validator orchestration and report |
|
|
33
33
|
| `security_checks.py` | Hardcoded-secret, SQL injection, and XSS checks |
|
|
34
|
+
| `subprocess_window_access.py` | Deployed-path shim re-exporting the hidden-window helpers |
|
|
34
35
|
| `system_temporary_roots.py` | System temp-root membership for staged copies |
|
|
35
36
|
| `todo_checks.py` | TODO/FIXME tracking with issue-reference requirement |
|
|
36
37
|
| `type_safety_checks.py` | Missing type hints and Any usage checks |
|
|
@@ -6,6 +6,8 @@ import sys
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from typing import List
|
|
8
8
|
|
|
9
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
10
|
+
|
|
9
11
|
SUBPROCESS_TIMEOUT_SECONDS = 30
|
|
10
12
|
|
|
11
13
|
|
|
@@ -26,6 +28,7 @@ def get_current_branch() -> str:
|
|
|
26
28
|
text=True,
|
|
27
29
|
check=True,
|
|
28
30
|
timeout=SUBPROCESS_TIMEOUT_SECONDS,
|
|
31
|
+
creationflags=hidden_window_creation_flags(),
|
|
29
32
|
)
|
|
30
33
|
return result.stdout.strip()
|
|
31
34
|
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
@@ -55,6 +58,7 @@ def check_draft_pr_state() -> List[Violation]:
|
|
|
55
58
|
text=True,
|
|
56
59
|
check=True,
|
|
57
60
|
timeout=SUBPROCESS_TIMEOUT_SECONDS,
|
|
61
|
+
creationflags=hidden_window_creation_flags(),
|
|
58
62
|
)
|
|
59
63
|
except FileNotFoundError:
|
|
60
64
|
return []
|
|
@@ -16,6 +16,8 @@ from enum import StrEnum
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Dict, Optional
|
|
18
18
|
|
|
19
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
20
|
+
|
|
19
21
|
VALIDATOR_FILES = [
|
|
20
22
|
"python_style_checks.py",
|
|
21
23
|
"test_safety_checks.py",
|
|
@@ -144,6 +146,7 @@ def check_optional_tool(tool_name: str) -> bool:
|
|
|
144
146
|
check=False,
|
|
145
147
|
capture_output=True,
|
|
146
148
|
text=True,
|
|
149
|
+
creationflags=hidden_window_creation_flags(),
|
|
147
150
|
)
|
|
148
151
|
return result.returncode == 0
|
|
149
152
|
except FileNotFoundError:
|
|
@@ -41,6 +41,8 @@ from pathlib import Path
|
|
|
41
41
|
from types import ModuleType
|
|
42
42
|
from typing import TextIO
|
|
43
43
|
|
|
44
|
+
from subprocess_window_access import hidden_window_creation_flags
|
|
45
|
+
|
|
44
46
|
|
|
45
47
|
def _load_module_from_path(module_name: str, module_path: Path) -> ModuleType:
|
|
46
48
|
"""Load and execute the module found at module_path, under module_name.
|
|
@@ -197,6 +199,7 @@ def run_hosted_command_once_milliseconds(
|
|
|
197
199
|
text=True,
|
|
198
200
|
timeout=_harness_constants.SUBPROCESS_TIMEOUT_SECONDS,
|
|
199
201
|
check=False,
|
|
202
|
+
creationflags=hidden_window_creation_flags(),
|
|
200
203
|
)
|
|
201
204
|
finished_at = time.perf_counter()
|
|
202
205
|
return (finished_at - started_at) * 1000.0
|
|
@@ -9,6 +9,8 @@ from collections.abc import Iterator
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
13
|
+
|
|
12
14
|
_validators_directory = str(Path(__file__).resolve().parent)
|
|
13
15
|
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
14
16
|
|
|
@@ -66,6 +68,7 @@ def check_mypy_available() -> bool:
|
|
|
66
68
|
check=False,
|
|
67
69
|
capture_output=True,
|
|
68
70
|
text=True,
|
|
71
|
+
creationflags=hidden_window_creation_flags(),
|
|
69
72
|
)
|
|
70
73
|
return result.returncode == 0
|
|
71
74
|
except FileNotFoundError:
|
|
@@ -224,6 +227,7 @@ def _run_mypy_subprocess(
|
|
|
224
227
|
text=True,
|
|
225
228
|
cwd=working_directory,
|
|
226
229
|
timeout=timeout_seconds,
|
|
230
|
+
creationflags=hidden_window_creation_flags(),
|
|
227
231
|
)
|
|
228
232
|
except subprocess.TimeoutExpired:
|
|
229
233
|
logger.warning(detached_timeout_skip_message)
|
|
@@ -6,6 +6,8 @@ import sys
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
10
|
+
|
|
9
11
|
_validators_directory = str(Path(__file__).resolve().parent)
|
|
10
12
|
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
11
13
|
|
|
@@ -53,6 +55,7 @@ def check_ruff_available() -> bool:
|
|
|
53
55
|
check=False,
|
|
54
56
|
capture_output=True,
|
|
55
57
|
text=True,
|
|
58
|
+
creationflags=hidden_window_creation_flags(),
|
|
56
59
|
)
|
|
57
60
|
return result.returncode == 0
|
|
58
61
|
except FileNotFoundError:
|
|
@@ -201,6 +204,7 @@ def _staged_ruff_result(
|
|
|
201
204
|
input=staged_content,
|
|
202
205
|
cwd=str(resolved_pyproject.parent),
|
|
203
206
|
env=_ruff_subprocess_environment(),
|
|
207
|
+
creationflags=hidden_window_creation_flags(),
|
|
204
208
|
)
|
|
205
209
|
return RuffResult(
|
|
206
210
|
passed=result.returncode == 0,
|
|
@@ -247,6 +251,7 @@ def _run_native_ruff_check(
|
|
|
247
251
|
capture_output=True,
|
|
248
252
|
text=True,
|
|
249
253
|
env=_ruff_subprocess_environment(),
|
|
254
|
+
creationflags=hidden_window_creation_flags(),
|
|
250
255
|
)
|
|
251
256
|
return RuffResult(
|
|
252
257
|
passed=result.returncode == 0,
|
|
@@ -308,6 +313,7 @@ def run_ruff_fix(all_files: list[Path]) -> RuffResult:
|
|
|
308
313
|
capture_output=True,
|
|
309
314
|
text=True,
|
|
310
315
|
env=_ruff_subprocess_environment(),
|
|
316
|
+
creationflags=hidden_window_creation_flags(),
|
|
311
317
|
)
|
|
312
318
|
return RuffResult(
|
|
313
319
|
passed=result.returncode == 0,
|
|
@@ -60,6 +60,8 @@ from hooks_constants.multi_edit_reconstruction import (
|
|
|
60
60
|
edits_for_tool,
|
|
61
61
|
)
|
|
62
62
|
|
|
63
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
64
|
+
|
|
63
65
|
VALIDATORS_DIR = Path(__file__).parent
|
|
64
66
|
hooks_dir = VALIDATORS_DIR.parent
|
|
65
67
|
package_name = VALIDATORS_DIR.name
|
|
@@ -131,6 +133,7 @@ def invoke_validator_module(module_stem: str, forwarded_file_paths: List[str]) -
|
|
|
131
133
|
text=True,
|
|
132
134
|
cwd=working_directory_string,
|
|
133
135
|
env=environment,
|
|
136
|
+
creationflags=hidden_window_creation_flags(),
|
|
134
137
|
)
|
|
135
138
|
|
|
136
139
|
|
|
@@ -160,6 +163,7 @@ def run_validators_entrypoint_subprocess(
|
|
|
160
163
|
cwd=working_directory_string,
|
|
161
164
|
env=environment,
|
|
162
165
|
input=stdin_text,
|
|
166
|
+
creationflags=hidden_window_creation_flags(),
|
|
163
167
|
)
|
|
164
168
|
|
|
165
169
|
|
|
@@ -347,6 +351,7 @@ def get_project_root() -> Optional[Path]:
|
|
|
347
351
|
["git", "-C", str(hooks_dir), "rev-parse", "--show-toplevel"],
|
|
348
352
|
capture_output=True,
|
|
349
353
|
text=True,
|
|
354
|
+
creationflags=hidden_window_creation_flags(),
|
|
350
355
|
)
|
|
351
356
|
if completed_git_lookup.returncode == 0:
|
|
352
357
|
return Path(completed_git_lookup.stdout.strip())
|
|
@@ -674,6 +679,7 @@ def get_changed_files() -> List[Path]:
|
|
|
674
679
|
["git", "diff", "--cached", "--name-only"],
|
|
675
680
|
capture_output=True,
|
|
676
681
|
text=True,
|
|
682
|
+
creationflags=hidden_window_creation_flags(),
|
|
677
683
|
)
|
|
678
684
|
|
|
679
685
|
files = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
|
@@ -684,6 +690,7 @@ def get_changed_files() -> List[Path]:
|
|
|
684
690
|
["git", "diff", "--name-only", "HEAD~1"],
|
|
685
691
|
capture_output=True,
|
|
686
692
|
text=True,
|
|
693
|
+
creationflags=hidden_window_creation_flags(),
|
|
687
694
|
)
|
|
688
695
|
files = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
|
689
696
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Reach the hidden-window helpers from the scripts this directory deploys.
|
|
2
|
+
|
|
3
|
+
The installer copies this directory into a profile that holds no installed
|
|
4
|
+
Python distribution, and this repository's own tests run the deployed scripts
|
|
5
|
+
under ``python -S -E``, which hides every installed package from the child
|
|
6
|
+
process. On both routes ``hooks_constants`` is reachable only through the
|
|
7
|
+
deployed ``hooks`` directory, so this module puts that directory on
|
|
8
|
+
``sys.path`` and re-exports the helpers a caller asks for::
|
|
9
|
+
|
|
10
|
+
from subprocess_window_access import hidden_window_creation_flags
|
|
11
|
+
|
|
12
|
+
The directory goes on the end of ``sys.path``. ``PathFinder`` runs ahead of
|
|
13
|
+
every editable-install finder, so a trailing entry still selects the deployed
|
|
14
|
+
copy, and the head of ``sys.path`` stays exactly as the caller left it. A
|
|
15
|
+
caller that prepended its own roots keeps them first.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
_subprocess_window_hooks_directory = str(
|
|
24
|
+
Path(__file__).resolve().parents[2] / "hooks"
|
|
25
|
+
)
|
|
26
|
+
if _subprocess_window_hooks_directory not in sys.path:
|
|
27
|
+
sys.path.append(_subprocess_window_hooks_directory)
|
|
28
|
+
|
|
29
|
+
from hooks_constants.subprocess_window import (
|
|
30
|
+
HiddenWindowStartupInfo,
|
|
31
|
+
detached_hidden_window_creation_flags,
|
|
32
|
+
hidden_window_creation_flags,
|
|
33
|
+
inherited_stream_startup_info,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"HiddenWindowStartupInfo",
|
|
38
|
+
"detached_hidden_window_creation_flags",
|
|
39
|
+
"hidden_window_creation_flags",
|
|
40
|
+
"inherited_stream_startup_info",
|
|
41
|
+
]
|
|
@@ -14,6 +14,12 @@ from collections.abc import Mapping
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from types import ModuleType
|
|
16
16
|
|
|
17
|
+
_subprocess_window_hooks_directory = str(Path(__file__).resolve().parents[2] / "hooks")
|
|
18
|
+
if _subprocess_window_hooks_directory not in sys.path:
|
|
19
|
+
sys.path.append(_subprocess_window_hooks_directory)
|
|
20
|
+
|
|
21
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
22
|
+
|
|
17
23
|
NOTIFICATION_UTILS_DIRECTORY = os.path.join(
|
|
18
24
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "notification"
|
|
19
25
|
)
|
|
@@ -118,6 +124,7 @@ def is_untracked_in_git(file_path: str) -> bool:
|
|
|
118
124
|
cwd=containing_directory,
|
|
119
125
|
timeout=GIT_LS_FILES_TIMEOUT_SECONDS,
|
|
120
126
|
env=_build_git_command_environment(),
|
|
127
|
+
creationflags=hidden_window_creation_flags(),
|
|
121
128
|
)
|
|
122
129
|
return git_check.returncode != 0
|
|
123
130
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
@@ -198,6 +205,7 @@ def _run_command(
|
|
|
198
205
|
capture_output=True,
|
|
199
206
|
text=True,
|
|
200
207
|
timeout=timeout_seconds,
|
|
208
|
+
creationflags=hidden_window_creation_flags(),
|
|
201
209
|
)
|
|
202
210
|
except FileNotFoundError:
|
|
203
211
|
return None, False
|
|
@@ -35,6 +35,8 @@ HOOKS_JSON_PATH = os.path.join(HOOKS_DIRECTORY_PATH, "hooks.json")
|
|
|
35
35
|
POST_TOOL_USE_DISPATCHER_COMMAND_FRAGMENT = "validation/post_tool_use_dispatcher.py"
|
|
36
36
|
UNUSED_IMPORT_SOURCE = "import os\n\n\nVALUE = 1\n"
|
|
37
37
|
HOOK_RUN_TIMEOUT_SECONDS = 60
|
|
38
|
+
WINDOWS_NO_WINDOW_FLAG = 0x08000000
|
|
39
|
+
EXPECTED_HIDDEN_WINDOW_FLAGS = WINDOWS_NO_WINDOW_FLAG if sys.platform == "win32" else 0
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
def build_fixture_git_environment() -> dict[str, str]:
|
|
@@ -415,3 +417,40 @@ class TestPythonFormatTimeoutBudget:
|
|
|
415
417
|
budgeted_total = auto_formatter_module.budgeted_python_format_seconds()
|
|
416
418
|
|
|
417
419
|
assert budgeted_total < _registered_auto_formatter_timeout()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _record_creation_flags(
|
|
423
|
+
auto_formatter_module: ModuleType, monkeypatch: pytest.MonkeyPatch
|
|
424
|
+
) -> dict[str, object]:
|
|
425
|
+
all_recorded_keyword_arguments: dict[str, object] = {}
|
|
426
|
+
|
|
427
|
+
def _record(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
428
|
+
del args
|
|
429
|
+
all_recorded_keyword_arguments.update(kwargs)
|
|
430
|
+
return subprocess.CompletedProcess([], 1, stdout="", stderr="")
|
|
431
|
+
|
|
432
|
+
monkeypatch.setattr(auto_formatter_module.subprocess, "run", _record)
|
|
433
|
+
return all_recorded_keyword_arguments
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def test_untracked_probe_should_start_without_a_console_window(
|
|
437
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
438
|
+
) -> None:
|
|
439
|
+
auto_formatter_module = _load_auto_formatter_module()
|
|
440
|
+
all_recorded_keyword_arguments = _record_creation_flags(auto_formatter_module, monkeypatch)
|
|
441
|
+
|
|
442
|
+
assert auto_formatter_module.is_untracked_in_git(str(tmp_path / "module.py")) is True
|
|
443
|
+
assert all_recorded_keyword_arguments["creationflags"] == EXPECTED_HIDDEN_WINDOW_FLAGS
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def test_formatter_command_should_start_without_a_console_window(
|
|
447
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
448
|
+
) -> None:
|
|
449
|
+
auto_formatter_module = _load_auto_formatter_module()
|
|
450
|
+
all_recorded_keyword_arguments = _record_creation_flags(auto_formatter_module, monkeypatch)
|
|
451
|
+
|
|
452
|
+
auto_formatter_module._run_command(
|
|
453
|
+
["ruff", "format"], str(tmp_path / "module.py"), HOOK_RUN_TIMEOUT_SECONDS
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
assert all_recorded_keyword_arguments["creationflags"] == EXPECTED_HIDDEN_WINDOW_FLAGS
|
package/package.json
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Route every user-directed question through the `AskUserQuestion` tool — never a plain-text question in a response's final paragraph. Structure: concise `question`, `header` of 12 chars or fewer, 2-4 options (the UI adds the "Other" fallback), `multiSelect` only when choices genuinely combine.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
No hook checks this. Read your own final paragraph before you send it. A paragraph that ends in a question mark, or that carries an ask-phrase such as "would you like", "should I", or "let me know if", belongs in an `AskUserQuestion` call instead. A rhetorical question answered in the same paragraph is fine, and so is a question inside code or a blockquote. `verify-before-asking` settles whether the question belongs to the user at all.
|
|
6
6
|
|
|
7
7
|
## The question block stays lean
|
|
8
8
|
|
|
9
9
|
`AskUserQuestion` renders as one plain unformatted text block. Detail — plans, counts, tradeoffs, background — goes in chat text before the call. The block itself carries a lean question and short choices. When a choice needs formatting, an inline visualizer tool carries it.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
No hook denies an over-full block, so keep an `AskUserQuestion` call inside these caps yourself. They apply to the `question` text and to the `description` under any of the `options`:
|
|
12
12
|
|
|
13
13
|
| What the block carries | Cap |
|
|
14
14
|
|---|---|
|
|
@@ -28,4 +28,4 @@ Structure is read at block level on the raw text: a marker counts when it opens
|
|
|
28
28
|
|
|
29
29
|
An inline code span — a path, a flag, a command the reader needs verbatim — weighs one word against either word cap on both fields, so a question naming `--dry-run` and a choice naming `C:\dev\gate.py` both pass. A span sits inside a line, so it never opens one with a marker.
|
|
30
30
|
|
|
31
|
-
A sentence closes on `.`, `!`, or `?` followed by a capitalized word or by the end of the text. A word is any whitespace-separated token carrying a letter or a digit.
|
|
31
|
+
A sentence closes on `.`, `!`, or `?` followed by a capitalized word or by the end of the text. A word is any whitespace-separated token carrying a letter or a digit. When a block breaks a cap, move that detail back into chat text before the call.
|
package/rules/code-standards.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> **Canonical review contract:** [`CODE_RULES.md`](../docs/CODE_RULES.md) — the human and AI review contract for code quality, loaded on demand.
|
|
4
4
|
> **Checked-in pointer:** [`.cursor/BUGBOT.md`](../../../.cursor/BUGBOT.md) — the file Cursor BugBot reads; it points at `CODE_RULES.md`.
|
|
5
|
-
> **Production enforcement
|
|
5
|
+
> **Production enforcement.** The staged policy lint runs `hooks/blocking/code_rules_enforcer.py` over each changed file. No write-time hook runs it. Use `python packages/claude-dev-env/scripts/cde_lint.py --staged` before you commit, and CI runs the same lint against the merge base. Each mechanical rule carries a synchronization test.
|
|
6
6
|
|
|
7
7
|
## Policy surface map
|
|
8
8
|
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
|---|---|---|
|
|
11
11
|
| Contract | `docs/CODE_RULES.md` | Full review criteria for PR agents, loaded on demand |
|
|
12
12
|
| Pointer | `.cursor/BUGBOT.md` | Checked-in file Cursor BugBot reads; points at `CODE_RULES.md` |
|
|
13
|
-
| Enforcer | `hooks/blocking/code_rules_enforcer.py` | Hand-maintained
|
|
13
|
+
| Enforcer | `hooks/blocking/code_rules_enforcer.py` | Hand-maintained checks the staged policy lint runs; not generated from the docs |
|
|
14
|
+
| Lint | `scripts/cde_lint.py` | Runs the enforcer and the other policy rules over staged or changed files |
|
|
14
15
|
| Session rules | `rules/*.md` | Runtime session policy (questions, tasks, shell) |
|
|
15
16
|
|
|
16
17
|
Load `CODE_RULES.md` when reviewing a PR, resolving a policy conflict, or generating code. Prefer linking this ref over restating rules.
|
|
@@ -6,4 +6,6 @@ How to ask: plain language, one option per path with its tradeoff in the descrip
|
|
|
6
6
|
|
|
7
7
|
Not a fork (just proceed): trivially reversible internal-only choices, anything the codebase / stated goal / an existing rule already determines (see `verify-before-asking`), or a single viable path.
|
|
8
8
|
|
|
9
|
+
Naming a gap in chat is not surfacing a fork. Saying "no production path calls this yet" or "that is a follow-up" describes the fork; it does not ask it. Shipping code that nothing calls is a fork, because the alternative — wire the caller, or hold the change — changes the deliverable. Route it through `AskUserQuestion` before the merge, not into the closing summary after it.
|
|
10
|
+
|
|
9
11
|
When an option you offered names a repair and the answer leaves it undone, write it down before the turn moves on: a todo entry, a `spawn_task` chip, or a filed issue. An answer of "no preference" settles which path you take now. It leaves the repair you found still waiting.
|