claude-dev-env 8.17.1 → 8.19.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.
Files changed (30) hide show
  1. package/.agents/skills/eli5/SKILL.md +15 -1
  2. package/.agents/skills/eli5/reference/task-seeds.md +3 -1
  3. package/.agents/skills/step-notes/SKILL.md +30 -0
  4. package/.agents/skills/step-notes/scripts/step_notes_constants/__init__.py +0 -0
  5. package/.agents/skills/step-notes/scripts/step_notes_constants/config/__init__.py +1 -0
  6. package/.agents/skills/step-notes/scripts/step_notes_constants/config/constants.py +12 -0
  7. package/.agents/skills/step-notes/scripts/test_toggle_step_notes.py +62 -0
  8. package/.agents/skills/step-notes/scripts/toggle_step_notes.py +68 -0
  9. package/bin/ever-shipped-skills.mjs +1 -0
  10. package/hooks/blocking/step_note_gate.py +160 -0
  11. package/hooks/blocking/test_step_note_gate.py +245 -0
  12. package/hooks/hooks.json +5 -0
  13. package/hooks/hooks_constants/step_note_gate_constants.py +19 -0
  14. package/package.json +1 -1
  15. package/rules/review-closure-is-a-check.md +5 -2
  16. package/scripts/dev_env_scripts_constants/digest_check_constants.py +76 -0
  17. package/scripts/dev_env_scripts_constants/review_closure_constants.py +29 -4
  18. package/scripts/digest_check.py +227 -0
  19. package/scripts/policy_lint/adapter_callers.py +46 -2
  20. package/scripts/policy_lint/adapter_configuration.py +4 -0
  21. package/scripts/policy_lint/config/constants.py +1 -0
  22. package/scripts/review_closure.py +5 -2
  23. package/scripts/review_closure_github.py +65 -11
  24. package/scripts/review_closure_model.py +132 -3
  25. package/scripts/test_digest_check.py +147 -0
  26. package/scripts/tests/test_adapter_configuration.py +8 -0
  27. package/scripts/tests/test_policy_lint_rules_callers.py +31 -2
  28. package/scripts/tests/test_review_closure.py +25 -0
  29. package/scripts/tests/test_review_closure_github.py +51 -0
  30. package/scripts/tests/test_review_closure_model.py +141 -4
@@ -71,6 +71,8 @@ keep ownership of their native artifact rules while ELI5 supplies the global
71
71
  - Preserve an explicit user-supplied output path and an existing artifact path.
72
72
  - Keep one artifact path across all updates in the active task or conversation.
73
73
  - Keep every page self-contained and browser-ready.
74
+ - Give every source change a card in a digest. The reader reads less and still
75
+ knows every change, so a dropped change is a defect.
74
76
  - Preserve native wrapper, payload, evidence, and file-format contracts owned by
75
77
  the named capability skill.
76
78
  - Use human review for accuracy, terminology, safety, confidentiality, meaning,
@@ -92,7 +94,19 @@ Process steps in order.
92
94
  `~/.claude/rules/asd-ste100-language.md` to every sentence. Frame the topic
93
95
  for a beginner, use a large useful visual, keep the text minimal, and update
94
96
  the same artifact in place.
95
- 3. **Judgment — share the updated artifact with the user.**
97
+ 3. **Deterministic — run the digest check on a summary of many changes.** A
98
+ summary of a set of changes, such as the pull requests merged in a day, is a
99
+ digest. Give each change one card marked `data-digest-card="<id>"` that holds
100
+ its picture and its one-line change. Pass every source id to the check:
101
+
102
+ ```bash
103
+ python "$HOME/.claude/scripts/digest_check.py" <page.html> --expect <id> <id>
104
+ ```
105
+
106
+ The check reports each source id with no card, each card over 25 words, each
107
+ card without a picture, and more than 40 words outside the cards. Fix every
108
+ finding and run it again until it prints `CLEAN`.
109
+ 4. **Judgment — share the updated artifact with the user.**
96
110
 
97
111
  ### Examples
98
112
 
@@ -7,4 +7,6 @@ the start of the ELI5 process.
7
7
  2. Create the artifact when no active path exists and keep it self-contained.
8
8
  3. Apply the ASD-STE100 rule to page and response sentences.
9
9
  4. Add the concise beginner explanation and useful large visual in place.
10
- 5. Share the updated artifact with the user.
10
+ 5. Run `digest_check.py` on a digest with every source id until it prints
11
+ `CLEAN`.
12
+ 6. Share the updated artifact with the user.
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: step-notes
3
+ description: Turns the status-line gate for step notes on or off. While on, a hook asks for a short status line naming each tool action, like a progress caption, so the user can follow the session. Use when the user types /step-notes or asks to turn step notes on or off.
4
+ argument-hint: "[on | off | status]"
5
+ ---
6
+
7
+ # /step-notes
8
+
9
+ The step-notes gate is a PreToolUse hook (`hooks/blocking/step_note_gate.py`). It is off by default. While it is on, each message that calls a tool starts with a short status line naming the action, such as "Reading hooks.json." or "Running the gate tests." A call with no status line fails with "Status line missing".
10
+
11
+ ## Run the toggle
12
+
13
+ Pass the user's argument through. With no argument, the toggle flips the current state.
14
+
15
+ Run `python "${CLAUDE_SKILL_DIR}/scripts/toggle_step_notes.py" <on|off|status>`.
16
+
17
+ Tell the user the line the script prints. The change applies at once, to every session on this machine.
18
+
19
+ ## Status lines
20
+
21
+ A status line names the action and its target in a few words. One status line covers every call in the same message. Subagent calls pass without a check.
22
+
23
+ ## Layout
24
+
25
+ | File | Role |
26
+ |---|---|
27
+ | `SKILL.md` | This flow: run the toggle, add status lines while the gate is on |
28
+ | `scripts/toggle_step_notes.py` | The on, off, flip, and status CLI for the flag file |
29
+ | `scripts/test_toggle_step_notes.py` | Behavior tests for each action |
30
+ | `scripts/step_notes_constants/config/constants.py` | Flag path, action names, report lines |
@@ -0,0 +1 @@
1
+ """Config package for the toggle_step_notes constants."""
@@ -0,0 +1,12 @@
1
+ """Flag path, action names, and report lines for the toggle script."""
2
+
3
+ from pathlib import Path
4
+
5
+ STEP_NOTES_ON_FLAG_PATH = Path.home() / ".claude" / ".step-notes-on"
6
+ ON_ACTION = "on"
7
+ OFF_ACTION = "off"
8
+ STATUS_ACTION = "status"
9
+ FLIP_ACTION = "flip"
10
+ ALL_ACTIONS = (ON_ACTION, OFF_ACTION, STATUS_ACTION, FLIP_ACTION)
11
+ ON_REPORT = "Step notes are on. Each tool call needs a short status line before it."
12
+ OFF_REPORT = "Step notes are off. Tool calls run without a status line."
@@ -0,0 +1,62 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ scripts_directory = str(Path(__file__).resolve().parent)
7
+ if scripts_directory not in sys.path:
8
+ sys.path.insert(0, scripts_directory)
9
+
10
+ import toggle_step_notes
11
+
12
+
13
+ @pytest.fixture
14
+ def on_flag(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
15
+ flag_path = tmp_path / "config" / ".step-notes-on"
16
+ monkeypatch.setattr(toggle_step_notes, "STEP_NOTES_ON_FLAG_PATH", flag_path)
17
+ return flag_path
18
+
19
+
20
+ def run_toggle(monkeypatch: pytest.MonkeyPatch, *all_arguments: str) -> None:
21
+ monkeypatch.setattr(sys, "argv", ["toggle_step_notes.py", *all_arguments])
22
+ toggle_step_notes.main()
23
+
24
+
25
+ def test_should_create_the_flag_when_turned_on(
26
+ on_flag: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
27
+ ) -> None:
28
+ run_toggle(monkeypatch, "on")
29
+
30
+ assert on_flag.exists()
31
+ assert capsys.readouterr().out.strip() == toggle_step_notes.ON_REPORT
32
+
33
+
34
+ def test_should_remove_the_flag_when_turned_off(
35
+ on_flag: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
36
+ ) -> None:
37
+ on_flag.parent.mkdir(parents=True)
38
+ on_flag.touch()
39
+
40
+ run_toggle(monkeypatch, "off")
41
+
42
+ assert not on_flag.exists()
43
+ assert capsys.readouterr().out.strip() == toggle_step_notes.OFF_REPORT
44
+
45
+
46
+ def test_should_flip_off_to_on_and_back_with_no_argument(
47
+ on_flag: Path, monkeypatch: pytest.MonkeyPatch
48
+ ) -> None:
49
+ run_toggle(monkeypatch)
50
+ was_on_after_first_flip = on_flag.exists()
51
+ run_toggle(monkeypatch)
52
+
53
+ assert (was_on_after_first_flip, on_flag.exists()) == (True, False)
54
+
55
+
56
+ def test_should_report_off_by_default_without_changing_the_flag(
57
+ on_flag: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
58
+ ) -> None:
59
+ run_toggle(monkeypatch, "status")
60
+
61
+ assert not on_flag.exists()
62
+ assert capsys.readouterr().out.strip() == toggle_step_notes.OFF_REPORT
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env python3
2
+ """Turn the step-notes gate on or off, or report its state.
3
+
4
+ The gate is on while STEP_NOTES_ON_FLAG_PATH exists and off by default. `on`
5
+ creates the flag file, `off` removes it, `flip` does whichever changes the
6
+ state, and `status` changes nothing. The flag applies to every session on
7
+ this machine.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ scripts_directory = str(Path(__file__).resolve().parent)
17
+ if scripts_directory not in sys.path:
18
+ sys.path.insert(0, scripts_directory)
19
+
20
+ from step_notes_constants.config.constants import (
21
+ ALL_ACTIONS,
22
+ FLIP_ACTION,
23
+ OFF_ACTION,
24
+ OFF_REPORT,
25
+ ON_ACTION,
26
+ ON_REPORT,
27
+ STEP_NOTES_ON_FLAG_PATH,
28
+ )
29
+
30
+
31
+ def apply_action(action: str) -> bool:
32
+ """Apply one action to the flag file and return whether step notes are on.
33
+
34
+ Args:
35
+ action: One of ALL_ACTIONS. `on` creates the flag file, `off` removes it,
36
+ `flip` does whichever changes the state, and `status` changes nothing.
37
+
38
+ Returns:
39
+ True when the flag file exists after the action, False otherwise.
40
+ """
41
+ is_on = STEP_NOTES_ON_FLAG_PATH.exists()
42
+ if action == FLIP_ACTION:
43
+ action = OFF_ACTION if is_on else ON_ACTION
44
+ if action == ON_ACTION:
45
+ STEP_NOTES_ON_FLAG_PATH.parent.mkdir(parents=True, exist_ok=True)
46
+ STEP_NOTES_ON_FLAG_PATH.touch()
47
+ return True
48
+ if action == OFF_ACTION:
49
+ STEP_NOTES_ON_FLAG_PATH.unlink(missing_ok=True)
50
+ return False
51
+ return is_on
52
+
53
+
54
+ def main() -> int:
55
+ """Parse the action from the command line, apply it, and print the new state.
56
+
57
+ Returns:
58
+ 0 after printing ON_REPORT or OFF_REPORT.
59
+ """
60
+ parser = argparse.ArgumentParser(description=__doc__)
61
+ parser.add_argument("action", nargs="?", default=FLIP_ACTION, choices=ALL_ACTIONS)
62
+ parsed_arguments = parser.parse_args()
63
+ print(ON_REPORT if apply_action(parsed_arguments.action) else OFF_REPORT)
64
+ return 0
65
+
66
+
67
+ if __name__ == "__main__":
68
+ sys.exit(main())
@@ -102,6 +102,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
102
102
  'source-command-logifix',
103
103
  'source-command-sr-loop',
104
104
  'split-pr',
105
+ 'step-notes',
105
106
  'structure-prompt',
106
107
  'syncing-submodules',
107
108
  'task-build',
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook that asks for a status line before each tool call.
3
+
4
+ A status line is a short text block, such as "Reading hooks.json.", that
5
+ opens the message holding the call. It works like a progress caption so the
6
+ user can follow the session.
7
+
8
+ The gate is off by default. The step-notes skill turns it on by creating the
9
+ flag file at STEP_NOTES_ON_FLAG_PATH, and off again by removing it.
10
+
11
+ Claude Code writes the assistant message to the transcript after this hook
12
+ starts: 100 to 420 ms later in headless runs, and 650 ms to past 3 s in the
13
+ desktop app. The hook polls the transcript tail until its own tool_use entry
14
+ appears. Three cases pass without a check:
15
+
16
+ - A subagent call. Its input carries agent_id, its messages live in a separate
17
+ transcript, and its status lines stay inside the subagent.
18
+ - A call that does not appear before the poll limit.
19
+ - A call whose message is larger than the tail window the hook reads.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import enum
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+ from collections.abc import Iterator
30
+ from pathlib import Path
31
+
32
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
33
+ if hooks_root_directory not in sys.path:
34
+ sys.path.insert(0, hooks_root_directory)
35
+
36
+ from hooks_constants.step_note_gate_constants import (
37
+ ALLOW_EXIT_CODE,
38
+ ASSISTANT_ROLE,
39
+ BLOCK_EXIT_CODE,
40
+ BLOCK_MESSAGE,
41
+ POLL_INTERVAL_SECONDS,
42
+ POLL_LIMIT_SECONDS,
43
+ STEP_NOTES_ON_FLAG_PATH,
44
+ TAIL_WINDOW_BYTES,
45
+ TEXT_BLOCK_TYPE,
46
+ TOOL_USE_BLOCK_TYPE,
47
+ USER_ROLE,
48
+ )
49
+
50
+
51
+ class NoteCheck(enum.Enum):
52
+ NOTED = "noted"
53
+ MISSING = "missing"
54
+ CALL_NOT_WRITTEN = "call_not_written"
55
+ WINDOW_TOO_SHORT = "window_too_short"
56
+
57
+
58
+ def read_transcript_tail(transcript_path: Path) -> list[dict]:
59
+ """Parse the complete JSON lines in the last TAIL_WINDOW_BYTES of the transcript."""
60
+ try:
61
+ with transcript_path.open("rb") as transcript_file:
62
+ file_size = transcript_file.seek(0, os.SEEK_END)
63
+ window_start = max(0, file_size - TAIL_WINDOW_BYTES)
64
+ transcript_file.seek(window_start)
65
+ window_bytes = transcript_file.read()
66
+ except OSError:
67
+ return []
68
+ window_lines = window_bytes.split(b"\n")
69
+ if window_start > 0:
70
+ window_lines = window_lines[1:]
71
+ all_entries = []
72
+ for each_line in window_lines:
73
+ try:
74
+ parsed_entry = json.loads(each_line)
75
+ except ValueError:
76
+ continue
77
+ if isinstance(parsed_entry, dict):
78
+ all_entries.append(parsed_entry)
79
+ return all_entries
80
+
81
+
82
+ def entry_content_blocks(entry: dict) -> list[dict]:
83
+ """Return the content blocks of one transcript entry, wrapping plain text as a text block."""
84
+ content = (entry.get("message") or {}).get("content")
85
+ if isinstance(content, str):
86
+ return [{"type": TEXT_BLOCK_TYPE, "text": content}]
87
+ if isinstance(content, list):
88
+ return [each_block for each_block in content if isinstance(each_block, dict)]
89
+ return []
90
+
91
+
92
+ def transcript_blocks(all_entries: list[dict]) -> Iterator[tuple[str, dict]]:
93
+ """Yield (role, content block) pairs for user and assistant entries, in order."""
94
+ for each_entry in all_entries:
95
+ role = each_entry.get("type")
96
+ if role not in (USER_ROLE, ASSISTANT_ROLE):
97
+ continue
98
+ for each_block in entry_content_blocks(each_entry):
99
+ yield role, each_block
100
+
101
+
102
+ def is_step_note(block: dict) -> bool:
103
+ return block.get("type") == TEXT_BLOCK_TYPE and bool(str(block.get("text", "")).strip())
104
+
105
+
106
+ def check_step_note(all_entries: list[dict], tool_use_id: str) -> NoteCheck:
107
+ """Report whether a text block sits between the last user entry and this call.
108
+
109
+ A user entry is either a tool result or a prompt, so it marks where the
110
+ previous step ended. Sibling calls in one message share the note that
111
+ opens the message.
112
+ """
113
+ all_blocks = list(transcript_blocks(all_entries))
114
+ call_position = next(
115
+ (
116
+ position
117
+ for position, (role, block) in enumerate(all_blocks)
118
+ if role == ASSISTANT_ROLE
119
+ and block.get("type") == TOOL_USE_BLOCK_TYPE
120
+ and block.get("id") == tool_use_id
121
+ ),
122
+ None,
123
+ )
124
+ if call_position is None:
125
+ return NoteCheck.CALL_NOT_WRITTEN
126
+ for each_preceding_role, each_preceding_block in reversed(all_blocks[:call_position]):
127
+ if each_preceding_role == USER_ROLE:
128
+ return NoteCheck.MISSING
129
+ if is_step_note(each_preceding_block):
130
+ return NoteCheck.NOTED
131
+ return NoteCheck.WINDOW_TOO_SHORT
132
+
133
+
134
+ def wait_for_note_check(transcript_path: Path, tool_use_id: str) -> NoteCheck:
135
+ deadline = time.monotonic() + POLL_LIMIT_SECONDS
136
+ while True:
137
+ note_check = check_step_note(read_transcript_tail(transcript_path), tool_use_id)
138
+ if note_check is not NoteCheck.CALL_NOT_WRITTEN or time.monotonic() >= deadline:
139
+ return note_check
140
+ time.sleep(POLL_INTERVAL_SECONDS)
141
+
142
+
143
+ def main() -> int:
144
+ if not STEP_NOTES_ON_FLAG_PATH.exists():
145
+ return ALLOW_EXIT_CODE
146
+ hook_input = json.load(sys.stdin)
147
+ if hook_input.get("agent_id"):
148
+ return ALLOW_EXIT_CODE
149
+ tool_use_id = hook_input.get("tool_use_id")
150
+ transcript_path = hook_input.get("transcript_path")
151
+ if not tool_use_id or not transcript_path:
152
+ return ALLOW_EXIT_CODE
153
+ if wait_for_note_check(Path(transcript_path), tool_use_id) is NoteCheck.MISSING:
154
+ sys.stderr.write(BLOCK_MESSAGE)
155
+ return BLOCK_EXIT_CODE
156
+ return ALLOW_EXIT_CODE
157
+
158
+
159
+ if __name__ == "__main__":
160
+ sys.exit(main())
@@ -0,0 +1,245 @@
1
+ import io
2
+ import json
3
+ import threading
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ import step_note_gate
9
+
10
+ PROMPT_ENTRY = {"type": "user", "message": {"role": "user", "content": "List the folder."}}
11
+
12
+
13
+ def assistant_entry(message_id, block):
14
+ return {
15
+ "type": "assistant",
16
+ "message": {"id": message_id, "role": "assistant", "content": [block]},
17
+ }
18
+
19
+
20
+ def note_entry(message_id, text):
21
+ return assistant_entry(message_id, {"type": "text", "text": text})
22
+
23
+
24
+ def call_entry(message_id, tool_use_id):
25
+ return assistant_entry(
26
+ message_id,
27
+ {"type": "tool_use", "id": tool_use_id, "name": "Bash", "input": {"command": "echo one"}},
28
+ )
29
+
30
+
31
+ def result_entry(tool_use_id):
32
+ return {
33
+ "type": "user",
34
+ "message": {
35
+ "role": "user",
36
+ "content": [{"type": "tool_result", "tool_use_id": tool_use_id, "content": "one"}],
37
+ },
38
+ }
39
+
40
+
41
+ def write_transcript(path, all_entries):
42
+ path.write_text("".join(json.dumps(each) + "\n" for each in all_entries), encoding="utf-8")
43
+
44
+
45
+ def run_gate(
46
+ monkeypatch: pytest.MonkeyPatch,
47
+ capsys: pytest.CaptureFixture[str],
48
+ transcript_path: Path,
49
+ tool_use_id: str,
50
+ **extra_input: str,
51
+ ) -> tuple[int, str]:
52
+ hook_input = {
53
+ "hook_event_name": "PreToolUse",
54
+ "tool_name": "Bash",
55
+ "tool_use_id": tool_use_id,
56
+ "transcript_path": str(transcript_path),
57
+ **extra_input,
58
+ }
59
+ monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(hook_input)))
60
+ exit_code = step_note_gate.main()
61
+ return exit_code, capsys.readouterr().err
62
+
63
+
64
+ @pytest.fixture(autouse=True)
65
+ def isolated_gate(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
66
+ monkeypatch.setattr(step_note_gate, "POLL_LIMIT_SECONDS", 0.3)
67
+ on_flag_path = tmp_path / ".step-notes-on"
68
+ on_flag_path.write_text("", encoding="utf-8")
69
+ monkeypatch.setattr(step_note_gate, "STEP_NOTES_ON_FLAG_PATH", on_flag_path)
70
+
71
+
72
+ def test_should_allow_a_call_that_follows_a_note_in_its_message(
73
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
74
+ ) -> None:
75
+ transcript = tmp_path / "session.jsonl"
76
+ write_transcript(
77
+ transcript,
78
+ [PROMPT_ENTRY, note_entry("m1", "Listing the folder."), call_entry("m1", "call_1")],
79
+ )
80
+
81
+ exit_code, stderr_text = run_gate(monkeypatch, capsys, transcript, "call_1")
82
+
83
+ assert (exit_code, stderr_text) == (0, "")
84
+
85
+
86
+ def test_should_block_a_call_with_no_note_after_the_previous_result(
87
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
88
+ ) -> None:
89
+ transcript = tmp_path / "session.jsonl"
90
+ write_transcript(
91
+ transcript,
92
+ [
93
+ PROMPT_ENTRY,
94
+ note_entry("m1", "Listing the folder."),
95
+ call_entry("m1", "call_1"),
96
+ result_entry("call_1"),
97
+ call_entry("m2", "call_2"),
98
+ ],
99
+ )
100
+
101
+ exit_code, stderr_text = run_gate(monkeypatch, capsys, transcript, "call_2")
102
+
103
+ assert exit_code == 2
104
+ assert stderr_text == step_note_gate.BLOCK_MESSAGE
105
+
106
+
107
+ def test_should_allow_a_bare_call_while_the_on_flag_is_absent(
108
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
109
+ ) -> None:
110
+ transcript = tmp_path / "session.jsonl"
111
+ write_transcript(transcript, [PROMPT_ENTRY, call_entry("m1", "call_1")])
112
+ step_note_gate.STEP_NOTES_ON_FLAG_PATH.unlink()
113
+
114
+ exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_1")
115
+
116
+ assert exit_code == 0
117
+
118
+
119
+ def test_should_block_the_first_call_after_a_prompt_when_no_note_precedes_it(
120
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
121
+ ) -> None:
122
+ transcript = tmp_path / "session.jsonl"
123
+ write_transcript(transcript, [PROMPT_ENTRY, call_entry("m1", "call_1")])
124
+
125
+ exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_1")
126
+
127
+ assert exit_code == 2
128
+
129
+
130
+ def test_should_block_when_the_only_text_is_blank(
131
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
132
+ ) -> None:
133
+ transcript = tmp_path / "session.jsonl"
134
+ write_transcript(
135
+ transcript, [PROMPT_ENTRY, note_entry("m1", " \n "), call_entry("m1", "call_1")]
136
+ )
137
+
138
+ exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_1")
139
+
140
+ assert exit_code == 2
141
+
142
+
143
+ def test_should_allow_every_parallel_call_under_one_note(
144
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
145
+ ) -> None:
146
+ transcript = tmp_path / "session.jsonl"
147
+ write_transcript(
148
+ transcript,
149
+ [
150
+ PROMPT_ENTRY,
151
+ note_entry("m1", "Running two echoes together."),
152
+ call_entry("m1", "call_1"),
153
+ call_entry("m1", "call_2"),
154
+ ],
155
+ )
156
+
157
+ first_exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_1")
158
+ second_exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_2")
159
+
160
+ assert (first_exit_code, second_exit_code) == (0, 0)
161
+
162
+
163
+ def test_should_see_a_note_the_harness_writes_after_the_hook_starts(
164
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
165
+ ) -> None:
166
+ transcript = tmp_path / "session.jsonl"
167
+ write_transcript(transcript, [PROMPT_ENTRY])
168
+ late_lines = "".join(
169
+ json.dumps(each) + "\n"
170
+ for each in (note_entry("m1", "Listing the folder."), call_entry("m1", "call_1"))
171
+ )
172
+
173
+ def append_late_lines():
174
+ with transcript.open("a", encoding="utf-8") as transcript_file:
175
+ transcript_file.write(late_lines)
176
+
177
+ writer = threading.Timer(0.15, append_late_lines)
178
+ writer.start()
179
+ exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_1")
180
+ writer.join()
181
+
182
+ assert exit_code == 0
183
+
184
+
185
+ def test_should_block_a_bare_call_the_harness_writes_after_the_hook_starts(
186
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
187
+ ) -> None:
188
+ transcript = tmp_path / "session.jsonl"
189
+ write_transcript(transcript, [PROMPT_ENTRY])
190
+
191
+ def append_late_call():
192
+ with transcript.open("a", encoding="utf-8") as transcript_file:
193
+ transcript_file.write(json.dumps(call_entry("m1", "call_1")) + "\n")
194
+
195
+ writer = threading.Timer(0.15, append_late_call)
196
+ writer.start()
197
+ exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_1")
198
+ writer.join()
199
+
200
+ assert exit_code == 2
201
+
202
+
203
+ def test_should_allow_a_call_that_never_reaches_the_transcript(
204
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
205
+ ) -> None:
206
+ transcript = tmp_path / "session.jsonl"
207
+ write_transcript(transcript, [PROMPT_ENTRY])
208
+
209
+ exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_1")
210
+
211
+ assert exit_code == 0
212
+
213
+
214
+ def test_should_allow_a_subagent_call_even_without_a_note(
215
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
216
+ ) -> None:
217
+ transcript = tmp_path / "session.jsonl"
218
+ write_transcript(transcript, [PROMPT_ENTRY, call_entry("m1", "call_1")])
219
+
220
+ exit_code, _ = run_gate(
221
+ monkeypatch, capsys, transcript, "call_1", agent_id="a1", agent_type="general-purpose"
222
+ )
223
+
224
+ assert exit_code == 0
225
+
226
+
227
+ def test_should_allow_a_call_whose_message_outgrows_the_tail_window(
228
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
229
+ ) -> None:
230
+ transcript = tmp_path / "session.jsonl"
231
+ write_transcript(
232
+ transcript,
233
+ [
234
+ PROMPT_ENTRY,
235
+ call_entry("m1", "call_0"),
236
+ result_entry("call_0"),
237
+ call_entry("m2", "call_1"),
238
+ ],
239
+ )
240
+ call_line_bytes = len(json.dumps(call_entry("m2", "call_1")).encode("utf-8")) + 1
241
+ monkeypatch.setattr(step_note_gate, "TAIL_WINDOW_BYTES", call_line_bytes + 10)
242
+
243
+ exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_1")
244
+
245
+ assert exit_code == 0
package/hooks/hooks.json CHANGED
@@ -59,6 +59,11 @@
59
59
  "type": "command",
60
60
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
61
61
  "timeout": 120
62
+ },
63
+ {
64
+ "type": "command",
65
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/step_note_gate.py",
66
+ "timeout": 15
62
67
  }
63
68
  ]
64
69
  }
@@ -0,0 +1,19 @@
1
+ """Constants for the step-note gate PreToolUse hook."""
2
+
3
+ from pathlib import Path
4
+
5
+ STEP_NOTES_ON_FLAG_PATH = Path.home() / ".claude" / ".step-notes-on"
6
+ POLL_INTERVAL_SECONDS = 0.05
7
+ POLL_LIMIT_SECONDS = 8.0
8
+ TAIL_WINDOW_BYTES = 1_000_000
9
+ BLOCK_EXIT_CODE = 2
10
+ ALLOW_EXIT_CODE = 0
11
+ USER_ROLE = "user"
12
+ ASSISTANT_ROLE = "assistant"
13
+ TEXT_BLOCK_TYPE = "text"
14
+ TOOL_USE_BLOCK_TYPE = "tool_use"
15
+ BLOCK_MESSAGE = (
16
+ "Status line missing. Start the message with a short status line that names "
17
+ "this action, such as 'Reading hooks.json.', then retry the call. "
18
+ "The user turns this gate on and off with /step-notes."
19
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.17.1",
3
+ "version": "8.19.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {