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.
- package/.agents/skills/eli5/SKILL.md +15 -1
- package/.agents/skills/eli5/reference/task-seeds.md +3 -1
- package/.agents/skills/step-notes/SKILL.md +30 -0
- package/.agents/skills/step-notes/scripts/step_notes_constants/__init__.py +0 -0
- package/.agents/skills/step-notes/scripts/step_notes_constants/config/__init__.py +1 -0
- package/.agents/skills/step-notes/scripts/step_notes_constants/config/constants.py +12 -0
- package/.agents/skills/step-notes/scripts/test_toggle_step_notes.py +62 -0
- package/.agents/skills/step-notes/scripts/toggle_step_notes.py +68 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/hooks/blocking/step_note_gate.py +160 -0
- package/hooks/blocking/test_step_note_gate.py +245 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/step_note_gate_constants.py +19 -0
- package/package.json +1 -1
- package/rules/review-closure-is-a-check.md +5 -2
- package/scripts/dev_env_scripts_constants/digest_check_constants.py +76 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +29 -4
- package/scripts/digest_check.py +227 -0
- package/scripts/policy_lint/adapter_callers.py +46 -2
- package/scripts/policy_lint/adapter_configuration.py +4 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/review_closure.py +5 -2
- package/scripts/review_closure_github.py +65 -11
- package/scripts/review_closure_model.py +132 -3
- package/scripts/test_digest_check.py +147 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +31 -2
- package/scripts/tests/test_review_closure.py +25 -0
- package/scripts/tests/test_review_closure_github.py +51 -0
- 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. **
|
|
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.
|
|
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 |
|
|
File without changes
|
|
@@ -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())
|
|
@@ -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
|
+
)
|