claude-dev-env 2.30.0 → 2.30.1

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.
@@ -25,7 +25,7 @@ Triggers: `/e-simplify` on the current diff (or a PR/branch/path passed as an ar
25
25
 
26
26
  ## The process
27
27
 
28
- `/simplify → 4 cleanup agents in parallel → apply the fixes`
28
+ `/simplify → up to 4 cleanup agents in parallel → apply the fixes`
29
29
 
30
30
  You are improving the quality of the changed code, not hunting for bugs. Review
31
31
  it for reuse, simplification, efficiency, and altitude issues, then fix what you
@@ -40,13 +40,25 @@ include the working-tree changes in scope — the review often runs before the
40
40
  commit. If a PR number, branch name, or file path was passed as an argument,
41
41
  review that target instead. Treat this diff as the review scope.
42
42
 
43
- ### Phase 1 — Review (4 cleanup agents in parallel)
43
+ **Diff-size cap.** Run `git diff --shortstat` on the same range Phase 0 used
44
+ to get the diff, and add its insertions and deletions for the changed-line
45
+ count. Default ceiling: 400 changed lines for one run — a tunable default the
46
+ user may adjust. Above the ceiling, narrow the target first — a subdirectory,
47
+ a package, or one file path — and review that narrowed target. Do not launch
48
+ Phase 1 over a diff above the ceiling.
44
49
 
45
- Launch **4 independent review agents** via the Agent tool, all in a
46
- single message so they run concurrently. Pass each agent the diff and one of
47
- the four angles below. Each returns its findings with `file`, `line`, a
48
- one-line `summary`, and the concrete cost (what is duplicated, wasted, or
49
- harder to maintain).
50
+ ### Phase 1 — Review (up to 4 cleanup agents in parallel)
51
+
52
+ **Lens cap.** Four lenses is the ceiling, not the floor. Default split — also
53
+ tunable — by the `git diff --shortstat` changed-line count from Phase 0:
54
+ under 100 changed lines, run 2 lenses (reuse and simplification); at 100
55
+ changed lines or above, and under the diff-size cap, run all 4 lenses.
56
+
57
+ Launch the review agents for the lenses in scope for this run — 2 or 4 by the
58
+ cap above — via the Agent tool, all in a single message so they run
59
+ concurrently. Pass each agent the diff and one of the angles below. Each
60
+ returns its findings with `file`, `line`, a one-line `summary`, and the
61
+ concrete cost (what is duplicated, wasted, or harder to maintain).
50
62
 
51
63
  #### Reuse
52
64
 
@@ -79,7 +91,7 @@ special cases.
79
91
 
80
92
  ### Phase 2 — Apply the fixes
81
93
 
82
- Wait for all four agents to complete, dedup findings that point at the same
94
+ Wait for every launched agent to complete, dedup findings that point at the same
83
95
  line or mechanism, and fix each remaining one directly. Skip any finding whose
84
96
  fix would change intended behavior, require changes well outside the reviewed
85
97
  diff, or that you judge to be a false positive — note the skip rather than
@@ -799,11 +799,8 @@ def _hook_infrastructure_blocking_issues(
799
799
  Returns:
800
800
  The in-scope violations for the target at the given phase.
801
801
  """
802
- effective_content = content if full_file_content is None else full_file_content
803
- all_changed_lines = (
804
- changed_line_numbers(prior_full_file_content, full_file_content)
805
- if full_file_content is not None
806
- else None
802
+ effective_content, all_changed_lines = _effective_content_and_changed_lines(
803
+ content, full_file_content, prior_full_file_content
807
804
  )
808
805
  all_issues: list[str] = []
809
806
  if phase == FULL_GATE_PHASE:
@@ -24,13 +24,28 @@ are treated as literal only when the count of preceding backslashes is odd;
24
24
  an even count (including zero) means the backtick is a live substitution.
25
25
 
26
26
  Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
27
- spawn a subshell and does not defeat the allowlist matcher, so the
28
- ``$(`` pattern uses a negative lookahead to skip the ``$((`` case.
27
+ spawn a subshell and does not defeat the allowlist matcher. Telling it apart
28
+ from a disguised command substitution takes more than counting two open
29
+ parens, because bash itself falls back from arithmetic to command
30
+ substitution when the expansion body does not close as arithmetic. Observed
31
+ against real bash:
32
+
33
+ echo $((1 + 2)) prints 3 (arithmetic; not blocked)
34
+ echo $((cd /tmp) && pwd) prints the new cwd (command substitution; blocked)
35
+ echo $((1+2) ) runs "1+2" as a command, rc=0, no output
36
+ (command substitution; blocked)
37
+
38
+ Bash's own rule is the terminator: walk forward from ``$((`` tracking paren
39
+ depth; when depth returns to zero on a closer immediately preceded by
40
+ another ``)`` (an ``))`` pair), the expansion is arithmetic and passes. When
41
+ the closer that brings depth to zero is a lone ``)``, bash has fallen back to
42
+ running a parenthesized command inside the substitution, so it is blocked.
43
+ This hook applies that walk, tracking paren depth from the opening ``$((``.
29
44
 
30
45
  False positives are still possible when the user is intentionally writing
31
46
  shell-script content (for example, authoring a .sh file via heredoc) and the
32
47
  substitution is literal payload rather than something to execute. In that
33
- case, write via the Write tool instead of a Bash heredoc.
48
+ case, author the file with the Write tool, not a Bash heredoc.
34
49
  """
35
50
 
36
51
  from __future__ import annotations
@@ -39,34 +54,95 @@ import json
39
54
  import sys
40
55
  from pathlib import Path
41
56
 
42
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
43
- if _hooks_dir not in sys.path:
44
- sys.path.insert(0, _hooks_dir)
45
-
46
- from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
47
- BASH_TOOL_NAME,
48
- COMMAND_KEY,
49
- CORRECTIVE_MESSAGE,
50
- DENY_DECISION,
51
- DOLLAR_PAREN_PATTERN,
52
- EVEN_BACKSLASH_BACKTICK_PATTERN,
53
- HOOK_EVENT_NAME,
54
- HOOK_EVENT_NAME_KEY,
55
- HOOK_SPECIFIC_OUTPUT_KEY,
56
- PERMISSION_DECISION_KEY,
57
- PERMISSION_DECISION_REASON_KEY,
58
- PROCESS_SUBSTITUTION_PATTERN,
59
- SINGLE_QUOTED_RUN_PATTERN,
60
- STRIPPED_RUN_REPLACEMENT,
61
- TOOL_INPUT_KEY,
62
- TOOL_NAME_KEY,
63
- )
57
+ try:
58
+ from hooks_constants.shell_substitution_blocker_constants import (
59
+ BASH_TOOL_NAME,
60
+ CLOSE_PAREN_CHARACTER,
61
+ COMMAND_KEY,
62
+ CORRECTIVE_MESSAGE,
63
+ DENY_DECISION,
64
+ DOLLAR_PAREN_PATTERN,
65
+ EVEN_BACKSLASH_BACKTICK_PATTERN,
66
+ HOOK_EVENT_NAME,
67
+ HOOK_EVENT_NAME_KEY,
68
+ HOOK_SPECIFIC_OUTPUT_KEY,
69
+ OPEN_PAREN_CHARACTER,
70
+ PAREN_DEPTH_AFTER_DOUBLE_OPEN,
71
+ PERMISSION_DECISION_KEY,
72
+ PERMISSION_DECISION_REASON_KEY,
73
+ PROCESS_SUBSTITUTION_PATTERN,
74
+ SINGLE_QUOTED_RUN_PATTERN,
75
+ STRIPPED_RUN_REPLACEMENT,
76
+ TOOL_INPUT_KEY,
77
+ TOOL_NAME_KEY,
78
+ )
79
+ except ModuleNotFoundError:
80
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
81
+ if _hooks_dir not in sys.path:
82
+ sys.path.insert(0, _hooks_dir)
83
+ from hooks_constants.shell_substitution_blocker_constants import (
84
+ BASH_TOOL_NAME,
85
+ CLOSE_PAREN_CHARACTER,
86
+ COMMAND_KEY,
87
+ CORRECTIVE_MESSAGE,
88
+ DENY_DECISION,
89
+ DOLLAR_PAREN_PATTERN,
90
+ EVEN_BACKSLASH_BACKTICK_PATTERN,
91
+ HOOK_EVENT_NAME,
92
+ HOOK_EVENT_NAME_KEY,
93
+ HOOK_SPECIFIC_OUTPUT_KEY,
94
+ OPEN_PAREN_CHARACTER,
95
+ PAREN_DEPTH_AFTER_DOUBLE_OPEN,
96
+ PERMISSION_DECISION_KEY,
97
+ PERMISSION_DECISION_REASON_KEY,
98
+ PROCESS_SUBSTITUTION_PATTERN,
99
+ SINGLE_QUOTED_RUN_PATTERN,
100
+ STRIPPED_RUN_REPLACEMENT,
101
+ TOOL_INPUT_KEY,
102
+ TOOL_NAME_KEY,
103
+ )
64
104
 
65
105
 
66
106
  def _strip_single_quoted_runs(command: str) -> str:
67
107
  return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
68
108
 
69
109
 
110
+ def _closes_as_arithmetic_expansion(command: str, scan_start_index: int) -> bool:
111
+ """Return True when a ``$((`` expansion closes on an ``))`` pair.
112
+
113
+ See the module docstring for the real-bash evidence behind this rule.
114
+ """
115
+ paren_depth = PAREN_DEPTH_AFTER_DOUBLE_OPEN
116
+ was_previous_character_close_paren = False
117
+ for each_index in range(scan_start_index, len(command)):
118
+ current_character = command[each_index]
119
+ if current_character == OPEN_PAREN_CHARACTER:
120
+ paren_depth += 1
121
+ was_previous_character_close_paren = False
122
+ continue
123
+ if current_character != CLOSE_PAREN_CHARACTER:
124
+ was_previous_character_close_paren = False
125
+ continue
126
+ paren_depth -= 1
127
+ if paren_depth == 0:
128
+ return was_previous_character_close_paren
129
+ was_previous_character_close_paren = True
130
+ return False
131
+
132
+
133
+ def _has_unsafe_dollar_paren_expansion(command: str) -> bool:
134
+ """Return True for a `$(` that is not a safe `$((...))` arithmetic pair."""
135
+ for each_match in DOLLAR_PAREN_PATTERN.finditer(command):
136
+ is_arithmetic_style_open = (
137
+ command[each_match.end() : each_match.end() + 1] == OPEN_PAREN_CHARACTER
138
+ )
139
+ if not is_arithmetic_style_open:
140
+ return True
141
+ if not _closes_as_arithmetic_expansion(command, each_match.end() + 1):
142
+ return True
143
+ return False
144
+
145
+
70
146
  def has_shell_substitution(command: str) -> bool:
71
147
  """Return True when a Bash command carries a live shell substitution.
72
148
 
@@ -79,18 +155,18 @@ def has_shell_substitution(command: str) -> bool:
79
155
  echo $((2 + 2)) ok: arithmetic expansion
80
156
 
81
157
  Single-quoted runs are stripped first because bash substitutes nothing
82
- inside them. A backtick preceded by an odd number of backslashes is escaped
83
- and stays allowed.
158
+ inside them. A backtick preceded by an odd backslash count is escaped.
159
+ A `$((` pair is walked to its terminator (module docstring) to tell
160
+ real arithmetic from a disguised subshell.
84
161
 
85
162
  Args:
86
163
  command: The raw Bash command string from the tool input.
87
164
 
88
165
  Returns:
89
- True when a substitution the allowlist matcher cannot descend into is
90
- present outside single quotes.
166
+ True when a substitution the matcher cannot descend into is present.
91
167
  """
92
168
  scannable_command = _strip_single_quoted_runs(command)
93
- if DOLLAR_PAREN_PATTERN.search(scannable_command):
169
+ if _has_unsafe_dollar_paren_expansion(scannable_command):
94
170
  return True
95
171
  if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):
96
172
  return True
@@ -261,6 +261,20 @@ def test_emit_decision_re_emits_silent_deny_fields(
261
261
  assert emitted_payload["suppressOutput"] is True
262
262
 
263
263
 
264
+ def test_dispatcher_denies_subshell_disguised_as_arithmetic_expansion() -> None:
265
+ """The dispatcher denies a `$((cd X) && Y)` subshell, not only the bare hook.
266
+
267
+ `echo $((cd /tmp) && pwd)` opens with a `$((` two-character lookahead
268
+ match, so a function-level test alone does not prove the block reaches
269
+ the live Bash tool path.
270
+ """
271
+ payload_text = _bash_payload("echo $((cd /tmp) && pwd)")
272
+ decision, _reason = _decision_from_stdout(
273
+ _run_process(_DISPATCHER_SCRIPT, payload_text).stdout
274
+ )
275
+ assert decision == "deny"
276
+
277
+
264
278
  def test_dispatch_emits_deny_immediately_and_skips_later_hooks(
265
279
  monkeypatch: pytest.MonkeyPatch,
266
280
  capsys: pytest.CaptureFixture[str],
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import ast
5
6
  import contextlib
6
7
  import importlib
7
8
  import importlib.util
@@ -10,6 +11,7 @@ import re
10
11
  import shutil
11
12
  import sys
12
13
  import tempfile
14
+ import textwrap
13
15
  from pathlib import Path
14
16
  from types import ModuleType
15
17
  from unittest.mock import DEFAULT, MagicMock, patch
@@ -279,3 +281,52 @@ def test_gate_running_selects_the_full_gate(tmp_path: Path) -> None:
279
281
  assert scoped_violations is not None
280
282
  blocking_violations, _advisory_violations = scoped_violations
281
283
  assert any("9999" in each_issue for each_issue in blocking_violations)
284
+
285
+
286
+ ALL_CHECK_FUNCTION_NAME_PREFIXES = ("check_", "advise_")
287
+
288
+
289
+ def _check_names_called_in(enforcer_function_name: str) -> set[str]:
290
+ """Return the check-function names called directly inside a phase router.
291
+
292
+ Parses the router's own source and collects every direct call to a
293
+ module-level name beginning with a check prefix, so a roster constant can
294
+ assert against the calls production really makes rather than against a
295
+ second hand-written literal.
296
+
297
+ Args:
298
+ enforcer_function_name: The attribute name of the router on the
299
+ enforcer module, such as ``_python_full_gate_only_issues``.
300
+
301
+ Returns:
302
+ The set of check-function names the router calls directly.
303
+ """
304
+ router_source = textwrap.dedent(
305
+ inspect.getsource(getattr(code_rules_enforcer, enforcer_function_name))
306
+ )
307
+ return {
308
+ each_node.func.id
309
+ for each_node in ast.walk(ast.parse(router_source))
310
+ if isinstance(each_node, ast.Call)
311
+ and isinstance(each_node.func, ast.Name)
312
+ and each_node.func.id.startswith(ALL_CHECK_FUNCTION_NAME_PREFIXES)
313
+ }
314
+
315
+
316
+ def test_full_gate_only_roster_matches_the_checks_the_router_calls() -> None:
317
+ """The roster constant tracks the calls in _python_full_gate_only_issues."""
318
+ assert (
319
+ _check_names_called_in("_python_full_gate_only_issues")
320
+ == validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
321
+ ), "ALL_FULL_GATE_ONLY_CHECK_NAMES must name exactly the router's own calls"
322
+
323
+
324
+ def test_hook_infrastructure_edit_lane_roster_matches_the_checks_the_router_calls() -> None:
325
+ """The roster constant tracks the always-run calls in the hook-infrastructure router."""
326
+ all_always_run_check_names = _check_names_called_in(
327
+ "_hook_infrastructure_blocking_issues"
328
+ ) - validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
329
+ assert (
330
+ all_always_run_check_names
331
+ == validation_phase_constants.ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES
332
+ ), "ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES must name exactly the router's own calls"
@@ -122,3 +122,72 @@ def test_allows_arithmetic_expansion() -> None:
122
122
  "tool_input": {"command": "echo $((2+2))"},
123
123
  }
124
124
  assert _run_hook(payload).stdout == ""
125
+
126
+
127
+ def test_allows_arithmetic_expansion_with_nested_parens() -> None:
128
+ payload = {
129
+ "tool_name": "Bash",
130
+ "tool_input": {"command": "echo $(( (1+2) * 3 ))"},
131
+ }
132
+ assert _run_hook(payload).stdout == ""
133
+
134
+
135
+ def test_allows_arithmetic_expansion_whose_body_bash_itself_rejects() -> None:
136
+ payload = {
137
+ "tool_name": "Bash",
138
+ "tool_input": {"command": "echo $((echo hi))"},
139
+ }
140
+ assert _run_hook(payload).stdout == ""
141
+
142
+
143
+ def test_allows_arithmetic_lookalike_inside_single_quotes() -> None:
144
+ payload = {
145
+ "tool_name": "Bash",
146
+ "tool_input": {"command": "echo '$((cd /tmp) && pwd)'"},
147
+ }
148
+ assert _run_hook(payload).stdout == ""
149
+
150
+
151
+ def test_denies_subshell_disguised_as_arithmetic_expansion() -> None:
152
+ payload = {
153
+ "tool_name": "Bash",
154
+ "tool_input": {"command": "echo $((cd /tmp) && pwd)"},
155
+ }
156
+ response = json.loads(_run_hook(payload).stdout)
157
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
158
+
159
+
160
+ def test_denies_parenthesized_command_word_disguised_as_arithmetic() -> None:
161
+ payload = {
162
+ "tool_name": "Bash",
163
+ "tool_input": {"command": "echo $((1+2) )"},
164
+ }
165
+ response = json.loads(_run_hook(payload).stdout)
166
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
167
+
168
+
169
+ def test_denies_command_substitution_nested_inside_arithmetic_expansion() -> None:
170
+ payload = {
171
+ "tool_name": "Bash",
172
+ "tool_input": {"command": "echo $(( $(id -u) + 1 ))"},
173
+ }
174
+ response = json.loads(_run_hook(payload).stdout)
175
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
176
+
177
+
178
+ def test_denies_backtick_substitution_nested_inside_arithmetic_expansion() -> None:
179
+ payload = {
180
+ "tool_name": "Bash",
181
+ "tool_input": {"command": "echo $(( `id -u` + 1 ))"},
182
+ }
183
+ response = json.loads(_run_hook(payload).stdout)
184
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
185
+
186
+
187
+ def test_denies_nested_command_substitution_without_arithmetic() -> None:
188
+ payload = {
189
+ "tool_name": "Bash",
190
+ "tool_input": {"command": "echo $(echo $(whoami))"},
191
+ }
192
+ response = json.loads(_run_hook(payload).stdout)
193
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
package/hooks/hooks.json CHANGED
@@ -171,7 +171,7 @@
171
171
  },
172
172
  {
173
173
  "type": "command",
174
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/task_list_loop_starter.py",
174
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/task_tool_prompt.py",
175
175
  "timeout": 10
176
176
  },
177
177
  {
@@ -7,7 +7,6 @@ import re
7
7
  import tokenize
8
8
 
9
9
  from hooks_constants.validation_phase_constants import ( # noqa: F401 re-exported for code_rules_enforcer.py
10
- ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES,
11
10
  ALL_VALIDATION_PHASES,
12
11
  EDIT_LANE_PHASE,
13
12
  FULL_GATE_PHASE,
@@ -41,7 +41,10 @@ PERMISSION_DECISION_KEY = "permissionDecision"
41
41
  DENY_DECISION = "deny"
42
42
  PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
43
43
 
44
- DOLLAR_PAREN_PATTERN = re.compile(r"\$\((?!\()")
44
+ DOLLAR_PAREN_PATTERN = re.compile(r"\$\(")
45
+ OPEN_PAREN_CHARACTER = "("
46
+ CLOSE_PAREN_CHARACTER = ")"
47
+ PAREN_DEPTH_AFTER_DOUBLE_OPEN = 2
45
48
  EVEN_BACKSLASH_BACKTICK_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*`")
46
49
  PROCESS_SUBSTITUTION_PATTERN = re.compile(r"[<>]\(")
47
50
  SINGLE_QUOTED_RUN_PATTERN = re.compile(r"'[^']*'")
@@ -0,0 +1,13 @@
1
+ """Directive text for the task_tool_prompt SessionStart hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "TASK_TOOL_DIRECTIVE",
7
+ ]
8
+
9
+ TASK_TOOL_DIRECTIVE = (
10
+ "TASK TRACKING: Track this session's work with the session's task or todo "
11
+ "tracking tool, whichever this host provides. Create tasks at the start "
12
+ "of the session, then update them as work proceeds."
13
+ )
@@ -17,7 +17,6 @@ if str(_HOOKS_ROOT) not in sys.path:
17
17
  sys.path.insert(0, str(_HOOKS_ROOT))
18
18
 
19
19
  from hooks_constants.code_rules_enforcer_constants import (
20
- ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES,
21
20
  ALL_VALIDATION_PHASES,
22
21
  CONFIG_DIRECTORY_SEGMENT,
23
22
  CONSTANTS_MODULE_SUFFIX,
@@ -111,11 +110,3 @@ def test_validation_phase_constants_are_reexported_and_render() -> None:
111
110
  phase="bogus_phase", all_phases=sorted(ALL_VALIDATION_PHASES)
112
111
  )
113
112
  assert "bogus_phase" in rendered_message
114
-
115
-
116
- def test_hook_infrastructure_edit_lane_check_names_are_reexported() -> None:
117
- assert ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES == {
118
- "check_same_file_inline_duplicate_body",
119
- "check_zero_payload_function_alias",
120
- "check_unanchored_command_dispatch",
121
- }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook — direct the session to track work with its task tool.
3
+
4
+ At session start this hook emits an ``additionalContext`` directive asking
5
+ the session to track its work with the task or todo tracking tool its host
6
+ provides: create tasks at the start of the session, then update them as
7
+ work proceeds.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+
15
+ import _path_setup # noqa: F401
16
+
17
+ from hooks_constants.task_tool_prompt_constants import TASK_TOOL_DIRECTIVE
18
+
19
+
20
+ def build_session_directive() -> str:
21
+ """Return the task-tool directive emitted at session start."""
22
+ return TASK_TOOL_DIRECTIVE
23
+
24
+
25
+ def main() -> None:
26
+ """Emit the task-tool directive as SessionStart additionalContext."""
27
+ payload = {
28
+ "hookSpecificOutput": {
29
+ "hookEventName": "SessionStart",
30
+ "additionalContext": build_session_directive(),
31
+ }
32
+ }
33
+ sys.stdout.write(json.dumps(payload) + "\n")
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
@@ -0,0 +1,65 @@
1
+ """Tests for task_tool_prompt — SessionStart hook that directs task-tool tracking."""
2
+
3
+ import importlib
4
+ import json
5
+ import sys
6
+ from io import StringIO
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ _SESSION_DIR = Path(__file__).resolve().parent
11
+ _HOOKS_ROOT = _SESSION_DIR.parent
12
+ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
13
+ if each_sys_path_entry not in sys.path:
14
+ sys.path.insert(0, each_sys_path_entry)
15
+
16
+ prompt = importlib.import_module("task_tool_prompt")
17
+ task_tool_prompt_constants = importlib.import_module("hooks_constants.task_tool_prompt_constants")
18
+ TASK_TOOL_DIRECTIVE = task_tool_prompt_constants.TASK_TOOL_DIRECTIVE
19
+
20
+
21
+ def _run_main() -> str:
22
+ """Return stdout produced by running the hook's main()."""
23
+ captured_stdout = StringIO()
24
+ with patch("sys.stdout", captured_stdout):
25
+ prompt.main()
26
+ return captured_stdout.getvalue()
27
+
28
+
29
+ class TestSessionDirective:
30
+ def test_main_emits_additional_context(self) -> None:
31
+ emitted = json.loads(_run_main())
32
+ hook_output = emitted["hookSpecificOutput"]
33
+ assert hook_output["hookEventName"] == "SessionStart"
34
+ assert "additionalContext" in hook_output
35
+
36
+ def test_directive_names_the_tracking_tool(self) -> None:
37
+ emitted = json.loads(_run_main())
38
+ assert "task or todo tracking tool" in emitted["hookSpecificOutput"]["additionalContext"]
39
+
40
+ def test_directive_names_no_vendor_or_host_product(self) -> None:
41
+ emitted = json.loads(_run_main())
42
+ additional_context = emitted["hookSpecificOutput"]["additionalContext"]
43
+ for each_forbidden_term in ("Claude", "Cursor", "Codex", "TaskCreate", "TodoWrite"):
44
+ assert each_forbidden_term not in additional_context
45
+
46
+ def test_directive_says_create_tasks_at_session_start(self) -> None:
47
+ emitted = json.loads(_run_main())
48
+ additional_context = emitted["hookSpecificOutput"]["additionalContext"]
49
+ assert "start of the session" in additional_context
50
+
51
+ def test_directive_says_update_as_work_proceeds(self) -> None:
52
+ emitted = json.loads(_run_main())
53
+ additional_context = emitted["hookSpecificOutput"]["additionalContext"]
54
+ assert "update" in additional_context
55
+ assert "as work proceeds" in additional_context
56
+
57
+ def test_directive_names_no_loop_cadence(self) -> None:
58
+ emitted = json.loads(_run_main())
59
+ additional_context = emitted["hookSpecificOutput"]["additionalContext"]
60
+ assert "/loop" not in additional_context
61
+ assert "cadence" not in additional_context
62
+ assert "minute" not in additional_context
63
+
64
+ def test_build_session_directive_returns_the_shared_constant(self) -> None:
65
+ assert prompt.build_session_directive() == TASK_TOOL_DIRECTIVE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.30.0",
3
+ "version": "2.30.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,19 +0,0 @@
1
- """Directive text for the task_list_loop_starter SessionStart hook."""
2
-
3
- from __future__ import annotations
4
-
5
- __all__ = [
6
- "TASK_LIST_MAINTENANCE_INSTRUCTION",
7
- "TASK_LIST_LOOP_DIRECTIVE",
8
- ]
9
-
10
- TASK_LIST_MAINTENANCE_INSTRUCTION = "build or update the task list using the task tools"
11
-
12
- TASK_LIST_LOOP_DIRECTIVE = (
13
- "TASK LIST MAINTENANCE: Keep this session's task list current on a 10-minute "
14
- "cadence. Reuse the active task-list maintenance loop when available. Start "
15
- "Claude Code's built-in /loop skill with a 10-minute interval when this session "
16
- "needs its first loop: "
17
- f'/loop 10m "{TASK_LIST_MAINTENANCE_INSTRUCTION}". Run that instruction once '
18
- "immediately when starting the loop, then let the loop refresh the list every 10 minutes."
19
- )
@@ -1,45 +0,0 @@
1
- #!/usr/bin/env python3
2
- """SessionStart hook — start a task-list maintenance loop for the session.
3
-
4
- At session start this hook emits an ``additionalContext`` directive asking Claude
5
- to keep the session's task list current on a 10-minute cadence. Claude reuses the
6
- active ``/loop`` skill when available and starts the skill for the session's first
7
- task-list maintenance loop.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import json
13
- import sys
14
- from pathlib import Path
15
-
16
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
17
- if _hooks_dir not in sys.path:
18
- sys.path.insert(0, _hooks_dir)
19
-
20
- from hooks_constants.task_list_loop_starter_constants import ( # noqa: E402
21
- TASK_LIST_LOOP_DIRECTIVE,
22
- )
23
-
24
-
25
- def build_session_directive() -> str:
26
- """Return the task-list loop directive emitted at session start."""
27
- return TASK_LIST_LOOP_DIRECTIVE
28
-
29
-
30
- def main() -> None:
31
- """Emit the task-list loop directive as SessionStart additionalContext."""
32
- print(
33
- json.dumps(
34
- {
35
- "hookSpecificOutput": {
36
- "hookEventName": "SessionStart",
37
- "additionalContext": build_session_directive(),
38
- }
39
- }
40
- )
41
- )
42
-
43
-
44
- if __name__ == "__main__":
45
- main()
@@ -1,63 +0,0 @@
1
- """Tests for task_list_loop_starter — SessionStart hook that starts a task-list loop."""
2
-
3
- import json
4
- import sys
5
- from io import StringIO
6
- from pathlib import Path
7
- from unittest.mock import patch
8
-
9
- _SESSION_DIR = Path(__file__).resolve().parent
10
- _HOOKS_ROOT = _SESSION_DIR.parent
11
- for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
12
- if each_sys_path_entry not in sys.path:
13
- sys.path.insert(0, each_sys_path_entry)
14
-
15
- import task_list_loop_starter as starter
16
-
17
- from hooks_constants.task_list_loop_starter_constants import (
18
- TASK_LIST_LOOP_DIRECTIVE,
19
- TASK_LIST_MAINTENANCE_INSTRUCTION,
20
- )
21
-
22
-
23
- def _run_main() -> str:
24
- """Return stdout produced by running the hook's main()."""
25
- captured_stdout = StringIO()
26
- with patch("sys.stdout", captured_stdout):
27
- starter.main()
28
- return captured_stdout.getvalue()
29
-
30
-
31
- class TestSessionDirective:
32
- def test_main_emits_additional_context(self) -> None:
33
- emitted = json.loads(_run_main())
34
- hook_output = emitted["hookSpecificOutput"]
35
- assert hook_output["hookEventName"] == "SessionStart"
36
- assert "additionalContext" in hook_output
37
-
38
- def test_directive_carries_the_one_line_instruction(self) -> None:
39
- emitted = json.loads(_run_main())
40
- assert TASK_LIST_MAINTENANCE_INSTRUCTION in emitted["hookSpecificOutput"]["additionalContext"]
41
-
42
- def test_directive_names_the_ten_minute_cadence(self) -> None:
43
- emitted = json.loads(_run_main())
44
- assert "10-minute" in emitted["hookSpecificOutput"]["additionalContext"]
45
-
46
- def test_directive_names_the_loop_command(self) -> None:
47
- emitted = json.loads(_run_main())
48
- assert "/loop 10m" in emitted["hookSpecificOutput"]["additionalContext"]
49
-
50
- def test_directive_runs_the_instruction_immediately(self) -> None:
51
- emitted = json.loads(_run_main())
52
- assert "Run that instruction once immediately when starting the loop, then" in emitted[
53
- "hookSpecificOutput"
54
- ]["additionalContext"]
55
-
56
- def test_directive_reuses_an_existing_loop(self) -> None:
57
- emitted = json.loads(_run_main())
58
- assert "Reuse the active task-list maintenance loop when available." in emitted[
59
- "hookSpecificOutput"
60
- ]["additionalContext"]
61
-
62
- def test_build_session_directive_returns_the_shared_constant(self) -> None:
63
- assert starter.build_session_directive() == TASK_LIST_LOOP_DIRECTIVE