claude-dev-env 1.13.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.
package/bin/install.mjs CHANGED
@@ -29,6 +29,7 @@ const INSTALL_GROUPS = {
29
29
  description: 'Prompt engineering tools',
30
30
  skills: ['prompt-generator', 'agent-prompt'],
31
31
  includeHookFiles: [
32
+ 'blocking/prompt_workflow_gate_config.py',
32
33
  'blocking/prompt_workflow_gate_core.py',
33
34
  'blocking/prompt-workflow-stop-guard.py',
34
35
  'HOOK_SPECS_PROMPT_WORKFLOW.md',
@@ -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,
@@ -18,6 +25,7 @@ from prompt_workflow_gate_core import (
18
25
  is_prompt_workflow_response,
19
26
  missing_context_control_signals,
20
27
  missing_checklist_rows,
28
+ missing_required_xml_sections,
21
29
  missing_scope_anchors,
22
30
  )
23
31
 
@@ -150,10 +158,23 @@ def _check_negative_keywords_in_artifact(assistant_message: str) -> dict | None:
150
158
  ),
151
159
  )
152
160
 
161
+ def _check_required_xml_sections(assistant_message: str) -> dict | None:
162
+ missing_sections = missing_required_xml_sections(assistant_message)
163
+ if not missing_sections:
164
+ return None
165
+ return _build_block(
166
+ brief_label="retrying: include all required XML sections",
167
+ full_reason=(
168
+ "PROMPT-WORKFLOW GATE: Fenced XML artifact missing required sections: "
169
+ + ", ".join(missing_sections)
170
+ ),
171
+ )
172
+
153
173
  def _evaluate_workflow_gates(assistant_message: str) -> dict | None:
154
174
  if not is_prompt_workflow_response(assistant_message):
155
175
  return None
156
176
  workflow_gate_checks: tuple[Callable[[str], dict | None], ...] = (
177
+ _check_required_xml_sections,
157
178
  _check_missing_checklist_rows,
158
179
  _check_missing_scope_anchors,
159
180
  _check_missing_context_signals,
@@ -185,6 +206,10 @@ def main() -> None:
185
206
 
186
207
  if block is not None:
187
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)
188
213
 
189
214
  sys.exit(0)
190
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
@@ -0,0 +1,113 @@
1
+ """Static lists and compiled regexes for prompt-workflow gate checks.
2
+
3
+ Edit this file to change scope anchors, checklist rows, markers, or keyword lists
4
+ without touching gate logic in prompt_workflow_gate_core.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ REQUIRED_SCOPE_ANCHORS: tuple[str, ...] = (
12
+ "target_local_roots",
13
+ "target_canonical_roots",
14
+ "target_file_globs",
15
+ "comparison_basis",
16
+ "completion_boundary",
17
+ )
18
+
19
+ REQUIRED_CHECKLIST_ROWS: tuple[str, ...] = (
20
+ "structured_scoped_instructions",
21
+ "sequential_steps_present",
22
+ "positive_framing",
23
+ "acceptance_criteria_defined",
24
+ "safety_reversibility_language",
25
+ "reversible_action_and_safety_check_guidance",
26
+ "concrete_output_contract",
27
+ "scope_boundary_present",
28
+ "explicit_scope_anchors_present",
29
+ "all_instructions_artifact_bound",
30
+ "scope_terms_explicit_and_anchored",
31
+ "completion_boundary_measurable",
32
+ "citation_grounding_policy_present",
33
+ "source_priority_rules_present",
34
+ "artifact_language_confidence",
35
+ )
36
+
37
+ AMBIGUOUS_SCOPE_TERMS: tuple[str, ...] = (
38
+ "this session",
39
+ "current files",
40
+ "here",
41
+ "above",
42
+ "as needed",
43
+ )
44
+
45
+ INTERNAL_OBJECT_MARKERS: tuple[str, ...] = (
46
+ '"pipeline_mode": "internal_section_refinement_with_final_audit"',
47
+ '"scope_block": {',
48
+ '"required_sections": [',
49
+ '"section_output_contract": {',
50
+ '"merge_output_contract": {',
51
+ '"audit_output_contract": {',
52
+ )
53
+
54
+ PROMPT_WORKFLOW_RESPONSE_MARKERS: tuple[str, ...] = (
55
+ "checklist_results",
56
+ "overall_status",
57
+ "scope anchors",
58
+ "target_local_roots",
59
+ "target_canonical_roots",
60
+ "target_file_globs",
61
+ "comparison_basis",
62
+ "completion_boundary",
63
+ )
64
+
65
+ DEBUG_INTENT_MARKERS: tuple[str, ...] = (
66
+ "debug",
67
+ "show internal",
68
+ "raw internal object",
69
+ "pipeline object",
70
+ )
71
+
72
+ NEGATIVE_KEYWORDS_IN_ARTIFACT: tuple[str, ...] = (
73
+ "no",
74
+ "not",
75
+ "don't",
76
+ "do not",
77
+ "never",
78
+ "avoid",
79
+ "without",
80
+ "refrain",
81
+ "stop",
82
+ "prevent",
83
+ "exclude",
84
+ "prohibit",
85
+ "forbid",
86
+ "reject",
87
+ "cannot",
88
+ "unless",
89
+ )
90
+
91
+ NEGATIVE_INDIRECT_PATTERNS_IN_ARTIFACT: tuple[str, ...] = (
92
+ r"instead of\s+\w+",
93
+ r"rather than\s+\w+",
94
+ r"as opposed to\s+\w+",
95
+ )
96
+
97
+ REQUIRED_XML_SECTIONS: tuple[str, ...] = (
98
+ "role",
99
+ "background",
100
+ "instructions",
101
+ "constraints",
102
+ "output_format",
103
+ )
104
+
105
+ COMPILED_NEGATIVE_KEYWORD_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
106
+ re.compile(rf"\b{re.escape(keyword)}\b", re.IGNORECASE)
107
+ for keyword in NEGATIVE_KEYWORDS_IN_ARTIFACT
108
+ )
109
+
110
+ COMPILED_NEGATIVE_INDIRECT_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
111
+ re.compile(pattern, re.IGNORECASE)
112
+ for pattern in NEGATIVE_INDIRECT_PATTERNS_IN_ARTIFACT
113
+ )
@@ -6,116 +6,87 @@ from __future__ import annotations
6
6
  import re
7
7
  from typing import Iterable
8
8
 
9
- REQUIRED_SCOPE_ANCHORS: tuple[str, ...] = (
10
- "target_local_roots",
11
- "target_canonical_roots",
12
- "target_file_globs",
13
- "comparison_basis",
14
- "completion_boundary",
9
+ from prompt_workflow_gate_config import (
10
+ AMBIGUOUS_SCOPE_TERMS,
11
+ COMPILED_NEGATIVE_INDIRECT_PATTERNS,
12
+ COMPILED_NEGATIVE_KEYWORD_PATTERNS,
13
+ DEBUG_INTENT_MARKERS,
14
+ INTERNAL_OBJECT_MARKERS,
15
+ PROMPT_WORKFLOW_RESPONSE_MARKERS,
16
+ REQUIRED_CHECKLIST_ROWS,
17
+ REQUIRED_SCOPE_ANCHORS,
18
+ REQUIRED_XML_SECTIONS,
15
19
  )
16
20
 
17
- REQUIRED_CHECKLIST_ROWS: tuple[str, ...] = (
18
- "structured_scoped_instructions",
19
- "sequential_steps_present",
20
- "positive_framing",
21
- "acceptance_criteria_defined",
22
- "safety_reversibility_language",
23
- "reversible_action_and_safety_check_guidance",
24
- "concrete_output_contract",
25
- "scope_boundary_present",
26
- "explicit_scope_anchors_present",
27
- "all_instructions_artifact_bound",
28
- "scope_terms_explicit_and_anchored",
29
- "completion_boundary_measurable",
30
- "citation_grounding_policy_present",
31
- "source_priority_rules_present",
32
- "artifact_language_confidence",
33
- )
34
-
35
- REQUIRED_CONTEXT_CONTROL_SIGNALS: tuple[str, ...] = (
36
- "base_minimal_instruction_layer: true",
37
- "on_demand_skill_loading: true",
38
- )
39
-
40
- AMBIGUOUS_SCOPE_TERMS: tuple[str, ...] = (
41
- "this session",
42
- "current files",
43
- "here",
44
- "above",
45
- "as needed",
46
- )
47
21
 
48
- INTERNAL_OBJECT_MARKERS: tuple[str, ...] = (
49
- '"pipeline_mode": "internal_section_refinement_with_final_audit"',
50
- '"scope_block": {',
51
- '"required_sections": [',
52
- '"section_output_contract": {',
53
- '"merge_output_contract": {',
54
- '"audit_output_contract": {',
55
- )
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 ")
56
28
 
57
- PROMPT_WORKFLOW_RESPONSE_MARKERS: tuple[str, ...] = (
58
- "checklist_results",
59
- "overall_status",
60
- "scope anchors",
61
- "target_local_roots",
62
- "target_canonical_roots",
63
- "target_file_globs",
64
- "comparison_basis",
65
- "completion_boundary",
66
- )
67
29
 
68
- DEBUG_INTENT_MARKERS: tuple[str, ...] = (
69
- "debug",
70
- "show internal",
71
- "raw internal object",
72
- "pipeline object",
73
- )
30
+ def _line_is_bare_fence_close(line: str) -> bool:
31
+ return line.strip() == "```"
74
32
 
75
33
 
76
- NEGATIVE_KEYWORDS_IN_ARTIFACT: tuple[str, ...] = (
77
- "no",
78
- "not",
79
- "don't",
80
- "do not",
81
- "never",
82
- "avoid",
83
- "without",
84
- "refrain",
85
- "stop",
86
- "prevent",
87
- "exclude",
88
- "prohibit",
89
- "forbid",
90
- "reject",
91
- "cannot",
92
- "unless",
93
- )
94
-
95
- NEGATIVE_INDIRECT_PATTERNS_IN_ARTIFACT: tuple[str, ...] = (
96
- r"instead of\s+\w+",
97
- r"rather than\s+\w+",
98
- r"as opposed to\s+\w+",
99
- )
100
-
101
- COMPILED_NEGATIVE_KEYWORD_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
102
- re.compile(rf"\b{re.escape(keyword)}\b", re.IGNORECASE)
103
- for keyword in NEGATIVE_KEYWORDS_IN_ARTIFACT
104
- )
105
-
106
- COMPILED_NEGATIVE_INDIRECT_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
107
- re.compile(pattern, re.IGNORECASE)
108
- for pattern in NEGATIVE_INDIRECT_PATTERNS_IN_ARTIFACT
109
- )
110
-
111
- FENCED_XML_BLOCK_PATTERN: re.Pattern[str] = re.compile(
112
- r"```xml\s*\n(.*?)```", re.DOTALL
113
- )
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 != "```"
114
39
 
115
40
 
116
41
  def extract_fenced_xml_content(text: str) -> str:
117
- all_matches = FENCED_XML_BLOCK_PATTERN.findall(text)
118
- 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)
77
+
78
+
79
+ def missing_required_xml_sections(text: str) -> list[str]:
80
+ fenced_body = extract_fenced_xml_content(text)
81
+ if not fenced_body.strip():
82
+ return []
83
+ missing_sections: list[str] = []
84
+ for section_name in REQUIRED_XML_SECTIONS:
85
+ open_tag = re.compile(rf"<{re.escape(section_name)}(\s[^>]*)?>")
86
+ close_tag = re.compile(rf"</{re.escape(section_name)}>")
87
+ if not open_tag.search(fenced_body) or not close_tag.search(fenced_body):
88
+ missing_sections.append(section_name)
89
+ return missing_sections
119
90
 
120
91
 
121
92
  def find_negative_keywords_in_fenced_xml(
@@ -192,6 +163,9 @@ def is_prompt_workflow_response(text: str) -> bool:
192
163
 
193
164
 
194
165
  def missing_context_control_signals(text: str) -> list[str]:
195
- return [
196
- signal for signal in REQUIRED_CONTEXT_CONTROL_SIGNALS if signal not in text.lower()
197
- ]
166
+ required_signals: tuple[str, ...] = (
167
+ "base_minimal_instruction_layer: true",
168
+ "on_demand_skill_loading: true",
169
+ )
170
+ lowered = text.lower()
171
+ return [signal for signal in required_signals if signal not in lowered]
@@ -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,12 +1,14 @@
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,
7
8
  is_prompt_workflow_response,
8
9
  missing_context_control_signals,
9
10
  missing_checklist_rows,
11
+ missing_required_xml_sections,
10
12
  missing_scope_anchors,
11
13
  )
12
14
 
@@ -52,3 +54,71 @@ def test_ambiguous_scope_terms_detected() -> None:
52
54
  terms = find_ambiguous_scope_terms(text)
53
55
  assert "this session" in terms
54
56
  assert "current files" in terms
57
+
58
+
59
+ def _fenced_xml(body: str) -> str:
60
+ return f"```xml\n{body}\n```"
61
+
62
+
63
+ def test_missing_required_xml_sections_all_present_returns_empty() -> None:
64
+ body = (
65
+ "<role>R.</role>\n"
66
+ "<background>C.</background>\n"
67
+ "<instructions>I.</instructions>\n"
68
+ "<constraints>Co.</constraints>\n"
69
+ "<output_format>O.</output_format>\n"
70
+ )
71
+ assert missing_required_xml_sections(_fenced_xml(body)) == []
72
+
73
+
74
+ def test_missing_required_xml_sections_missing_background() -> None:
75
+ body = (
76
+ "<role>R.</role>\n"
77
+ "<instructions>I.</instructions>\n"
78
+ "<constraints>Co.</constraints>\n"
79
+ "<output_format>O.</output_format>\n"
80
+ )
81
+ assert missing_required_xml_sections(_fenced_xml(body)) == ["background"]
82
+
83
+
84
+ def test_missing_required_xml_sections_missing_role_and_output_format() -> None:
85
+ body = (
86
+ "<background>C.</background>\n"
87
+ "<instructions>I.</instructions>\n"
88
+ "<constraints>Co.</constraints>\n"
89
+ )
90
+ missing = missing_required_xml_sections(_fenced_xml(body))
91
+ assert missing == ["role", "output_format"]
92
+
93
+
94
+ def test_missing_required_xml_sections_no_fence_returns_empty() -> None:
95
+ assert missing_required_xml_sections("no fenced xml here") == []
96
+
97
+
98
+ def test_missing_required_xml_sections_prose_without_tags_counts_as_missing() -> None:
99
+ body = (
100
+ "<role>R.</role>\n"
101
+ "background appears in prose but has no tags.\n"
102
+ "<instructions>I.</instructions>\n"
103
+ "<constraints>Co.</constraints>\n"
104
+ "<output_format>O.</output_format>\n"
105
+ )
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)],
@@ -118,6 +126,16 @@ def test_blocks_ambiguous_scope_phrasing() -> None:
118
126
  assert response["decision"] == "block"
119
127
  assert "Ambiguous scope phrasing detected" in response["reason"]
120
128
 
129
+ def _wrap_five_section_scaffold(inner_body: str) -> str:
130
+ return (
131
+ "<role>Test role sentence one.</role>\n"
132
+ "<background>Test background sentence one.</background>\n"
133
+ f"{inner_body}\n"
134
+ "<constraints>Test constraints sentence one.</constraints>\n"
135
+ "<output_format>Test output format sentence one.</output_format>\n"
136
+ )
137
+
138
+
121
139
  def _build_prompt_workflow_message_with_fenced_xml(fenced_xml_body: str) -> str:
122
140
  return (
123
141
  "Audit: pass 15/15\n"
@@ -137,7 +155,9 @@ def _build_prompt_workflow_message_with_fenced_xml(fenced_xml_body: str) -> str:
137
155
 
138
156
 
139
157
  def test_allows_positive_phrasing_inside_fenced_xml() -> None:
140
- fenced_content = "<instructions>Ensure all functions have explicit return types.</instructions>"
158
+ fenced_content = _wrap_five_section_scaffold(
159
+ "<instructions>Ensure all functions have explicit return types.</instructions>"
160
+ )
141
161
  payload = {
142
162
  "last_assistant_message": _build_prompt_workflow_message_with_fenced_xml(fenced_content),
143
163
  }
@@ -172,7 +192,9 @@ def test_blocks_banned_pattern_inside_fenced_xml(
172
192
  fenced_xml_content: str,
173
193
  ) -> None:
174
194
  payload = {
175
- "last_assistant_message": _build_prompt_workflow_message_with_fenced_xml(fenced_xml_content),
195
+ "last_assistant_message": _build_prompt_workflow_message_with_fenced_xml(
196
+ _wrap_five_section_scaffold(fenced_xml_content)
197
+ ),
176
198
  }
177
199
  result = _run_hook(payload)
178
200
  response = json.loads(result.stdout)
@@ -180,12 +202,15 @@ def test_blocks_banned_pattern_inside_fenced_xml(
180
202
 
181
203
 
182
204
  def test_permits_negative_keywords_outside_fenced_xml() -> None:
205
+ fenced_inner = _wrap_five_section_scaffold(
206
+ "<instructions>Ensure all functions have explicit return types.</instructions>"
207
+ )
183
208
  message = (
184
209
  "Audit: pass 15/15\n"
185
210
  "Do not skip the audit line.\n"
186
211
  "```xml\n"
187
- "<instructions>Ensure all functions have explicit return types.</instructions>\n"
188
- "```\n"
212
+ + fenced_inner
213
+ + "\n```\n"
189
214
  "overall_status: pass\n"
190
215
  + _full_checklist_rows()
191
216
  + "target_local_roots\n"
@@ -201,6 +226,23 @@ def test_permits_negative_keywords_outside_fenced_xml() -> None:
201
226
  assert result.stdout.strip() == ""
202
227
 
203
228
 
229
+ def test_blocks_when_fenced_xml_missing_background_section() -> None:
230
+ fenced_body = (
231
+ "<role>Test role sentence one.</role>\n"
232
+ "<instructions>Test instructions sentence one.</instructions>\n"
233
+ "<constraints>Test constraints sentence one.</constraints>\n"
234
+ "<output_format>Test output format sentence one.</output_format>\n"
235
+ )
236
+ payload = {
237
+ "last_assistant_message": _build_prompt_workflow_message_with_fenced_xml(fenced_body),
238
+ }
239
+ result = _run_hook(payload)
240
+ response = json.loads(result.stdout)
241
+ assert response["decision"] == "block"
242
+ assert "background" in response["reason"]
243
+ assert "include all required XML sections" in response["systemMessage"]
244
+
245
+
204
246
  def test_allows_fully_structured_prompt_workflow_output() -> None:
205
247
  payload = {
206
248
  "last_assistant_message": (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.13.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": {
@@ -26,7 +26,7 @@ Use this command:
26
26
  - `target_file_globs`
27
27
  - `comparison_basis`
28
28
  - `completion_boundary`
29
- - XML scaffold includes all sections:
29
+ - XML scaffold includes all sections — verified by the Stop hook at runtime; each required section tag must have both an opening and a closing tag:
30
30
  - `<role>`
31
31
  - `<context>`
32
32
  - `<instructions>`
@@ -129,6 +129,7 @@ If `overall_status` is `fail`:
129
129
  Validate fail-closed runtime gates:
130
130
 
131
131
  1. **Stop leakage/scope/checklist gate**
132
+ - **Section-presence gate (Stop)** — Block responses where the fenced XML artifact is missing any of the five required section tag pairs: `role`, `context`, `instructions`, `constraints`, `output_format`.
132
133
  - Block responses that leak raw internal refinement object fields unless debug intent is explicit.
133
134
  - Block responses missing deterministic checklist rows when audit output is present.
134
135
  - Block responses using ambiguous scope phrasing in scope-bound sections.
@@ -148,6 +149,7 @@ Validate fail-closed runtime gates:
148
149
  - Missing required scope anchors (when Stop guard applies)
149
150
  - Raw internal object leakage without debug intent
150
151
  - Missing required checklist rows in audit output
152
+ - Missing required XML sections (`role`, `context`, `instructions`, `constraints`, `output_format`) in the fenced artifact (opening and closing tags)
151
153
  - Ambiguous scope terms in scope-bound text
152
154
  - Negative keywords inside fenced XML artifacts
153
155
  - Hedging language inside fenced XML artifacts
@@ -185,6 +185,7 @@ Expand the light self-check with this internal checklist when useful:
185
185
  - [ ] Emotion-informed framing is present: collaborative language, explicit success criteria, and explicit permission to express uncertainty ("say so if unsure")
186
186
  - [ ] Constraints are surfaced upfront (proactive constraint awareness) so the model can incorporate them into its plan, and each non-obvious constraint carries its motivation
187
187
  - [ ] Self-correction chaining is considered when the prompt must hold up over time (generate → review → refine)
188
+ - [ ] All five required XML sections (`<role>`, `<context>`, `<instructions>`, `<constraints>`, `<output_format>`) are present with both opening and closing tags in the fenced artifact
188
189
 
189
190
  ### 9. Deliver (orchestrator)
190
191
 
@@ -196,6 +197,8 @@ Audit: pass 15/15
196
197
 
197
198
  (or `fail N/15 — …`), immediately followed by **one** fenced XML block; **send boundary** is immediately after the closing fence so the user receives a copy-ready pair (audit line + artifact) in one assistant message before the conversation continues.
198
199
 
200
+ **Render-survival:** When the fenced XML uses tag names that **collide with HTML5 elements** (`context`, `section`, `summary`, `details`, `header`, `footer`, `main`, `aside`, `article`, `nav`, `figure`), or when the artifact is **very large**, **write the artifact to a file** and give the user the path together with the usual one-line audit. Add a brief **section inventory** (confirming the five required sections) so the user can trust the file even if the inline fence would render poorly. Details: **TARGET_OUTPUT.md — Structural invariant E**.
201
+
199
202
  ### 10. Default refinement mode (subagent-internal)
200
203
 
201
204
  For non-trivial requests, run inside the drafting subagent (use **draft-only** when the user explicitly asks for a quick draft / no refinement loop):
@@ -212,6 +215,8 @@ Required section list is immutable for this pipeline: `role`, `context`, `instru
212
215
 
213
216
  **Two-tier validation — tier 2:** The `15` in `Audit: pass 15/15` counts these **compliance** rows (stable ids for hooks). Tier 1 is the **light self-check** in §8—keep the steps separate so models do not merge them.
214
217
 
218
+ **Runtime Stop hook:** In addition to the 15-row internal audit, the `prompt-workflow-stop-guard` Stop hook enforces **section presence** on prompt-workflow responses: any fenced Markdown XML block must include opening and closing tags for `role`, `context`, `instructions`, `constraints`, and `output_format`. Missing tags trigger a retry before the user sees a passing turn. Pair this with **Structural invariant E** in `TARGET_OUTPUT.md` so users still receive intact XML when chat renderers strip HTML-named tags.
219
+
215
220
  | # | Row name |
216
221
  |---|----------|
217
222
  | 1 | structured_scoped_instructions |
@@ -87,6 +87,13 @@ This file is the **target output spec** for eval-driven iteration of the `prompt
87
87
  - Place residual uncertainty only in `<open_question>` elements (one topic per tag) with a clear decision you need from the executor or user.
88
88
  - Use definitive phrasing inside instructions (e.g. “Run tests in `packages/foo` with `pytest tests/`”) so each step reads like an executable checklist.
89
89
 
90
+ ## Structural invariant E — Render-survival for XML sections
91
+
92
+ - **Problem:** Tag names used for prompt XML sections can overlap **HTML5 element names**. Chat renderers may treat those tokens as HTML and hide or alter the content between tags. High-risk examples include: `context`, `section`, `summary`, `details`, `header`, `footer`, `main`, `aside`, `article`, `nav`, `figure`. The raw assistant text may be complete while the **rendered** message looks like sections are missing (notably `<context>`).
93
+ - **Primary mitigation:** When the fenced XML artifact **contains any tag whose local name is on that HTML-collision list**, or when the artifact is **large enough that render truncation is likely**, the orchestrator **must write the full artifact to a file** (default: under `data/prompts/` or a path the user supplied earlier) and **paste the absolute file path** in the chat message. Pair the path with a **short section inventory** confirming all five required sections (`role`, `context`, `instructions`, `constraints`, `output_format`) are present in the file.
94
+ - **Fallback when file write is unavailable:** Escape the **opening angle bracket** of colliding tags (for example `&lt;context>` — user restores `<` when pasting) or use another distinctive wrapper **documented in the same message**, so the user can recover literal XML. State explicitly that the user should restore brackets when copying into another system.
95
+ - **Structural safety net:** Regardless of renderer behavior, the **Stop hook section-presence gate** blocks any prompt-workflow response whose fenced XML is missing any required opening/closing section tag pair. Methodology: [Anthropic — Agent Skills: evaluation and iteration](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices#evaluation-and-iteration).
96
+
90
97
  ## XML artifact (minimum sections)
91
98
 
92
99
  Include at least:
@@ -133,6 +133,43 @@
133
133
  "Example: 'Ensure all functions have explicit return types' passes; 'Do not leave return types implicit' fails; 'Avoid missing return types' fails",
134
134
  "Applies to all sections inside the fenced block: <role>, <context>, <instructions>, <constraints>, <output_format>"
135
135
  ]
136
+ },
137
+ {
138
+ "id": 10,
139
+ "name": "required_sections_present_in_artifact",
140
+ "scenario": "Section completeness gate (render-survival)",
141
+ "prompt": "/prompt-generator Write a system prompt for a Python linting agent that auto-fixes code style issues in this repo",
142
+ "files": [],
143
+ "expected_behavior": [
144
+ "Fenced XML block contains opening and closing tags for all five required sections: role, context, instructions, constraints, output_format",
145
+ "Each required section contains substantive content (minimum one sentence each)",
146
+ "The Stop hook section-presence check passes for this output (no missing section tags)",
147
+ "Sections appear in order: role first, output_format last among the five required sections"
148
+ ]
149
+ },
150
+ {
151
+ "id": 11,
152
+ "name": "section_missing_triggers_hook_block",
153
+ "scenario": "Section completeness gate — failure path",
154
+ "prompt": "Synthetic eval: assistant final message is prompt-workflow shaped (overall_status, checklist, scope anchors, runtime signals) with a fenced Markdown XML block whose body omits the entire context section (no context opening/closing tags); observer asserts Stop hook behavior and successful retry.",
155
+ "files": [],
156
+ "expected_behavior": [
157
+ "The Stop hook runs _check_required_xml_sections and returns a block decision naming context as a missing section",
158
+ "The model retry includes all five required sections with both opening and closing tags",
159
+ "The retry output passes the section-presence gate (empty missing list from missing_required_xml_sections)"
160
+ ]
161
+ },
162
+ {
163
+ "id": 12,
164
+ "name": "render_survival_file_fallback",
165
+ "scenario": "Render-layer mitigation",
166
+ "prompt": "/prompt-generator Write a comprehensive agent prompt for migrating a large Prisma schema and all related API routes, with step-by-step rollout, rollback, and verification — artifact sized like the migration prompt that triggered chat render stripping.",
167
+ "files": [],
168
+ "expected_behavior": [
169
+ "When the artifact exceeds a size threshold or contains XML section tag names that collide with HTML5 elements (context, section, summary, details, header, footer, main, aside, article, nav, figure), the orchestrator writes the full artifact to a file under data/prompts/ or a user-specified path",
170
+ "The file contains the complete XML with all tags preserved as literal text",
171
+ "The user-facing message states the file path and briefly inventories which required sections the artifact contains"
172
+ ]
136
173
  }
137
174
  ]
138
175
  }