elspais 0.9.3__py3-none-any.whl → 0.11.1__py3-none-any.whl
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.
- elspais/cli.py +141 -10
- elspais/commands/hash_cmd.py +72 -26
- elspais/commands/reformat_cmd.py +458 -0
- elspais/commands/trace.py +157 -3
- elspais/commands/validate.py +44 -16
- elspais/core/models.py +2 -0
- elspais/core/parser.py +68 -24
- elspais/reformat/__init__.py +50 -0
- elspais/reformat/detector.py +119 -0
- elspais/reformat/hierarchy.py +246 -0
- elspais/reformat/line_breaks.py +220 -0
- elspais/reformat/prompts.py +123 -0
- elspais/reformat/transformer.py +264 -0
- elspais/sponsors/__init__.py +432 -0
- elspais/trace_view/__init__.py +54 -0
- elspais/trace_view/coverage.py +183 -0
- elspais/trace_view/generators/__init__.py +12 -0
- elspais/trace_view/generators/base.py +329 -0
- elspais/trace_view/generators/csv.py +122 -0
- elspais/trace_view/generators/markdown.py +175 -0
- elspais/trace_view/html/__init__.py +31 -0
- elspais/trace_view/html/generator.py +1006 -0
- elspais/trace_view/html/templates/base.html +283 -0
- elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
- elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
- elspais/trace_view/html/templates/components/legend_modal.html +69 -0
- elspais/trace_view/html/templates/components/review_panel.html +118 -0
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
- elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
- elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
- elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
- elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
- elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
- elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
- elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
- elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
- elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
- elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
- elspais/trace_view/html/templates/partials/scripts.js +1741 -0
- elspais/trace_view/html/templates/partials/styles.css +1756 -0
- elspais/trace_view/models.py +353 -0
- elspais/trace_view/review/__init__.py +60 -0
- elspais/trace_view/review/branches.py +1149 -0
- elspais/trace_view/review/models.py +1205 -0
- elspais/trace_view/review/position.py +609 -0
- elspais/trace_view/review/server.py +1056 -0
- elspais/trace_view/review/status.py +470 -0
- elspais/trace_view/review/storage.py +1367 -0
- elspais/trace_view/scanning.py +213 -0
- elspais/trace_view/specs/README.md +84 -0
- elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
- elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
- elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
- elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
- elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
- elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
- elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
- elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
- elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
- elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
- elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
- elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
- elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
- elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
- {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/METADATA +36 -18
- elspais-0.11.1.dist-info/RECORD +101 -0
- elspais-0.9.3.dist-info/RECORD +0 -40
- {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/WHEEL +0 -0
- {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/entry_points.txt +0 -0
- {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Implements: REQ-int-d00008-C (Line break normalization)
|
|
2
|
+
"""
|
|
3
|
+
Line break normalization for requirement content.
|
|
4
|
+
|
|
5
|
+
Provides functions to:
|
|
6
|
+
- Remove unnecessary blank lines after section headers
|
|
7
|
+
- Reflow paragraphs (join lines broken mid-sentence)
|
|
8
|
+
- Preserve intentional structure (list items, code blocks)
|
|
9
|
+
|
|
10
|
+
IMPLEMENTS REQUIREMENTS:
|
|
11
|
+
REQ-int-d00008-C: Line break normalization SHALL be included.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from typing import List, Tuple
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_line_breaks(content: str, reflow: bool = True) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Normalize line breaks in requirement content.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
content: Raw requirement markdown content
|
|
24
|
+
reflow: If True, also reflow paragraphs (join broken lines)
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Content with normalized line breaks
|
|
28
|
+
"""
|
|
29
|
+
lines = content.split('\n')
|
|
30
|
+
result_lines: List[str] = []
|
|
31
|
+
|
|
32
|
+
i = 0
|
|
33
|
+
while i < len(lines):
|
|
34
|
+
line = lines[i]
|
|
35
|
+
|
|
36
|
+
# Check if this is a section header (## Something)
|
|
37
|
+
if re.match(r'^##\s+\w', line):
|
|
38
|
+
result_lines.append(line)
|
|
39
|
+
# Skip blank lines immediately after section header
|
|
40
|
+
i += 1
|
|
41
|
+
while i < len(lines) and lines[i].strip() == '':
|
|
42
|
+
i += 1
|
|
43
|
+
# Add single blank line after header for readability
|
|
44
|
+
result_lines.append('')
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
# Check if this starts a paragraph that might need reflowing
|
|
48
|
+
if reflow and line.strip() and not _is_structural_line(line):
|
|
49
|
+
# Collect paragraph lines
|
|
50
|
+
para_lines = [line.rstrip()]
|
|
51
|
+
i += 1
|
|
52
|
+
while i < len(lines):
|
|
53
|
+
next_line = lines[i]
|
|
54
|
+
# Stop at blank lines, structural elements, or next section
|
|
55
|
+
if (next_line.strip() == '' or
|
|
56
|
+
_is_structural_line(next_line) or
|
|
57
|
+
re.match(r'^##\s+', next_line)):
|
|
58
|
+
break
|
|
59
|
+
para_lines.append(next_line.rstrip())
|
|
60
|
+
i += 1
|
|
61
|
+
|
|
62
|
+
# Join and reflow the paragraph
|
|
63
|
+
reflowed = _reflow_paragraph(para_lines)
|
|
64
|
+
result_lines.append(reflowed)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Keep structural lines and blank lines as-is
|
|
68
|
+
result_lines.append(line.rstrip())
|
|
69
|
+
i += 1
|
|
70
|
+
|
|
71
|
+
# Clean up multiple consecutive blank lines
|
|
72
|
+
return _collapse_blank_lines('\n'.join(result_lines))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_structural_line(line: str) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Check if a line is structural (should not be reflowed).
|
|
78
|
+
|
|
79
|
+
Structural lines include:
|
|
80
|
+
- List items (A., B., 1., -, *)
|
|
81
|
+
- Headers (# or ##)
|
|
82
|
+
- Metadata lines (**Level**: etc)
|
|
83
|
+
- End markers (*End* ...)
|
|
84
|
+
- Code fence markers (```)
|
|
85
|
+
"""
|
|
86
|
+
stripped = line.strip()
|
|
87
|
+
|
|
88
|
+
if not stripped:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
# Headers
|
|
92
|
+
if stripped.startswith('#'):
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
# Lettered assertions (A. B. C. etc)
|
|
96
|
+
if re.match(r'^[A-Z]\.\s', stripped):
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
# Numbered lists (1. 2. 3. etc)
|
|
100
|
+
if re.match(r'^\d+\.\s', stripped):
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
# Bullet points
|
|
104
|
+
if stripped.startswith(('- ', '* ', '+ ')):
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
# Metadata line
|
|
108
|
+
if stripped.startswith('**Level**:') or stripped.startswith('**Status**:'):
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
# Combined metadata line
|
|
112
|
+
if re.match(r'\*\*Level\*\*:', stripped):
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
# End marker
|
|
116
|
+
if stripped.startswith('*End*'):
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
# Code fence
|
|
120
|
+
if stripped.startswith('```'):
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _reflow_paragraph(lines: List[str]) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Reflow a list of paragraph lines into a single line.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
lines: Lines that form a paragraph
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Single reflowed line
|
|
135
|
+
"""
|
|
136
|
+
if not lines:
|
|
137
|
+
return ''
|
|
138
|
+
|
|
139
|
+
if len(lines) == 1:
|
|
140
|
+
return lines[0]
|
|
141
|
+
|
|
142
|
+
# Join lines with space, collapsing multiple spaces
|
|
143
|
+
joined = ' '.join(line.strip() for line in lines if line.strip())
|
|
144
|
+
# Collapse multiple spaces
|
|
145
|
+
return re.sub(r'\s+', ' ', joined)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _collapse_blank_lines(content: str) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Collapse multiple consecutive blank lines into single blank lines.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
content: Content that may have multiple blank lines
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Content with at most one blank line between paragraphs
|
|
157
|
+
"""
|
|
158
|
+
# Replace 3+ newlines with 2 newlines (one blank line)
|
|
159
|
+
return re.sub(r'\n{3,}', '\n\n', content)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def fix_requirement_line_breaks(
|
|
163
|
+
body: str,
|
|
164
|
+
rationale: str,
|
|
165
|
+
reflow: bool = True
|
|
166
|
+
) -> Tuple[str, str]:
|
|
167
|
+
"""
|
|
168
|
+
Fix line breaks in requirement body and rationale.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
body: Requirement body text
|
|
172
|
+
rationale: Requirement rationale text
|
|
173
|
+
reflow: Whether to reflow paragraphs
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Tuple of (fixed_body, fixed_rationale)
|
|
177
|
+
"""
|
|
178
|
+
fixed_body = normalize_line_breaks(body, reflow=reflow) if body else ''
|
|
179
|
+
fixed_rationale = normalize_line_breaks(rationale, reflow=reflow) if rationale else ''
|
|
180
|
+
|
|
181
|
+
return fixed_body, fixed_rationale
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def detect_line_break_issues(content: str) -> List[str]:
|
|
185
|
+
"""
|
|
186
|
+
Detect potential line break issues in content.
|
|
187
|
+
|
|
188
|
+
Returns list of issues found for reporting.
|
|
189
|
+
"""
|
|
190
|
+
issues = []
|
|
191
|
+
lines = content.split('\n')
|
|
192
|
+
|
|
193
|
+
for i, line in enumerate(lines):
|
|
194
|
+
# Check for blank line after section header
|
|
195
|
+
if re.match(r'^##\s+\w', line):
|
|
196
|
+
# Look ahead for multiple blank lines
|
|
197
|
+
blank_count = 0
|
|
198
|
+
j = i + 1
|
|
199
|
+
while j < len(lines) and lines[j].strip() == '':
|
|
200
|
+
blank_count += 1
|
|
201
|
+
j += 1
|
|
202
|
+
if blank_count > 1:
|
|
203
|
+
issues.append(
|
|
204
|
+
f"Line {i+1}: Multiple blank lines ({blank_count}) after section header"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Check for mid-sentence line break (line ends without punctuation)
|
|
208
|
+
stripped = line.rstrip()
|
|
209
|
+
if (stripped and
|
|
210
|
+
not _is_structural_line(line) and
|
|
211
|
+
i + 1 < len(lines) and
|
|
212
|
+
lines[i + 1].strip() and
|
|
213
|
+
not _is_structural_line(lines[i + 1])):
|
|
214
|
+
# Line ends with a word (not punctuation), followed by non-empty line
|
|
215
|
+
if stripped and stripped[-1].isalnum():
|
|
216
|
+
issues.append(
|
|
217
|
+
f"Line {i+1}: Possible mid-sentence line break"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return issues
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Implements: REQ-int-d00008 (Reformat Command)
|
|
2
|
+
"""
|
|
3
|
+
Prompts and JSON schema for Claude Code CLI integration.
|
|
4
|
+
|
|
5
|
+
Defines the system prompt and output schema for AI-assisted
|
|
6
|
+
requirement reformatting.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
# JSON Schema for structured output validation
|
|
12
|
+
JSON_SCHEMA = {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {
|
|
15
|
+
"rationale": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Non-normative context explaining why this requirement exists. No SHALL/MUST language."
|
|
18
|
+
},
|
|
19
|
+
"assertions": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"items": {"type": "string"},
|
|
22
|
+
"description": "List of assertions, each starting with 'The system SHALL...' or similar prescriptive language."
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"required": ["rationale", "assertions"]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
JSON_SCHEMA_STR = json.dumps(JSON_SCHEMA, separators=(',', ':'))
|
|
29
|
+
|
|
30
|
+
# System prompt for requirement reformatting
|
|
31
|
+
REFORMAT_SYSTEM_PROMPT = """You are a requirements engineering expert specializing in FDA 21 CFR Part 11 compliant clinical trial systems.
|
|
32
|
+
|
|
33
|
+
Your task is to reformat requirements from an old descriptive format to a new prescriptive assertion-based format.
|
|
34
|
+
|
|
35
|
+
EXTRACTION RULES:
|
|
36
|
+
1. Extract ALL obligations from the old format (body text, bullet points, acceptance criteria)
|
|
37
|
+
2. Convert each distinct obligation to a labeled assertion
|
|
38
|
+
3. Each assertion MUST use "SHALL" for mandatory obligations or "SHALL NOT" for prohibitions
|
|
39
|
+
4. Each assertion MUST be independently testable (decidable as true/false)
|
|
40
|
+
5. Assertions MUST be prescriptive, not descriptive
|
|
41
|
+
6. Maximum 26 assertions (A-Z) - if more needed, consolidate related obligations
|
|
42
|
+
7. Do NOT add obligations that were not in the original
|
|
43
|
+
8. Do NOT remove or weaken any obligations from the original
|
|
44
|
+
9. Combine related acceptance criteria into single assertions when appropriate
|
|
45
|
+
|
|
46
|
+
RATIONALE RULES:
|
|
47
|
+
1. The rationale provides context for WHY this requirement exists
|
|
48
|
+
2. Rationale MUST NOT introduce new obligations
|
|
49
|
+
3. Rationale MUST NOT use SHALL/MUST language
|
|
50
|
+
4. Rationale can explain regulatory context, design decisions, or relationships to other requirements
|
|
51
|
+
|
|
52
|
+
LANGUAGE GUIDELINES:
|
|
53
|
+
- Use "The system SHALL..." for system behaviors
|
|
54
|
+
- Use "The platform SHALL..." for platform-wide requirements
|
|
55
|
+
- Use "Data SHALL..." for data-related requirements
|
|
56
|
+
- Be specific and unambiguous
|
|
57
|
+
- Avoid vague terms like "appropriate", "adequate", "reasonable" unless quantified
|
|
58
|
+
|
|
59
|
+
OUTPUT FORMAT:
|
|
60
|
+
Return a JSON object with:
|
|
61
|
+
- "rationale": A paragraph explaining the requirement's purpose (no SHALL language)
|
|
62
|
+
- "assertions": An array of strings, each being a complete assertion starting with the subject and SHALL
|
|
63
|
+
|
|
64
|
+
Example output:
|
|
65
|
+
{
|
|
66
|
+
"rationale": "This requirement ensures complete audit trails for regulatory compliance. FDA 21 CFR Part 11 mandates that electronic records maintain tamper-evident histories of all modifications.",
|
|
67
|
+
"assertions": [
|
|
68
|
+
"The system SHALL store all data changes as immutable events.",
|
|
69
|
+
"The system SHALL preserve the complete history of all modifications.",
|
|
70
|
+
"Event records SHALL include timestamp, user ID, and action type.",
|
|
71
|
+
"The system SHALL NOT allow modification or deletion of stored events."
|
|
72
|
+
]
|
|
73
|
+
}"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_user_prompt(
|
|
77
|
+
req_id: str,
|
|
78
|
+
title: str,
|
|
79
|
+
level: str,
|
|
80
|
+
status: str,
|
|
81
|
+
implements: list,
|
|
82
|
+
body: str,
|
|
83
|
+
rationale: str = ""
|
|
84
|
+
) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Build the user prompt for reformatting a requirement.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
req_id: Requirement ID (e.g., 'REQ-p00046')
|
|
90
|
+
title: Requirement title
|
|
91
|
+
level: Requirement level (PRD, Dev, Ops)
|
|
92
|
+
status: Requirement status (Draft, Active, etc.)
|
|
93
|
+
implements: List of parent requirement IDs
|
|
94
|
+
body: Current requirement body text
|
|
95
|
+
rationale: Current rationale text (if any)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
User prompt string
|
|
99
|
+
"""
|
|
100
|
+
implements_str = ", ".join(implements) if implements else "-"
|
|
101
|
+
|
|
102
|
+
prompt = f"""Reformat the following requirement from old format to new assertion-based format.
|
|
103
|
+
|
|
104
|
+
REQUIREMENT ID: {req_id}
|
|
105
|
+
TITLE: {title}
|
|
106
|
+
LEVEL: {level}
|
|
107
|
+
STATUS: {status}
|
|
108
|
+
IMPLEMENTS: {implements_str}
|
|
109
|
+
|
|
110
|
+
CURRENT BODY:
|
|
111
|
+
{body}
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
if rationale and rationale.strip():
|
|
115
|
+
prompt += f"""
|
|
116
|
+
CURRENT RATIONALE:
|
|
117
|
+
{rationale}
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
prompt += """
|
|
121
|
+
Extract all obligations and convert them to labeled assertions. Return ONLY the JSON object with "rationale" and "assertions" fields."""
|
|
122
|
+
|
|
123
|
+
return prompt
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Implements: REQ-int-d00008 (Reformat Command)
|
|
2
|
+
"""
|
|
3
|
+
AI-assisted requirement transformation using Claude Code CLI.
|
|
4
|
+
|
|
5
|
+
Invokes claude CLI to reformat requirements and assembles the output
|
|
6
|
+
into the new format.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from elspais.reformat.hierarchy import RequirementNode
|
|
15
|
+
from elspais.reformat.prompts import REFORMAT_SYSTEM_PROMPT, JSON_SCHEMA_STR, build_user_prompt
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def reformat_requirement(
|
|
19
|
+
node: RequirementNode,
|
|
20
|
+
model: str = "sonnet",
|
|
21
|
+
verbose: bool = False
|
|
22
|
+
) -> Tuple[Optional[dict], bool, str]:
|
|
23
|
+
"""
|
|
24
|
+
Use Claude CLI to reformat a requirement.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
node: RequirementNode with current content
|
|
28
|
+
model: Claude model to use (sonnet, opus, haiku)
|
|
29
|
+
verbose: Print debug information
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Tuple of (parsed_result, success, error_message)
|
|
33
|
+
parsed_result is a dict with 'rationale' and 'assertions' keys
|
|
34
|
+
"""
|
|
35
|
+
# Build the prompt
|
|
36
|
+
user_prompt = build_user_prompt(
|
|
37
|
+
req_id=node.req_id,
|
|
38
|
+
title=node.title,
|
|
39
|
+
level=node.level,
|
|
40
|
+
status=node.status,
|
|
41
|
+
implements=node.implements,
|
|
42
|
+
body=node.body,
|
|
43
|
+
rationale=node.rationale
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Build the claude command
|
|
47
|
+
cmd = [
|
|
48
|
+
'claude',
|
|
49
|
+
'-p', # Print mode (non-interactive)
|
|
50
|
+
'--output-format', 'json',
|
|
51
|
+
'--json-schema', JSON_SCHEMA_STR,
|
|
52
|
+
'--system-prompt', REFORMAT_SYSTEM_PROMPT,
|
|
53
|
+
'--tools', '', # Disable all tools
|
|
54
|
+
'--model', model,
|
|
55
|
+
user_prompt
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
if verbose:
|
|
59
|
+
print(f" Running: claude -p --output-format json ...", file=sys.stderr)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
cmd,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
timeout=120 # 2 minute timeout
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if result.returncode != 0:
|
|
70
|
+
error_msg = result.stderr.strip() if result.stderr else "Unknown error"
|
|
71
|
+
return None, False, f"Claude CLI failed: {error_msg}"
|
|
72
|
+
|
|
73
|
+
# Parse the JSON response
|
|
74
|
+
parsed = parse_claude_response(result.stdout)
|
|
75
|
+
if parsed is None:
|
|
76
|
+
return None, False, "Failed to parse Claude response"
|
|
77
|
+
|
|
78
|
+
return parsed, True, ""
|
|
79
|
+
|
|
80
|
+
except subprocess.TimeoutExpired:
|
|
81
|
+
return None, False, "Claude CLI timed out"
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
return None, False, "Claude CLI not found - ensure 'claude' is in PATH"
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return None, False, f"Unexpected error: {e}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_claude_response(response: str) -> Optional[dict]:
|
|
89
|
+
"""
|
|
90
|
+
Parse the JSON response from Claude CLI.
|
|
91
|
+
|
|
92
|
+
The response format with --output-format json is a JSON object containing:
|
|
93
|
+
- type: "result"
|
|
94
|
+
- subtype: "success" or "error"
|
|
95
|
+
- structured_output: the actual JSON matching our schema
|
|
96
|
+
- result: text result (may be empty with structured output)
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
response: Raw stdout from claude CLI
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Parsed dict with 'rationale' and 'assertions', or None on failure
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(response)
|
|
106
|
+
|
|
107
|
+
# Check for error
|
|
108
|
+
if data.get('is_error') or data.get('subtype') == 'error':
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# The structured output is in 'structured_output' field
|
|
112
|
+
if 'structured_output' in data:
|
|
113
|
+
structured = data['structured_output']
|
|
114
|
+
if isinstance(structured, dict) and 'rationale' in structured and 'assertions' in structured:
|
|
115
|
+
return structured
|
|
116
|
+
|
|
117
|
+
# Fallback: Direct result (if schema not used)
|
|
118
|
+
if 'rationale' in data and 'assertions' in data:
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
# Fallback: Wrapped in result field
|
|
122
|
+
if 'result' in data:
|
|
123
|
+
result = data['result']
|
|
124
|
+
if isinstance(result, dict) and 'rationale' in result:
|
|
125
|
+
return result
|
|
126
|
+
# Result might be a JSON string
|
|
127
|
+
if isinstance(result, str) and result.strip():
|
|
128
|
+
try:
|
|
129
|
+
parsed = json.loads(result)
|
|
130
|
+
if 'rationale' in parsed:
|
|
131
|
+
return parsed
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
# Try to extract JSON from the response
|
|
139
|
+
try:
|
|
140
|
+
json_start = response.find('{')
|
|
141
|
+
json_end = response.rfind('}') + 1
|
|
142
|
+
if json_start >= 0 and json_end > json_start:
|
|
143
|
+
parsed = json.loads(response[json_start:json_end])
|
|
144
|
+
if 'structured_output' in parsed:
|
|
145
|
+
return parsed['structured_output']
|
|
146
|
+
if 'rationale' in parsed and 'assertions' in parsed:
|
|
147
|
+
return parsed
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
pass
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def assemble_new_format(
|
|
154
|
+
req_id: str,
|
|
155
|
+
title: str,
|
|
156
|
+
level: str,
|
|
157
|
+
status: str,
|
|
158
|
+
implements: List[str],
|
|
159
|
+
rationale: str,
|
|
160
|
+
assertions: List[str]
|
|
161
|
+
) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Assemble the new format requirement markdown.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
req_id: Requirement ID (e.g., 'REQ-p00046')
|
|
167
|
+
title: Requirement title
|
|
168
|
+
level: Requirement level (PRD, Dev, Ops)
|
|
169
|
+
status: Requirement status
|
|
170
|
+
implements: List of parent requirement IDs
|
|
171
|
+
rationale: Rationale text (from AI)
|
|
172
|
+
assertions: List of assertion strings (from AI)
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Complete requirement markdown in new format
|
|
176
|
+
"""
|
|
177
|
+
# Format implements field
|
|
178
|
+
if implements:
|
|
179
|
+
implements_str = ", ".join(implements)
|
|
180
|
+
else:
|
|
181
|
+
implements_str = "-"
|
|
182
|
+
|
|
183
|
+
# Build header
|
|
184
|
+
lines = [
|
|
185
|
+
f"# {req_id}: {title}",
|
|
186
|
+
"",
|
|
187
|
+
f"**Level**: {level} | **Status**: {status} | **Implements**: {implements_str}",
|
|
188
|
+
"",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
# Add rationale section
|
|
192
|
+
lines.append("## Rationale")
|
|
193
|
+
lines.append("")
|
|
194
|
+
lines.append(rationale.strip())
|
|
195
|
+
lines.append("")
|
|
196
|
+
|
|
197
|
+
# Add assertions section
|
|
198
|
+
lines.append("## Assertions")
|
|
199
|
+
lines.append("")
|
|
200
|
+
|
|
201
|
+
# Label assertions A, B, C, etc.
|
|
202
|
+
for i, assertion in enumerate(assertions):
|
|
203
|
+
label = chr(ord('A') + i)
|
|
204
|
+
# Clean up assertion text
|
|
205
|
+
assertion_text = assertion.strip()
|
|
206
|
+
# Remove any existing label if present
|
|
207
|
+
if len(assertion_text) > 2 and assertion_text[1] == '.' and assertion_text[0].isupper():
|
|
208
|
+
assertion_text = assertion_text[2:].strip()
|
|
209
|
+
lines.append(f"{label}. {assertion_text}")
|
|
210
|
+
|
|
211
|
+
lines.append("")
|
|
212
|
+
|
|
213
|
+
# Add footer with placeholder hash (will be updated by elspais)
|
|
214
|
+
# Use 8 zeros as placeholder - elspais expects valid hex format
|
|
215
|
+
lines.append(f"*End* *{title}* | **Hash**: 00000000")
|
|
216
|
+
lines.append("")
|
|
217
|
+
|
|
218
|
+
return "\n".join(lines)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def validate_reformatted_content(
|
|
222
|
+
original: RequirementNode,
|
|
223
|
+
rationale: str,
|
|
224
|
+
assertions: List[str]
|
|
225
|
+
) -> Tuple[bool, List[str]]:
|
|
226
|
+
"""
|
|
227
|
+
Validate that reformatted content is well-formed.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
original: Original requirement node
|
|
231
|
+
rationale: New rationale text
|
|
232
|
+
assertions: New assertions list
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Tuple of (is_valid, list of warnings)
|
|
236
|
+
"""
|
|
237
|
+
warnings = []
|
|
238
|
+
|
|
239
|
+
# Check assertions exist
|
|
240
|
+
if not assertions:
|
|
241
|
+
warnings.append("No assertions generated")
|
|
242
|
+
return False, warnings
|
|
243
|
+
|
|
244
|
+
# Check each assertion uses SHALL
|
|
245
|
+
for i, assertion in enumerate(assertions):
|
|
246
|
+
label = chr(ord('A') + i)
|
|
247
|
+
if 'SHALL' not in assertion.upper():
|
|
248
|
+
warnings.append(f"Assertion {label} missing SHALL keyword")
|
|
249
|
+
|
|
250
|
+
# Check rationale doesn't use SHALL
|
|
251
|
+
if 'SHALL' in rationale.upper():
|
|
252
|
+
warnings.append("Rationale contains SHALL (should be non-normative)")
|
|
253
|
+
|
|
254
|
+
# Check assertion count
|
|
255
|
+
if len(assertions) > 26:
|
|
256
|
+
warnings.append(f"Too many assertions ({len(assertions)} > 26)")
|
|
257
|
+
return False, warnings
|
|
258
|
+
|
|
259
|
+
# Warning if very few assertions from complex body
|
|
260
|
+
if len(assertions) < 2 and len(original.body) > 500:
|
|
261
|
+
warnings.append("Few assertions from large body - may have missed obligations")
|
|
262
|
+
|
|
263
|
+
is_valid = not any("missing SHALL" in w or "No assertions" in w for w in warnings)
|
|
264
|
+
return is_valid, warnings
|