claude-dev-env 1.57.1 → 1.58.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.mjs +217 -27
- package/bin/install.test.mjs +344 -1
- package/hooks/blocking/intent_only_ending_blocker.py +155 -0
- package/hooks/blocking/session_handoff_blocker.py +190 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
- package/hooks/blocking/test_session_handoff_blocker.py +312 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/messages.py +4 -0
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
- package/hooks/workflow/auto_formatter.py +26 -1
- package/hooks/workflow/test_auto_formatter.py +134 -0
- package/package.json +1 -1
- package/rules/conservative-action.md +1 -0
- package/rules/long-horizon-autonomy.md +43 -0
- package/skills/autoconverge/SKILL.md +56 -6
- package/skills/autoconverge/reference/closing-report.md +44 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
- package/skills/autoconverge/workflow/converge.mjs +12 -14
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
- package/skills/autoconverge/workflow/render_report.py +903 -0
- package/skills/autoconverge/workflow/test_render_report.py +484 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Stop hook that blocks Claude responses ending on a promise about undone work.
|
|
4
|
+
|
|
5
|
+
When a turn ends on a forward-looking statement of intent ("I'll now run the
|
|
6
|
+
tests", "Let me implement the fix") instead of actually doing the work, the
|
|
7
|
+
agent is forced to do the work now with tool calls, or - when genuinely blocked
|
|
8
|
+
on input only the user can supply - route through AskUserQuestion and end
|
|
9
|
+
cleanly. The rule name is long-horizon-autonomy.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _hooks_dir not in sys.path:
|
|
19
|
+
sys.path.insert(0, _hooks_dir)
|
|
20
|
+
|
|
21
|
+
from hooks_constants.messages import USER_FACING_INTENT_ENDING_NOTICE # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def strip_code_and_quotes(text: str) -> str:
|
|
25
|
+
"""Remove code blocks, inline code, and blockquotes to avoid false positives.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
text: The raw assistant message to clean.
|
|
29
|
+
"""
|
|
30
|
+
code_block_pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE)
|
|
31
|
+
inline_code_pattern = re.compile(r"`[^`]+`")
|
|
32
|
+
quoted_block_pattern = re.compile(r"^>.*$", re.MULTILINE)
|
|
33
|
+
text = code_block_pattern.sub("", text)
|
|
34
|
+
text = inline_code_pattern.sub("", text)
|
|
35
|
+
text = quoted_block_pattern.sub("", text)
|
|
36
|
+
return text
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_final_paragraph(text: str) -> str:
|
|
40
|
+
"""Return the last non-empty paragraph of the prose after stripping code and quotes.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
text: The raw assistant message to extract the closing paragraph from.
|
|
44
|
+
"""
|
|
45
|
+
paragraph_split_pattern = re.compile(r"\n\s*\n")
|
|
46
|
+
prose_text = strip_code_and_quotes(text)
|
|
47
|
+
candidate_paragraphs = [
|
|
48
|
+
each_paragraph.strip()
|
|
49
|
+
for each_paragraph in paragraph_split_pattern.split(prose_text)
|
|
50
|
+
if each_paragraph.strip()
|
|
51
|
+
]
|
|
52
|
+
if not candidate_paragraphs:
|
|
53
|
+
return ""
|
|
54
|
+
return candidate_paragraphs[-1]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extract_first_sentence(paragraph: str) -> str:
|
|
58
|
+
"""Return the first sentence of a paragraph.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
paragraph: The paragraph whose leading sentence is needed.
|
|
62
|
+
"""
|
|
63
|
+
sentence_boundary_pattern = re.compile(r"(?<=[.!?])\s+")
|
|
64
|
+
sentences = sentence_boundary_pattern.split(paragraph.strip(), maxsplit=1)
|
|
65
|
+
if not sentences:
|
|
66
|
+
return ""
|
|
67
|
+
return sentences[0].strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_intent_only_ending(text: str) -> bool:
|
|
71
|
+
"""Return whether the final paragraph ends the turn on a promise about undone work.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
text: The raw assistant message to evaluate.
|
|
75
|
+
"""
|
|
76
|
+
future_intent_opener_pattern = re.compile(
|
|
77
|
+
r"^(?:(?:now|next|then|okay|alright)\s*,?\s*)?"
|
|
78
|
+
r"(?:i['’]ll(?:\s+now|\s+go\s+ahead\s+and|\s+proceed\s+to)?"
|
|
79
|
+
r"|i\s+will"
|
|
80
|
+
r"|i['’]m\s+going\s+to"
|
|
81
|
+
r"|i\s+am\s+going\s+to"
|
|
82
|
+
r"|i['’]m\s+about\s+to"
|
|
83
|
+
r"|i\s+plan\s+to"
|
|
84
|
+
r"|let\s+me"
|
|
85
|
+
r"|let['’]s"
|
|
86
|
+
r"|going\s+to)\b",
|
|
87
|
+
re.IGNORECASE,
|
|
88
|
+
)
|
|
89
|
+
undone_work_verb_pattern = re.compile(
|
|
90
|
+
r"\b(?:run|start|implement|create|write|add|fix|update|check|test|wire"
|
|
91
|
+
r"|build|deploy|push|git\s+commit|commit\s+the\s+changes"
|
|
92
|
+
r"|commit\s+the\s+fix|investigate|set\s+up|refactor|generate"
|
|
93
|
+
r"|install|continue|look\s+into)\b",
|
|
94
|
+
re.IGNORECASE,
|
|
95
|
+
)
|
|
96
|
+
next_steps_lead_in_pattern = re.compile(r"^next\s+steps?:", re.IGNORECASE)
|
|
97
|
+
second_person_subject_pattern = re.compile(
|
|
98
|
+
r"\b(?:you|your|you['’]?re|user['’]?s?)\b",
|
|
99
|
+
re.IGNORECASE,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
final_paragraph = extract_final_paragraph(text)
|
|
103
|
+
if not final_paragraph:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
if next_steps_lead_in_pattern.match(final_paragraph):
|
|
107
|
+
return not second_person_subject_pattern.search(final_paragraph)
|
|
108
|
+
|
|
109
|
+
first_sentence = extract_first_sentence(final_paragraph)
|
|
110
|
+
if not future_intent_opener_pattern.match(first_sentence):
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
return bool(undone_work_verb_pattern.search(first_sentence))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> None:
|
|
117
|
+
"""Read the stop-hook payload and block turns that end on a promise of undone work."""
|
|
118
|
+
try:
|
|
119
|
+
hook_input = json.load(sys.stdin)
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
sys.exit(0)
|
|
122
|
+
|
|
123
|
+
if hook_input.get("stop_hook_active", False):
|
|
124
|
+
sys.exit(0)
|
|
125
|
+
|
|
126
|
+
assistant_message = hook_input.get("last_assistant_message", "")
|
|
127
|
+
|
|
128
|
+
if not assistant_message:
|
|
129
|
+
sys.exit(0)
|
|
130
|
+
|
|
131
|
+
if not find_intent_only_ending(assistant_message):
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
|
|
134
|
+
block_response = {
|
|
135
|
+
"decision": "block",
|
|
136
|
+
"reason": (
|
|
137
|
+
"LONG-HORIZON-AUTONOMY GUARDRAIL: Your turn ends on a promise about work "
|
|
138
|
+
"that is not yet done, rather than doing it. Do the work NOW with tool calls "
|
|
139
|
+
"instead of describing what you are about to do.\n\n"
|
|
140
|
+
"If the work is genuinely blocked on input only the user can give, route the "
|
|
141
|
+
"ask through an AskUserQuestion tool call and end the turn cleanly. Otherwise, "
|
|
142
|
+
"carry out the stated action this turn.\n\n"
|
|
143
|
+
"You MUST re-output the complete response with the work actually performed, "
|
|
144
|
+
"per the long-horizon-autonomy rule."
|
|
145
|
+
),
|
|
146
|
+
"systemMessage": USER_FACING_INTENT_ENDING_NOTICE,
|
|
147
|
+
"suppressOutput": True,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
print(json.dumps(block_response))
|
|
151
|
+
sys.exit(0)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Stop hook that blocks Claude responses proposing to stop on account of context.
|
|
4
|
+
|
|
5
|
+
When a turn proposes stopping, summarizing to hand off, or starting a new
|
|
6
|
+
session because of context or token limits, the agent is reassured that ample
|
|
7
|
+
context remains and forced to continue the work. A mere topical mention of the
|
|
8
|
+
word "context" does not fire - only a self-termination or handoff proposal does.
|
|
9
|
+
The rule name is long-horizon-autonomy.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _hooks_dir not in sys.path:
|
|
19
|
+
sys.path.insert(0, _hooks_dir)
|
|
20
|
+
|
|
21
|
+
from hooks_constants.messages import USER_FACING_CONTEXT_REASSURANCE_NOTICE # noqa: E402
|
|
22
|
+
from hooks_constants.session_handoff_blocker_constants import ( # noqa: E402
|
|
23
|
+
FIRST_PERSON_SUBJECT_PATTERN,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def strip_code_and_quotes(text: str) -> str:
|
|
28
|
+
"""Remove code blocks, inline code, and blockquotes to avoid false positives.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
text: The raw assistant message to clean.
|
|
32
|
+
"""
|
|
33
|
+
code_block_pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE)
|
|
34
|
+
inline_code_pattern = re.compile(r"`[^`]+`")
|
|
35
|
+
quoted_block_pattern = re.compile(r"^>.*$", re.MULTILINE)
|
|
36
|
+
text = code_block_pattern.sub("", text)
|
|
37
|
+
text = inline_code_pattern.sub("", text)
|
|
38
|
+
text = quoted_block_pattern.sub("", text)
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def split_into_sentences(text: str) -> list[str]:
|
|
43
|
+
"""Return the non-empty sentences of the prose.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
text: The prose to split on sentence boundaries.
|
|
47
|
+
"""
|
|
48
|
+
sentence_boundary_pattern = re.compile(r"(?<=[.!?])\s+")
|
|
49
|
+
return [
|
|
50
|
+
each_sentence.strip()
|
|
51
|
+
for each_sentence in sentence_boundary_pattern.split(text)
|
|
52
|
+
if each_sentence.strip()
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def has_first_person_self_termination(text: str) -> bool:
|
|
57
|
+
"""Return whether any sentence binds a first-person subject to a stop or handoff cue.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
text: The prose to scan sentence by sentence.
|
|
61
|
+
"""
|
|
62
|
+
self_termination_cue_pattern = re.compile(
|
|
63
|
+
r"\b(?:stop|summari[sz]\w*|wrap\s+up|wrap\s+things\s+up"
|
|
64
|
+
r"|hand\s+(?:off|it\s+off|this\s+off)|pause"
|
|
65
|
+
r"|continue\s+(?:this|later)|pick\s+(?:this|it)\s+up"
|
|
66
|
+
r"|new\s+session|fresh\s+session|separate\s+session|clean\s+session"
|
|
67
|
+
r"|running\s+(?:low|out)\s+(?:on|of)\s+(?:context|tokens)"
|
|
68
|
+
r"|(?:low|short)\s+on\s+(?:context|tokens))\b",
|
|
69
|
+
re.IGNORECASE,
|
|
70
|
+
)
|
|
71
|
+
for each_sentence in split_into_sentences(text):
|
|
72
|
+
if FIRST_PERSON_SUBJECT_PATTERN.search(
|
|
73
|
+
each_sentence
|
|
74
|
+
) and self_termination_cue_pattern.search(each_sentence):
|
|
75
|
+
return True
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def has_resource_reference_with_handoff_cue(text: str) -> bool:
|
|
80
|
+
"""Return whether any sentence pairs a context/token reference with a stop cue.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
text: The prose to scan sentence by sentence.
|
|
84
|
+
"""
|
|
85
|
+
resource_reference_pattern = re.compile(
|
|
86
|
+
r"\b(?:context|token)\s+(?:budget|window|limit|count|usage)\b"
|
|
87
|
+
r"|\b(?:low|short)\s+on\s+(?:context|tokens)\b"
|
|
88
|
+
r"|\bto\s+(?:save|conserve|preserve|free\s+up)\s+(?:context|tokens)\b",
|
|
89
|
+
re.IGNORECASE,
|
|
90
|
+
)
|
|
91
|
+
stop_or_handoff_cue_pattern = re.compile(
|
|
92
|
+
r"\b(?:stop|summari[sz]\w*|wrap\s+up|wrap\s+things\s+up|hand\s+off"
|
|
93
|
+
r"|new\s+session|pause|continue\s+later|pick\s+this\s+up\s+later)\b",
|
|
94
|
+
re.IGNORECASE,
|
|
95
|
+
)
|
|
96
|
+
for each_sentence in split_into_sentences(text):
|
|
97
|
+
if resource_reference_pattern.search(
|
|
98
|
+
each_sentence
|
|
99
|
+
) and stop_or_handoff_cue_pattern.search(each_sentence):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def has_first_person_direct_handoff(text: str) -> bool:
|
|
105
|
+
"""Return whether any sentence binds a first-person subject to a direct-handoff cue.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
text: The prose to scan sentence by sentence.
|
|
109
|
+
"""
|
|
110
|
+
new_session_proposal_pattern = re.compile(
|
|
111
|
+
r"\b(?:wrap\s+up|wrap\s+things\s+up|hand\s+off|hand\s+this\s+off"
|
|
112
|
+
r"|hand\s+it\s+off|continue\s+this|continue\s+later|pick\s+this\s+up"
|
|
113
|
+
r"|pick\s+it\s+up|pause|resume)\b"
|
|
114
|
+
r"[^.!?]*"
|
|
115
|
+
r"\b(?:a\s+|the\s+)?(?:new|fresh|separate|clean)\s+session\b",
|
|
116
|
+
re.IGNORECASE,
|
|
117
|
+
)
|
|
118
|
+
running_low_pattern = re.compile(
|
|
119
|
+
r"\brunning\s+(?:low|out)\s+(?:on|of)\s+(?:context|tokens)\b",
|
|
120
|
+
re.IGNORECASE,
|
|
121
|
+
)
|
|
122
|
+
all_direct_handoff_patterns = [
|
|
123
|
+
new_session_proposal_pattern,
|
|
124
|
+
running_low_pattern,
|
|
125
|
+
]
|
|
126
|
+
for each_sentence in split_into_sentences(text):
|
|
127
|
+
if not FIRST_PERSON_SUBJECT_PATTERN.search(each_sentence):
|
|
128
|
+
continue
|
|
129
|
+
if any(
|
|
130
|
+
each_pattern.search(each_sentence)
|
|
131
|
+
for each_pattern in all_direct_handoff_patterns
|
|
132
|
+
):
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def find_session_handoff_proposal(text: str) -> bool:
|
|
138
|
+
"""Return whether the message proposes stopping on account of context or tokens.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
text: The raw assistant message to evaluate.
|
|
142
|
+
"""
|
|
143
|
+
prose_text = strip_code_and_quotes(text)
|
|
144
|
+
|
|
145
|
+
if not has_first_person_self_termination(prose_text):
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
if has_first_person_direct_handoff(prose_text):
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
return has_resource_reference_with_handoff_cue(prose_text)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main() -> None:
|
|
155
|
+
"""Read the stop-hook payload and block turns proposing a context-driven handoff."""
|
|
156
|
+
try:
|
|
157
|
+
hook_input = json.load(sys.stdin)
|
|
158
|
+
except json.JSONDecodeError:
|
|
159
|
+
sys.exit(0)
|
|
160
|
+
|
|
161
|
+
if hook_input.get("stop_hook_active", False):
|
|
162
|
+
sys.exit(0)
|
|
163
|
+
|
|
164
|
+
assistant_message = hook_input.get("last_assistant_message", "")
|
|
165
|
+
|
|
166
|
+
if not assistant_message:
|
|
167
|
+
sys.exit(0)
|
|
168
|
+
|
|
169
|
+
if not find_session_handoff_proposal(assistant_message):
|
|
170
|
+
sys.exit(0)
|
|
171
|
+
|
|
172
|
+
block_response = {
|
|
173
|
+
"decision": "block",
|
|
174
|
+
"reason": (
|
|
175
|
+
"LONG-HORIZON-AUTONOMY GUARDRAIL: You have ample context remaining. Do not "
|
|
176
|
+
"stop, summarize, or suggest a new session on account of context limits. "
|
|
177
|
+
"Continue the work.\n\n"
|
|
178
|
+
"Re-output your response continuing the task without the handoff suggestion, "
|
|
179
|
+
"per the long-horizon-autonomy rule."
|
|
180
|
+
),
|
|
181
|
+
"systemMessage": USER_FACING_CONTEXT_REASSURANCE_NOTICE,
|
|
182
|
+
"suppressOutput": True,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
print(json.dumps(block_response))
|
|
186
|
+
sys.exit(0)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Tests for intent_only_ending_blocker hook response shape."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "intent_only_ending_blocker.py")
|
|
10
|
+
_HOOKS_DIR = os.path.dirname(HOOK_SCRIPT_PATH)
|
|
11
|
+
_HOOKS_ROOT = os.path.join(_HOOKS_DIR, "..")
|
|
12
|
+
_HOOK_CONFIG_DIR = os.path.join(_HOOKS_ROOT, "hooks_constants")
|
|
13
|
+
if _HOOKS_DIR not in sys.path:
|
|
14
|
+
sys.path.insert(0, _HOOKS_DIR)
|
|
15
|
+
if _HOOKS_ROOT not in sys.path:
|
|
16
|
+
sys.path.insert(0, _HOOKS_ROOT)
|
|
17
|
+
import intent_only_ending_blocker
|
|
18
|
+
from hooks_constants.messages import USER_FACING_INTENT_ENDING_NOTICE
|
|
19
|
+
|
|
20
|
+
INTENT_ENDING_MESSAGE = "I'll now run the test suite and fix any failures that come up."
|
|
21
|
+
NEXT_STEPS_MESSAGE = "Next steps:"
|
|
22
|
+
COMPLETED_WORK_MESSAGE = "Done - all tests pass. The fix is in place."
|
|
23
|
+
BENIGN_PAST_TENSE_MESSAGE = "I implemented the parser and verified it against the fixtures."
|
|
24
|
+
CLEAN_MESSAGE = "The function returns the parsed payload to its caller."
|
|
25
|
+
COMMITMENT_PHRASING_MESSAGE = "I'll commit to keeping the API stable across the next release."
|
|
26
|
+
NAMING_COMMITMENT_MESSAGE = "Let me commit to a clear naming convention for these helpers."
|
|
27
|
+
GIT_COMMIT_INTENT_MESSAGE = "I'll commit the changes once the tests pass."
|
|
28
|
+
DEFERRED_TO_USER_CI_RUNS_MESSAGE = (
|
|
29
|
+
"Let me know if this looks right. The CI will run automatically on push."
|
|
30
|
+
)
|
|
31
|
+
DEFERRED_TO_USER_EITHER_WAY_MESSAGE = (
|
|
32
|
+
"Let me know which option you want. I can check either way."
|
|
33
|
+
)
|
|
34
|
+
USER_DIRECTED_NEXT_STEPS_MESSAGE = (
|
|
35
|
+
"All done. The PR is merged.\n\nNext steps: you should deploy when ready."
|
|
36
|
+
)
|
|
37
|
+
EMPTY_MESSAGE = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess:
|
|
41
|
+
hook_input_payload = json.dumps({"last_assistant_message": assistant_message})
|
|
42
|
+
return subprocess.run(
|
|
43
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
44
|
+
input=hook_input_payload,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
check=False,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_hook_with_payload(hook_input_payload: dict) -> subprocess.CompletedProcess:
|
|
52
|
+
return subprocess.run(
|
|
53
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
54
|
+
input=json.dumps(hook_input_payload),
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
check=False,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_user_facing_notice_matches_config_messages_module():
|
|
62
|
+
config_messages_path = os.path.join(_HOOK_CONFIG_DIR, "messages.py")
|
|
63
|
+
specification = importlib.util.spec_from_file_location("messages", config_messages_path)
|
|
64
|
+
module = importlib.util.module_from_spec(specification)
|
|
65
|
+
specification.loader.exec_module(module)
|
|
66
|
+
|
|
67
|
+
assert module.USER_FACING_INTENT_ENDING_NOTICE == USER_FACING_INTENT_ENDING_NOTICE
|
|
68
|
+
assert (
|
|
69
|
+
intent_only_ending_blocker.USER_FACING_INTENT_ENDING_NOTICE
|
|
70
|
+
== module.USER_FACING_INTENT_ENDING_NOTICE
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_intent_ending_message_emits_block_with_short_user_notice():
|
|
75
|
+
completed_process = run_hook_with_message(INTENT_ENDING_MESSAGE)
|
|
76
|
+
|
|
77
|
+
assert completed_process.returncode == 0
|
|
78
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
79
|
+
|
|
80
|
+
assert parsed_response["decision"] == "block"
|
|
81
|
+
assert parsed_response["systemMessage"] == USER_FACING_INTENT_ENDING_NOTICE
|
|
82
|
+
assert parsed_response["suppressOutput"] is True
|
|
83
|
+
assert "long-horizon-autonomy" in parsed_response["reason"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_next_steps_lead_in_emits_block():
|
|
87
|
+
completed_process = run_hook_with_message(NEXT_STEPS_MESSAGE)
|
|
88
|
+
|
|
89
|
+
assert completed_process.returncode == 0
|
|
90
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
91
|
+
|
|
92
|
+
assert parsed_response["decision"] == "block"
|
|
93
|
+
assert parsed_response["systemMessage"] == USER_FACING_INTENT_ENDING_NOTICE
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_completed_work_summary_passes_through_with_no_output():
|
|
97
|
+
completed_process = run_hook_with_message(COMPLETED_WORK_MESSAGE)
|
|
98
|
+
|
|
99
|
+
assert completed_process.returncode == 0
|
|
100
|
+
assert completed_process.stdout == ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_past_tense_summary_passes_through_with_no_output():
|
|
104
|
+
completed_process = run_hook_with_message(BENIGN_PAST_TENSE_MESSAGE)
|
|
105
|
+
|
|
106
|
+
assert completed_process.returncode == 0
|
|
107
|
+
assert completed_process.stdout == ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_commitment_phrasing_passes_through_with_no_output():
|
|
111
|
+
completed_process = run_hook_with_message(COMMITMENT_PHRASING_MESSAGE)
|
|
112
|
+
|
|
113
|
+
assert completed_process.returncode == 0
|
|
114
|
+
assert completed_process.stdout == ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_naming_commitment_passes_through_with_no_output():
|
|
118
|
+
completed_process = run_hook_with_message(NAMING_COMMITMENT_MESSAGE)
|
|
119
|
+
|
|
120
|
+
assert completed_process.returncode == 0
|
|
121
|
+
assert completed_process.stdout == ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_git_commit_intent_emits_block():
|
|
125
|
+
completed_process = run_hook_with_message(GIT_COMMIT_INTENT_MESSAGE)
|
|
126
|
+
|
|
127
|
+
assert completed_process.returncode == 0
|
|
128
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
129
|
+
|
|
130
|
+
assert parsed_response["decision"] == "block"
|
|
131
|
+
assert parsed_response["systemMessage"] == USER_FACING_INTENT_ENDING_NOTICE
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_benign_opener_with_unrelated_work_verb_passes_through_with_no_output():
|
|
135
|
+
completed_process = run_hook_with_message(DEFERRED_TO_USER_CI_RUNS_MESSAGE)
|
|
136
|
+
|
|
137
|
+
assert completed_process.returncode == 0
|
|
138
|
+
assert completed_process.stdout == ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_deferral_to_user_with_later_verb_passes_through_with_no_output():
|
|
142
|
+
completed_process = run_hook_with_message(DEFERRED_TO_USER_EITHER_WAY_MESSAGE)
|
|
143
|
+
|
|
144
|
+
assert completed_process.returncode == 0
|
|
145
|
+
assert completed_process.stdout == ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_user_directed_next_steps_passes_through_with_no_output():
|
|
149
|
+
completed_process = run_hook_with_message(USER_DIRECTED_NEXT_STEPS_MESSAGE)
|
|
150
|
+
|
|
151
|
+
assert completed_process.returncode == 0
|
|
152
|
+
assert completed_process.stdout == ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_clean_message_passes_through_with_no_output():
|
|
156
|
+
completed_process = run_hook_with_message(CLEAN_MESSAGE)
|
|
157
|
+
|
|
158
|
+
assert completed_process.returncode == 0
|
|
159
|
+
assert completed_process.stdout == ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_empty_message_passes_through_with_no_output():
|
|
163
|
+
completed_process = run_hook_with_message(EMPTY_MESSAGE)
|
|
164
|
+
|
|
165
|
+
assert completed_process.returncode == 0
|
|
166
|
+
assert completed_process.stdout == ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_stop_hook_active_short_circuits_with_no_output():
|
|
170
|
+
completed_process = run_hook_with_payload(
|
|
171
|
+
{"last_assistant_message": INTENT_ENDING_MESSAGE, "stop_hook_active": True}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
assert completed_process.returncode == 0
|
|
175
|
+
assert completed_process.stdout == ""
|