claude-dev-env 8.97.0 → 8.98.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/bin/install.prompt-context.test.mjs +3 -2
- package/bin/install.settings-defaults.test.mjs +4 -3
- package/docs/agents-guidance/asks.md +6 -1
- package/hooks/blocking/open_tasks_stop_gate.py +128 -34
- package/hooks/blocking/open_tasks_transcript.py +112 -5
- package/hooks/blocking/pr_lifecycle_skill_gate.py +56 -14
- package/hooks/blocking/python_windows_invocation_gate.py +249 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +202 -0
- package/hooks/blocking/test_open_tasks_transcript.py +89 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
- package/hooks/blocking/test_python_windows_invocation_gate.py +181 -0
- package/hooks/blocking/test_verify_before_commit_gate.py +334 -0
- package/hooks/blocking/verify_before_commit_gate.py +278 -0
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +10 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +50 -0
- package/hooks/hooks_constants/python_windows_invocation_gate_constants.py +60 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
- package/hooks/hooks_constants/verify_before_commit_gate_constants.py +54 -0
- package/hooks/open_task_asks.py +163 -0
- package/hooks/session/open_task_ask_recorder.py +38 -0
- package/hooks/session/test_open_task_ask_recorder.py +57 -0
- package/hooks/test_open_task_asks.py +52 -0
- package/hooks/test_transcript_skill_scan.py +24 -0
- package/hooks/transcript_skill_scan.py +54 -3
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/settings.json +2 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Per-session record of the user's prompts, read by the open tasks Stop gate.
|
|
2
|
+
|
|
3
|
+
Every prompt lands in one file outside the task store::
|
|
4
|
+
|
|
5
|
+
~/.claude/open-task-asks/<session id>.json
|
|
6
|
+
{"prompts": [{"text": "...", "recorded_at": 1761076800.0, "ask_id": "ask-1"}, ...]}
|
|
7
|
+
|
|
8
|
+
A prompt that asks for work gets the next ``ask-N`` id. A question-only
|
|
9
|
+
prompt, an acknowledgement such as ``thanks!``, an empty prompt, and a harness envelope such as
|
|
10
|
+
``<task-notification>`` get ``ask_id`` null, and the gate still reads their
|
|
11
|
+
text when it checks the quote on a ``Dropped:`` line.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import NamedTuple
|
|
22
|
+
|
|
23
|
+
from atomic_file_writer import write_text_atomically
|
|
24
|
+
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
25
|
+
ACKNOWLEDGEMENT_PATTERN,
|
|
26
|
+
ASK_ID_TEMPLATE,
|
|
27
|
+
ASK_PROMPT_ID_KEY,
|
|
28
|
+
ASK_PROMPT_RECORDED_AT_KEY,
|
|
29
|
+
ASK_PROMPT_TEXT_KEY,
|
|
30
|
+
ASKS_DIRECTORY_NAME,
|
|
31
|
+
ASKS_FILE_SUFFIX,
|
|
32
|
+
ASKS_PROMPTS_KEY,
|
|
33
|
+
ASKS_TEMPORARY_PREFIX,
|
|
34
|
+
ASKS_TEMPORARY_SUFFIX,
|
|
35
|
+
CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE,
|
|
36
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
37
|
+
INTERROGATIVE_START_PATTERN,
|
|
38
|
+
POLITE_REQUEST_START_PATTERN,
|
|
39
|
+
QUESTION_END_PATTERN,
|
|
40
|
+
SAFE_SESSION_ID_PATTERN,
|
|
41
|
+
SENTENCE_SPLIT_PATTERN,
|
|
42
|
+
UTF8_ENCODING,
|
|
43
|
+
YES_OR_NO_END_PATTERN,
|
|
44
|
+
)
|
|
45
|
+
from hooks_constants.spawn_readiness_hook_constants import ALL_HARNESS_ENVELOPE_MARKERS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RecordedPrompt(NamedTuple):
|
|
49
|
+
"""One prompt the user sent.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
text: The prompt as the user sent it.
|
|
53
|
+
recorded_at: Seconds since the epoch when the recorder saw it.
|
|
54
|
+
ask_id: ``ask-N`` for a prompt that asks for work, or None.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
text: str
|
|
58
|
+
recorded_at: float
|
|
59
|
+
ask_id: str | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def claude_home() -> Path:
|
|
63
|
+
"""Return the Claude home directory, honoring ``CLAUDE_CONFIG_DIR``."""
|
|
64
|
+
configured_directory = os.environ.get(CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE)
|
|
65
|
+
if configured_directory:
|
|
66
|
+
return Path(configured_directory)
|
|
67
|
+
return Path.home() / CLAUDE_HOME_DIRECTORY_NAME
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _asks_file(session_id: object) -> Path | None:
|
|
71
|
+
if not isinstance(session_id, str) or not re.fullmatch(SAFE_SESSION_ID_PATTERN, session_id):
|
|
72
|
+
return None
|
|
73
|
+
return claude_home() / ASKS_DIRECTORY_NAME / (session_id + ASKS_FILE_SUFFIX)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_question_only(prompt_text: str) -> bool:
|
|
77
|
+
"""Return True when the prompt asks a question and asks for no work.
|
|
78
|
+
|
|
79
|
+
::
|
|
80
|
+
|
|
81
|
+
"did the export fail?" -> True
|
|
82
|
+
"are you monitoring these? yes or no" -> True
|
|
83
|
+
"can you check the release dates in neon?" -> False
|
|
84
|
+
"Export the APKs and submit them" -> False
|
|
85
|
+
"""
|
|
86
|
+
all_sentences = [
|
|
87
|
+
each_part.strip()
|
|
88
|
+
for each_part in re.split(SENTENCE_SPLIT_PATTERN, prompt_text.strip())
|
|
89
|
+
if each_part.strip()
|
|
90
|
+
]
|
|
91
|
+
if not all_sentences or re.search(POLITE_REQUEST_START_PATTERN, prompt_text, re.IGNORECASE):
|
|
92
|
+
return False
|
|
93
|
+
if all(re.search(QUESTION_END_PATTERN, each_sentence) for each_sentence in all_sentences):
|
|
94
|
+
return True
|
|
95
|
+
return bool(
|
|
96
|
+
re.search(YES_OR_NO_END_PATTERN, all_sentences[-1], re.IGNORECASE)
|
|
97
|
+
and re.search(INTERROGATIVE_START_PATTERN, all_sentences[0], re.IGNORECASE)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_ask(prompt_text: str) -> bool:
|
|
102
|
+
"""Return True when the prompt asks for work the gate should hold the turn for."""
|
|
103
|
+
stripped_text = prompt_text.strip()
|
|
104
|
+
if not stripped_text or stripped_text.startswith(ALL_HARNESS_ENVELOPE_MARKERS):
|
|
105
|
+
return False
|
|
106
|
+
if re.fullmatch(ACKNOWLEDGEMENT_PATTERN, stripped_text, re.IGNORECASE):
|
|
107
|
+
return False
|
|
108
|
+
return not is_question_only(stripped_text)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def recorded_prompts(session_id: object) -> list[RecordedPrompt]:
|
|
112
|
+
"""Return every prompt recorded for the session, oldest first."""
|
|
113
|
+
asks_file = _asks_file(session_id)
|
|
114
|
+
if asks_file is None:
|
|
115
|
+
return []
|
|
116
|
+
try:
|
|
117
|
+
all_file_fields = json.loads(asks_file.read_text(encoding=UTF8_ENCODING))
|
|
118
|
+
except (OSError, ValueError):
|
|
119
|
+
return []
|
|
120
|
+
all_entries = (
|
|
121
|
+
all_file_fields.get(ASKS_PROMPTS_KEY) if isinstance(all_file_fields, dict) else None
|
|
122
|
+
)
|
|
123
|
+
if not isinstance(all_entries, list):
|
|
124
|
+
return []
|
|
125
|
+
return [
|
|
126
|
+
_parsed_prompt(each_entry) for each_entry in all_entries if isinstance(each_entry, dict)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parsed_prompt(all_entry_fields: dict[str, object]) -> RecordedPrompt:
|
|
131
|
+
recorded_at = all_entry_fields.get(ASK_PROMPT_RECORDED_AT_KEY)
|
|
132
|
+
ask_id = all_entry_fields.get(ASK_PROMPT_ID_KEY)
|
|
133
|
+
return RecordedPrompt(
|
|
134
|
+
str(all_entry_fields.get(ASK_PROMPT_TEXT_KEY, "")),
|
|
135
|
+
float(recorded_at) if isinstance(recorded_at, (int, float)) else 0.0,
|
|
136
|
+
ask_id if isinstance(ask_id, str) else None,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def record_prompt(session_id: object, prompt_text: str) -> str | None:
|
|
141
|
+
"""Append the prompt to the session's file and return its ``ask-N`` id, or None for no ask."""
|
|
142
|
+
asks_file = _asks_file(session_id)
|
|
143
|
+
if asks_file is None:
|
|
144
|
+
return None
|
|
145
|
+
all_prompts = recorded_prompts(session_id)
|
|
146
|
+
ask_count = sum(1 for each_prompt in all_prompts if each_prompt.ask_id)
|
|
147
|
+
ask_id = ASK_ID_TEMPLATE.format(ask_number=ask_count + 1) if is_ask(prompt_text) else None
|
|
148
|
+
all_prompts.append(RecordedPrompt(prompt_text, time.time(), ask_id))
|
|
149
|
+
serialized_text = json.dumps(
|
|
150
|
+
{ASKS_PROMPTS_KEY: [each_prompt._asdict() for each_prompt in all_prompts]}
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
write_text_atomically(
|
|
154
|
+
asks_file,
|
|
155
|
+
serialized_text,
|
|
156
|
+
UTF8_ENCODING,
|
|
157
|
+
ASKS_TEMPORARY_PREFIX,
|
|
158
|
+
ASKS_TEMPORARY_SUFFIX,
|
|
159
|
+
False,
|
|
160
|
+
)
|
|
161
|
+
except OSError:
|
|
162
|
+
return None
|
|
163
|
+
return ask_id
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""UserPromptSubmit hook that records each prompt for the open tasks Stop gate.
|
|
3
|
+
|
|
4
|
+
A prompt that asks for work becomes ``#ask-N`` in
|
|
5
|
+
``~/.claude/open-task-asks/<session id>.json``. The gate then holds the turn
|
|
6
|
+
until a reply closes it with ``Done: #ask-N``, or with
|
|
7
|
+
``Dropped: #ask-N - "<the user's words>"`` quoting a later prompt. The hook
|
|
8
|
+
prints nothing and always exits 0.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
if hooks_root_directory not in sys.path:
|
|
18
|
+
sys.path.insert(0, hooks_root_directory)
|
|
19
|
+
|
|
20
|
+
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
21
|
+
ALLOW_EXIT_CODE,
|
|
22
|
+
PROMPT_KEY,
|
|
23
|
+
SESSION_ID_KEY,
|
|
24
|
+
)
|
|
25
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
26
|
+
from open_task_asks import record_prompt
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> int:
|
|
30
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
31
|
+
prompt_value = hook_input.get(PROMPT_KEY) if hook_input is not None else None
|
|
32
|
+
if hook_input is not None and isinstance(prompt_value, str):
|
|
33
|
+
record_prompt(hook_input.get(SESSION_ID_KEY), prompt_value)
|
|
34
|
+
return ALLOW_EXIT_CODE
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
sys.exit(main())
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
RECORDER_PATH = Path(__file__).resolve().parent / "open_task_ask_recorder.py"
|
|
7
|
+
SESSION_ID = "s-123"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def submit_prompt(home_directory: Path, prompt_text: str) -> subprocess.CompletedProcess[str]:
|
|
11
|
+
hook_input = {
|
|
12
|
+
"hook_event_name": "UserPromptSubmit",
|
|
13
|
+
"session_id": SESSION_ID,
|
|
14
|
+
"prompt": prompt_text,
|
|
15
|
+
}
|
|
16
|
+
return subprocess.run(
|
|
17
|
+
[sys.executable, str(RECORDER_PATH)],
|
|
18
|
+
input=json.dumps(hook_input),
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
env={
|
|
22
|
+
"HOME": str(home_directory),
|
|
23
|
+
"USERPROFILE": str(home_directory),
|
|
24
|
+
"PATH": "",
|
|
25
|
+
},
|
|
26
|
+
check=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def recorded_ask_ids(home_directory: Path) -> list[str]:
|
|
31
|
+
asks_path = home_directory / ".claude" / "open-task-asks" / f"{SESSION_ID}.json"
|
|
32
|
+
all_prompts = json.loads(asks_path.read_text(encoding="utf-8"))["prompts"]
|
|
33
|
+
return [each_prompt["ask_id"] for each_prompt in all_prompts if each_prompt["ask_id"]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_should_record_each_ask_outside_the_task_store_and_stay_silent(
|
|
37
|
+
tmp_path: Path,
|
|
38
|
+
) -> None:
|
|
39
|
+
first_process = submit_prompt(tmp_path, "Export the OneUI8 and OneUI9 APKs and submit them")
|
|
40
|
+
submit_prompt(tmp_path, "did the export fail?")
|
|
41
|
+
submit_prompt(tmp_path, "<task-notification>\n<task-id>b1</task-id>\n</task-notification>")
|
|
42
|
+
submit_prompt(tmp_path, "can you check the release dates in neon?")
|
|
43
|
+
assert (first_process.returncode, first_process.stdout) == (0, "")
|
|
44
|
+
assert recorded_ask_ids(tmp_path) == ["ask-1", "ask-2"]
|
|
45
|
+
assert not (tmp_path / ".claude" / "tasks").exists()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_should_allow_malformed_input(tmp_path: Path) -> None:
|
|
49
|
+
completed_process = subprocess.run(
|
|
50
|
+
[sys.executable, str(RECORDER_PATH)],
|
|
51
|
+
input="not json",
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
env={"HOME": str(tmp_path), "USERPROFILE": str(tmp_path), "PATH": ""},
|
|
55
|
+
check=False,
|
|
56
|
+
)
|
|
57
|
+
assert (completed_process.returncode, completed_process.stdout) == (0, "")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from open_task_asks import is_ask, record_prompt, recorded_prompts
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(autouse=True)
|
|
9
|
+
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
10
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
11
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
12
|
+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize(
|
|
16
|
+
("prompt_text", "is_expected_ask"),
|
|
17
|
+
[
|
|
18
|
+
("Export the OneUI8 and OneUI9 APKs and submit them", True),
|
|
19
|
+
("can you check the release dates in neon?", True),
|
|
20
|
+
(
|
|
21
|
+
"set the 8 to ready to export in neon\n\ni dont think you're correct in none are released?",
|
|
22
|
+
True,
|
|
23
|
+
),
|
|
24
|
+
("wait, did /deepstack-mode fail?", False),
|
|
25
|
+
("are you monitoring these? yes or no", False),
|
|
26
|
+
("<task-notification>\n<task-id>b1</task-id>", False),
|
|
27
|
+
(" ", False),
|
|
28
|
+
("thanks!", False),
|
|
29
|
+
("ok, thanks", False),
|
|
30
|
+
("Looks good", False),
|
|
31
|
+
("ok, ship it", True),
|
|
32
|
+
("yes", True),
|
|
33
|
+
],
|
|
34
|
+
)
|
|
35
|
+
def test_should_classify_work_prompts_as_asks(prompt_text: str, is_expected_ask: bool) -> None:
|
|
36
|
+
assert is_ask(prompt_text) is is_expected_ask
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_should_number_asks_and_keep_every_prompt_in_order() -> None:
|
|
40
|
+
assert record_prompt("s-1", "Export the APKs") == "ask-1"
|
|
41
|
+
assert record_prompt("s-1", "did it fail?") is None
|
|
42
|
+
assert record_prompt("s-1", "Submit them") == "ask-2"
|
|
43
|
+
assert [each_prompt.ask_id for each_prompt in recorded_prompts("s-1")] == [
|
|
44
|
+
"ask-1",
|
|
45
|
+
None,
|
|
46
|
+
"ask-2",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_should_skip_a_session_id_that_is_not_a_plain_name() -> None:
|
|
51
|
+
assert record_prompt("../escape", "Export the APKs") is None
|
|
52
|
+
assert recorded_prompts("../escape") == []
|
|
@@ -11,6 +11,7 @@ if str(HOOKS_DIRECTORY) not in sys.path:
|
|
|
11
11
|
from transcript_skill_scan import (
|
|
12
12
|
invokes_skill_with_argument,
|
|
13
13
|
is_skill_loaded_after_last_compaction,
|
|
14
|
+
is_skill_loaded_since_last_prompt,
|
|
14
15
|
skill_invocation_status,
|
|
15
16
|
)
|
|
16
17
|
|
|
@@ -134,3 +135,26 @@ def test_build_eval_invocation_before_a_compact_boundary_should_still_count() ->
|
|
|
134
135
|
_skill_entry("other"),
|
|
135
136
|
]
|
|
136
137
|
assert _invokes_build_eval(lines)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _prompt_entry(content: object) -> str:
|
|
141
|
+
return json.dumps({"type": "user", "message": {"content": content}})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_skill_since_last_prompt_survives_tool_results_and_skill_bodies() -> None:
|
|
145
|
+
tool_result = _prompt_entry([{"type": "tool_result", "content": "Launching skill: verify"}])
|
|
146
|
+
skill_body = json.dumps({"type": "user", "isMeta": True, "message": {"content": "Base directory for this skill"}})
|
|
147
|
+
lines = [_prompt_entry("Commit it."), _skill_entry("verify"), tool_result, skill_body]
|
|
148
|
+
assert is_skill_loaded_since_last_prompt(lines, ("verify",), ())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_next_prompt_drops_a_skill_loaded_in_the_last_turn() -> None:
|
|
152
|
+
lines = [_prompt_entry("First."), _skill_entry("verify"), _prompt_entry([{"type": "text", "text": "Second."}])]
|
|
153
|
+
assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_slash_command_prompt_loads_the_skill_for_its_own_turn() -> None:
|
|
157
|
+
marker = "<command-name>/verify</command-name>"
|
|
158
|
+
assert is_skill_loaded_since_last_prompt([_prompt_entry("First."), _prompt_entry(marker)], ("verify",), (marker,))
|
|
159
|
+
compaction = json.dumps({"subtype": "compact_boundary"})
|
|
160
|
+
assert not is_skill_loaded_since_last_prompt([_prompt_entry(marker), compaction], ("verify",), (marker,))
|
|
@@ -10,7 +10,9 @@ from hooks_constants.transcript_skill_scan_constants import (
|
|
|
10
10
|
ASSISTANT_ENTRY_TYPE,
|
|
11
11
|
COMMAND_ARGUMENTS_PATTERN,
|
|
12
12
|
COMPACT_BOUNDARY_SUBTYPE,
|
|
13
|
+
META_ENTRY_FIELD,
|
|
13
14
|
SKILL_TOOL_NAME,
|
|
15
|
+
TOOL_RESULT_BLOCK_TYPE,
|
|
14
16
|
TOOL_USE_BLOCK_TYPE,
|
|
15
17
|
USER_ENTRY_TYPE,
|
|
16
18
|
)
|
|
@@ -87,9 +89,7 @@ def _invokes_skill_with_argument(
|
|
|
87
89
|
)
|
|
88
90
|
|
|
89
91
|
|
|
90
|
-
def
|
|
91
|
-
if not any(name in each_line for name in skill_names) and COMPACT_BOUNDARY_SUBTYPE not in each_line:
|
|
92
|
-
return None
|
|
92
|
+
def _parsed_entry(each_line: str) -> dict[str, object] | None:
|
|
93
93
|
try:
|
|
94
94
|
all_entry_fields = json.loads(each_line)
|
|
95
95
|
except json.JSONDecodeError:
|
|
@@ -97,6 +97,12 @@ def _relevant_entry(each_line: str, skill_names: Collection[str]) -> dict[str, o
|
|
|
97
97
|
return all_entry_fields if isinstance(all_entry_fields, dict) else None
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
def _relevant_entry(each_line: str, skill_names: Collection[str]) -> dict[str, object] | None:
|
|
101
|
+
if not any(name in each_line for name in skill_names) and COMPACT_BOUNDARY_SUBTYPE not in each_line:
|
|
102
|
+
return None
|
|
103
|
+
return _parsed_entry(each_line)
|
|
104
|
+
|
|
105
|
+
|
|
100
106
|
def skill_invocation_status(
|
|
101
107
|
all_transcript_lines: Iterable[str],
|
|
102
108
|
skill_names: Collection[str],
|
|
@@ -140,6 +146,51 @@ def is_skill_loaded_after_last_compaction(
|
|
|
140
146
|
return skill_invocation_status(all_transcript_lines, skill_names, slash_command_markers)[1]
|
|
141
147
|
|
|
142
148
|
|
|
149
|
+
def _is_user_prompt(all_entry_fields: dict[str, object]) -> bool:
|
|
150
|
+
message = all_entry_fields.get("message")
|
|
151
|
+
if all_entry_fields.get("type") != USER_ENTRY_TYPE or all_entry_fields.get(META_ENTRY_FIELD):
|
|
152
|
+
return False
|
|
153
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
154
|
+
if isinstance(content, str):
|
|
155
|
+
return True
|
|
156
|
+
return isinstance(content, list) and not any(
|
|
157
|
+
isinstance(each_block, dict) and each_block.get("type") == TOOL_RESULT_BLOCK_TYPE
|
|
158
|
+
for each_block in content
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_skill_loaded_since_last_prompt(
|
|
163
|
+
all_transcript_lines: Iterable[str],
|
|
164
|
+
skill_names: Collection[str],
|
|
165
|
+
slash_command_markers: Collection[str],
|
|
166
|
+
) -> bool:
|
|
167
|
+
"""Return whether a matching invocation follows the last user prompt and compact boundary.
|
|
168
|
+
|
|
169
|
+
::
|
|
170
|
+
|
|
171
|
+
prompt, Skill verify, tool result, skill body -> True
|
|
172
|
+
prompt, Skill verify, prompt -> False
|
|
173
|
+
prompt carrying the /verify command marker -> True
|
|
174
|
+
prompt with the marker, compact boundary -> False
|
|
175
|
+
|
|
176
|
+
A tool result and a meta entry, such as the skill body, leave the turn open.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
all_transcript_lines: JSON transcript entries, one per line.
|
|
180
|
+
skill_names: Accepted skill names, with plugin prefixes accepted.
|
|
181
|
+
slash_command_markers: Accepted user command tags.
|
|
182
|
+
"""
|
|
183
|
+
loaded = False
|
|
184
|
+
for each_line in all_transcript_lines:
|
|
185
|
+
all_entry_fields = _parsed_entry(each_line)
|
|
186
|
+
if all_entry_fields is None:
|
|
187
|
+
continue
|
|
188
|
+
if all_entry_fields.get("subtype") == COMPACT_BOUNDARY_SUBTYPE or _is_user_prompt(all_entry_fields):
|
|
189
|
+
loaded = False
|
|
190
|
+
loaded = loaded or _invokes_skill(all_entry_fields, skill_names, slash_command_markers)
|
|
191
|
+
return loaded
|
|
192
|
+
|
|
193
|
+
|
|
143
194
|
def invokes_skill_with_argument(
|
|
144
195
|
all_transcript_lines: Iterable[str],
|
|
145
196
|
skill_names: Collection[str],
|
package/package.json
CHANGED
|
@@ -128,6 +128,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
128
128
|
|
|
129
129
|
hooks/blocking/bash_pre_tool_use_dispatcher.py -> exempt
|
|
130
130
|
hooks/blocking/pr_lifecycle_skill_gate.py -> exempt
|
|
131
|
+
hooks/blocking/verify_before_commit_gate.py -> exempt
|
|
131
132
|
hooks/blocking/step_note_gate.py -> exempt
|
|
132
133
|
hooks/blocking/reply_length_gate.py -> exempt
|
|
133
134
|
hooks/blocking/chat_reply_gate.py -> exempt
|
|
@@ -155,6 +156,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
155
156
|
a second open ``Follow-up to #N`` pull request for one parent, a delivery
|
|
156
157
|
rule that names the open follow-up to extend.
|
|
157
158
|
|
|
159
|
+
The verify-before-commit gate denies a code commit in a repository with a
|
|
160
|
+
verify skill until the session loads that skill in the current turn. It
|
|
161
|
+
decides when a skill loads and no code or safety policy.
|
|
162
|
+
|
|
158
163
|
The step-note gate allows every call until the user runs ``/step-notes on``.
|
|
159
164
|
It asks for a readable status line and decides no code or safety policy.
|
|
160
165
|
|
|
@@ -64,6 +64,7 @@ ALL_ACTION_BOUNDARY_PREFIXES = ("deny_", "block_", "ask_")
|
|
|
64
64
|
ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
65
65
|
"blocking/bash_pre_tool_use_dispatcher.py",
|
|
66
66
|
"blocking/pr_lifecycle_skill_gate.py",
|
|
67
|
+
"blocking/verify_before_commit_gate.py",
|
|
67
68
|
"blocking/merge_review_gate.py",
|
|
68
69
|
"blocking/step_note_gate.py",
|
|
69
70
|
"blocking/reply_length_gate.py",
|
|
@@ -170,6 +170,14 @@ def test_new_eval_write_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
|
170
170
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
171
171
|
|
|
172
172
|
|
|
173
|
+
def test_new_verify_before_commit_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
174
|
+
current_document = _hook_document(
|
|
175
|
+
["hooks/blocking/verify_before_commit_gate.py"],
|
|
176
|
+
'{"hooks": {}}',
|
|
177
|
+
)
|
|
178
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
179
|
+
|
|
180
|
+
|
|
173
181
|
def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
|
|
174
182
|
current_document = _hook_document(
|
|
175
183
|
["hooks/blocking/new.py"],
|