claude-dev-env 8.98.1 → 8.98.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/blocking/pr_lifecycle_skill_gate.py +51 -18
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +35 -0
- package/hooks/features/conduct-gates.md +1 -1
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +2 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +20 -0
- package/hooks/open_task_asks.py +22 -6
- package/hooks/test_open_task_asks.py +5 -0
- package/package.json +1 -1
|
@@ -26,8 +26,9 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
26
26
|
ALL_TRANSCRIPT_PATH_FIELDS,
|
|
27
27
|
BUILD_EVAL_ARGUMENT_WORD,
|
|
28
28
|
BUILD_EVAL_COMMAND_MARKER,
|
|
29
|
-
|
|
29
|
+
COMMAND_PIECE_PATTERN,
|
|
30
30
|
COMMAND_WHITESPACE,
|
|
31
|
+
COMMENT_PIECE,
|
|
31
32
|
DENY_DECISION,
|
|
32
33
|
DENY_REASON,
|
|
33
34
|
ENVIRONMENT_ASSIGNMENT_PATTERN,
|
|
@@ -36,17 +37,23 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
36
37
|
GIT_ACTIONS,
|
|
37
38
|
GIT_GLOBAL_OPTIONS_WITH_VALUE,
|
|
38
39
|
HOOK_EVENT_NAME,
|
|
40
|
+
LINE_CONTINUATION_PIECE,
|
|
39
41
|
MERGE_PATH_SUFFIX,
|
|
42
|
+
NO_COMMENT_CHARACTERS,
|
|
40
43
|
OPTION_AND_VALUE_WORD_COUNT,
|
|
41
44
|
PATH_SEPARATOR_PATTERN,
|
|
42
45
|
PERMISSION_DECISION_REASON_KEY,
|
|
43
46
|
PULL_REQUEST_SCRIPT_NAME,
|
|
44
47
|
PYTHON_OPTIONS_WITH_VALUE,
|
|
48
|
+
REMOVED_TEXT,
|
|
49
|
+
SEGMENT_BREAK,
|
|
50
|
+
SEPARATOR_PIECE,
|
|
45
51
|
SESSION_TRANSCRIPT_PATH_FIELD,
|
|
46
52
|
SHELL_TOOL_NAMES,
|
|
47
53
|
SKILL_NAME,
|
|
48
54
|
SUBAGENT_TRANSCRIPT_DIRECTORY_NAME,
|
|
49
55
|
SUBAGENT_TRANSCRIPT_FILE_TEMPLATE,
|
|
56
|
+
UNCLOSED_QUOTE_PIECE,
|
|
50
57
|
)
|
|
51
58
|
from hooks_constants.gh_command_words import gh_command_words
|
|
52
59
|
from hooks_constants.hook_specific_output_keys import (
|
|
@@ -70,25 +77,51 @@ from transcript_skill_scan import (
|
|
|
70
77
|
)
|
|
71
78
|
|
|
72
79
|
|
|
80
|
+
def _unquoted_segment_texts(command: str) -> list[str] | None:
|
|
81
|
+
"""Split a command at each separator outside quotes, escapes, and comments.
|
|
82
|
+
|
|
83
|
+
::
|
|
84
|
+
|
|
85
|
+
git commit -m "&" -a && git push -> ['git commit -m "&" -a ', '', ' git push']
|
|
86
|
+
git status # a; b -> ['git status ']
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
command: One shell command as the tool received it.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The raw text of each segment in order, or None when a quote never closes.
|
|
93
|
+
"""
|
|
94
|
+
all_pieces = list(COMMAND_PIECE_PATTERN.finditer(command))
|
|
95
|
+
if any(each_piece.lastgroup == UNCLOSED_QUOTE_PIECE for each_piece in all_pieces):
|
|
96
|
+
return None
|
|
97
|
+
all_replacement_texts: dict[str | None, str] = {
|
|
98
|
+
SEPARATOR_PIECE: SEGMENT_BREAK,
|
|
99
|
+
COMMENT_PIECE: REMOVED_TEXT,
|
|
100
|
+
LINE_CONTINUATION_PIECE: REMOVED_TEXT,
|
|
101
|
+
}
|
|
102
|
+
marked_command = REMOVED_TEXT.join(
|
|
103
|
+
all_replacement_texts.get(each_piece.lastgroup, each_piece.group()) for each_piece in all_pieces
|
|
104
|
+
)
|
|
105
|
+
return marked_command.split(SEGMENT_BREAK)
|
|
106
|
+
|
|
107
|
+
|
|
73
108
|
def _command_segments(command: str) -> list[list[str]]:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
lexer.whitespace_split = True
|
|
77
|
-
all_segments: list[list[str]] = []
|
|
78
|
-
each_segment: list[str] = []
|
|
79
|
-
try:
|
|
80
|
-
all_words = list(lexer)
|
|
81
|
-
except ValueError:
|
|
109
|
+
all_segment_texts = _unquoted_segment_texts(command)
|
|
110
|
+
if all_segment_texts is None:
|
|
82
111
|
return []
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
all_segments: list[list[str]] = []
|
|
113
|
+
for each_text in all_segment_texts:
|
|
114
|
+
lexer = shlex.shlex(each_text, posix=True)
|
|
115
|
+
lexer.whitespace = COMMAND_WHITESPACE
|
|
116
|
+
lexer.whitespace_split = True
|
|
117
|
+
lexer.commenters = NO_COMMENT_CHARACTERS
|
|
118
|
+
try:
|
|
119
|
+
all_words = list(lexer)
|
|
120
|
+
except ValueError:
|
|
121
|
+
all_words = each_text.split()
|
|
122
|
+
if all_words:
|
|
123
|
+
all_segments.append(all_words)
|
|
124
|
+
return all_segments
|
|
92
125
|
|
|
93
126
|
|
|
94
127
|
def next_command_word(
|
|
@@ -74,6 +74,41 @@ def test_executable_word_lists_reads_chains_prefixes_and_shell_wrappers() -> Non
|
|
|
74
74
|
assert gate.executable_word_lists("bash -c 'git add x; git commit'")[-2:] == [["git", "add", "x"], ["git", "commit"]]
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def test_executable_word_lists_keeps_an_empty_quoted_argument() -> None:
|
|
78
|
+
assert gate.executable_word_lists('git commit -m ""') == [["git", "commit", "-m", ""]]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_executable_word_lists_keeps_a_quoted_argument_that_starts_with_a_separator() -> None:
|
|
82
|
+
assert gate.executable_word_lists('git -C "(dir)" commit -m x') == [["git", "-C", "(dir)", "commit", "-m", "x"]]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_executable_word_lists_keeps_a_quoted_separator_argument_in_its_command() -> None:
|
|
86
|
+
assert gate.executable_word_lists('git commit -m "&" -a') == [["git", "commit", "-m", "&", "-a"]]
|
|
87
|
+
assert gate.executable_word_lists("git commit -m ';' -a && git push") == [
|
|
88
|
+
["git", "commit", "-m", ";", "-a"],
|
|
89
|
+
["git", "push"],
|
|
90
|
+
]
|
|
91
|
+
assert gate.executable_word_lists("git commit -m x\\; -a") == [["git", "commit", "-m", "x;", "-a"]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_executable_word_lists_skips_a_comment_and_resumes_on_the_next_line() -> None:
|
|
95
|
+
assert gate.executable_word_lists("git status #note; more\ngit push") == [
|
|
96
|
+
["git", "status"],
|
|
97
|
+
["git", "push"],
|
|
98
|
+
]
|
|
99
|
+
assert gate.executable_word_lists("echo 'a#b' c#d") == [["echo", "a#b", "c#d"]]
|
|
100
|
+
assert gate.executable_word_lists("git status x\\ #y; git push") == [["git", "status", "x #y"], ["git", "push"]]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_executable_word_lists_joins_a_line_continuation() -> None:
|
|
104
|
+
assert gate.executable_word_lists("git \\\npush origin") == [["git", "push", "origin"]]
|
|
105
|
+
assert gate.executable_word_lists("git\\\n#note; git push") == [["git#note"], ["git", "push"]]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_executable_word_lists_keeps_the_other_segments_beside_a_trailing_backslash() -> None:
|
|
109
|
+
assert gate.executable_word_lists("git push; echo a\\") == [["git", "push"], ["echo", "a\\"]]
|
|
110
|
+
|
|
111
|
+
|
|
77
112
|
def test_loaded_skill_allows_action_silently(tmp_path: Path) -> None:
|
|
78
113
|
path = _transcript(tmp_path, "plugin:pr-lifecycle")
|
|
79
114
|
assert _run_main(_payload("gh pr view", path)) == (0, "")
|
|
@@ -7,7 +7,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
7
7
|
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
|
|
8
8
|
- `blocking/final_reply_gate.py` applies the banned words, `unvalidated_claim_violation`, and `unseen_citation_violation` from the reply gate to the reply a session ends its turn with. Its `windows_path_violation` blocks a path the terminal would render without one of its backslashes.
|
|
9
9
|
- `blocking/open_tasks_stop_gate.py` holds the end of a turn while the session has open items that the closing reply does not name on a `Blocked:` or `Open:` line. It merges the task store, the transcript task calls, the desktop `mcp__ccd_turn__update_plan` plan, and the recorded asks. A transcript item whose subject the task store holds is dropped, and every other item stays. `blocking/open_tasks_transcript.py` rebuilds the task list and the last reply from the transcript for it. Each new desktop plan list carries the unfinished items of the list before it after its own items, so `done` and `start` positions still point at its own items. An ask stays open until a reply line `Done: #ask-N` closes it. A line `Dropped: <#ask-N or carried item> - "<quote>"` closes an ask or a carried plan item when the quote is in a prompt the user sent after that ask or that list.
|
|
10
|
-
- `session/open_task_ask_recorder.py` records each prompt in `~/.claude/open-task-asks/<session id>.json`, outside the task store. A prompt that asks for work gets the next `#ask-N` id. A question-only prompt, an acknowledgement such as `thanks!`, an empty prompt, and
|
|
10
|
+
- `session/open_task_ask_recorder.py` records each prompt in `~/.claude/open-task-asks/<session id>.json`, outside the task store. A prompt that asks for work gets the next `#ask-N` id. A question-only prompt, an acknowledgement such as `thanks!`, an empty prompt, a prompt of only `<system-reminder>` blocks, and an envelope such as `<agent-message>` or `<task-notification>` get none.
|
|
11
11
|
- The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies the first claim about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
|
|
12
12
|
- `blocking/chat_reply_gate.py` runs the `reply_length_gate.py` reply checks on a plain chat reply, which is no tool call. On `UserPromptSubmit` it adds the reply rules as `additionalContext`. On `MessageDisplay` it holds each streamed batch until the message ends. A message that passes shows whole. A message that fails shows as a draft from `claude -p` on Haiku that passes the same checks, and the original shows when two drafts fail. The draft changes only the text on screen, so the next `UserPromptSubmit` tells the agent the text the user saw, and `SessionEnd` deletes the stored draft. The [reply arena harness](https://github.com/jl-cmd-projects/claude-dev-env/tree/160349b90f627ea542fc6bc7b2b7f86dcbf98b73/tests/reply_arena) ran the comparison that chose this display path.
|
|
13
13
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
@@ -109,6 +109,8 @@ INTERROGATIVE_START_PATTERN = (
|
|
|
109
109
|
r"^\s*(?:\w+,\s*)?(?:are|is|am|was|were|do|does|did|have|has|had|can|could|will|would"
|
|
110
110
|
r"|should|shall|may|what|why|how|when|where|which|who|whose)\b"
|
|
111
111
|
)
|
|
112
|
+
ALL_ASK_ONLY_ENVELOPE_MARKERS = ("<agent-message",)
|
|
113
|
+
SYSTEM_REMINDER_BLOCK_PATTERN = r"<system-reminder>.*?</system-reminder>"
|
|
112
114
|
ACKNOWLEDGEMENT_PATTERN = (
|
|
113
115
|
r"^\s*(?:ok(?:ay)?|k|thanks?|thank\s+you|thx|ty|cool|great|nice|perfect|awesome|got\s+it"
|
|
114
116
|
r"|sounds\s+good|looks\s+good|lgtm)(?:[\s,]+(?:thanks?|thank\s+you))?\W*$"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Constants for the pull request lifecycle skill gate."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
4
|
+
|
|
3
5
|
HOOK_EVENT_NAME = "PreToolUse"
|
|
4
6
|
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
5
7
|
DENY_DECISION = "deny"
|
|
@@ -23,6 +25,24 @@ SUBAGENT_TRANSCRIPT_DIRECTORY_NAME = "subagents"
|
|
|
23
25
|
SUBAGENT_TRANSCRIPT_FILE_TEMPLATE = "agent-{agent_id}.jsonl"
|
|
24
26
|
COMMAND_SEPARATORS = ";|&\n()`"
|
|
25
27
|
COMMAND_WHITESPACE = " \t\r"
|
|
28
|
+
NO_COMMENT_CHARACTERS = ""
|
|
29
|
+
REMOVED_TEXT = ""
|
|
30
|
+
SEGMENT_BREAK = "\0"
|
|
31
|
+
SEPARATOR_PIECE = "separator"
|
|
32
|
+
COMMENT_PIECE = "comment"
|
|
33
|
+
LINE_CONTINUATION_PIECE = "continuation"
|
|
34
|
+
UNCLOSED_QUOTE_PIECE = "unclosed"
|
|
35
|
+
COMMAND_PIECE_PATTERN = re.compile(
|
|
36
|
+
r"'[^']*'"
|
|
37
|
+
r'|"(?:\\.|[^"\\])*"'
|
|
38
|
+
rf"|(?P<{LINE_CONTINUATION_PIECE}>\\\n)"
|
|
39
|
+
rf"|\\.[^{re.escape(COMMAND_WHITESPACE + COMMAND_SEPARATORS)}'\"\\]*"
|
|
40
|
+
rf"|(?P<{COMMENT_PIECE}>(?<![^{re.escape(COMMAND_WHITESPACE + COMMAND_SEPARATORS)}])(?<!\\\n)#[^\n]*)"
|
|
41
|
+
rf"|(?P<{SEPARATOR_PIECE}>[{re.escape(COMMAND_SEPARATORS)}])"
|
|
42
|
+
rf"|(?P<{UNCLOSED_QUOTE_PIECE}>['\"])"
|
|
43
|
+
r"|.",
|
|
44
|
+
re.DOTALL,
|
|
45
|
+
)
|
|
26
46
|
OPTION_AND_VALUE_WORD_COUNT = 2
|
|
27
47
|
GIT_GLOBAL_OPTIONS_WITH_VALUE = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"})
|
|
28
48
|
GIT_ACTIONS = frozenset({"commit", "push"})
|
package/hooks/open_task_asks.py
CHANGED
|
@@ -6,7 +6,8 @@ Every prompt lands in one file outside the task store::
|
|
|
6
6
|
{"prompts": [{"text": "...", "recorded_at": 1761076800.0, "ask_id": "ask-1"}, ...]}
|
|
7
7
|
|
|
8
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,
|
|
9
|
+
prompt, an acknowledgement such as ``thanks!``, an empty prompt, a prompt of only
|
|
10
|
+
``<system-reminder>`` blocks, and an envelope such as ``<agent-message>`` or
|
|
10
11
|
``<task-notification>`` get ``ask_id`` null, and the gate still reads their
|
|
11
12
|
text when it checks the quote on a ``Dropped:`` line.
|
|
12
13
|
"""
|
|
@@ -23,6 +24,7 @@ from typing import NamedTuple
|
|
|
23
24
|
from atomic_file_writer import write_text_atomically
|
|
24
25
|
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
25
26
|
ACKNOWLEDGEMENT_PATTERN,
|
|
27
|
+
ALL_ASK_ONLY_ENVELOPE_MARKERS,
|
|
26
28
|
ASK_ID_TEMPLATE,
|
|
27
29
|
ASK_PROMPT_ID_KEY,
|
|
28
30
|
ASK_PROMPT_RECORDED_AT_KEY,
|
|
@@ -39,6 +41,7 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
39
41
|
QUESTION_END_PATTERN,
|
|
40
42
|
SAFE_SESSION_ID_PATTERN,
|
|
41
43
|
SENTENCE_SPLIT_PATTERN,
|
|
44
|
+
SYSTEM_REMINDER_BLOCK_PATTERN,
|
|
42
45
|
UTF8_ENCODING,
|
|
43
46
|
YES_OR_NO_END_PATTERN,
|
|
44
47
|
)
|
|
@@ -49,7 +52,7 @@ class RecordedPrompt(NamedTuple):
|
|
|
49
52
|
"""One prompt the user sent.
|
|
50
53
|
|
|
51
54
|
Attributes:
|
|
52
|
-
text: The prompt as the user sent it.
|
|
55
|
+
text: The prompt as the user sent it, with ``<system-reminder>`` blocks removed.
|
|
53
56
|
recorded_at: Seconds since the epoch when the recorder saw it.
|
|
54
57
|
ask_id: ``ask-N`` for a prompt that asks for work, or None.
|
|
55
58
|
"""
|
|
@@ -98,10 +101,23 @@ def is_question_only(prompt_text: str) -> bool:
|
|
|
98
101
|
)
|
|
99
102
|
|
|
100
103
|
|
|
104
|
+
def prompt_without_reminders(prompt_text: str) -> str:
|
|
105
|
+
"""Return the prompt with every ``<system-reminder>`` block removed, stripped."""
|
|
106
|
+
return re.sub(SYSTEM_REMINDER_BLOCK_PATTERN, "", prompt_text, flags=re.DOTALL).strip()
|
|
107
|
+
|
|
108
|
+
|
|
101
109
|
def is_ask(prompt_text: str) -> bool:
|
|
102
|
-
"""Return True when the prompt asks for work the gate should hold the turn for.
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
"""Return True when the prompt asks for work the gate should hold the turn for.
|
|
111
|
+
|
|
112
|
+
::
|
|
113
|
+
|
|
114
|
+
'<agent-message from="a1">Report</agent-message>' -> False
|
|
115
|
+
"<system-reminder>Tool loaded.</system-reminder>" -> False
|
|
116
|
+
"<system-reminder>...</system-reminder> Export it" -> True
|
|
117
|
+
"""
|
|
118
|
+
stripped_text = prompt_without_reminders(prompt_text)
|
|
119
|
+
all_envelope_markers = (*ALL_HARNESS_ENVELOPE_MARKERS, *ALL_ASK_ONLY_ENVELOPE_MARKERS)
|
|
120
|
+
if not stripped_text or stripped_text.startswith(all_envelope_markers):
|
|
105
121
|
return False
|
|
106
122
|
if re.fullmatch(ACKNOWLEDGEMENT_PATTERN, stripped_text, re.IGNORECASE):
|
|
107
123
|
return False
|
|
@@ -145,7 +161,7 @@ def record_prompt(session_id: object, prompt_text: str) -> str | None:
|
|
|
145
161
|
all_prompts = recorded_prompts(session_id)
|
|
146
162
|
ask_count = sum(1 for each_prompt in all_prompts if each_prompt.ask_id)
|
|
147
163
|
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))
|
|
164
|
+
all_prompts.append(RecordedPrompt(prompt_without_reminders(prompt_text), time.time(), ask_id))
|
|
149
165
|
serialized_text = json.dumps(
|
|
150
166
|
{ASKS_PROMPTS_KEY: [each_prompt._asdict() for each_prompt in all_prompts]}
|
|
151
167
|
)
|
|
@@ -30,6 +30,11 @@ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
|
30
30
|
("Looks good", False),
|
|
31
31
|
("ok, ship it", True),
|
|
32
32
|
("yes", True),
|
|
33
|
+
('<agent-message from="afbaf992127c7512f">\nReport delivered.\n</agent-message>', False),
|
|
34
|
+
('<cross-session-message from="s-2">\nBuild the PR\n</cross-session-message>', False),
|
|
35
|
+
("<system-reminder>\nTool loaded.\n</system-reminder>", False),
|
|
36
|
+
("<system-reminder>\nTool loaded.\n</system-reminder>\n<system-reminder>\nMore.\n</system-reminder>", False),
|
|
37
|
+
("<system-reminder>\nTool loaded.\n</system-reminder>\nExport the APKs", True),
|
|
33
38
|
],
|
|
34
39
|
)
|
|
35
40
|
def test_should_classify_work_prompts_as_asks(prompt_text: str, is_expected_ask: bool) -> None:
|