claude-dev-env 8.44.5 → 8.44.6

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/hooks/hooks.json CHANGED
@@ -14,6 +14,11 @@
14
14
  "type": "command",
15
15
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
16
16
  "timeout": 10
17
+ },
18
+ {
19
+ "type": "command",
20
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/subagent_model_pin_hook.py",
21
+ "timeout": 10
17
22
  }
18
23
  ]
19
24
  },
@@ -0,0 +1,16 @@
1
+ """Constants for the subagent model pin PreToolUse hook.
2
+
3
+ Groups: the model every subagent runs on, the model families the hook moves,
4
+ and the hook input and output keys.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ SUBAGENT_MODEL_ALIAS = "opus"
10
+ ALL_MOVED_MODEL_FAMILIES = ("sonnet", "haiku")
11
+ MODEL_INPUT_KEY = "model"
12
+ TOOL_INPUT_KEY = "tool_input"
13
+ CONTEXT_PREFIX = "Subagent model moved to opus (was "
14
+ CONTEXT_OMITTED_MODEL_TEXT = "inherited from the parent"
15
+ CONTEXT_SUFFIX = ")."
16
+ ADDITIONAL_CONTEXT_KEY = "additionalContext"
@@ -11,35 +11,18 @@ USAGE_PACE_SCRIPT_FILE_NAME = "usage_pace.py"
11
11
  USAGE_PACE_TIMEOUT_SECONDS = 15
12
12
 
13
13
  TOOL_INPUT_KEY = "tool_input"
14
- THREAD_MODEL_WHEN_OVER_PACE = "claude-sonnet-5-5"
15
- THREAD_EFFORT_WHEN_OVER_PACE = "medium"
14
+ THREAD_MODEL_WHEN_OVER_PACE = "opus"
15
+ THREAD_EFFORT_WHEN_OVER_PACE = "low"
16
16
  MODEL_INPUT_KEY = "model"
17
17
  EFFORT_INPUT_KEY = "effort"
18
- INSTRUCTIONS_INPUT_KEY = "instructions"
19
- INSTRUCTIONS_MAXIMUM_BYTES = 8192
20
- INSTRUCTIONS_SEPARATOR = "\n\n"
21
- INSTRUCTIONS_ENCODING = "utf-8"
22
- FABLE_ADVISOR_LINE = (
23
- "Mandatory Fable advisor: usage is over pace, so this thread runs on "
24
- "Sonnet 5.5 at medium effort. Invoke /team-advisor before substantive "
25
- "work and bind a Fable advisor through it. Consult that advisor again "
26
- "before you report done, and name its verdict in your report."
27
- )
28
18
 
29
19
  PERMISSION_DENY = "deny"
30
20
  PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
31
21
  ADDITIONAL_CONTEXT_KEY = "additionalContext"
32
22
  NOT_AN_OBJECT_REASON = "start_thread_session input is not a JSON object"
33
- NO_INSTRUCTIONS_REASON = "start_thread_session input has no instructions text"
34
- OVER_BYTE_CAP_REASON_TEMPLATE = (
35
- "usage is over pace and the brief plus the mandatory Fable advisor line is "
36
- "{reshaped_byte_count} bytes, over the {maximum_bytes}-byte cap; "
37
- "shorten the instructions by {excess_byte_count} bytes"
38
- )
39
23
  RESHAPE_CONTEXT_PREFIX = (
40
24
  "Usage over pace or unreadable: this thread spawn now runs on "
41
- "claude-sonnet-5-5 at medium effort with a mandatory Fable advisor line. "
42
- "Pace verdict: "
25
+ "opus at low effort. Pace verdict: "
43
26
  )
44
27
  PACE_VERDICT_CONTEXT_MAXIMUM_CHARACTERS = 600
45
28
  LAUNCH_FAILURE_ERROR_TEMPLATE = "usage pace did not finish: {failure_type}"
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: run every Agent and Task subagent on Opus.
3
+
4
+ Registered on ``Agent|Task``. Nested spawns pass through the same hook, so the
5
+ pin holds at every level.
6
+
7
+ ::
8
+
9
+ model omitted -> allow with updatedInput model "opus"
10
+ model sonnet or haiku -> allow with updatedInput model "opus"
11
+ model opus or fable -> no output; the call runs unchanged
12
+
13
+ A model id counts by its family: ``claude-sonnet-5-5`` is sonnet. A fork
14
+ ignores the model field, so the rewrite leaves it unchanged.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
24
+ if hooks_root_directory not in sys.path:
25
+ sys.path.insert(0, hooks_root_directory)
26
+
27
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
28
+ ALLOW_DECISION,
29
+ HOOK_EVENT_NAME,
30
+ )
31
+ from hooks_constants.pre_tool_use_allow_output import (
32
+ HOOK_EVENT_NAME_KEY,
33
+ HOOK_SPECIFIC_OUTPUT_KEY,
34
+ PERMISSION_DECISION_KEY,
35
+ UPDATED_INPUT_KEY,
36
+ )
37
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
38
+ from hooks_constants.subagent_model_pin_hook_constants import (
39
+ ADDITIONAL_CONTEXT_KEY,
40
+ ALL_MOVED_MODEL_FAMILIES,
41
+ CONTEXT_OMITTED_MODEL_TEXT,
42
+ CONTEXT_PREFIX,
43
+ CONTEXT_SUFFIX,
44
+ MODEL_INPUT_KEY,
45
+ SUBAGENT_MODEL_ALIAS,
46
+ TOOL_INPUT_KEY,
47
+ )
48
+
49
+
50
+ def is_moved_model(model: object) -> bool:
51
+ """Return True when the spawn names no model or a model family the pin moves.
52
+
53
+ ::
54
+
55
+ None -> True "sonnet" -> True "claude-haiku-4-5-20251001" -> True
56
+ "opus" -> False "claude-fable-5-1" -> False
57
+
58
+ Args:
59
+ model: The ``model`` field of the spawn input.
60
+ """
61
+ if model is None or model == "":
62
+ return True
63
+ if not isinstance(model, str):
64
+ return False
65
+ lowered_model = model.lower()
66
+ return any(each_family in lowered_model for each_family in ALL_MOVED_MODEL_FAMILIES)
67
+
68
+
69
+ def decide_hook_output(tool_input: object) -> dict[str, object] | None:
70
+ """Choose the hook's output for one subagent spawn.
71
+
72
+ Args:
73
+ tool_input: The ``tool_input`` of the PreToolUse payload.
74
+
75
+ Returns:
76
+ None to pass the call through unchanged, else the hook JSON output.
77
+ """
78
+ if not isinstance(tool_input, dict):
79
+ return None
80
+ requested_model = tool_input.get(MODEL_INPUT_KEY)
81
+ if not is_moved_model(requested_model):
82
+ return None
83
+ previous_model_text = requested_model or CONTEXT_OMITTED_MODEL_TEXT
84
+ return {
85
+ HOOK_SPECIFIC_OUTPUT_KEY: {
86
+ HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
87
+ PERMISSION_DECISION_KEY: ALLOW_DECISION,
88
+ UPDATED_INPUT_KEY: {**tool_input, MODEL_INPUT_KEY: SUBAGENT_MODEL_ALIAS},
89
+ ADDITIONAL_CONTEXT_KEY: f"{CONTEXT_PREFIX}{previous_model_text}{CONTEXT_SUFFIX}",
90
+ }
91
+ }
92
+
93
+
94
+ def main() -> int:
95
+ """Read the PreToolUse payload and print the decision.
96
+
97
+ Returns:
98
+ 0 in every case.
99
+ """
100
+ hook_payload = read_hook_input_dictionary_from_stdin()
101
+ tool_input = hook_payload.get(TOOL_INPUT_KEY) if hook_payload is not None else None
102
+ hook_output = decide_hook_output(tool_input)
103
+ if hook_output is not None:
104
+ sys.stdout.write(json.dumps(hook_output))
105
+ sys.stdout.flush()
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ sys.exit(main())
@@ -0,0 +1,67 @@
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ HOOK_SCRIPT = Path(__file__).resolve().parent / "subagent_model_pin_hook.py"
9
+ HOOKS_JSON = Path(__file__).resolve().parent.parent / "hooks.json"
10
+ CONSTANTS_DIRECTORY = Path(__file__).resolve().parent.parent / "hooks_constants"
11
+ SPAWN_INPUT = {"subagent_type": "pstack:poteto-agent", "prompt": "Do X.", "description": "d"}
12
+
13
+
14
+ def _run_hook(tool_input: object) -> str:
15
+ completed = subprocess.run(
16
+ [sys.executable, str(HOOK_SCRIPT)],
17
+ input=json.dumps({"tool_name": "Agent", "tool_input": tool_input}),
18
+ capture_output=True,
19
+ text=True,
20
+ check=True,
21
+ )
22
+ return completed.stdout
23
+
24
+
25
+ @pytest.mark.parametrize(
26
+ "requested_model",
27
+ [None, "", "sonnet", "haiku", "claude-sonnet-5-5", "claude-sonnet-5-5[1m]", "claude-haiku-4-5-20251001"],
28
+ )
29
+ def test_should_move_the_spawn_to_opus(requested_model: object) -> None:
30
+ tool_input = dict(SPAWN_INPUT)
31
+ if requested_model is not None:
32
+ tool_input["model"] = requested_model
33
+ decision = json.loads(_run_hook(tool_input))["hookSpecificOutput"]
34
+ assert decision["permissionDecision"] == "allow"
35
+ assert decision["updatedInput"] == {**SPAWN_INPUT, "model": "opus"}
36
+
37
+
38
+ @pytest.mark.parametrize("requested_model", ["opus", "fable", "claude-opus-5-5", "claude-fable-5-1"])
39
+ def test_should_leave_an_opus_or_fable_spawn_alone(requested_model: str) -> None:
40
+ assert _run_hook({**SPAWN_INPUT, "model": requested_model}) == ""
41
+
42
+
43
+ def test_should_pass_a_tool_input_that_is_not_an_object() -> None:
44
+ assert _run_hook(["not", "an", "object"]) == ""
45
+
46
+
47
+ def test_should_register_the_pin_on_the_agent_and_task_matcher() -> None:
48
+ all_agent_entries = [
49
+ each_entry
50
+ for each_entry in json.loads(HOOKS_JSON.read_text(encoding="utf-8"))["hooks"]["PreToolUse"]
51
+ if each_entry["matcher"] == "Agent|Task"
52
+ ]
53
+ all_commands = [
54
+ each_hook["command"] for each_entry in all_agent_entries for each_hook in each_entry["hooks"]
55
+ ]
56
+ assert any("subagent_model_pin_hook.py" in each_command for each_command in all_commands)
57
+
58
+
59
+ def test_should_keep_every_constant_free_of_a_sonnet_model_id() -> None:
60
+ all_offending_files = [
61
+ each_file.name
62
+ for each_file in CONSTANTS_DIRECTORY.glob("*.py")
63
+ if not each_file.name.startswith("test_")
64
+ and each_file.name != "subagent_model_pin_hook_constants.py"
65
+ and "claude-sonnet" in each_file.read_text(encoding="utf-8")
66
+ ]
67
+ assert all_offending_files == []
@@ -5,7 +5,6 @@ import sys
5
5
  from pathlib import Path
6
6
 
7
7
  import pytest
8
- from hooks_constants.thread_spawn_pace_hook_constants import FABLE_ADVISOR_LINE
9
8
 
10
9
  HOOK_SCRIPT = Path(__file__).resolve().parent / "thread_spawn_pace_hook.py"
11
10
  SPAWN_INPUT = {
@@ -55,9 +54,8 @@ def _assert_reshaped(stdout: str) -> None:
55
54
  assert decision["permissionDecision"] == "allow"
56
55
  assert decision["updatedInput"] == {
57
56
  **SPAWN_INPUT,
58
- "model": "claude-sonnet-5-5",
59
- "effort": "medium",
60
- "instructions": f"Do the example task.\n\n{FABLE_ADVISOR_LINE}",
57
+ "model": "opus",
58
+ "effort": "low",
61
59
  }
62
60
 
63
61
 
@@ -91,27 +89,15 @@ def test_should_reshape_the_spawn_when_the_pace_script_is_missing(
91
89
  _assert_reshaped(_run_hook(tmp_path / "absent.py", SPAWN_INPUT))
92
90
 
93
91
 
94
- def test_should_append_the_advisor_line_once(tmp_path: Path) -> None:
95
- already_reshaped = {
96
- **SPAWN_INPUT,
97
- "instructions": f"Do the example task.\n\n{FABLE_ADVISOR_LINE}",
98
- }
99
- stdout = _run_hook(
100
- _write_pace_stub(tmp_path, 0, {"over_pace": True}), already_reshaped
101
- )
102
- assert (
103
- _hook_specific_output(stdout)["updatedInput"]["instructions"].count(
104
- FABLE_ADVISOR_LINE
105
- )
106
- == 1
107
- )
92
+ def test_should_name_no_sonnet_model_in_the_reshaped_spawn(tmp_path: Path) -> None:
93
+ stdout = _run_hook(_write_pace_stub(tmp_path, 0, {"over_pace": True}), SPAWN_INPUT)
94
+ assert "sonnet" not in stdout.lower()
108
95
 
109
96
 
110
97
  @pytest.mark.parametrize(
111
98
  ("tool_input", "reason"),
112
99
  [
113
100
  (["not", "an", "object"], "start_thread_session input is not a JSON object"),
114
- ({"title": "No brief"}, "start_thread_session input has no instructions text"),
115
101
  ],
116
102
  )
117
103
  def test_should_deny_a_spawn_it_cannot_reshape(
@@ -123,17 +109,3 @@ def test_should_deny_a_spawn_it_cannot_reshape(
123
109
  assert decision["permissionDecision"] == "deny"
124
110
  assert decision["permissionDecisionReason"] == reason
125
111
  assert "updatedInput" not in decision
126
-
127
-
128
- def test_should_deny_a_brief_the_advisor_line_would_push_past_the_byte_cap(
129
- tmp_path: Path,
130
- ) -> None:
131
- full_brief = {**SPAWN_INPUT, "instructions": "x" * 8100}
132
- decision = _hook_specific_output(
133
- _run_hook(_write_pace_stub(tmp_path, 0, {"over_pace": True}), full_brief)
134
- )
135
- assert decision["permissionDecision"] == "deny"
136
- over_by = 8100 + 2 + len(FABLE_ADVISOR_LINE.encode("utf-8")) - 8192
137
- assert decision["permissionDecisionReason"].endswith(
138
- f"shorten the instructions by {over_by} bytes"
139
- )
@@ -8,8 +8,7 @@ beside it, or the script ``COORDINATOR_USAGE_PACE_SCRIPT`` names.
8
8
 
9
9
  usage under pace (exit 1) -> no output; the call runs unchanged
10
10
  over pace (exit 0) or unreadable -> allow with updatedInput:
11
- model "claude-sonnet-5-5", effort "medium",
12
- instructions + the mandatory Fable advisor line
11
+ model "opus", effort "low"
13
12
  input that cannot be reshaped -> deny with a one-line reason
14
13
 
15
14
  ``updatedInput`` is the whole tool input the call runs with, so it carries
@@ -42,16 +41,9 @@ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_s
42
41
  from hooks_constants.thread_spawn_pace_hook_constants import (
43
42
  ADDITIONAL_CONTEXT_KEY,
44
43
  EFFORT_INPUT_KEY,
45
- FABLE_ADVISOR_LINE,
46
- INSTRUCTIONS_ENCODING,
47
- INSTRUCTIONS_INPUT_KEY,
48
- INSTRUCTIONS_MAXIMUM_BYTES,
49
- INSTRUCTIONS_SEPARATOR,
50
44
  LAUNCH_FAILURE_ERROR_TEMPLATE,
51
45
  MODEL_INPUT_KEY,
52
- NO_INSTRUCTIONS_REASON,
53
46
  NOT_AN_OBJECT_REASON,
54
- OVER_BYTE_CAP_REASON_TEMPLATE,
55
47
  PACE_VERDICT_CONTEXT_MAXIMUM_CHARACTERS,
56
48
  PERMISSION_DECISION_REASON_KEY,
57
49
  PERMISSION_DENY,
@@ -72,55 +64,30 @@ from hooks_constants.usage_pace_constants import (
72
64
 
73
65
 
74
66
  class SpawnNotReshapable(Exception):
75
- """The spawn input cannot carry the over-pace model, effort, and advisor line."""
76
-
77
-
78
- def _instructions_with_advisor_line(instructions: str) -> str:
79
- reshaped_instructions = (
80
- instructions
81
- if FABLE_ADVISOR_LINE in instructions
82
- else f"{instructions}{INSTRUCTIONS_SEPARATOR}{FABLE_ADVISOR_LINE}"
83
- )
84
- reshaped_byte_count = len(reshaped_instructions.encode(INSTRUCTIONS_ENCODING))
85
- if reshaped_byte_count > INSTRUCTIONS_MAXIMUM_BYTES:
86
- raise SpawnNotReshapable(
87
- OVER_BYTE_CAP_REASON_TEMPLATE.format(
88
- reshaped_byte_count=reshaped_byte_count,
89
- maximum_bytes=INSTRUCTIONS_MAXIMUM_BYTES,
90
- excess_byte_count=reshaped_byte_count - INSTRUCTIONS_MAXIMUM_BYTES,
91
- )
92
- )
93
- return reshaped_instructions
67
+ """The spawn input cannot carry the over-pace model and effort."""
94
68
 
95
69
 
96
70
  def reshape_thread_spawn(tool_input: object) -> dict[str, object]:
97
- """Move a thread spawn to Sonnet at medium effort with a Fable advisor line.
71
+ """Move a thread spawn to the latest Opus at low effort.
98
72
 
99
73
  ::
100
74
 
101
75
  {"title": "t", "instructions": "Do X."}
102
- -> {"title": "t", "instructions": "Do X.\\n\\nMandatory Fable advisor: ...",
103
- "model": "claude-sonnet-5-5", "effort": "medium"}
104
-
105
- An instructions text that already carries the advisor line keeps one copy.
76
+ -> {"title": "t", "instructions": "Do X.",
77
+ "model": "opus", "effort": "low"}
106
78
 
107
79
  Args:
108
80
  tool_input: The ``tool_input`` object of the start_thread_session call.
109
81
 
110
82
  Raises:
111
- SpawnNotReshapable: The input is not an object, has no instructions
112
- text, or would pass the server's instructions byte cap.
83
+ SpawnNotReshapable: The input is not an object.
113
84
  """
114
85
  if not isinstance(tool_input, dict):
115
86
  raise SpawnNotReshapable(NOT_AN_OBJECT_REASON)
116
- instructions = tool_input.get(INSTRUCTIONS_INPUT_KEY)
117
- if not isinstance(instructions, str):
118
- raise SpawnNotReshapable(NO_INSTRUCTIONS_REASON)
119
87
  return {
120
88
  **tool_input,
121
89
  MODEL_INPUT_KEY: THREAD_MODEL_WHEN_OVER_PACE,
122
90
  EFFORT_INPUT_KEY: THREAD_EFFORT_WHEN_OVER_PACE,
123
- INSTRUCTIONS_INPUT_KEY: _instructions_with_advisor_line(instructions),
124
91
  }
125
92
 
126
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.44.5",
3
+ "version": "8.44.6",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {