claude-dev-env 8.68.0 → 8.68.2

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.
@@ -13,7 +13,6 @@ This family stops a chat reply, edited message, or tool call when its payload br
13
13
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
14
14
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
15
15
  - `blocking/artifact_look_gate.py` denies an Artifact publish of an HTML page until an image was opened with Read after the page file was last written, so the agent looks at its page before it ships.
16
- - `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
17
16
 
18
17
  ## When it fires
19
18
 
@@ -26,7 +25,6 @@ This family stops a chat reply, edited message, or tool call when its payload br
26
25
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
27
26
  - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)`, timeout `10` seconds in `hooks.json`.
28
27
  - `blocking/artifact_look_gate.py` runs on `PreToolUse`, matcher `Artifact|Skill`, timeout `10` seconds in `hooks.json`. It speaks only on an `Artifact` publish of a `.html` or `.htm` file.
29
- - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
30
28
 
31
29
  ## Proving it
32
30
 
@@ -42,7 +40,6 @@ Preconditions:
42
40
  - **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
43
41
  - **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
44
42
  - **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
45
- - **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
46
43
 
47
44
  ## Gotchas
48
45
 
@@ -7,7 +7,6 @@ This family reminds the agent to gather context before a spawn, tells it that it
7
7
  - `routing/spawn_readiness_hook.py` adds context to a spawn that comes before a read step, or before an answered question or a settled-scope line. The spawn still runs.
8
8
  - `routing/spawn_oversight_hook.py` adds the orchestrator oversight directive to every spawn: watch the agent, check its output against the user's words and standards, and correct it before the output reaches the user. The spawn still runs.
9
9
  - `routing/thread_spawn_pace_hook.py` reshapes a thread request when usage is over pace or unreadable.
10
- - `routing/subagent_model_pin_hook.py` moves every Agent and Task subagent to Opus. A spawn that names Opus or Fable runs unchanged.
11
10
  - `routing/subagent_model_routing.mjs` allows a selected Luna model, remaps eligible requests, and denies unsupported model routing.
12
11
 
13
12
  ## When it fires
@@ -15,7 +14,6 @@ This family reminds the agent to gather context before a spawn, tells it that it
15
14
  - `routing/spawn_readiness_hook.py` runs on `PreToolUse` with matchers `Agent|Task`, `multi_agent_v1__spawn_agent`, `Workflow|mcp__github__actions_run_trigger`, and `mcp__hearthbot__start_thread_session`, each at `10` seconds in `hooks.json`. A workflow dispatch counts only when its inputs carry a `prompt`.
16
15
  - `routing/spawn_oversight_hook.py` runs on `PreToolUse` with matchers `Agent|Task`, `multi_agent_v1__spawn_agent`, `Workflow|mcp__github__actions_run_trigger`, `mcp__hearthbot__start_thread_session`, and `mcp__hearthbot__start_rc_session`, each at `10` seconds in `hooks.json`. A workflow dispatch counts only when its inputs carry a `prompt`.
17
16
  - `routing/thread_spawn_pace_hook.py` runs on `PreToolUse`, matcher `mcp__hearthbot__start_thread_session`, timeout `30` seconds in `hooks.json`.
18
- - `routing/subagent_model_pin_hook.py` runs on `PreToolUse`, matcher `Agent|Task`, timeout `10` seconds in `hooks.json`.
19
17
  - `routing/subagent_model_routing.mjs` runs on `PreToolUse`, matcher `multi_agent_v1__spawn_agent`, timeout `10` seconds through the quoted Node command in `hooks.json`.
20
18
 
21
19
  ## Proving it
@@ -27,7 +25,6 @@ Preconditions:
27
25
  - **Spawn readiness.** Input is a spawn after a read, a question, and its answer. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_readiness_hook.py -q`. The adjacent test observes no output for a ready spawn and reminder context for each missing step.
28
26
  - **Spawn oversight.** Input is a spawn from each registered tool, a subagent's own spawn, and a non-spawn call. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_spawn_oversight_hook.py -q`. The adjacent test observes the directive for every spawn and no output for the other calls.
29
27
  - **Thread pace.** Input is a thread spawn with usage over pace. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_thread_spawn_pace_hook.py -q`. The adjacent test observes updated tool input with the selected model, effort, and advisor line.
30
- - **Model pin.** Input is an Agent spawn that names a Sonnet model. Run `python -m pytest packages/claude-dev-env/hooks/routing/test_subagent_model_pin_hook.py -q`. The adjacent test observes updated tool input with model `opus`.
31
28
  - **Model routing.** Input is a `multi_agent_v1__spawn_agent` payload with a model choice. Run `node --test packages/claude-dev-env/hooks/routing/subagent_model_routing.test.mjs`. The adjacent test observes an allow or deny decision with the routed model.
32
29
 
33
30
  ## Gotchas
package/hooks/hooks.json CHANGED
@@ -15,11 +15,6 @@
15
15
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_readiness_hook.py",
16
16
  "timeout": 10
17
17
  },
18
- {
19
- "type": "command",
20
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/subagent_model_pin_hook.py",
21
- "timeout": 10
22
- },
23
18
  {
24
19
  "type": "command",
25
20
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/spawn_oversight_hook.py",
@@ -157,8 +152,9 @@
157
152
  "hooks": [
158
153
  {
159
154
  "type": "command",
160
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/artifact_dark_mode_gate.py",
161
- "timeout": 60
155
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
156
+ "timeout": 1800,
157
+ "async": true
162
158
  }
163
159
  ]
164
160
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.68.0",
3
+ "version": "8.68.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,7 @@ a workflow runner.
19
19
  from __future__ import annotations
20
20
 
21
21
  import functools
22
+ import http.client
22
23
  import json
23
24
  import os
24
25
  import urllib.error
@@ -126,7 +127,12 @@ def request_json(
126
127
  try:
127
128
  with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as reply:
128
129
  return _decoded_answer(url, reply.status, reply.read())
129
- except (urllib.error.URLError, OSError, ValueError) as failure:
130
+ except (
131
+ urllib.error.URLError,
132
+ OSError,
133
+ ValueError,
134
+ http.client.HTTPException,
135
+ ) as failure:
130
136
  raise GitHubError(str(failure)) from failure
131
137
 
132
138
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import http.client
3
4
  import json
4
5
  import os
5
6
  import sys
@@ -379,3 +380,23 @@ def should_read_a_bot_comment_carrying_a_passed_marker_as_a_notice(
379
380
  )
380
381
 
381
382
  assert [each.is_notice for each in all_comments] == [True]
383
+
384
+
385
+ class TruncatedReply:
386
+ status = 200
387
+
388
+ def read(self) -> bytes:
389
+ raise http.client.IncompleteRead(b"{", 39041)
390
+
391
+
392
+ def should_report_a_reply_cut_short_as_a_github_error(
393
+ monkeypatch: pytest.MonkeyPatch,
394
+ ) -> None:
395
+ @contextmanager
396
+ def fake_urlopen(request: object, timeout: int = 0) -> Iterator[TruncatedReply]:
397
+ yield TruncatedReply()
398
+
399
+ monkeypatch.setattr(reader.urllib.request, "urlopen", fake_urlopen)
400
+
401
+ with pytest.raises(GitHubError, match="IncompleteRead"):
402
+ reader.read_pull_request("jl-cmd/claude-dev-env", 4347, TOKEN)
@@ -1,16 +0,0 @@
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"
@@ -1,105 +0,0 @@
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 -> updatedInput model "opus"
10
- model sonnet or haiku -> 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 HOOK_EVENT_NAME
28
- from hooks_constants.hook_specific_output_keys import (
29
- HOOK_EVENT_NAME_KEY,
30
- HOOK_SPECIFIC_OUTPUT_KEY,
31
- UPDATED_INPUT_KEY,
32
- )
33
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
34
- from hooks_constants.subagent_model_pin_hook_constants import (
35
- ADDITIONAL_CONTEXT_KEY,
36
- ALL_MOVED_MODEL_FAMILIES,
37
- CONTEXT_OMITTED_MODEL_TEXT,
38
- CONTEXT_PREFIX,
39
- CONTEXT_SUFFIX,
40
- MODEL_INPUT_KEY,
41
- SUBAGENT_MODEL_ALIAS,
42
- TOOL_INPUT_KEY,
43
- )
44
-
45
-
46
- def is_moved_model(model: object) -> bool:
47
- """Return True when the spawn names no model or a model family the pin moves.
48
-
49
- ::
50
-
51
- None -> True "sonnet" -> True "claude-haiku-4-5-20251001" -> True
52
- "opus" -> False "claude-fable-5-1" -> False
53
-
54
- Args:
55
- model: The ``model`` field of the spawn input.
56
- """
57
- if model is None or model == "":
58
- return True
59
- if not isinstance(model, str):
60
- return False
61
- lowered_model = model.lower()
62
- return any(each_family in lowered_model for each_family in ALL_MOVED_MODEL_FAMILIES)
63
-
64
-
65
- def decide_hook_output(tool_input: object) -> dict[str, object] | None:
66
- """Choose the hook's output for one subagent spawn.
67
-
68
- Args:
69
- tool_input: The ``tool_input`` of the PreToolUse payload.
70
-
71
- Returns:
72
- None to pass the call through unchanged, else the hook JSON output.
73
- """
74
- if not isinstance(tool_input, dict):
75
- return None
76
- requested_model = tool_input.get(MODEL_INPUT_KEY)
77
- if not is_moved_model(requested_model):
78
- return None
79
- previous_model_text = requested_model or CONTEXT_OMITTED_MODEL_TEXT
80
- return {
81
- HOOK_SPECIFIC_OUTPUT_KEY: {
82
- HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
83
- UPDATED_INPUT_KEY: {**tool_input, MODEL_INPUT_KEY: SUBAGENT_MODEL_ALIAS},
84
- ADDITIONAL_CONTEXT_KEY: f"{CONTEXT_PREFIX}{previous_model_text}{CONTEXT_SUFFIX}",
85
- }
86
- }
87
-
88
-
89
- def main() -> int:
90
- """Read the PreToolUse payload and print the decision.
91
-
92
- Returns:
93
- 0 in every case.
94
- """
95
- hook_payload = read_hook_input_dictionary_from_stdin()
96
- tool_input = hook_payload.get(TOOL_INPUT_KEY) if hook_payload is not None else None
97
- hook_output = decide_hook_output(tool_input)
98
- if hook_output is not None:
99
- sys.stdout.write(json.dumps(hook_output))
100
- sys.stdout.flush()
101
- return 0
102
-
103
-
104
- if __name__ == "__main__":
105
- sys.exit(main())
@@ -1,67 +0,0 @@
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["hookEventName"] == "PreToolUse"
35
- assert "permissionDecision" not in decision
36
- assert decision["updatedInput"] == {**SPAWN_INPUT, "model": "opus"}
37
-
38
-
39
- @pytest.mark.parametrize("requested_model", ["opus", "fable", "claude-opus-5-5", "claude-fable-5-1"])
40
- def test_should_leave_an_opus_or_fable_spawn_alone(requested_model: str) -> None:
41
- assert _run_hook({**SPAWN_INPUT, "model": requested_model}) == ""
42
-
43
-
44
- def test_should_pass_a_tool_input_that_is_not_an_object() -> None:
45
- assert _run_hook(["not", "an", "object"]) == ""
46
-
47
-
48
- def test_should_register_the_pin_on_the_agent_and_task_matcher() -> None:
49
- all_agent_entries = [
50
- each_entry
51
- for each_entry in json.loads(HOOKS_JSON.read_text(encoding="utf-8"))["hooks"]["PreToolUse"]
52
- if each_entry["matcher"] == "Agent|Task"
53
- ]
54
- all_commands = [
55
- each_hook["command"] for each_entry in all_agent_entries for each_hook in each_entry["hooks"]
56
- ]
57
- assert any("subagent_model_pin_hook.py" in each_command for each_command in all_commands)
58
-
59
-
60
- def test_should_keep_every_constant_free_of_a_sonnet_model_id() -> None:
61
- all_offending_files = [
62
- each_file.name
63
- for each_file in CONSTANTS_DIRECTORY.glob("*.py")
64
- if not each_file.name.startswith("test_")
65
- and "claude-sonnet" in each_file.read_text(encoding="utf-8")
66
- ]
67
- assert all_offending_files == []