julee 0.1.1__py3-none-any.whl → 0.1.3__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 (157) hide show
  1. julee/api/app.py +9 -8
  2. julee/api/dependencies.py +15 -15
  3. julee/api/requests.py +10 -9
  4. julee/api/responses.py +2 -1
  5. julee/api/routers/__init__.py +5 -5
  6. julee/api/routers/assembly_specifications.py +5 -4
  7. julee/api/routers/documents.py +1 -1
  8. julee/api/routers/knowledge_service_configs.py +4 -3
  9. julee/api/routers/knowledge_service_queries.py +7 -6
  10. julee/api/routers/system.py +4 -3
  11. julee/api/routers/workflows.py +4 -5
  12. julee/api/services/system_initialization.py +6 -6
  13. julee/api/tests/routers/test_assembly_specifications.py +4 -3
  14. julee/api/tests/routers/test_documents.py +5 -4
  15. julee/api/tests/routers/test_knowledge_service_configs.py +7 -6
  16. julee/api/tests/routers/test_knowledge_service_queries.py +4 -3
  17. julee/api/tests/routers/test_system.py +5 -4
  18. julee/api/tests/routers/test_workflows.py +5 -4
  19. julee/api/tests/test_app.py +5 -4
  20. julee/api/tests/test_dependencies.py +3 -2
  21. julee/api/tests/test_requests.py +2 -1
  22. julee/contrib/__init__.py +15 -0
  23. julee/contrib/polling/__init__.py +47 -0
  24. julee/contrib/polling/domain/__init__.py +17 -0
  25. julee/contrib/polling/domain/models/__init__.py +13 -0
  26. julee/contrib/polling/domain/models/polling_config.py +39 -0
  27. julee/contrib/polling/domain/services/__init__.py +11 -0
  28. julee/contrib/polling/domain/services/poller.py +39 -0
  29. julee/contrib/polling/infrastructure/__init__.py +15 -0
  30. julee/contrib/polling/infrastructure/services/__init__.py +12 -0
  31. julee/contrib/polling/infrastructure/services/polling/__init__.py +12 -0
  32. julee/contrib/polling/infrastructure/services/polling/http/__init__.py +12 -0
  33. julee/contrib/polling/infrastructure/services/polling/http/http_poller_service.py +80 -0
  34. julee/contrib/polling/infrastructure/temporal/__init__.py +20 -0
  35. julee/contrib/polling/infrastructure/temporal/activities.py +42 -0
  36. julee/contrib/polling/infrastructure/temporal/activity_names.py +20 -0
  37. julee/contrib/polling/infrastructure/temporal/proxies.py +45 -0
  38. julee/contrib/polling/tests/__init__.py +6 -0
  39. julee/contrib/polling/tests/unit/__init__.py +6 -0
  40. julee/contrib/polling/tests/unit/infrastructure/__init__.py +7 -0
  41. julee/contrib/polling/tests/unit/infrastructure/services/__init__.py +6 -0
  42. julee/contrib/polling/tests/unit/infrastructure/services/polling/__init__.py +6 -0
  43. julee/contrib/polling/tests/unit/infrastructure/services/polling/http/__init__.py +7 -0
  44. julee/contrib/polling/tests/unit/infrastructure/services/polling/http/test_http_poller_service.py +163 -0
  45. julee/docs/__init__.py +5 -0
  46. julee/docs/sphinx_hcd/__init__.py +82 -0
  47. julee/docs/sphinx_hcd/accelerators.py +1078 -0
  48. julee/docs/sphinx_hcd/apps.py +499 -0
  49. julee/docs/sphinx_hcd/config.py +148 -0
  50. julee/docs/sphinx_hcd/epics.py +448 -0
  51. julee/docs/sphinx_hcd/integrations.py +306 -0
  52. julee/docs/sphinx_hcd/journeys.py +783 -0
  53. julee/docs/sphinx_hcd/personas.py +435 -0
  54. julee/docs/sphinx_hcd/stories.py +932 -0
  55. julee/docs/sphinx_hcd/utils.py +180 -0
  56. julee/domain/models/__init__.py +5 -6
  57. julee/domain/models/assembly/assembly.py +7 -7
  58. julee/domain/models/assembly/tests/factories.py +2 -1
  59. julee/domain/models/assembly/tests/test_assembly.py +16 -13
  60. julee/domain/models/assembly_specification/assembly_specification.py +11 -10
  61. julee/domain/models/assembly_specification/knowledge_service_query.py +14 -9
  62. julee/domain/models/assembly_specification/tests/factories.py +2 -1
  63. julee/domain/models/assembly_specification/tests/test_assembly_specification.py +9 -6
  64. julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +3 -1
  65. julee/domain/models/custom_fields/content_stream.py +3 -2
  66. julee/domain/models/custom_fields/tests/test_custom_fields.py +2 -1
  67. julee/domain/models/document/document.py +12 -10
  68. julee/domain/models/document/tests/factories.py +3 -2
  69. julee/domain/models/document/tests/test_document.py +6 -3
  70. julee/domain/models/knowledge_service_config/knowledge_service_config.py +4 -4
  71. julee/domain/models/policy/__init__.py +4 -4
  72. julee/domain/models/policy/document_policy_validation.py +17 -17
  73. julee/domain/models/policy/policy.py +12 -10
  74. julee/domain/models/policy/tests/factories.py +2 -1
  75. julee/domain/models/policy/tests/test_document_policy_validation.py +3 -1
  76. julee/domain/models/policy/tests/test_policy.py +2 -1
  77. julee/domain/repositories/__init__.py +3 -3
  78. julee/domain/repositories/assembly.py +3 -1
  79. julee/domain/repositories/assembly_specification.py +2 -0
  80. julee/domain/repositories/base.py +33 -16
  81. julee/domain/repositories/document.py +3 -1
  82. julee/domain/repositories/document_policy_validation.py +3 -1
  83. julee/domain/repositories/knowledge_service_config.py +2 -0
  84. julee/domain/repositories/knowledge_service_query.py +1 -0
  85. julee/domain/repositories/policy.py +3 -1
  86. julee/domain/use_cases/decorators.py +3 -2
  87. julee/domain/use_cases/extract_assemble_data.py +23 -13
  88. julee/domain/use_cases/initialize_system_data.py +13 -13
  89. julee/domain/use_cases/tests/test_extract_assemble_data.py +10 -10
  90. julee/domain/use_cases/tests/test_initialize_system_data.py +2 -2
  91. julee/domain/use_cases/tests/test_validate_document.py +11 -11
  92. julee/domain/use_cases/validate_document.py +26 -15
  93. julee/maintenance/__init__.py +1 -0
  94. julee/maintenance/release.py +188 -0
  95. julee/repositories/__init__.py +4 -1
  96. julee/repositories/memory/assembly.py +6 -5
  97. julee/repositories/memory/assembly_specification.py +9 -9
  98. julee/repositories/memory/base.py +12 -11
  99. julee/repositories/memory/document.py +8 -7
  100. julee/repositories/memory/document_policy_validation.py +7 -6
  101. julee/repositories/memory/knowledge_service_config.py +9 -7
  102. julee/repositories/memory/knowledge_service_query.py +9 -7
  103. julee/repositories/memory/policy.py +6 -5
  104. julee/repositories/memory/tests/test_document.py +6 -4
  105. julee/repositories/memory/tests/test_document_policy_validation.py +2 -1
  106. julee/repositories/memory/tests/test_policy.py +2 -1
  107. julee/repositories/minio/assembly.py +4 -4
  108. julee/repositories/minio/assembly_specification.py +6 -8
  109. julee/repositories/minio/client.py +22 -25
  110. julee/repositories/minio/document.py +11 -11
  111. julee/repositories/minio/document_policy_validation.py +5 -5
  112. julee/repositories/minio/knowledge_service_config.py +8 -8
  113. julee/repositories/minio/knowledge_service_query.py +6 -9
  114. julee/repositories/minio/policy.py +4 -4
  115. julee/repositories/minio/tests/fake_client.py +11 -9
  116. julee/repositories/minio/tests/test_assembly.py +3 -1
  117. julee/repositories/minio/tests/test_assembly_specification.py +2 -1
  118. julee/repositories/minio/tests/test_client_protocol.py +5 -5
  119. julee/repositories/minio/tests/test_document.py +7 -6
  120. julee/repositories/minio/tests/test_document_policy_validation.py +3 -1
  121. julee/repositories/minio/tests/test_knowledge_service_config.py +4 -2
  122. julee/repositories/minio/tests/test_knowledge_service_query.py +3 -2
  123. julee/repositories/minio/tests/test_policy.py +3 -1
  124. julee/repositories/temporal/activities.py +5 -5
  125. julee/repositories/temporal/proxies.py +5 -5
  126. julee/services/knowledge_service/__init__.py +1 -2
  127. julee/services/knowledge_service/anthropic/knowledge_service.py +8 -7
  128. julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +11 -10
  129. julee/services/knowledge_service/factory.py +8 -8
  130. julee/services/knowledge_service/knowledge_service.py +22 -18
  131. julee/services/knowledge_service/memory/knowledge_service.py +13 -12
  132. julee/services/knowledge_service/memory/test_knowledge_service.py +10 -7
  133. julee/services/knowledge_service/test_factory.py +11 -10
  134. julee/services/temporal/activities.py +10 -10
  135. julee/services/temporal/proxies.py +2 -2
  136. julee/util/domain.py +6 -6
  137. julee/util/repos/minio/file_storage.py +8 -9
  138. julee/util/repos/temporal/client_proxies/file_storage.py +3 -4
  139. julee/util/repos/temporal/data_converter.py +6 -6
  140. julee/util/repos/temporal/minio_file_storage.py +1 -1
  141. julee/util/repos/temporal/proxies/file_storage.py +2 -3
  142. julee/util/repositories.py +6 -7
  143. julee/util/temporal/activities.py +1 -1
  144. julee/util/temporal/decorators.py +28 -33
  145. julee/util/tests/test_decorators.py +13 -15
  146. julee/util/validation/repository.py +3 -3
  147. julee/util/validation/type_guards.py +12 -11
  148. julee/worker.py +9 -8
  149. julee/workflows/__init__.py +2 -2
  150. julee/workflows/extract_assemble.py +2 -1
  151. julee/workflows/validate_document.py +3 -2
  152. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/METADATA +4 -1
  153. julee-0.1.3.dist-info/RECORD +197 -0
  154. julee-0.1.1.dist-info/RECORD +0 -161
  155. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/WHEEL +0 -0
  156. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/licenses/LICENSE +0 -0
  157. {julee-0.1.1.dist-info → julee-0.1.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,448 @@
1
+ """Sphinx extension for defining and cross-referencing epics.
2
+
3
+ Provides directives:
4
+ - define-epic: Define an epic with description
5
+ - epic-story: Reference a story as part of the epic
6
+ - epic-index: Render index of all epics
7
+ - epics-for-persona: List epics for a persona (derived from stories)
8
+ """
9
+
10
+ from docutils import nodes
11
+ from sphinx.util.docutils import SphinxDirective
12
+ from sphinx.util import logging
13
+
14
+ from .config import get_config
15
+ from .utils import normalize_name, path_to_root
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def get_epic_registry(env):
21
+ """Get or create the epic registry on the environment."""
22
+ if not hasattr(env, 'epic_registry'):
23
+ env.epic_registry = {}
24
+ return env.epic_registry
25
+
26
+
27
+ def get_current_epic(env):
28
+ """Get or create the current epic tracker on the environment."""
29
+ if not hasattr(env, 'epic_current'):
30
+ env.epic_current = {}
31
+ return env.epic_current
32
+
33
+
34
+ class DefineEpicDirective(SphinxDirective):
35
+ """Define an epic with description.
36
+
37
+ Usage::
38
+
39
+ .. define-epic:: credential-creation
40
+
41
+ Covers the creation, attachment, and verification of UNTP-compliant
42
+ credentials including DPPs, DFRs, and DCCs.
43
+ """
44
+
45
+ required_arguments = 1 # epic slug
46
+ has_content = True
47
+ option_spec = {}
48
+
49
+ def run(self):
50
+ epic_slug = self.arguments[0]
51
+ docname = self.env.docname
52
+
53
+ # Description is the directive content
54
+ description = '\n'.join(self.content).strip()
55
+
56
+ # Register the epic in environment
57
+ epic_registry = get_epic_registry(self.env)
58
+ current_epic = get_current_epic(self.env)
59
+
60
+ epic_data = {
61
+ 'slug': epic_slug,
62
+ 'description': description,
63
+ 'stories': [], # Will be populated by epic-story
64
+ 'docname': docname,
65
+ }
66
+ epic_registry[epic_slug] = epic_data
67
+ current_epic[docname] = epic_slug
68
+
69
+ # Build output nodes
70
+ result_nodes = []
71
+
72
+ # Description paragraph
73
+ if description:
74
+ desc_para = nodes.paragraph(text=description)
75
+ result_nodes.append(desc_para)
76
+
77
+ # Add a placeholder for stories (will be filled in doctree-resolved)
78
+ stories_placeholder = nodes.container()
79
+ stories_placeholder['classes'].append('epic-stories-placeholder')
80
+ stories_placeholder['epic_slug'] = epic_slug
81
+ result_nodes.append(stories_placeholder)
82
+
83
+ return result_nodes
84
+
85
+
86
+ class EpicStoryDirective(SphinxDirective):
87
+ """Reference a story as part of the epic.
88
+
89
+ Usage::
90
+
91
+ .. epic-story:: Create DPP from Product Sheet
92
+ """
93
+
94
+ required_arguments = 1
95
+ final_argument_whitespace = True
96
+
97
+ def run(self):
98
+ story_title = self.arguments[0]
99
+ docname = self.env.docname
100
+
101
+ # Add to current epic's stories
102
+ epic_registry = get_epic_registry(self.env)
103
+ current_epic = get_current_epic(self.env)
104
+
105
+ epic_slug = current_epic.get(docname)
106
+ if epic_slug and epic_slug in epic_registry:
107
+ epic_registry[epic_slug]['stories'].append(story_title)
108
+
109
+ # Return empty - rendering happens in doctree-resolved
110
+ return []
111
+
112
+
113
+ class EpicIndexDirective(SphinxDirective):
114
+ """Render index of all epics.
115
+
116
+ Usage::
117
+
118
+ .. epic-index::
119
+ """
120
+
121
+ def run(self):
122
+ # Return placeholder - actual rendering in doctree-resolved
123
+ node = EpicIndexPlaceholder()
124
+ return [node]
125
+
126
+
127
+ class EpicIndexPlaceholder(nodes.General, nodes.Element):
128
+ """Placeholder node for epic index, replaced at doctree-resolved."""
129
+ pass
130
+
131
+
132
+ class EpicsForPersonaDirective(SphinxDirective):
133
+ """List epics for a specific persona (derived from stories).
134
+
135
+ Usage::
136
+
137
+ .. epics-for-persona:: Member Implementer
138
+ """
139
+
140
+ required_arguments = 1
141
+ final_argument_whitespace = True
142
+
143
+ def run(self):
144
+ # Return placeholder - actual rendering in doctree-resolved
145
+ node = EpicsForPersonaPlaceholder()
146
+ node['persona'] = self.arguments[0]
147
+ return [node]
148
+
149
+
150
+ class EpicsForPersonaPlaceholder(nodes.General, nodes.Element):
151
+ """Placeholder node for epics-for-persona, replaced at doctree-resolved."""
152
+ pass
153
+
154
+
155
+ def clear_epic_state(app, env, docname):
156
+ """Clear epic state when a document is re-read."""
157
+ current_epic = get_current_epic(env)
158
+ epic_registry = get_epic_registry(env)
159
+
160
+ if docname in current_epic:
161
+ del current_epic[docname]
162
+
163
+ # Remove epics defined in this document
164
+ to_remove = [slug for slug, e in epic_registry.items()
165
+ if e['docname'] == docname]
166
+ for slug in to_remove:
167
+ del epic_registry[slug]
168
+
169
+
170
+ def validate_epics(app, env):
171
+ """Validate epic references after all documents are read."""
172
+ from . import stories
173
+
174
+ epic_registry = get_epic_registry(env)
175
+ _story_registry = stories.get_story_registry()
176
+ story_titles = {normalize_name(s['feature']) for s in _story_registry}
177
+
178
+ for slug, epic in epic_registry.items():
179
+ # Validate story references
180
+ for story_title in epic['stories']:
181
+ if normalize_name(story_title) not in story_titles:
182
+ logger.warning(
183
+ f"Epic '{slug}' references unknown story: '{story_title}'"
184
+ )
185
+
186
+
187
+ def get_personas_for_epic(epic: dict, story_registry: list) -> set[str]:
188
+ """Get the set of personas for an epic based on its stories."""
189
+ personas = set()
190
+ for story_title in epic['stories']:
191
+ story_normalized = normalize_name(story_title)
192
+ for story in story_registry:
193
+ if normalize_name(story['feature']) == story_normalized:
194
+ personas.add(story['persona'])
195
+ break
196
+ return personas
197
+
198
+
199
+ def render_epic_stories(epic: dict, docname: str, story_registry: list, known_personas: set):
200
+ """Render epic stories as a simple bullet list."""
201
+ from . import stories
202
+
203
+ config = get_config()
204
+ _known_apps = stories.get_known_apps()
205
+
206
+ stories_data = []
207
+ for story_title in epic['stories']:
208
+ story_normalized = normalize_name(story_title)
209
+ for story in story_registry:
210
+ if normalize_name(story['feature']) == story_normalized:
211
+ stories_data.append(story)
212
+ break
213
+
214
+ if not stories_data:
215
+ return None
216
+
217
+ # Calculate paths
218
+ prefix = path_to_root(docname)
219
+
220
+ result_nodes = []
221
+
222
+ # Stories heading
223
+ stories_heading = nodes.paragraph()
224
+ stories_heading += nodes.strong(text="Stories")
225
+ result_nodes.append(stories_heading)
226
+
227
+ # Simple bullet list: "story name (App Name)"
228
+ story_list = nodes.bullet_list()
229
+
230
+ for story in sorted(stories_data, key=lambda s: s['feature'].lower()):
231
+ story_item = nodes.list_item()
232
+ story_para = nodes.paragraph()
233
+
234
+ # Story link
235
+ story_para += stories.make_story_reference(story, docname)
236
+
237
+ # App in parentheses
238
+ story_para += nodes.Text(" (")
239
+ app_path = f"{prefix}{config.get_doc_path('applications')}/{story['app']}.html"
240
+ app_valid = story['app_normalized'] in _known_apps
241
+
242
+ if app_valid:
243
+ app_ref = nodes.reference("", "", refuri=app_path)
244
+ app_ref += nodes.Text(story['app'].replace("-", " ").title())
245
+ story_para += app_ref
246
+ else:
247
+ story_para += nodes.Text(story['app'].replace("-", " ").title())
248
+
249
+ story_para += nodes.Text(")")
250
+
251
+ story_item += story_para
252
+ story_list += story_item
253
+
254
+ result_nodes.append(story_list)
255
+
256
+ return result_nodes
257
+
258
+
259
+ def process_epic_placeholders(app, doctree, docname):
260
+ """Replace epic placeholders with rendered content."""
261
+ from . import stories
262
+
263
+ config = get_config()
264
+ env = app.env
265
+ epic_registry = get_epic_registry(env)
266
+ current_epic = get_current_epic(env)
267
+ _story_registry = stories.get_story_registry()
268
+ _known_personas = stories.get_known_personas()
269
+
270
+ # Process epic stories placeholder
271
+ epic_slug = current_epic.get(docname)
272
+ if epic_slug and epic_slug in epic_registry:
273
+ epic = epic_registry[epic_slug]
274
+
275
+ for node in doctree.traverse(nodes.container):
276
+ if 'epic-stories-placeholder' in node.get('classes', []):
277
+ stories_nodes = render_epic_stories(
278
+ epic, docname, _story_registry, _known_personas
279
+ )
280
+ if stories_nodes:
281
+ node.replace_self(stories_nodes)
282
+ else:
283
+ node.replace_self([])
284
+ break
285
+
286
+ # Process epic index placeholder
287
+ for node in doctree.traverse(EpicIndexPlaceholder):
288
+ index_node = build_epic_index(env, docname, _story_registry)
289
+ node.replace_self(index_node)
290
+
291
+ # Process epics-for-persona placeholder
292
+ for node in doctree.traverse(EpicsForPersonaPlaceholder):
293
+ persona = node['persona']
294
+ epics_node = build_epics_for_persona(
295
+ env, docname, persona, _story_registry
296
+ )
297
+ node.replace_self(epics_node)
298
+
299
+
300
+ def build_epic_index(env, docname: str, story_registry: list):
301
+ """Build the epic index listing all epics, plus unassigned stories."""
302
+ from . import stories
303
+
304
+ config = get_config()
305
+ epic_registry = get_epic_registry(env)
306
+ _known_apps = stories.get_known_apps()
307
+
308
+ if not epic_registry:
309
+ para = nodes.paragraph()
310
+ para += nodes.emphasis(text="No epics defined")
311
+ return [para]
312
+
313
+ result_nodes = []
314
+ bullet_list = nodes.bullet_list()
315
+
316
+ # Collect all stories assigned to epics
317
+ assigned_stories = set()
318
+ for epic in epic_registry.values():
319
+ for story_title in epic['stories']:
320
+ assigned_stories.add(normalize_name(story_title))
321
+
322
+ for slug in sorted(epic_registry.keys()):
323
+ epic = epic_registry[slug]
324
+
325
+ item = nodes.list_item()
326
+ para = nodes.paragraph()
327
+
328
+ # Link to epic
329
+ epic_path = f"{slug}.html"
330
+ epic_ref = nodes.reference("", "", refuri=epic_path)
331
+ epic_ref += nodes.Text(slug.replace("-", " ").title())
332
+ para += epic_ref
333
+
334
+ # Story count
335
+ story_count = len(epic['stories'])
336
+ para += nodes.Text(f" ({story_count} stories)")
337
+
338
+ item += para
339
+ bullet_list += item
340
+
341
+ result_nodes.append(bullet_list)
342
+
343
+ # Find unassigned stories
344
+ unassigned_stories = []
345
+ for story in story_registry:
346
+ if normalize_name(story['feature']) not in assigned_stories:
347
+ unassigned_stories.append(story)
348
+
349
+ if unassigned_stories:
350
+ # Calculate paths
351
+ prefix = path_to_root(docname)
352
+
353
+ # Add section heading
354
+ heading = nodes.paragraph()
355
+ heading += nodes.strong(text="Unassigned Stories")
356
+ result_nodes.append(heading)
357
+
358
+ intro = nodes.paragraph()
359
+ intro += nodes.Text(f"{len(unassigned_stories)} stories not yet assigned to an epic:")
360
+ result_nodes.append(intro)
361
+
362
+ # List unassigned stories
363
+ unassigned_list = nodes.bullet_list()
364
+ for story in sorted(unassigned_stories, key=lambda s: s['feature'].lower()):
365
+ item = nodes.list_item()
366
+ para = nodes.paragraph()
367
+
368
+ # Story link
369
+ para += stories.make_story_reference(story, docname)
370
+
371
+ # App in parentheses
372
+ para += nodes.Text(" (")
373
+ app_path = f"{prefix}{config.get_doc_path('applications')}/{story['app']}.html"
374
+ app_valid = story['app_normalized'] in _known_apps
375
+
376
+ if app_valid:
377
+ app_ref = nodes.reference("", "", refuri=app_path)
378
+ app_ref += nodes.Text(story['app'].replace("-", " ").title())
379
+ para += app_ref
380
+ else:
381
+ para += nodes.Text(story['app'].replace("-", " ").title())
382
+
383
+ para += nodes.Text(")")
384
+
385
+ item += para
386
+ unassigned_list += item
387
+
388
+ result_nodes.append(unassigned_list)
389
+
390
+ return result_nodes
391
+
392
+
393
+ def build_epics_for_persona(env, docname: str, persona_arg: str, story_registry: list):
394
+ """Build list of epics for a persona."""
395
+ config = get_config()
396
+ epic_registry = get_epic_registry(env)
397
+ persona_normalized = normalize_name(persona_arg)
398
+
399
+ prefix = path_to_root(docname)
400
+
401
+ # Find epics that contain stories for this persona
402
+ matching_epics = []
403
+ for slug, epic in epic_registry.items():
404
+ personas = get_personas_for_epic(epic, story_registry)
405
+ persona_names_normalized = {normalize_name(p) for p in personas}
406
+ if persona_normalized in persona_names_normalized:
407
+ matching_epics.append((slug, epic))
408
+
409
+ if not matching_epics:
410
+ para = nodes.paragraph()
411
+ para += nodes.emphasis(text=f"No epics found for persona '{persona_arg}'")
412
+ return [para]
413
+
414
+ bullet_list = nodes.bullet_list()
415
+
416
+ for slug, epic in sorted(matching_epics, key=lambda x: x[0]):
417
+ item = nodes.list_item()
418
+ para = nodes.paragraph()
419
+
420
+ epic_path = f"{prefix}{config.get_doc_path('epics')}/{slug}.html"
421
+ epic_ref = nodes.reference("", "", refuri=epic_path)
422
+ epic_ref += nodes.Text(slug.replace("-", " ").title())
423
+ para += epic_ref
424
+
425
+ item += para
426
+ bullet_list += item
427
+
428
+ return [bullet_list]
429
+
430
+
431
+ def setup(app):
432
+ app.connect("env-purge-doc", clear_epic_state)
433
+ app.connect("env-check-consistency", validate_epics)
434
+ app.connect("doctree-resolved", process_epic_placeholders)
435
+
436
+ app.add_directive("define-epic", DefineEpicDirective)
437
+ app.add_directive("epic-story", EpicStoryDirective)
438
+ app.add_directive("epic-index", EpicIndexDirective)
439
+ app.add_directive("epics-for-persona", EpicsForPersonaDirective)
440
+
441
+ app.add_node(EpicIndexPlaceholder)
442
+ app.add_node(EpicsForPersonaPlaceholder)
443
+
444
+ return {
445
+ "version": "1.0",
446
+ "parallel_read_safe": False, # Uses global state
447
+ "parallel_write_safe": True,
448
+ }