claude-dev-env 1.17.1 → 1.17.2
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 +2 -1
- package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +8 -6
- package/hooks/blocking/prompt_workflow_validate.py +218 -0
- package/hooks/blocking/test_prompt_workflow_validate.py +339 -0
- package/hooks/hooks.json +0 -5
- package/package.json +1 -1
- package/skills/prompt-generator/ARCHITECTURE.md +2 -1
- package/skills/prompt-generator/REFERENCE.md +9 -11
- package/skills/prompt-generator/SKILL.md +41 -48
- package/skills/prompt-generator/TARGET_OUTPUT.md +25 -18
- package/hooks/blocking/prompt-workflow-stop-guard.py +0 -217
- package/hooks/blocking/test_prompt_workflow_stop_guard.py +0 -261
package/bin/install.mjs
CHANGED
|
@@ -31,7 +31,8 @@ const INSTALL_GROUPS = {
|
|
|
31
31
|
includeHookFiles: [
|
|
32
32
|
'blocking/prompt_workflow_gate_config.py',
|
|
33
33
|
'blocking/prompt_workflow_gate_core.py',
|
|
34
|
-
'blocking/
|
|
34
|
+
'blocking/prompt_workflow_validate.py',
|
|
35
|
+
'blocking/prompt_workflow_clipboard.py',
|
|
35
36
|
'HOOK_SPECS_PROMPT_WORKFLOW.md',
|
|
36
37
|
],
|
|
37
38
|
includeRules: ['prompt-workflow-context-controls.md'],
|
|
@@ -6,18 +6,20 @@ Deterministic runtime gates for prompt workflows.
|
|
|
6
6
|
|
|
7
7
|
The former `agent-execution-intent-gate.py` hook is **removed**. Native Agent/Task launches do not carry stable custom metadata; enforcing scope text on every spawn blocked legitimate `/agent-prompt` and refinement delegations. Scope and checklist rules remain enforced by the Stop guard when a prompt-workflow response is detected.
|
|
8
8
|
|
|
9
|
-
## Gate: Leakage + Checklist + Scope (
|
|
9
|
+
## Gate: Leakage + Checklist + Scope (file-based validation loop)
|
|
10
10
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- Fail
|
|
11
|
+
- Validator: `hooks/blocking/prompt_workflow_validate.py`
|
|
12
|
+
- Invocation: CLI against `data/prompts/.draft-prompt.xml` (exit 0 allowed, exit 2 blocked)
|
|
13
|
+
- Fail conditions:
|
|
14
14
|
- Raw internal refinement object appears in assistant output without explicit debug intent
|
|
15
15
|
- Prompt-workflow response detected but deterministic checklist container is missing
|
|
16
16
|
- Prompt-workflow response detected and required deterministic checklist rows are missing
|
|
17
17
|
- Prompt-workflow response detected and required scope anchors are missing
|
|
18
18
|
- Prompt-workflow response detected and runtime context-control signals are missing
|
|
19
19
|
- Scope-bound text uses banned ambiguous scope terms
|
|
20
|
-
-
|
|
20
|
+
- Banned negative keywords found inside fenced XML artifact
|
|
21
|
+
- Fenced XML artifact missing required sections
|
|
22
|
+
- Enforcement: The drafting subagent writes the draft file, runs the validator, reads stderr violations (each prefixed with `[reason_code]`), edits the file, and re-runs until exit 0.
|
|
21
23
|
|
|
22
24
|
## Required Scope Anchors
|
|
23
25
|
|
|
@@ -49,7 +51,7 @@ The former `agent-execution-intent-gate.py` hook is **removed**. Native Agent/Ta
|
|
|
49
51
|
- `base_minimal_instruction_layer: true`
|
|
50
52
|
- `on_demand_skill_loading: true`
|
|
51
53
|
|
|
52
|
-
These two signals are
|
|
54
|
+
These two signals are checked by the validator CLI whenever a prompt-workflow response is detected.
|
|
53
55
|
|
|
54
56
|
## Deterministic Boundary
|
|
55
57
|
|
|
@@ -0,0 +1,218 @@
|
|
|
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()
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Tests for prompt_workflow_validate module (shared validator + CLI entry point)."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from prompt_workflow_validate import ValidationResult, validate_prompt_workflow
|
|
10
|
+
|
|
11
|
+
VALIDATOR_MODULE_PATH = Path(__file__).parent / "prompt_workflow_validate.py"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _full_checklist_rows() -> str:
|
|
15
|
+
return (
|
|
16
|
+
"checklist_results:\n"
|
|
17
|
+
"- structured_scoped_instructions\n"
|
|
18
|
+
"- sequential_steps_present\n"
|
|
19
|
+
"- positive_framing\n"
|
|
20
|
+
"- acceptance_criteria_defined\n"
|
|
21
|
+
"- safety_reversibility_language\n"
|
|
22
|
+
"- reversible_action_and_safety_check_guidance\n"
|
|
23
|
+
"- concrete_output_contract\n"
|
|
24
|
+
"- scope_boundary_present\n"
|
|
25
|
+
"- explicit_scope_anchors_present\n"
|
|
26
|
+
"- all_instructions_artifact_bound\n"
|
|
27
|
+
"- scope_terms_explicit_and_anchored\n"
|
|
28
|
+
"- completion_boundary_measurable\n"
|
|
29
|
+
"- citation_grounding_policy_present\n"
|
|
30
|
+
"- source_priority_rules_present\n"
|
|
31
|
+
"- artifact_language_confidence\n"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _wrap_five_section_scaffold(inner_body: str) -> str:
|
|
36
|
+
has_instructions = "<instructions>" in inner_body
|
|
37
|
+
has_constraints = "<constraints>" in inner_body
|
|
38
|
+
instructions_section = (
|
|
39
|
+
"" if has_instructions else "<instructions>Test instructions sentence one.</instructions>\n"
|
|
40
|
+
)
|
|
41
|
+
constraints_section = (
|
|
42
|
+
"" if has_constraints else "<constraints>Test constraints sentence one.</constraints>\n"
|
|
43
|
+
)
|
|
44
|
+
return (
|
|
45
|
+
"<role>Test role sentence one.</role>\n"
|
|
46
|
+
"<background>Test background sentence one.</background>\n"
|
|
47
|
+
f"{instructions_section}"
|
|
48
|
+
f"{inner_body}\n"
|
|
49
|
+
f"{constraints_section}"
|
|
50
|
+
"<output_format>Test output format sentence one.</output_format>\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_prompt_workflow_message_with_fenced_xml(fenced_xml_body: str) -> str:
|
|
55
|
+
return (
|
|
56
|
+
"Audit: pass 15/15\n"
|
|
57
|
+
"```xml\n" + fenced_xml_body + "\n```\n"
|
|
58
|
+
"overall_status: pass\n" + _full_checklist_rows() + "target_local_roots\n"
|
|
59
|
+
"target_canonical_roots\n"
|
|
60
|
+
"target_file_globs\n"
|
|
61
|
+
"comparison_basis\n"
|
|
62
|
+
"completion_boundary\n"
|
|
63
|
+
"base_minimal_instruction_layer: true\n"
|
|
64
|
+
"on_demand_skill_loading: true\n"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestValidatePromptWorkflowFunction:
|
|
69
|
+
"""Tests that exercise the shared validate_prompt_workflow function directly."""
|
|
70
|
+
|
|
71
|
+
def test_allowed_complete_message_with_fenced_xml(self) -> None:
|
|
72
|
+
fenced_content = _wrap_five_section_scaffold(
|
|
73
|
+
"<instructions>Ensure all functions have explicit return types.</instructions>"
|
|
74
|
+
)
|
|
75
|
+
message = _build_prompt_workflow_message_with_fenced_xml(fenced_content)
|
|
76
|
+
validation_result = validate_prompt_workflow(message)
|
|
77
|
+
assert validation_result.allowed is True
|
|
78
|
+
assert validation_result.reasons == ()
|
|
79
|
+
|
|
80
|
+
def test_blocked_missing_context_control_lines(self) -> None:
|
|
81
|
+
message = (
|
|
82
|
+
"overall_status: pass\n"
|
|
83
|
+
+ _full_checklist_rows()
|
|
84
|
+
+ "target_local_roots\n"
|
|
85
|
+
+ "target_canonical_roots\n"
|
|
86
|
+
+ "target_file_globs\n"
|
|
87
|
+
+ "comparison_basis\n"
|
|
88
|
+
+ "completion_boundary\n"
|
|
89
|
+
)
|
|
90
|
+
validation_result = validate_prompt_workflow(message)
|
|
91
|
+
assert validation_result.allowed is False
|
|
92
|
+
assert "missing_context_signals" in validation_result.reason_codes
|
|
93
|
+
assert any(
|
|
94
|
+
"context-control" in each_message
|
|
95
|
+
for each_message in validation_result.reason_messages
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def test_allowed_empty_message(self) -> None:
|
|
99
|
+
validation_result = validate_prompt_workflow("")
|
|
100
|
+
assert validation_result.allowed is True
|
|
101
|
+
|
|
102
|
+
def test_allowed_non_workflow_message(self) -> None:
|
|
103
|
+
validation_result = validate_prompt_workflow("Just a regular response.")
|
|
104
|
+
assert validation_result.allowed is True
|
|
105
|
+
|
|
106
|
+
def test_blocked_internal_object_leak(self) -> None:
|
|
107
|
+
leak_message = (
|
|
108
|
+
'{"pipeline_mode": "internal_section_refinement_with_final_audit"}'
|
|
109
|
+
)
|
|
110
|
+
validation_result = validate_prompt_workflow(leak_message)
|
|
111
|
+
assert validation_result.allowed is False
|
|
112
|
+
assert "internal_object_leak" in validation_result.reason_codes
|
|
113
|
+
|
|
114
|
+
def test_allowed_internal_object_with_debug_context(self) -> None:
|
|
115
|
+
leak_message = (
|
|
116
|
+
'{"pipeline_mode": "internal_section_refinement_with_final_audit"}'
|
|
117
|
+
)
|
|
118
|
+
validation_result = validate_prompt_workflow(
|
|
119
|
+
leak_message,
|
|
120
|
+
user_context="debug: show internal pipeline object",
|
|
121
|
+
)
|
|
122
|
+
assert validation_result.allowed is True
|
|
123
|
+
|
|
124
|
+
def test_blocked_missing_checklist_rows(self) -> None:
|
|
125
|
+
message = (
|
|
126
|
+
"overall_status: pass\n"
|
|
127
|
+
"checklist_results: structured_scoped_instructions\n"
|
|
128
|
+
"target_local_roots\n"
|
|
129
|
+
"target_canonical_roots\n"
|
|
130
|
+
"target_file_globs\n"
|
|
131
|
+
"comparison_basis\n"
|
|
132
|
+
"completion_boundary\n"
|
|
133
|
+
)
|
|
134
|
+
validation_result = validate_prompt_workflow(message)
|
|
135
|
+
assert validation_result.allowed is False
|
|
136
|
+
assert "missing_checklist_rows" in validation_result.reason_codes
|
|
137
|
+
|
|
138
|
+
def test_blocked_negative_keywords_in_fenced_xml(self) -> None:
|
|
139
|
+
fenced_content = _wrap_five_section_scaffold(
|
|
140
|
+
"<instructions>Do not leave return types implicit.</instructions>"
|
|
141
|
+
)
|
|
142
|
+
message = _build_prompt_workflow_message_with_fenced_xml(fenced_content)
|
|
143
|
+
validation_result = validate_prompt_workflow(message)
|
|
144
|
+
assert validation_result.allowed is False
|
|
145
|
+
assert "negative_keywords_in_artifact" in validation_result.reason_codes
|
|
146
|
+
|
|
147
|
+
def test_blocked_ambiguous_scope(self) -> None:
|
|
148
|
+
message = (
|
|
149
|
+
"overall_status: pass\n"
|
|
150
|
+
+ _full_checklist_rows()
|
|
151
|
+
+ "scope block includes target_local_roots target_canonical_roots "
|
|
152
|
+
+ "target_file_globs comparison_basis completion_boundary "
|
|
153
|
+
+ "base_minimal_instruction_layer: true\n"
|
|
154
|
+
+ "on_demand_skill_loading: true\n"
|
|
155
|
+
+ "and applies to this session."
|
|
156
|
+
)
|
|
157
|
+
validation_result = validate_prompt_workflow(message)
|
|
158
|
+
assert validation_result.allowed is False
|
|
159
|
+
assert "ambiguous_scope" in validation_result.reason_codes
|
|
160
|
+
|
|
161
|
+
def test_reason_messages_property(self) -> None:
|
|
162
|
+
message = (
|
|
163
|
+
"overall_status: pass\n"
|
|
164
|
+
+ _full_checklist_rows()
|
|
165
|
+
+ "target_local_roots\n"
|
|
166
|
+
+ "target_canonical_roots\n"
|
|
167
|
+
+ "target_file_globs\n"
|
|
168
|
+
+ "comparison_basis\n"
|
|
169
|
+
+ "completion_boundary\n"
|
|
170
|
+
)
|
|
171
|
+
validation_result = validate_prompt_workflow(message)
|
|
172
|
+
assert len(validation_result.reason_messages) == 1
|
|
173
|
+
assert len(validation_result.reason_codes) == 1
|
|
174
|
+
|
|
175
|
+
def test_blocked_missing_scope_anchors(self) -> None:
|
|
176
|
+
message = (
|
|
177
|
+
"overall_status: pass\n"
|
|
178
|
+
+ _full_checklist_rows()
|
|
179
|
+
+ "base_minimal_instruction_layer: true\n"
|
|
180
|
+
+ "on_demand_skill_loading: true\n"
|
|
181
|
+
)
|
|
182
|
+
validation_result = validate_prompt_workflow(message)
|
|
183
|
+
assert validation_result.allowed is False
|
|
184
|
+
assert "missing_scope_anchors" in validation_result.reason_codes
|
|
185
|
+
|
|
186
|
+
def test_blocked_missing_xml_sections_in_fenced_artifact(self) -> None:
|
|
187
|
+
fenced_body = (
|
|
188
|
+
"<role>Test role sentence one.</role>\n"
|
|
189
|
+
"<instructions>Test instructions sentence one.</instructions>\n"
|
|
190
|
+
"<constraints>Test constraints sentence one.</constraints>\n"
|
|
191
|
+
"<output_format>Test output format sentence one.</output_format>\n"
|
|
192
|
+
)
|
|
193
|
+
message = _build_prompt_workflow_message_with_fenced_xml(fenced_body)
|
|
194
|
+
validation_result = validate_prompt_workflow(message)
|
|
195
|
+
assert validation_result.allowed is False
|
|
196
|
+
assert "missing_xml_sections" in validation_result.reason_codes
|
|
197
|
+
assert any(
|
|
198
|
+
"background" in each_message
|
|
199
|
+
for each_message in validation_result.reason_messages
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def test_allows_positive_phrasing_inside_fenced_xml(self) -> None:
|
|
203
|
+
fenced_content = _wrap_five_section_scaffold(
|
|
204
|
+
"<instructions>Ensure all functions have explicit return types.</instructions>"
|
|
205
|
+
)
|
|
206
|
+
message = _build_prompt_workflow_message_with_fenced_xml(fenced_content)
|
|
207
|
+
validation_result = validate_prompt_workflow(message)
|
|
208
|
+
assert validation_result.allowed is True
|
|
209
|
+
|
|
210
|
+
def test_permits_negative_keywords_outside_fenced_xml(self) -> None:
|
|
211
|
+
fenced_inner = _wrap_five_section_scaffold(
|
|
212
|
+
"<instructions>Ensure all functions have explicit return types.</instructions>"
|
|
213
|
+
)
|
|
214
|
+
message = (
|
|
215
|
+
"Audit: pass 15/15\n"
|
|
216
|
+
"Do not skip the audit line.\n"
|
|
217
|
+
"```xml\n" + fenced_inner + "\n```\n"
|
|
218
|
+
"overall_status: pass\n" + _full_checklist_rows() + "target_local_roots\n"
|
|
219
|
+
"target_canonical_roots\n"
|
|
220
|
+
"target_file_globs\n"
|
|
221
|
+
"comparison_basis\n"
|
|
222
|
+
"completion_boundary\n"
|
|
223
|
+
"base_minimal_instruction_layer: true\n"
|
|
224
|
+
"on_demand_skill_loading: true\n"
|
|
225
|
+
)
|
|
226
|
+
validation_result = validate_prompt_workflow(message)
|
|
227
|
+
assert validation_result.allowed is True
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@pytest.mark.parametrize(
|
|
231
|
+
("banned_pattern_name", "fenced_xml_content"),
|
|
232
|
+
[
|
|
233
|
+
("do_not", "<instructions>Do not leave return types implicit.</instructions>"),
|
|
234
|
+
("avoid", "<instructions>Avoid missing return types.</instructions>"),
|
|
235
|
+
("never", "<constraints>Never store credentials in plain text.</constraints>"),
|
|
236
|
+
("without", "<instructions>Deploy without running tests first.</instructions>"),
|
|
237
|
+
("prevent", "<constraints>Prevent unauthorized access to the API.</constraints>"),
|
|
238
|
+
("reject", "<constraints>Reject all unsigned commits.</constraints>"),
|
|
239
|
+
("cannot", "<constraints>The API cannot accept unauthenticated requests.</constraints>"),
|
|
240
|
+
("unless", "<constraints>Skip the build step unless the user explicitly approves.</constraints>"),
|
|
241
|
+
("must_not", "<constraints>The script must not produce duplicates.</constraints>"),
|
|
242
|
+
("must_never", "<constraints>You must never store credentials in environment variables.</constraints>"),
|
|
243
|
+
("instead_of", "<instructions>Use explicit types instead of implicit ones.</instructions>"),
|
|
244
|
+
("rather_than", "<constraints>Prefer explicit types rather than inferred ones.</constraints>"),
|
|
245
|
+
("as_opposed_to", "<instructions>Use Grid as opposed to floats for layout.</instructions>"),
|
|
246
|
+
],
|
|
247
|
+
)
|
|
248
|
+
def test_blocks_banned_pattern_inside_fenced_xml(
|
|
249
|
+
banned_pattern_name: str,
|
|
250
|
+
fenced_xml_content: str,
|
|
251
|
+
) -> None:
|
|
252
|
+
message = _build_prompt_workflow_message_with_fenced_xml(
|
|
253
|
+
_wrap_five_section_scaffold(fenced_xml_content)
|
|
254
|
+
)
|
|
255
|
+
validation_result = validate_prompt_workflow(message)
|
|
256
|
+
assert validation_result.allowed is False
|
|
257
|
+
assert "negative_keywords_in_artifact" in validation_result.reason_codes
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class TestValidatorCli:
|
|
261
|
+
"""Tests that exercise the CLI entry point via subprocess."""
|
|
262
|
+
|
|
263
|
+
def test_cli_exits_zero_for_valid_content(self, tmp_path: Path) -> None:
|
|
264
|
+
fenced_content = _wrap_five_section_scaffold(
|
|
265
|
+
"<instructions>Ensure all functions have explicit return types.</instructions>"
|
|
266
|
+
)
|
|
267
|
+
draft_file = tmp_path / "draft.xml"
|
|
268
|
+
draft_file.write_text(
|
|
269
|
+
_build_prompt_workflow_message_with_fenced_xml(fenced_content),
|
|
270
|
+
encoding="utf-8",
|
|
271
|
+
)
|
|
272
|
+
completed_process = subprocess.run(
|
|
273
|
+
[sys.executable, str(VALIDATOR_MODULE_PATH), str(draft_file)],
|
|
274
|
+
capture_output=True,
|
|
275
|
+
text=True,
|
|
276
|
+
check=False,
|
|
277
|
+
)
|
|
278
|
+
assert completed_process.returncode == 0
|
|
279
|
+
assert completed_process.stderr.strip() == ""
|
|
280
|
+
|
|
281
|
+
def test_cli_exits_two_with_bracketed_reason_code_on_stderr(
|
|
282
|
+
self,
|
|
283
|
+
tmp_path: Path,
|
|
284
|
+
) -> None:
|
|
285
|
+
message = (
|
|
286
|
+
"overall_status: pass\n"
|
|
287
|
+
+ _full_checklist_rows()
|
|
288
|
+
+ "target_local_roots\n"
|
|
289
|
+
+ "target_canonical_roots\n"
|
|
290
|
+
+ "target_file_globs\n"
|
|
291
|
+
+ "comparison_basis\n"
|
|
292
|
+
+ "completion_boundary\n"
|
|
293
|
+
)
|
|
294
|
+
draft_file = tmp_path / "draft.xml"
|
|
295
|
+
draft_file.write_text(message, encoding="utf-8")
|
|
296
|
+
completed_process = subprocess.run(
|
|
297
|
+
[sys.executable, str(VALIDATOR_MODULE_PATH), str(draft_file)],
|
|
298
|
+
capture_output=True,
|
|
299
|
+
text=True,
|
|
300
|
+
check=False,
|
|
301
|
+
)
|
|
302
|
+
assert completed_process.returncode == 2
|
|
303
|
+
assert "[missing_context_signals]" in completed_process.stderr
|
|
304
|
+
|
|
305
|
+
def test_cli_stderr_format_uses_reason_code_prefix(
|
|
306
|
+
self,
|
|
307
|
+
tmp_path: Path,
|
|
308
|
+
) -> None:
|
|
309
|
+
fenced_content = _wrap_five_section_scaffold(
|
|
310
|
+
"<instructions>Do not leave return types implicit.</instructions>"
|
|
311
|
+
)
|
|
312
|
+
draft_file = tmp_path / "draft.xml"
|
|
313
|
+
draft_file.write_text(
|
|
314
|
+
_build_prompt_workflow_message_with_fenced_xml(fenced_content),
|
|
315
|
+
encoding="utf-8",
|
|
316
|
+
)
|
|
317
|
+
completed_process = subprocess.run(
|
|
318
|
+
[sys.executable, str(VALIDATOR_MODULE_PATH), str(draft_file)],
|
|
319
|
+
capture_output=True,
|
|
320
|
+
text=True,
|
|
321
|
+
check=False,
|
|
322
|
+
)
|
|
323
|
+
assert completed_process.returncode == 2
|
|
324
|
+
assert "[negative_keywords_in_artifact]" in completed_process.stderr
|
|
325
|
+
assert "Banned negative keywords" in completed_process.stderr
|
|
326
|
+
|
|
327
|
+
def test_cli_reads_from_stdin_when_no_file_argument(self) -> None:
|
|
328
|
+
fenced_content = _wrap_five_section_scaffold(
|
|
329
|
+
"<instructions>Ensure all functions have explicit return types.</instructions>"
|
|
330
|
+
)
|
|
331
|
+
valid_message = _build_prompt_workflow_message_with_fenced_xml(fenced_content)
|
|
332
|
+
completed_process = subprocess.run(
|
|
333
|
+
[sys.executable, str(VALIDATOR_MODULE_PATH)],
|
|
334
|
+
input=valid_message,
|
|
335
|
+
capture_output=True,
|
|
336
|
+
text=True,
|
|
337
|
+
check=False,
|
|
338
|
+
)
|
|
339
|
+
assert completed_process.returncode == 0
|
package/hooks/hooks.json
CHANGED
|
@@ -150,11 +150,6 @@
|
|
|
150
150
|
"type": "command",
|
|
151
151
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hedging-language-blocker.py",
|
|
152
152
|
"timeout": 10
|
|
153
|
-
},
|
|
154
|
-
{
|
|
155
|
-
"type": "command",
|
|
156
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/prompt-workflow-stop-guard.py",
|
|
157
|
-
"timeout": 10
|
|
158
153
|
}
|
|
159
154
|
]
|
|
160
155
|
}
|
package/package.json
CHANGED
|
@@ -13,5 +13,6 @@ Baseline inventory of files in the prompt-generator skill package.
|
|
|
13
13
|
| `evals/prompt-generator.json` | Scenario eval rows |
|
|
14
14
|
| `templates/skill-from-ground-up.md` | Net-new skill checkpoint template |
|
|
15
15
|
| `templates/skill-refinement-package.md` | Existing-skill refinement template |
|
|
16
|
-
| `hooks/blocking/
|
|
16
|
+
| `hooks/blocking/prompt_workflow_validate.py` | Validator CLI (file-based loop) |
|
|
17
17
|
| `hooks/blocking/prompt_workflow_gate_core.py` | Fence extraction, markers |
|
|
18
|
+
| `hooks/blocking/prompt_workflow_clipboard.py` | Clipboard copy for artifacts |
|