claude-dev-env 1.14.0 → 1.15.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,5 +1,10 @@
1
1
  #!/usr/bin/env python3
2
- """Stop hook gate for prompt-workflow leakage and deterministic audit coverage."""
2
+ """Stop hook gate for prompt-workflow leakage and deterministic audit coverage.
3
+
4
+ When every workflow gate passes, the fenced ``xml`` artifact body is copied to the
5
+ system clipboard via :mod:`prompt_workflow_clipboard` (tkinter, then pyperclip).
6
+ Set ``PROMPT_WORKFLOW_SKIP_CLIPBOARD=1`` to disable (tests, CI, headless).
7
+ """
3
8
 
4
9
  from __future__ import annotations
5
10
 
@@ -9,7 +14,9 @@ import sys
9
14
  from collections.abc import Callable
10
15
  from pathlib import Path
11
16
 
17
+ from prompt_workflow_clipboard import copy_text_to_system_clipboard
12
18
  from prompt_workflow_gate_core import (
19
+ extract_fenced_xml_content,
13
20
  find_ambiguous_scope_terms,
14
21
  find_negative_keywords_in_fenced_xml,
15
22
  has_debug_intent,
@@ -199,6 +206,10 @@ def main() -> None:
199
206
 
200
207
  if block is not None:
201
208
  sys.stdout.write(json.dumps(block) + "\n")
209
+ elif is_prompt_workflow_response(assistant_message):
210
+ artifact_text = extract_fenced_xml_content(assistant_message).strip()
211
+ if artifact_text:
212
+ copy_text_to_system_clipboard(artifact_text)
202
213
 
203
214
  sys.exit(0)
204
215
 
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-platform clipboard writes for prompt-workflow XML artifacts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+
8
+
9
+ def _clipboard_disabled_by_env() -> bool:
10
+ flag = os.environ.get("PROMPT_WORKFLOW_SKIP_CLIPBOARD", "").strip().lower()
11
+ return flag in {"1", "true", "yes", "on"}
12
+
13
+
14
+ def copy_text_to_system_clipboard(text: str) -> bool:
15
+ """Write ``text`` to the OS clipboard using Python-only backends.
16
+
17
+ Tries :mod:`tkinter` (stdlib) first, then optional ``pyperclip`` if installed.
18
+ Returns ``True`` when a backend reports success, ``False`` otherwise
19
+ (missing dependency, headless display, empty payload, or env opt-out).
20
+ """
21
+ if not text.strip():
22
+ return False
23
+ if _clipboard_disabled_by_env():
24
+ return False
25
+ if _copy_via_tkinter(text):
26
+ return True
27
+ return _copy_via_pyperclip(text)
28
+
29
+
30
+ def _copy_via_tkinter(text: str) -> bool:
31
+ try:
32
+ import tkinter as tk
33
+ except ImportError:
34
+ return False
35
+ root: tk.Tk | None = None
36
+ try:
37
+ root = tk.Tk()
38
+ root.withdraw()
39
+ root.clipboard_clear()
40
+ root.clipboard_append(text)
41
+ root.update_idletasks()
42
+ root.update()
43
+ return True
44
+ except Exception:
45
+ return False
46
+ finally:
47
+ if root is not None:
48
+ try:
49
+ root.destroy()
50
+ except Exception:
51
+ pass
52
+
53
+
54
+ def _copy_via_pyperclip(text: str) -> bool:
55
+ try:
56
+ import pyperclip
57
+ except ImportError:
58
+ return False
59
+ try:
60
+ pyperclip.copy(text)
61
+ return True
62
+ except Exception:
63
+ return False
@@ -96,16 +96,12 @@ NEGATIVE_INDIRECT_PATTERNS_IN_ARTIFACT: tuple[str, ...] = (
96
96
 
97
97
  REQUIRED_XML_SECTIONS: tuple[str, ...] = (
98
98
  "role",
99
- "context",
99
+ "background",
100
100
  "instructions",
101
101
  "constraints",
102
102
  "output_format",
103
103
  )
104
104
 
105
- FENCED_XML_BLOCK_PATTERN: re.Pattern[str] = re.compile(
106
- r"```xml\s*\n(.*?)```", re.DOTALL
107
- )
108
-
109
105
  COMPILED_NEGATIVE_KEYWORD_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
110
106
  re.compile(rf"\b{re.escape(keyword)}\b", re.IGNORECASE)
111
107
  for keyword in NEGATIVE_KEYWORDS_IN_ARTIFACT
@@ -11,7 +11,6 @@ from prompt_workflow_gate_config import (
11
11
  COMPILED_NEGATIVE_INDIRECT_PATTERNS,
12
12
  COMPILED_NEGATIVE_KEYWORD_PATTERNS,
13
13
  DEBUG_INTENT_MARKERS,
14
- FENCED_XML_BLOCK_PATTERN,
15
14
  INTERNAL_OBJECT_MARKERS,
16
15
  PROMPT_WORKFLOW_RESPONSE_MARKERS,
17
16
  REQUIRED_CHECKLIST_ROWS,
@@ -20,9 +19,61 @@ from prompt_workflow_gate_config import (
20
19
  )
21
20
 
22
21
 
22
+ def _line_opens_xml_fence(line: str) -> bool:
23
+ stripped = line.strip()
24
+ if not stripped.startswith("```"):
25
+ return False
26
+ remainder = stripped[3:].strip()
27
+ return remainder == "xml" or remainder.startswith("xml ")
28
+
29
+
30
+ def _line_is_bare_fence_close(line: str) -> bool:
31
+ return line.strip() == "```"
32
+
33
+
34
+ def _line_opens_inner_markdown_fence(line: str) -> bool:
35
+ stripped = line.strip()
36
+ if not stripped.startswith("```"):
37
+ return False
38
+ return stripped != "```"
39
+
40
+
23
41
  def extract_fenced_xml_content(text: str) -> str:
24
- all_matches = FENCED_XML_BLOCK_PATTERN.findall(text)
25
- return "\n".join(all_matches)
42
+ """Extract bodies of ```xml fenced blocks.
43
+
44
+ The closing delimiter is a line whose stripped text is exactly three backticks.
45
+ Inner Markdown code fences (for example a line starting with three backticks
46
+ plus a language tag) are scanned until their own closing backtick line so the
47
+ outer ``xml`` fence does not end early.
48
+ """
49
+ results: list[str] = []
50
+ lines = text.splitlines()
51
+ index = 0
52
+ while index < len(lines):
53
+ if _line_opens_xml_fence(lines[index]):
54
+ index += 1
55
+ body_lines: list[str] = []
56
+ while index < len(lines):
57
+ line = lines[index]
58
+ if _line_is_bare_fence_close(line):
59
+ index += 1
60
+ break
61
+ if _line_opens_inner_markdown_fence(line):
62
+ body_lines.append(line)
63
+ index += 1
64
+ while index < len(lines):
65
+ inner_line = lines[index]
66
+ body_lines.append(inner_line)
67
+ index += 1
68
+ if _line_is_bare_fence_close(inner_line):
69
+ break
70
+ continue
71
+ body_lines.append(line)
72
+ index += 1
73
+ results.append("\n".join(body_lines))
74
+ continue
75
+ index += 1
76
+ return "\n".join(results)
26
77
 
27
78
 
28
79
  def missing_required_xml_sections(text: str) -> list[str]:
@@ -0,0 +1,54 @@
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,6 +1,7 @@
1
1
  """Unit tests for shared prompt workflow gate logic."""
2
2
 
3
3
  from prompt_workflow_gate_core import (
4
+ extract_fenced_xml_content,
4
5
  find_ambiguous_scope_terms,
5
6
  has_checklist_container,
6
7
  has_internal_object_leak,
@@ -62,7 +63,7 @@ def _fenced_xml(body: str) -> str:
62
63
  def test_missing_required_xml_sections_all_present_returns_empty() -> None:
63
64
  body = (
64
65
  "<role>R.</role>\n"
65
- "<context>C.</context>\n"
66
+ "<background>C.</background>\n"
66
67
  "<instructions>I.</instructions>\n"
67
68
  "<constraints>Co.</constraints>\n"
68
69
  "<output_format>O.</output_format>\n"
@@ -70,19 +71,19 @@ def test_missing_required_xml_sections_all_present_returns_empty() -> None:
70
71
  assert missing_required_xml_sections(_fenced_xml(body)) == []
71
72
 
72
73
 
73
- def test_missing_required_xml_sections_missing_context() -> None:
74
+ def test_missing_required_xml_sections_missing_background() -> None:
74
75
  body = (
75
76
  "<role>R.</role>\n"
76
77
  "<instructions>I.</instructions>\n"
77
78
  "<constraints>Co.</constraints>\n"
78
79
  "<output_format>O.</output_format>\n"
79
80
  )
80
- assert missing_required_xml_sections(_fenced_xml(body)) == ["context"]
81
+ assert missing_required_xml_sections(_fenced_xml(body)) == ["background"]
81
82
 
82
83
 
83
84
  def test_missing_required_xml_sections_missing_role_and_output_format() -> None:
84
85
  body = (
85
- "<context>C.</context>\n"
86
+ "<background>C.</background>\n"
86
87
  "<instructions>I.</instructions>\n"
87
88
  "<constraints>Co.</constraints>\n"
88
89
  )
@@ -97,9 +98,27 @@ def test_missing_required_xml_sections_no_fence_returns_empty() -> None:
97
98
  def test_missing_required_xml_sections_prose_without_tags_counts_as_missing() -> None:
98
99
  body = (
99
100
  "<role>R.</role>\n"
100
- "context appears in prose but has no tags.\n"
101
+ "background appears in prose but has no tags.\n"
101
102
  "<instructions>I.</instructions>\n"
102
103
  "<constraints>Co.</constraints>\n"
103
104
  "<output_format>O.</output_format>\n"
104
105
  )
105
- assert missing_required_xml_sections(_fenced_xml(body)) == ["context"]
106
+ assert missing_required_xml_sections(_fenced_xml(body)) == ["background"]
107
+
108
+
109
+ def test_extract_fenced_xml_preserves_content_after_nested_inner_fence() -> None:
110
+ message = (
111
+ "```xml\n"
112
+ "<role>R</role>\n"
113
+ "<illustrations>\n"
114
+ "```bash\necho hi\n```\n"
115
+ "</illustrations>\n"
116
+ "<background>B</background>\n"
117
+ "<instructions>I</instructions>\n"
118
+ "<constraints>C</constraints>\n"
119
+ "<output_format>O</output_format>\n"
120
+ "```\n"
121
+ )
122
+ extracted = extract_fenced_xml_content(message)
123
+ assert "</illustrations>" in extracted
124
+ assert "<background>B</background>" in extracted
@@ -10,6 +10,14 @@ import pytest
10
10
 
11
11
  SCRIPT_PATH = Path(__file__).parent / "prompt-workflow-stop-guard.py"
12
12
 
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def _disable_prompt_workflow_clipboard_in_subprocess(
16
+ monkeypatch: pytest.MonkeyPatch,
17
+ ) -> None:
18
+ """Subprocess hook inherits env; clipboard would be flaky in CI."""
19
+ monkeypatch.setenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", "1")
20
+
13
21
  def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
14
22
  return subprocess.run(
15
23
  [sys.executable, str(SCRIPT_PATH)],
@@ -121,7 +129,7 @@ def test_blocks_ambiguous_scope_phrasing() -> None:
121
129
  def _wrap_five_section_scaffold(inner_body: str) -> str:
122
130
  return (
123
131
  "<role>Test role sentence one.</role>\n"
124
- "<context>Test context sentence one.</context>\n"
132
+ "<background>Test background sentence one.</background>\n"
125
133
  f"{inner_body}\n"
126
134
  "<constraints>Test constraints sentence one.</constraints>\n"
127
135
  "<output_format>Test output format sentence one.</output_format>\n"
@@ -218,7 +226,7 @@ def test_permits_negative_keywords_outside_fenced_xml() -> None:
218
226
  assert result.stdout.strip() == ""
219
227
 
220
228
 
221
- def test_blocks_when_fenced_xml_missing_context_section() -> None:
229
+ def test_blocks_when_fenced_xml_missing_background_section() -> None:
222
230
  fenced_body = (
223
231
  "<role>Test role sentence one.</role>\n"
224
232
  "<instructions>Test instructions sentence one.</instructions>\n"
@@ -231,7 +239,7 @@ def test_blocks_when_fenced_xml_missing_context_section() -> None:
231
239
  result = _run_hook(payload)
232
240
  response = json.loads(result.stdout)
233
241
  assert response["decision"] == "block"
234
- assert "context" in response["reason"]
242
+ assert "background" in response["reason"]
235
243
  assert "include all required XML sections" in response["systemMessage"]
236
244
 
237
245
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {