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.
Files changed (77) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  9. package/bin/install.mjs +317 -54
  10. package/bin/install.test.mjs +478 -3
  11. package/docs/CODE_RULES.md +3 -3
  12. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  13. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  14. package/hooks/blocking/code_rules_duplicate_body.py +287 -0
  15. package/hooks/blocking/code_rules_enforcer.py +175 -21
  16. package/hooks/blocking/code_rules_magic_values.py +98 -0
  17. package/hooks/blocking/code_rules_shared.py +41 -0
  18. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  19. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  20. package/hooks/blocking/intent_only_ending_blocker.py +155 -0
  21. package/hooks/blocking/session_handoff_blocker.py +190 -0
  22. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  23. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  24. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  25. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  26. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  27. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  28. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  29. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  30. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  31. package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
  32. package/hooks/blocking/test_session_handoff_blocker.py +312 -0
  33. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  34. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  35. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  36. package/hooks/hooks.json +25 -0
  37. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  38. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  39. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  40. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  41. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  42. package/hooks/hooks_constants/messages.py +4 -0
  43. package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
  44. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  45. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  46. package/hooks/workflow/auto_formatter.py +26 -1
  47. package/hooks/workflow/test_auto_formatter.py +134 -0
  48. package/package.json +1 -1
  49. package/rules/conservative-action.md +1 -0
  50. package/rules/docstring-prose-matches-implementation.md +43 -0
  51. package/rules/hook-prose-matches-detector.md +26 -0
  52. package/rules/long-horizon-autonomy.md +43 -0
  53. package/rules/no-inline-destructive-literals.md +11 -0
  54. package/rules/workflow-substitution-slots.md +7 -0
  55. package/skills/autoconverge/SKILL.md +68 -6
  56. package/skills/autoconverge/reference/closing-report.md +44 -0
  57. package/skills/autoconverge/reference/convergence.md +7 -3
  58. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
  60. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
  62. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  63. package/skills/autoconverge/workflow/converge.mjs +106 -38
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
  66. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
  67. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
  68. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
  69. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
  70. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
  71. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
  72. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
  73. package/skills/autoconverge/workflow/render_report.py +903 -0
  74. package/skills/autoconverge/workflow/test_render_report.py +484 -0
  75. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  76. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  77. 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()