claude-dev-env 1.57.2 → 1.59.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/CLAUDE.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +317 -54
- package/bin/install.test.mjs +478 -3
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_duplicate_body.py +287 -0
- package/hooks/blocking/code_rules_enforcer.py +175 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/intent_only_ending_blocker.py +155 -0
- package/hooks/blocking/session_handoff_blocker.py +190 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
- package/hooks/blocking/test_session_handoff_blocker.py +312 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/messages.py +4 -0
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -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/docstring-prose-matches-implementation.md +43 -0
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/long-horizon-autonomy.md +43 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/autoconverge/SKILL.md +68 -6
- package/skills/autoconverge/reference/closing-report.md +44 -0
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- 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.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +106 -38
- 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
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/update/SKILL.md +37 -5
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: block a hook module whose prose overstates its path-shape detector.
|
|
3
|
+
|
|
4
|
+
A path-shape blocker hook detects a per-iteration token only when the token sits
|
|
5
|
+
next to a path separator (its detection regex keys off a `[\\/]`-style character
|
|
6
|
+
class). When such a hook's user-facing prose -- its module docstring lead
|
|
7
|
+
narrative or its CORRECTIVE_MESSAGE -- also claims it blocks an "output-key
|
|
8
|
+
segment", the prose describes a trigger the detector never fires on: a quoted
|
|
9
|
+
structured-output key alone, with no looped path, is never blocked.
|
|
10
|
+
|
|
11
|
+
This drift misleads two audiences at once. An author whose only per-iteration
|
|
12
|
+
token is an output key never sees the block, yet the message implies they would.
|
|
13
|
+
An author who does see the block is told an output key caused it, when only the
|
|
14
|
+
path-adjacent shape did.
|
|
15
|
+
|
|
16
|
+
Detection strategy: act only on Write/Edit to a `.py` file under `hooks/`. The
|
|
17
|
+
prose claim -- the phrase "output-key segment" describing a blocked trigger --
|
|
18
|
+
is the violation. A `*_constants.py` companion holds only the corrective message
|
|
19
|
+
and never the detector, so that file is flagged on the claim alone. Any other
|
|
20
|
+
hook module is flagged when it also keys a detection regex off a path-separator
|
|
21
|
+
character class (a `[...\\...]`/`[.../...]` class), proving the co-located
|
|
22
|
+
detector is path-shape only and the docstring claim overstates it.
|
|
23
|
+
|
|
24
|
+
This detector's own three source files -- the hook module, its `*_constants.py`
|
|
25
|
+
companion, and its `test_*` module -- carry the forbidden phrase and the
|
|
26
|
+
separator-class shape as load-bearing description, so they are exempt by basename
|
|
27
|
+
and stay editable through the harness this rule runs in.
|
|
28
|
+
|
|
29
|
+
Fails OPEN (approves) on malformed input or a non-hook path; the invariant is
|
|
30
|
+
narrow enough that a false negative is preferable to blocking unrelated edits.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
39
|
+
if _hooks_dir not in sys.path:
|
|
40
|
+
sys.path.insert(0, _hooks_dir)
|
|
41
|
+
|
|
42
|
+
from hooks_constants.hook_prose_detector_consistency_constants import ( # noqa: E402
|
|
43
|
+
CONSTANTS_MODULE_SUFFIX,
|
|
44
|
+
CORRECTIVE_MESSAGE,
|
|
45
|
+
EDIT_TOOL_NAME,
|
|
46
|
+
HOOK_MODULE_PATH_SEGMENT,
|
|
47
|
+
OVERSTATED_OUTPUT_KEY_PHRASE_PATTERN,
|
|
48
|
+
PATH_SEPARATOR_CLASS_PATTERN,
|
|
49
|
+
PYTHON_FILE_SUFFIX,
|
|
50
|
+
TEST_MODULE_PREFIX,
|
|
51
|
+
WRITE_TOOL_NAME,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def written_content(tool_name: str, all_tool_input: dict[str, object]) -> str:
|
|
56
|
+
if tool_name == WRITE_TOOL_NAME:
|
|
57
|
+
content = all_tool_input.get("content", "")
|
|
58
|
+
return content if isinstance(content, str) else ""
|
|
59
|
+
if tool_name == EDIT_TOOL_NAME:
|
|
60
|
+
new_string = all_tool_input.get("new_string", "")
|
|
61
|
+
return new_string if isinstance(new_string, str) else ""
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def target_path(all_tool_input: dict[str, object]) -> str:
|
|
66
|
+
file_path = all_tool_input.get("file_path", "")
|
|
67
|
+
return file_path if isinstance(file_path, str) else ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_hook_python_module(file_path: str) -> bool:
|
|
71
|
+
normalized_path = file_path.replace("\\", "/")
|
|
72
|
+
if not normalized_path.endswith(PYTHON_FILE_SUFFIX):
|
|
73
|
+
return False
|
|
74
|
+
return HOOK_MODULE_PATH_SEGMENT in normalized_path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_constants_module(file_path: str) -> bool:
|
|
78
|
+
normalized_path = file_path.replace("\\", "/")
|
|
79
|
+
return normalized_path.endswith(CONSTANTS_MODULE_SUFFIX)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_own_detector_family(file_path: str) -> bool:
|
|
83
|
+
own_module_stem = Path(__file__).stem
|
|
84
|
+
own_family_basenames = {
|
|
85
|
+
f"{own_module_stem}{PYTHON_FILE_SUFFIX}",
|
|
86
|
+
f"{own_module_stem}{CONSTANTS_MODULE_SUFFIX}",
|
|
87
|
+
f"{TEST_MODULE_PREFIX}{own_module_stem}{PYTHON_FILE_SUFFIX}",
|
|
88
|
+
}
|
|
89
|
+
normalized_path = file_path.replace("\\", "/")
|
|
90
|
+
edited_basename = normalized_path.rsplit("/", 1)[-1]
|
|
91
|
+
return edited_basename in own_family_basenames
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def detects_only_path_shape(content: str) -> bool:
|
|
95
|
+
separator_class_pattern = re.compile(PATH_SEPARATOR_CLASS_PATTERN)
|
|
96
|
+
return bool(separator_class_pattern.search(content))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def claims_output_key_trigger(content: str) -> bool:
|
|
100
|
+
overstated_phrase_pattern = re.compile(OVERSTATED_OUTPUT_KEY_PHRASE_PATTERN, re.IGNORECASE)
|
|
101
|
+
return bool(overstated_phrase_pattern.search(content))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def content_has_violation(content: str, file_path: str) -> bool:
|
|
105
|
+
if is_own_detector_family(file_path):
|
|
106
|
+
return False
|
|
107
|
+
if not claims_output_key_trigger(content):
|
|
108
|
+
return False
|
|
109
|
+
if is_constants_module(file_path):
|
|
110
|
+
return True
|
|
111
|
+
return detects_only_path_shape(content)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main() -> None:
|
|
115
|
+
try:
|
|
116
|
+
hook_input = json.load(sys.stdin)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
sys.exit(0)
|
|
119
|
+
|
|
120
|
+
tool_name = hook_input.get("tool_name", "")
|
|
121
|
+
if tool_name not in (WRITE_TOOL_NAME, EDIT_TOOL_NAME):
|
|
122
|
+
sys.exit(0)
|
|
123
|
+
|
|
124
|
+
all_tool_input = hook_input.get("tool_input", {})
|
|
125
|
+
if not isinstance(all_tool_input, dict):
|
|
126
|
+
sys.exit(0)
|
|
127
|
+
|
|
128
|
+
edited_path = target_path(all_tool_input)
|
|
129
|
+
if not is_hook_python_module(edited_path):
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
|
|
132
|
+
if not content_has_violation(
|
|
133
|
+
written_content(tool_name, all_tool_input), edited_path
|
|
134
|
+
):
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
|
|
137
|
+
deny_payload = {
|
|
138
|
+
"hookSpecificOutput": {
|
|
139
|
+
"hookEventName": "PreToolUse",
|
|
140
|
+
"permissionDecision": "deny",
|
|
141
|
+
"permissionDecisionReason": CORRECTIVE_MESSAGE,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
print(json.dumps(deny_payload))
|
|
145
|
+
sys.stdout.flush()
|
|
146
|
+
sys.exit(0)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
main()
|
|
@@ -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()
|