claude-dev-env 1.17.5 → 1.19.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.
@@ -1,218 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Shared prompt-workflow validator callable from tests, CLI, and the Stop hook.
3
-
4
- Public API
5
- ----------
6
- validate_prompt_workflow(assistant_message, user_context="")
7
- Returns a ``ValidationResult`` with allowed/blocked status and reasons.
8
-
9
- CLI
10
- ---
11
- python prompt_workflow_validate.py path/to/draft.md
12
- cat draft.md | python prompt_workflow_validate.py
13
-
14
- Exit codes match hook conventions: 0 allowed, 2 blocked.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import sys
20
- from dataclasses import dataclass, field
21
- from pathlib import Path
22
-
23
- from prompt_workflow_gate_core import (
24
- find_ambiguous_scope_terms,
25
- find_negative_keywords_in_fenced_xml,
26
- has_checklist_container,
27
- has_debug_intent,
28
- has_internal_object_leak,
29
- is_prompt_workflow_response,
30
- missing_checklist_rows,
31
- missing_context_control_signals,
32
- missing_required_xml_sections,
33
- missing_scope_anchors,
34
- )
35
-
36
- @dataclass(frozen=True)
37
- class ValidationReason:
38
- code: str
39
- message: str
40
-
41
-
42
- @dataclass(frozen=True)
43
- class ValidationResult:
44
- allowed: bool
45
- reasons: tuple[ValidationReason, ...] = field(default_factory=tuple)
46
-
47
- @property
48
- def reason_messages(self) -> list[str]:
49
- return [each_reason.message for each_reason in self.reasons]
50
-
51
- @property
52
- def reason_codes(self) -> list[str]:
53
- return [each_reason.code for each_reason in self.reasons]
54
-
55
-
56
- def _blocked(code: str, message: str) -> ValidationResult:
57
- return ValidationResult(
58
- allowed=False,
59
- reasons=(ValidationReason(code=code, message=message),),
60
- )
61
-
62
-
63
- def _check_internal_leak(
64
- assistant_message: str,
65
- debug_requested: bool,
66
- ) -> ValidationResult | None:
67
- if not has_internal_object_leak(assistant_message) or debug_requested:
68
- return None
69
- return _blocked(
70
- code="internal_object_leak",
71
- message=(
72
- "Raw internal refinement object leakage detected. "
73
- "Return sanitized user-facing output unless explicit debug intent is present."
74
- ),
75
- )
76
-
77
-
78
- def _check_required_sections(assistant_message: str) -> ValidationResult | None:
79
- missing_sections = missing_required_xml_sections(assistant_message)
80
- if not missing_sections:
81
- return None
82
- return _blocked(
83
- code="missing_xml_sections",
84
- message=(
85
- "Fenced XML artifact missing required sections: "
86
- + ", ".join(missing_sections)
87
- ),
88
- )
89
-
90
-
91
- def _check_checklist_rows(assistant_message: str) -> ValidationResult | None:
92
- if not has_checklist_container(assistant_message):
93
- return None
94
- missing_rows = missing_checklist_rows(assistant_message)
95
- if not missing_rows:
96
- return None
97
- return _blocked(
98
- code="missing_checklist_rows",
99
- message=("Deterministic checklist rows missing: " + ", ".join(missing_rows)),
100
- )
101
-
102
-
103
- def _check_scope_anchors(assistant_message: str) -> ValidationResult | None:
104
- missing_anchors = missing_scope_anchors(assistant_message)
105
- if not missing_anchors:
106
- return None
107
- return _blocked(
108
- code="missing_scope_anchors",
109
- message=("Required scope anchors missing: " + ", ".join(missing_anchors)),
110
- )
111
-
112
-
113
- def _check_context_signals(assistant_message: str) -> ValidationResult | None:
114
- missing_signals = missing_context_control_signals(assistant_message)
115
- if not missing_signals:
116
- return None
117
- return _blocked(
118
- code="missing_context_signals",
119
- message=(
120
- "Runtime context-control preamble missing. "
121
- "Include the two required lines from prompt-workflow-context-controls "
122
- "(minimal instruction layer and on-demand skill loading)."
123
- ),
124
- )
125
-
126
-
127
- def _check_ambiguous_scope(assistant_message: str) -> ValidationResult | None:
128
- ambiguous_terms = find_ambiguous_scope_terms(assistant_message)
129
- if not ambiguous_terms:
130
- return None
131
- return _blocked(
132
- code="ambiguous_scope",
133
- message=("Ambiguous scope phrasing detected: " + ", ".join(ambiguous_terms)),
134
- )
135
-
136
-
137
- def _check_negative_keywords(assistant_message: str) -> ValidationResult | None:
138
- violations = find_negative_keywords_in_fenced_xml(assistant_message)
139
- if not violations:
140
- return None
141
- violation_descriptions = [
142
- f" line {each_violation['line_number']}: "
143
- f'"{each_violation["keyword"]}" in: {each_violation["line_text"]}'
144
- for each_violation in violations
145
- ]
146
- return _blocked(
147
- code="negative_keywords_in_artifact",
148
- message=(
149
- "Banned negative keywords found inside fenced XML artifact. "
150
- "Rephrase as positive directives (what TO do, not what to avoid):\n"
151
- + "\n".join(violation_descriptions)
152
- ),
153
- )
154
-
155
-
156
- def validate_prompt_workflow(
157
- assistant_message: str,
158
- user_context: str = "",
159
- ) -> ValidationResult:
160
- """Run all prompt-workflow gates on *assistant_message*.
161
-
162
- Returns ``ValidationResult.allowed == True`` when every gate passes.
163
- The first failing gate short-circuits and its reason is returned.
164
- """
165
- allowed_result = ValidationResult(allowed=True)
166
-
167
- if not assistant_message.strip():
168
- return allowed_result
169
-
170
- debug_requested = has_debug_intent(user_context)
171
-
172
- leak_result = _check_internal_leak(assistant_message, debug_requested)
173
- if leak_result is not None:
174
- return leak_result
175
-
176
- if not is_prompt_workflow_response(assistant_message):
177
- return allowed_result
178
-
179
- workflow_checks = (
180
- _check_required_sections,
181
- _check_checklist_rows,
182
- _check_scope_anchors,
183
- _check_context_signals,
184
- _check_ambiguous_scope,
185
- _check_negative_keywords,
186
- )
187
- for each_check in workflow_checks:
188
- gate_result = each_check(assistant_message)
189
- if gate_result is not None:
190
- return gate_result
191
-
192
- return allowed_result
193
-
194
-
195
- def main() -> None:
196
- blocked_exit_code: int = 2
197
- allowed_exit_code: int = 0
198
-
199
- if len(sys.argv) > 1:
200
- file_path = Path(sys.argv[1])
201
- assistant_text = file_path.read_text(encoding="utf-8")
202
- elif not sys.stdin.isatty():
203
- assistant_text = sys.stdin.read()
204
- else:
205
- sys.stderr.write("Usage: prompt_workflow_validate.py [path/to/draft.md]\n")
206
- sys.stderr.write(" cat draft.md | prompt_workflow_validate.py\n")
207
- sys.exit(blocked_exit_code)
208
-
209
- validation_result = validate_prompt_workflow(assistant_text)
210
- if validation_result.allowed:
211
- sys.exit(allowed_exit_code)
212
- for each_reason in validation_result.reasons:
213
- sys.stderr.write(f"[{each_reason.code}] {each_reason.message}\n")
214
- sys.exit(blocked_exit_code)
215
-
216
-
217
- if __name__ == "__main__":
218
- main()
@@ -1,54 +0,0 @@
1
- """Tests for prompt_workflow_clipboard."""
2
-
3
- from __future__ import annotations
4
-
5
- import pytest
6
-
7
- from prompt_workflow_clipboard import copy_text_to_system_clipboard
8
-
9
-
10
- def test_empty_text_returns_false() -> None:
11
- assert copy_text_to_system_clipboard("") is False
12
- assert copy_text_to_system_clipboard(" \n") is False
13
-
14
-
15
- def test_respects_skip_env(monkeypatch: pytest.MonkeyPatch) -> None:
16
- monkeypatch.setenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", "1")
17
- assert copy_text_to_system_clipboard("hello") is False
18
-
19
-
20
- @pytest.mark.parametrize(
21
- "flag_value",
22
- ("1", "true", "YES", "on"),
23
- )
24
- def test_skip_env_variants(monkeypatch: pytest.MonkeyPatch, flag_value: str) -> None:
25
- monkeypatch.setenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", flag_value)
26
- assert copy_text_to_system_clipboard("payload") is False
27
-
28
-
29
- def test_prefers_tkinter_when_it_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
30
- monkeypatch.delenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", raising=False)
31
- monkeypatch.setattr(
32
- "prompt_workflow_clipboard._copy_via_tkinter",
33
- lambda t: True,
34
- )
35
-
36
- def _fail_pyperclip(_t: str) -> bool:
37
- raise AssertionError("pyperclip must run only when tkinter fails")
38
-
39
- monkeypatch.setattr("prompt_workflow_clipboard._copy_via_pyperclip", _fail_pyperclip)
40
- assert copy_text_to_system_clipboard("hello") is True
41
-
42
-
43
- def test_falls_back_to_pyperclip(monkeypatch: pytest.MonkeyPatch) -> None:
44
- monkeypatch.delenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", raising=False)
45
- monkeypatch.setattr("prompt_workflow_clipboard._copy_via_tkinter", lambda t: False)
46
- monkeypatch.setattr("prompt_workflow_clipboard._copy_via_pyperclip", lambda t: True)
47
- assert copy_text_to_system_clipboard("hello") is True
48
-
49
-
50
- def test_returns_false_when_both_backends_fail(monkeypatch: pytest.MonkeyPatch) -> None:
51
- monkeypatch.delenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", raising=False)
52
- monkeypatch.setattr("prompt_workflow_clipboard._copy_via_tkinter", lambda t: False)
53
- monkeypatch.setattr("prompt_workflow_clipboard._copy_via_pyperclip", lambda t: False)
54
- assert copy_text_to_system_clipboard("hello") is False
@@ -1,195 +0,0 @@
1
- """Unit tests for shared prompt workflow gate logic."""
2
-
3
- from prompt_workflow_gate_core import (
4
- extract_fenced_xml_content,
5
- extract_fenced_xml_content_from_export,
6
- find_ambiguous_scope_terms,
7
- has_checklist_container,
8
- has_internal_object_leak,
9
- is_prompt_workflow_response,
10
- missing_context_control_signals,
11
- missing_checklist_rows,
12
- missing_required_xml_sections,
13
- missing_scope_anchors,
14
- normalize_prompt_workflow_export,
15
- )
16
-
17
- def test_internal_object_leak_detected() -> None:
18
- text = '{"pipeline_mode": "internal_section_refinement_with_final_audit"}'
19
- assert has_internal_object_leak(text)
20
-
21
- def test_missing_scope_anchors_returns_expected_rows() -> None:
22
- text = "target_local_roots only."
23
- missing = missing_scope_anchors(text)
24
- assert "target_canonical_roots" in missing
25
- assert "completion_boundary" in missing
26
-
27
- def test_missing_checklist_rows_detected() -> None:
28
- text = "checklist_results: structured_scoped_instructions only"
29
- missing = missing_checklist_rows(text)
30
- assert "completion_boundary_measurable" in missing
31
-
32
- def test_checklist_container_detection() -> None:
33
- assert has_checklist_container("checklist_results:\n- structured_scoped_instructions")
34
-
35
- def test_prompt_workflow_response_detection() -> None:
36
- message = (
37
- "overall_status: pass\n"
38
- "target_local_roots: /repo\n"
39
- "comparison_basis: current behavior vs deterministic guarantees\n"
40
- )
41
- assert is_prompt_workflow_response(message)
42
-
43
- def test_missing_context_control_signals_detected() -> None:
44
- missing = missing_context_control_signals("base_minimal_instruction_layer: true")
45
- assert "on_demand_skill_loading: true" in missing
46
-
47
- def test_ambiguous_scope_terms_detected() -> None:
48
- text = "Scope applies to this session and current files."
49
- terms = find_ambiguous_scope_terms(text)
50
- assert "this session" in terms
51
- assert "current files" in terms
52
-
53
- def _fenced_xml(body: str) -> str:
54
- return f"```xml\n{body}\n```"
55
-
56
- def _runtime_context_lines() -> tuple[str, ...]:
57
- return (
58
- "<runtime_context>",
59
- "base_minimal_instruction_layer: true",
60
- "on_demand_skill_loading: true",
61
- "</runtime_context>",
62
- "",
63
- )
64
-
65
- def _flattened_transcript(*lines: str) -> str:
66
- return "\n".join(lines) + "\n"
67
-
68
- def _flattened_attempt(*body_lines: str, audit_line: str = "Audit: pass 15/15") -> str:
69
- flattened_lines = [audit_line, ""]
70
- for line in body_lines:
71
- flattened_lines.append(f" {line}" if line else "")
72
- return "\n".join(flattened_lines)
73
-
74
- def test_missing_required_xml_sections_all_present_returns_empty() -> None:
75
- body = (
76
- "<role>R.</role>\n"
77
- "<background>C.</background>\n"
78
- "<instructions>I.</instructions>\n"
79
- "<constraints>Co.</constraints>\n"
80
- "<output_format>O.</output_format>\n"
81
- )
82
- assert missing_required_xml_sections(_fenced_xml(body)) == []
83
-
84
- def test_missing_required_xml_sections_missing_background() -> None:
85
- body = (
86
- "<role>R.</role>\n"
87
- "<instructions>I.</instructions>\n"
88
- "<constraints>Co.</constraints>\n"
89
- "<output_format>O.</output_format>\n"
90
- )
91
- assert missing_required_xml_sections(_fenced_xml(body)) == ["background"]
92
-
93
- def test_missing_required_xml_sections_missing_role_and_output_format() -> None:
94
- body = (
95
- "<background>C.</background>\n"
96
- "<instructions>I.</instructions>\n"
97
- "<constraints>Co.</constraints>\n"
98
- )
99
- missing = missing_required_xml_sections(_fenced_xml(body))
100
- assert missing == ["role", "output_format"]
101
-
102
- def test_missing_required_xml_sections_no_fence_returns_empty() -> None:
103
- assert missing_required_xml_sections("no fenced xml here") == []
104
-
105
- def test_missing_required_xml_sections_prose_without_tags_counts_as_missing() -> None:
106
- body = (
107
- "<role>R.</role>\n"
108
- "background appears in prose but has no tags.\n"
109
- "<instructions>I.</instructions>\n"
110
- "<constraints>Co.</constraints>\n"
111
- "<output_format>O.</output_format>\n"
112
- )
113
- assert missing_required_xml_sections(_fenced_xml(body)) == ["background"]
114
-
115
- def test_extract_fenced_xml_preserves_content_after_nested_inner_fence() -> None:
116
- message = (
117
- "```xml\n"
118
- "<role>R</role>\n"
119
- "<illustrations>\n"
120
- "```bash\necho hi\n```\n"
121
- "</illustrations>\n"
122
- "<background>B</background>\n"
123
- "<instructions>I</instructions>\n"
124
- "<constraints>C</constraints>\n"
125
- "<output_format>O</output_format>\n"
126
- "```\n"
127
- )
128
- extracted = extract_fenced_xml_content(message)
129
- assert "</illustrations>" in extracted
130
- assert "<background>B</background>" in extracted
131
-
132
- def test_normalize_prompt_workflow_export_rebuilds_fence_from_flattened_transcript() -> None:
133
- transcript = _flattened_transcript(
134
- _flattened_attempt(
135
- *_runtime_context_lines(),
136
- "<role>R</role>",
137
- "<background>B</background>",
138
- "<instructions>I</instructions>",
139
- "<constraints>C</constraints>",
140
- "<output_format>O</output_format>",
141
- "✻ Worked for 1m 7s",
142
- audit_line="● Audit: pass 15/15",
143
- ),
144
- )
145
- normalized = normalize_prompt_workflow_export(transcript)
146
- assert normalized.startswith("Audit: pass 15/15\n```xml\n")
147
- assert normalized.endswith("\n```")
148
- assert "<runtime_context>" in normalized
149
- assert "✻ Worked for 1m 7s" not in normalized
150
-
151
- def test_normalize_prompt_workflow_export_uses_last_audit_attempt() -> None:
152
- first_attempt = _flattened_attempt(
153
- "<role>FIRST</role>",
154
- "<background>Old</background>",
155
- "<instructions>Old</instructions>",
156
- "<constraints>Old</constraints>",
157
- "<output_format>Old</output_format>",
158
- audit_line="● Audit: pass 15/15",
159
- )
160
- second_attempt = _flattened_attempt(
161
- *_runtime_context_lines(),
162
- "<role>FINAL</role>",
163
- "<background>Fresh</background>",
164
- "<instructions>I</instructions>",
165
- "<constraints>C</constraints>",
166
- "<output_format>O</output_format>",
167
- "✻ Worked for 2m 8s",
168
- )
169
- transcript = _flattened_transcript(
170
- first_attempt,
171
- "",
172
- "● Re-emitting the full artifact with the runtime signals added.",
173
- "",
174
- second_attempt,
175
- )
176
- normalized = normalize_prompt_workflow_export(transcript)
177
- assert "<role>FINAL</role>" in normalized
178
- assert "<role>FIRST</role>" not in normalized
179
-
180
- def test_extract_fenced_xml_content_from_export_supports_flattened_transcript() -> None:
181
- transcript = _flattened_transcript(
182
- _flattened_attempt(
183
- "<role>R</role>",
184
- "<background>B</background>",
185
- "<instructions>I</instructions>",
186
- "<constraints>C</constraints>",
187
- "<output_format>O</output_format>",
188
- "✻ Worked for 31s",
189
- audit_line="● Audit: pass 15/15",
190
- ),
191
- )
192
- extracted = extract_fenced_xml_content_from_export(transcript)
193
- assert extracted.startswith("<role>R</role>")
194
- assert "<output_format>O</output_format>" in extracted
195
- assert "Worked for" not in extracted