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.
Files changed (73) hide show
  1. elspais/cli.py +141 -10
  2. elspais/commands/hash_cmd.py +72 -26
  3. elspais/commands/reformat_cmd.py +458 -0
  4. elspais/commands/trace.py +157 -3
  5. elspais/commands/validate.py +44 -16
  6. elspais/core/models.py +2 -0
  7. elspais/core/parser.py +68 -24
  8. elspais/reformat/__init__.py +50 -0
  9. elspais/reformat/detector.py +119 -0
  10. elspais/reformat/hierarchy.py +246 -0
  11. elspais/reformat/line_breaks.py +220 -0
  12. elspais/reformat/prompts.py +123 -0
  13. elspais/reformat/transformer.py +264 -0
  14. elspais/sponsors/__init__.py +432 -0
  15. elspais/trace_view/__init__.py +54 -0
  16. elspais/trace_view/coverage.py +183 -0
  17. elspais/trace_view/generators/__init__.py +12 -0
  18. elspais/trace_view/generators/base.py +329 -0
  19. elspais/trace_view/generators/csv.py +122 -0
  20. elspais/trace_view/generators/markdown.py +175 -0
  21. elspais/trace_view/html/__init__.py +31 -0
  22. elspais/trace_view/html/generator.py +1006 -0
  23. elspais/trace_view/html/templates/base.html +283 -0
  24. elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
  25. elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
  26. elspais/trace_view/html/templates/components/legend_modal.html +69 -0
  27. elspais/trace_view/html/templates/components/review_panel.html +118 -0
  28. elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
  29. elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
  30. elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
  31. elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
  32. elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
  33. elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
  34. elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
  35. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
  36. elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
  37. elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
  38. elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
  39. elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
  40. elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
  41. elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
  42. elspais/trace_view/html/templates/partials/scripts.js +1741 -0
  43. elspais/trace_view/html/templates/partials/styles.css +1756 -0
  44. elspais/trace_view/models.py +353 -0
  45. elspais/trace_view/review/__init__.py +60 -0
  46. elspais/trace_view/review/branches.py +1149 -0
  47. elspais/trace_view/review/models.py +1205 -0
  48. elspais/trace_view/review/position.py +609 -0
  49. elspais/trace_view/review/server.py +1056 -0
  50. elspais/trace_view/review/status.py +470 -0
  51. elspais/trace_view/review/storage.py +1367 -0
  52. elspais/trace_view/scanning.py +213 -0
  53. elspais/trace_view/specs/README.md +84 -0
  54. elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
  55. elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
  56. elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
  57. elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
  58. elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
  59. elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
  60. elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
  61. elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
  62. elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
  63. elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
  64. elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
  65. elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
  66. elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
  67. elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
  68. {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/METADATA +36 -18
  69. elspais-0.11.1.dist-info/RECORD +101 -0
  70. elspais-0.9.3.dist-info/RECORD +0 -40
  71. {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/WHEEL +0 -0
  72. {elspais-0.9.3.dist-info → elspais-0.11.1.dist-info}/entry_points.txt +0 -0
  73. {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